mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-25 08:42:35 +08:00
Compare commits
229 Commits
v2026.6.20
...
codex/refa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7641ec8ba7 | ||
|
|
28cbeaaa80 | ||
|
|
e773981088 | ||
|
|
9d4d07175d | ||
|
|
cdb7e64994 | ||
|
|
7daba184b5 | ||
|
|
3d1935dcc1 | ||
|
|
b832153d0a | ||
|
|
9bd2b4b34b | ||
|
|
02abd3adb5 | ||
|
|
0dda56e0f6 | ||
|
|
365d78605d | ||
|
|
a2c64b08ff | ||
|
|
90decc657d | ||
|
|
e670de672d | ||
|
|
c4aba64b58 | ||
|
|
3a024f6a8d | ||
|
|
c866b087eb | ||
|
|
620cca1d7f | ||
|
|
353012b8a8 | ||
|
|
a37c6c935a | ||
|
|
97b97a9999 | ||
|
|
cbbb466852 | ||
|
|
c2de9d0822 | ||
|
|
e46aaead2c | ||
|
|
c191d7978b | ||
|
|
1b5e1e2d53 | ||
|
|
ab41a311cf | ||
|
|
9e70d251b0 | ||
|
|
025f8fb087 | ||
|
|
e012f2cd3c | ||
|
|
73c988a9c8 | ||
|
|
8b4eedf1bc | ||
|
|
b43eedbb18 | ||
|
|
ebbf506a77 | ||
|
|
e0c1add79a | ||
|
|
eae1e8f3f8 | ||
|
|
ba34052a0e | ||
|
|
ce78ac3efb | ||
|
|
6c2c43d63f | ||
|
|
0168ff0c2e | ||
|
|
22bdda2555 | ||
|
|
2755112353 | ||
|
|
57c7fa22bb | ||
|
|
269e44e164 | ||
|
|
2e75d925ad | ||
|
|
0e763c1499 | ||
|
|
6ed9fb8ec2 | ||
|
|
a826d6a4a4 | ||
|
|
9d519c1481 | ||
|
|
b09b35c13c | ||
|
|
15f2a56590 | ||
|
|
e66c36df37 | ||
|
|
42c504b8b1 | ||
|
|
9ac3759ffc | ||
|
|
d7f747af3b | ||
|
|
9cb3b4ea2b | ||
|
|
f257116c92 | ||
|
|
a88ce96ee1 | ||
|
|
448b3fa0be | ||
|
|
88ad407be2 | ||
|
|
13e77cc055 | ||
|
|
b78718f42a | ||
|
|
54dddda68d | ||
|
|
6084442ab6 | ||
|
|
692c7e78f4 | ||
|
|
3f166b1f64 | ||
|
|
8e8905560b | ||
|
|
3968fea383 | ||
|
|
43e8c29fbf | ||
|
|
7e80bb8abf | ||
|
|
9826619e22 | ||
|
|
8f3672beaa | ||
|
|
0d25928fa4 | ||
|
|
807641548b | ||
|
|
f87f30d429 | ||
|
|
22c5ced69f | ||
|
|
faa4f5a23b | ||
|
|
fc0b28cb73 | ||
|
|
8265acaacb | ||
|
|
d20e96a650 | ||
|
|
8ff1d3e67b | ||
|
|
49a9032705 | ||
|
|
aab1dd88e0 | ||
|
|
a81a505c72 | ||
|
|
7d219bd6e7 | ||
|
|
ece7d0945c | ||
|
|
979238dbb3 | ||
|
|
ab165d119c | ||
|
|
6f3df79f17 | ||
|
|
7c2fb845db | ||
|
|
00f67b845b | ||
|
|
6ef9207201 | ||
|
|
3283540c78 | ||
|
|
03b022b88e | ||
|
|
45f9358877 | ||
|
|
29ec5b331c | ||
|
|
fa08942396 | ||
|
|
2c9192a9a8 | ||
|
|
955f3ed094 | ||
|
|
7217477553 | ||
|
|
97b0b559ad | ||
|
|
1b299f4dbf | ||
|
|
7064d198a3 | ||
|
|
f18ff7551e | ||
|
|
58d295840e | ||
|
|
896b3c612d | ||
|
|
2f240a4a4c | ||
|
|
172412d756 | ||
|
|
58578b3250 | ||
|
|
ce9769faae | ||
|
|
e8920f6f6b | ||
|
|
9be53b4aa2 | ||
|
|
3ec2a46907 | ||
|
|
15a2d74320 | ||
|
|
77f07a11e7 | ||
|
|
7a0d36f3d0 | ||
|
|
0a707afb9a | ||
|
|
bdeda6553b | ||
|
|
3499b277e3 | ||
|
|
8c8857c3ef | ||
|
|
d75613e794 | ||
|
|
beb8897f49 | ||
|
|
add5f76a1e | ||
|
|
9a9f4dbefe | ||
|
|
5beaaf343c | ||
|
|
1db811282c | ||
|
|
aa23d9f34e | ||
|
|
2962c95010 | ||
|
|
80d3b132a5 | ||
|
|
1a5d84d3fe | ||
|
|
71a75b9b28 | ||
|
|
b1f562570a | ||
|
|
bdcc691745 | ||
|
|
4461e257e3 | ||
|
|
76014cfe95 | ||
|
|
498ff1fb5a | ||
|
|
ae81aa018d | ||
|
|
1706bfda2c | ||
|
|
a1201e99fc | ||
|
|
90d2f161c9 | ||
|
|
bff7134a69 | ||
|
|
e59d0b540e | ||
|
|
aa5fcf70f7 | ||
|
|
63ac2e2ce0 | ||
|
|
803064c6e0 | ||
|
|
577e5a4692 | ||
|
|
a49f3f9362 | ||
|
|
7b9ddbda99 | ||
|
|
0f83051353 | ||
|
|
4341cf24cc | ||
|
|
6a3f990140 | ||
|
|
81abc2b21b | ||
|
|
09fcafffbc | ||
|
|
2a93d7b9c5 | ||
|
|
0eaefc9050 | ||
|
|
52e01676be | ||
|
|
df68b81006 | ||
|
|
a5417b5c6c | ||
|
|
da2c7e2d2b | ||
|
|
3a14f247ad | ||
|
|
5c36001fcb | ||
|
|
05bed72a8d | ||
|
|
c2433d41a7 | ||
|
|
d368fd620c | ||
|
|
7dc7deaa13 | ||
|
|
a2ff59fdb2 | ||
|
|
b12223a79f | ||
|
|
f519ceab9c | ||
|
|
1f1b1aee6b | ||
|
|
62b2e9ef14 | ||
|
|
0f67474251 | ||
|
|
e56fd1dc04 | ||
|
|
b3968f69c9 | ||
|
|
b0df6dc10e | ||
|
|
141fb2b119 | ||
|
|
64b6488f6c | ||
|
|
e1fc4683bb | ||
|
|
85ab952956 | ||
|
|
abd5fb4494 | ||
|
|
aea050b43e | ||
|
|
85f552bf37 | ||
|
|
dafd98dd98 | ||
|
|
3632c62f85 | ||
|
|
ad5d2cbc1b | ||
|
|
7cda58c109 | ||
|
|
5c0b99ae2b | ||
|
|
979925c194 | ||
|
|
2f9f45f734 | ||
|
|
32cbaecd09 | ||
|
|
1989726eb6 | ||
|
|
2454acc287 | ||
|
|
fce5db415b | ||
|
|
2166652eb3 | ||
|
|
7a9c269541 | ||
|
|
aa893b9228 | ||
|
|
98a7741468 | ||
|
|
3df4341e5a | ||
|
|
ecac665bf3 | ||
|
|
021fd5de2b | ||
|
|
60159b9f00 | ||
|
|
165440117e | ||
|
|
fddfcbe10e | ||
|
|
7c850bdf38 | ||
|
|
2bc20f2ec5 | ||
|
|
ed500dda25 | ||
|
|
bc754b3160 | ||
|
|
b972956173 | ||
|
|
29444b26f2 | ||
|
|
7fc5a72433 | ||
|
|
a590f7f690 | ||
|
|
2252674168 | ||
|
|
60612ff492 | ||
|
|
c5623e72f3 | ||
|
|
947c21ee5a | ||
|
|
99f58ae6d6 | ||
|
|
3f0e740f83 | ||
|
|
106961b513 | ||
|
|
d0001f96f0 | ||
|
|
527bd807b9 | ||
|
|
7546231762 | ||
|
|
a977dc843d | ||
|
|
6ad7f66af2 | ||
|
|
1b4fb6291d | ||
|
|
ee69465fe9 | ||
|
|
7b329ade32 | ||
|
|
44422b2151 | ||
|
|
48b338a5a9 | ||
|
|
d4f68475fd |
10
CHANGELOG.md
10
CHANGELOG.md
@@ -2,16 +2,6 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.6.20-alpha.1
|
||||
|
||||
### Changes
|
||||
|
||||
- Alpha nightly from current `main`, including unreleased agent, release, plugin, UI, mobile, and channel changes since 2026.6.9.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Release validation includes refreshed config documentation and plugin SDK metadata for the alpha package, and alpha packaging includes refreshed plugin version and shrinkwrap evidence for 2026.6.20-alpha.1.
|
||||
|
||||
## 2026.6.9
|
||||
|
||||
### Highlights
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
37742164ebf1765a735c4d56000a5ba18e817b6ac71782371c863a564cf6e7c5 config-baseline.json
|
||||
2923c1120c0369aeca6646cd67f7264590c6a1f4e5bc3157a04d7661324c6868 config-baseline.core.json
|
||||
ac06b6c20a93a8543ec1bd3748ef4f7bdae5006839dd93b3fff874d0da4244aa config-baseline.json
|
||||
e7965566fdaedef445bcd562141f4f3ea1a499cf8ea5956418af7c98049bf242 config-baseline.core.json
|
||||
2d735389858305509528e74329b6f8c65d311e1471c3b4e91dc17aaab8e63a80 config-baseline.channel.json
|
||||
0039da0cf2ba2845b37db52c4cf3a0f25e367cf3d2d507c5d6f8a5e5bdfdc4d4 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
d5a8dc906d615f081799783ffda46b48dac43c3de9ddcb7c4b95311031fbe80e plugin-sdk-api-baseline.json
|
||||
47d93e8b79e5d5fd0ef0a607a831a5b205c94e759a48401ce4a34da98e42b93d plugin-sdk-api-baseline.jsonl
|
||||
6f442c09ff2fa618f6f68cc866091a713d2c730090380dd726a9845f4d0fd9bd plugin-sdk-api-baseline.json
|
||||
d6b1929a42117759a3d0908fb68866e721ee7f0840279dce905a975b461c5b67 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -172,10 +172,12 @@ A finding includes:
|
||||
| `ocPath` | Precise `oc://` address when a check can point to one. |
|
||||
| `fixHint` | Suggested operator action or repair summary. |
|
||||
|
||||
This release registers the modernized core doctor checks on the structured
|
||||
health path. The `openclaw/plugin-sdk/health` subpath exposes the same
|
||||
contract for bundled follow-up consumers, but plugin-backed checks only run
|
||||
after their owning package registers them in the active command path.
|
||||
Modernized core doctor checks stay attached to the ordered doctor contribution
|
||||
that owns their human `doctor` / `doctor --fix` behavior. The shared structured
|
||||
health registry is the extension point: bundled and plugin-backed checks run
|
||||
after core doctor checks once their owning package registers them in the active
|
||||
command path. The `openclaw/plugin-sdk/health` subpath exposes the same
|
||||
contract for those extension consumers.
|
||||
|
||||
## Check Selection
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -79,9 +79,9 @@ Pin one model (or one provider) to the harness:
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "github-copilot/gpt-5.5",
|
||||
model: "github-copilot/auto",
|
||||
models: {
|
||||
"github-copilot/gpt-5.5": {
|
||||
"github-copilot/auto": {
|
||||
agentRuntime: { id: "copilot" },
|
||||
},
|
||||
},
|
||||
@@ -95,6 +95,10 @@ when only that model should be routed through the harness; set
|
||||
`agentRuntime.id` on a provider when every model under that provider should
|
||||
use it.
|
||||
|
||||
`github-copilot/auto` is the portable starting point. Named Copilot models are
|
||||
account- and organization-policy-dependent, so only pin one after confirming
|
||||
that the authenticated Copilot CLI exposes it.
|
||||
|
||||
## Supported providers
|
||||
|
||||
The harness advertises support for the canonical `github-copilot` provider
|
||||
@@ -169,8 +173,9 @@ The harness reads its config from per-attempt input
|
||||
- `infiniteSessionConfig` — optional override for the SDK
|
||||
`infiniteSessions` block driven by `harness.compact`. Defaults are safe to
|
||||
leave as-is.
|
||||
- `hooksConfig` — optional bridge config exposing OpenClaw
|
||||
before/after-message-write hooks to the SDK loop.
|
||||
- `hooksConfig` — optional native Copilot SDK `SessionHooks` compatibility
|
||||
config for tool/MCP, user-prompt, session, and error callbacks.
|
||||
It is separate from OpenClaw's portable lifecycle hooks.
|
||||
- `permissionPolicy` — optional override for the SDK's
|
||||
`onPermissionRequest` handler used for built-in SDK tool kinds
|
||||
(`shell`, `write`, `read`, `url`, `mcp`, `memory`, `hook`). Defaults
|
||||
@@ -181,6 +186,14 @@ The harness reads its config from per-attempt input
|
||||
wrapped `execute()`. See [Permissions and ask_user](#permissions-and-ask_user).
|
||||
- `enableSessionTelemetry` — optional SDK session telemetry flag.
|
||||
|
||||
OpenClaw plugin hooks do not need Copilot-specific attempt configuration. The
|
||||
harness runs `before_prompt_build` (and the legacy `before_agent_start`
|
||||
compatibility hook), `llm_input`, `llm_output`, and `agent_end` through the
|
||||
standard harness helpers. Successful SDK compactions also run
|
||||
`before_compaction` and `after_compaction`. Bridged OpenClaw tools continue to
|
||||
run `before_tool_call` and report `after_tool_call`; `hooksConfig` remains for
|
||||
native SDK-only callbacks that have no portable equivalent.
|
||||
|
||||
Nothing in the rest of OpenClaw needs to know about these fields. Other
|
||||
plugins, channels, and core code only see the standard
|
||||
`AgentHarnessAttemptParams` / `AgentHarnessAttemptResult` shape.
|
||||
|
||||
@@ -185,6 +185,17 @@ field; OpenClaw does not infer it from assistant prose. The helper intentionally
|
||||
leaves prompt errors, in-flight turns, and intentional silent replies such as
|
||||
`NO_REPLY` unclassified.
|
||||
|
||||
### Agent-end side effects
|
||||
|
||||
Native harnesses must call `runAgentEndSideEffects(...)` from
|
||||
`openclaw/plugin-sdk/agent-harness-runtime` after they finalize an attempt. It
|
||||
dispatches the portable `agent_end` hook and OpenClaw's research capture without
|
||||
delaying interactive replies. Use `awaitAgentEndSideEffects(...)` for local,
|
||||
non-interactive runs where the attempt must not resolve until those side effects
|
||||
finish. Both helpers accept the same `{ event, ctx }` payload as
|
||||
`runAgentHarnessAgentEndHook(...)`; their failures do not alter the completed
|
||||
attempt result.
|
||||
|
||||
### Native Codex harness mode
|
||||
|
||||
The bundled `codex` harness is the native Codex mode for embedded OpenClaw
|
||||
|
||||
@@ -166,7 +166,9 @@ two-party event loops that do not go through the shared inbound reply runner.
|
||||
|
||||
Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted.
|
||||
|
||||
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are kept only during the transition before SQLite migration for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers must migrate to entry helpers before the SQLite storage flip.
|
||||
For transcript reads and writes, import `openclaw/plugin-sdk/session-transcript-runtime` and use `resolveSessionTranscriptIdentity(...)`, `resolveSessionTranscriptTarget(...)`, `readSessionTranscriptEvents(...)`, `appendSessionTranscriptMessageByIdentity(...)`, `publishSessionTranscriptUpdateByIdentity(...)`, or `withSessionTranscriptWriteLock(...)` with `{ agentId, sessionKey, sessionId }`. These APIs let plugins identify a transcript, read its events, append messages, publish updates, and run related operations under the same transcript write lock. Pass `sessionFile` only when adapting code that already receives an active transcript artifact and needs each helper to operate on that same artifact.
|
||||
|
||||
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are compatibility helpers for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers should migrate to entry helpers.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="api.runtime.agent.defaults">
|
||||
|
||||
@@ -248,6 +248,7 @@ usage endpoint failed or returned no usable usage data.
|
||||
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
|
||||
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
|
||||
| `plugin-sdk/session-transcript-runtime` | Transcript identity, scoped target/read/write helpers, update publishing, write locks, and transcript memory hit keys |
|
||||
| `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers for first-party runtime |
|
||||
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
|
||||
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |
|
||||
|
||||
4
extensions/acpx/npm-shrinkwrap.json
generated
4
extensions/acpx/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "0.39.0",
|
||||
"@zed-industries/codex-acp": "0.15.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"description": "OpenClaw ACP runtime backend with plugin-owned session and transport management.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -26,10 +26,10 @@
|
||||
"minHostVersion": ">=2026.4.25"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.20-alpha.1"
|
||||
"pluginApi": ">=2026.6.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.20-alpha.1",
|
||||
"openclawVersion": "2026.6.8",
|
||||
"staticAssets": [
|
||||
{
|
||||
"source": "./src/runtime-internals/mcp-proxy.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/admin-http-rpc",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw admin HTTP RPC endpoint",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/alibaba-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Alibaba Model Studio video provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.100.1",
|
||||
"@aws/bedrock-token-generator": "1.1.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"description": "OpenClaw Amazon Bedrock Mantle provider plugin for OpenAI-compatible model routing.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -24,10 +24,10 @@
|
||||
"minHostVersion": ">=2026.5.12-beta.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.20-alpha.1"
|
||||
"pluginApi": ">=2026.6.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.20-alpha.1",
|
||||
"openclawVersion": "2026.6.8",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
4
extensions/amazon-bedrock/npm-shrinkwrap.json
generated
4
extensions/amazon-bedrock/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock": "3.1056.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1056.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"description": "OpenClaw Amazon Bedrock provider plugin with model discovery, embeddings, and guardrail support.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -28,10 +28,10 @@
|
||||
"minHostVersion": ">=2026.5.12-beta.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.20-alpha.1"
|
||||
"pluginApi": ">=2026.6.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.20-alpha.1",
|
||||
"openclawVersion": "2026.6.8",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
4
extensions/anthropic-vertex/npm-shrinkwrap.json
generated
4
extensions/anthropic-vertex/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/vertex-sdk": "0.16.1"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"description": "OpenClaw Anthropic Vertex provider plugin for Claude models on Google Vertex AI.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -23,10 +23,10 @@
|
||||
"minHostVersion": ">=2026.5.12-beta.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.20-alpha.1"
|
||||
"pluginApi": ">=2026.6.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.20-alpha.1",
|
||||
"openclawVersion": "2026.6.8",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Anthropic provider plugin",
|
||||
"type": "module",
|
||||
|
||||
4
extensions/arcee/npm-shrinkwrap.json
generated
4
extensions/arcee/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/arcee-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/arcee-provider",
|
||||
"version": "2026.6.20-alpha.1"
|
||||
"version": "2026.6.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/arcee-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"description": "OpenClaw Arcee provider plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.20-alpha.1"
|
||||
"pluginApi": ">=2026.6.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.20-alpha.1",
|
||||
"openclawVersion": "2026.6.8",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/azure-speech",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Azure Speech plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bonjour",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"description": "OpenClaw Bonjour/mDNS gateway discovery",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
4
extensions/brave/npm-shrinkwrap.json
generated
4
extensions/brave/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.6.20-alpha.1"
|
||||
"version": "2026.6.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"description": "OpenClaw Brave Search provider plugin for web search.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"allowInvalidConfigRecovery": true
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.20-alpha.1"
|
||||
"pluginApi": ">=2026.6.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.20-alpha.1"
|
||||
"openclawVersion": "2026.6.8"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/browser-plugin",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw browser tool plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/byteplus-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw BytePlus provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/canvas-plugin",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Canvas plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export interface PnpmRunnerParams {
|
||||
comSpec?: string;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
nodeArgs?: string[];
|
||||
nodeExecPath?: string;
|
||||
npmExecPath?: string;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Cross-platform pnpm command resolver used by Canvas build scripts.
|
||||
*/
|
||||
import { accessSync, closeSync, constants, openSync, readSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>%\r\n]/;
|
||||
const PNPM_EXECUTABLE_RE = /^pnpm(?:-cli)?(?:\.(?:[cm]?js|cmd|exe))?$/;
|
||||
@@ -48,13 +49,56 @@ function isExecutableFile(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function isFile(value) {
|
||||
try {
|
||||
return statSync(value).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePathEnvKey(env) {
|
||||
return Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH";
|
||||
}
|
||||
|
||||
function findExecutableOnPath(command, envPath, platform, env, cwd) {
|
||||
if (typeof envPath !== "string" || envPath.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const extensions =
|
||||
platform === "win32"
|
||||
? (env[Object.keys(env).find((key) => key.toLowerCase() === "pathext") ?? "PATHEXT"] ??
|
||||
".COM;.EXE;.BAT;.CMD")
|
||||
.split(";")
|
||||
.filter(Boolean)
|
||||
.map((extension) => extension.toLowerCase())
|
||||
: [""];
|
||||
const pathImpl = platform === "win32" ? path.win32 : path;
|
||||
const pathDelimiter = platform === "win32" ? ";" : path.delimiter;
|
||||
for (const directory of envPath.split(pathDelimiter)) {
|
||||
if (!directory) {
|
||||
continue;
|
||||
}
|
||||
const resolvedDirectory = pathImpl.isAbsolute(directory)
|
||||
? directory
|
||||
: pathImpl.resolve(cwd, directory);
|
||||
for (const extension of extensions) {
|
||||
const candidate = pathImpl.join(resolvedDirectory, `${command}${extension}`);
|
||||
if ((platform === "win32" ? isFile(candidate) : isExecutableFile(candidate))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isNodeRunnablePnpmExecPath(value) {
|
||||
if (!isPnpmExecPath(value)) {
|
||||
return false;
|
||||
}
|
||||
const { extension } = inspectExecutablePath(value);
|
||||
if (NODE_RUNNABLE_EXTENSIONS.has(extension)) {
|
||||
return true;
|
||||
return isFile(value);
|
||||
}
|
||||
if (extension.length > 0) {
|
||||
return false;
|
||||
@@ -129,6 +173,22 @@ export function resolvePnpmRunner(params = {}) {
|
||||
|
||||
const pnpmArgs = params.pnpmArgs ?? [];
|
||||
const platform = params.platform ?? process.platform;
|
||||
const env = params.env ?? process.env;
|
||||
const envPath = env[platform === "win32" ? resolvePathEnvKey(env) : "PATH"];
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const pnpmPath = findExecutableOnPath("pnpm", envPath, platform, env, cwd);
|
||||
if (pnpmPath) {
|
||||
return platform === "win32"
|
||||
? windowsCmdSpec(pnpmPath, pnpmArgs, params.comSpec ?? process.env.ComSpec ?? "cmd.exe")
|
||||
: { args: pnpmArgs, command: pnpmPath, shell: false };
|
||||
}
|
||||
const corepackPath = findExecutableOnPath("corepack", envPath, platform, env, cwd);
|
||||
if (corepackPath) {
|
||||
const args = ["pnpm", ...pnpmArgs];
|
||||
return platform === "win32"
|
||||
? windowsCmdSpec(corepackPath, args, params.comSpec ?? process.env.ComSpec ?? "cmd.exe")
|
||||
: { args, command: corepackPath, shell: false };
|
||||
}
|
||||
if (platform === "win32") {
|
||||
return windowsCmdSpec("pnpm.cmd", pnpmArgs, params.comSpec ?? process.env.ComSpec ?? "cmd.exe");
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ describe("canvas pnpm runner", () => {
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: "" },
|
||||
npmExecPath,
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
@@ -40,6 +41,7 @@ describe("canvas pnpm runner", () => {
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: "" },
|
||||
npmExecPath,
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
@@ -53,4 +55,79 @@ describe("canvas pnpm runner", () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
posixIt("uses Corepack when pnpm is not directly available on PATH", () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-corepack-"));
|
||||
const corepackPath = path.join(tempDir, "corepack");
|
||||
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(corepackPath, 0o755);
|
||||
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: tempDir },
|
||||
npmExecPath: "",
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
}),
|
||||
).toEqual({
|
||||
args: ["pnpm", "exec", "rolldown", "-c"],
|
||||
command: corepackPath,
|
||||
shell: false,
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
posixIt("ignores a missing pnpm JS npm_execpath before checking PATH", () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-missing-"));
|
||||
const corepackPath = path.join(tempDir, "corepack");
|
||||
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(corepackPath, 0o755);
|
||||
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: tempDir },
|
||||
npmExecPath: path.join(tempDir, "missing-pnpm.mjs"),
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
}),
|
||||
).toEqual({
|
||||
args: ["pnpm", "exec", "rolldown", "-c"],
|
||||
command: corepackPath,
|
||||
shell: false,
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
posixIt("prefers a direct pnpm executable over Corepack", () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "canvas-pnpm-runner-path-"));
|
||||
const pnpmPath = path.join(tempDir, "pnpm");
|
||||
const corepackPath = path.join(tempDir, "corepack");
|
||||
writeFileSync(pnpmPath, "#!/bin/sh\nexit 0\n");
|
||||
writeFileSync(corepackPath, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(pnpmPath, 0o755);
|
||||
chmodSync(corepackPath, 0o755);
|
||||
|
||||
try {
|
||||
expect(
|
||||
resolvePnpmRunner({
|
||||
env: { PATH: tempDir },
|
||||
npmExecPath: "",
|
||||
platform: "darwin",
|
||||
pnpmArgs: ["exec", "rolldown", "-c"],
|
||||
}),
|
||||
).toEqual({
|
||||
args: ["exec", "rolldown", "-c"],
|
||||
command: pnpmPath,
|
||||
shell: false,
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
4
extensions/cerebras/npm-shrinkwrap.json
generated
4
extensions/cerebras/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/cerebras-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/cerebras-provider",
|
||||
"version": "2026.6.20-alpha.1"
|
||||
"version": "2026.6.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cerebras-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"description": "OpenClaw Cerebras provider plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.20-alpha.1"
|
||||
"pluginApi": ">=2026.6.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.20-alpha.1",
|
||||
"openclawVersion": "2026.6.8",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
4
extensions/chutes/npm-shrinkwrap.json
generated
4
extensions/chutes/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/chutes-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/chutes-provider",
|
||||
"version": "2026.6.20-alpha.1"
|
||||
"version": "2026.6.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/chutes-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"description": "OpenClaw Chutes.ai provider plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.20-alpha.1"
|
||||
"pluginApi": ">=2026.6.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.20-alpha.1",
|
||||
"openclawVersion": "2026.6.8",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/clickclack",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw ClickClack channel plugin",
|
||||
"type": "module",
|
||||
@@ -18,7 +18,7 @@
|
||||
"openclaw": "2026.5.28"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.20-alpha.1"
|
||||
"openclaw": ">=2026.6.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.6.20-alpha.1"
|
||||
"version": "2026.6.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"description": "OpenClaw Cloudflare AI Gateway provider plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.20-alpha.1"
|
||||
"pluginApi": ">=2026.6.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.20-alpha.1",
|
||||
"openclawVersion": "2026.6.8",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/codex-supervisor",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"private": true,
|
||||
"description": "OpenClaw Codex app-server fleet supervision plugin.",
|
||||
"type": "module",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
|
||||
@@ -51,9 +48,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[];
|
||||
@@ -71,10 +66,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: [] };
|
||||
}
|
||||
@@ -121,3 +115,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,
|
||||
}));
|
||||
@@ -40,7 +60,6 @@ describe("codex plugin", () => {
|
||||
const registerProvider = vi.fn();
|
||||
const registerWebSearchProvider = vi.fn();
|
||||
const on = vi.fn();
|
||||
const onConversationBindingResolved = vi.fn();
|
||||
|
||||
plugin.register(
|
||||
createTestPluginApi({
|
||||
@@ -49,7 +68,7 @@ describe("codex plugin", () => {
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig: {},
|
||||
runtime: {} as never,
|
||||
runtime: createCodexTestRuntime(),
|
||||
registerAgentHarness,
|
||||
registerCommand,
|
||||
registerMediaUnderstandingProvider,
|
||||
@@ -57,7 +76,6 @@ describe("codex plugin", () => {
|
||||
registerProvider,
|
||||
registerWebSearchProvider,
|
||||
on,
|
||||
onConversationBindingResolved,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -67,9 +85,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");
|
||||
@@ -103,33 +118,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(
|
||||
@@ -150,8 +144,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);
|
||||
|
||||
@@ -160,6 +342,7 @@ describe("codex plugin", () => {
|
||||
expect(runCodexAppServerAttemptMock).toHaveBeenCalledWith(
|
||||
{ prompt: "hello" },
|
||||
{
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
pluginConfig: { appServer: {} },
|
||||
nativeHookRelay: { enabled: true },
|
||||
},
|
||||
@@ -194,11 +377,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(),
|
||||
@@ -218,14 +397,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);
|
||||
@@ -238,6 +452,7 @@ describe("codex plugin", () => {
|
||||
expect(runCodexAppServerSideQuestionMock).toHaveBeenCalledWith(
|
||||
{ question: "btw" },
|
||||
{
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
pluginConfig: { appServer: {} },
|
||||
nativeHookRelay: { enabled: true },
|
||||
},
|
||||
|
||||
@@ -4,48 +4,72 @@
|
||||
*/
|
||||
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";
|
||||
import { createCodexWebSearchProvider } from "./src/web-search-provider.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.registerWebSearchProvider(
|
||||
createCodexWebSearchProvider({ resolvePluginConfig: resolveCurrentPluginConfig }),
|
||||
@@ -59,43 +83,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>,
|
||||
});
|
||||
},
|
||||
@@ -105,17 +129,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);
|
||||
},
|
||||
});
|
||||
@@ -124,14 +143,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,42 +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,
|
||||
{ timeoutMs: 30_000 },
|
||||
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.apps": false,
|
||||
"features.code_mode": false,
|
||||
"features.code_mode_only": false,
|
||||
"features.image_generation": false,
|
||||
"features.multi_agent": false,
|
||||
"features.plugins": false,
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
},
|
||||
config: EXPECTED_MEDIA_THREAD_CONFIG,
|
||||
environments: [],
|
||||
dynamicTools: [],
|
||||
experimentalRawEvents: true,
|
||||
ephemeral: true,
|
||||
persistExtendedHistory: false,
|
||||
});
|
||||
expect(requests[2]?.params).toEqual({
|
||||
threadId: "thread-1",
|
||||
@@ -262,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",
|
||||
});
|
||||
});
|
||||
@@ -272,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"),
|
||||
@@ -286,11 +313,16 @@ describe("codex media understanding provider", () => {
|
||||
agentDir: " ",
|
||||
});
|
||||
|
||||
expect(clientFactory).toHaveBeenCalledWith(expect.any(Object), undefined, undefined, cfg, {
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
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("preserves configured WebSocket transport for media turns", async () => {
|
||||
@@ -370,7 +402,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?.({
|
||||
@@ -393,33 +425,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?.({
|
||||
@@ -438,7 +534,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(
|
||||
@@ -459,7 +555,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(
|
||||
@@ -476,12 +572,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?.({
|
||||
@@ -522,31 +713,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.apps": false,
|
||||
"features.code_mode": false,
|
||||
"features.code_mode_only": false,
|
||||
"features.image_generation": false,
|
||||
"features.multi_agent": false,
|
||||
"features.plugins": false,
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
},
|
||||
config: EXPECTED_MEDIA_THREAD_CONFIG,
|
||||
environments: [],
|
||||
dynamicTools: [],
|
||||
experimentalRawEvents: true,
|
||||
ephemeral: true,
|
||||
persistExtendedHistory: false,
|
||||
});
|
||||
const turnParams = requests[2]?.params as
|
||||
| {
|
||||
@@ -559,9 +740,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");
|
||||
@@ -584,7 +765,7 @@ describe("codex media understanding provider", () => {
|
||||
responseText: '{"summary":"only text"}',
|
||||
});
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientFactory: async () => client,
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -604,7 +785,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(
|
||||
@@ -633,7 +814,7 @@ describe("codex media understanding provider", () => {
|
||||
responseText: '{"summary":123,"tags":["shape"]}',
|
||||
});
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientFactory: async () => client,
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
|
||||
});
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -1,216 +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";
|
||||
/** 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 {
|
||||
runBoundedCodexAppServerTurn,
|
||||
type CodexBoundedTurnOptions,
|
||||
} from "./src/app-server/bounded-turn.js";
|
||||
import type { CodexUserInput } from "./src/app-server/protocol.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.";
|
||||
|
||||
export type CodexMediaUnderstandingProviderOptions = CodexBoundedTurnOptions;
|
||||
/** Dependencies and plugin config for Codex media-understanding calls. */
|
||||
export type CodexMediaUnderstandingProviderOptions = {
|
||||
pluginConfig?: unknown;
|
||||
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 runBoundedCodexAppServerTurn({
|
||||
config: req.cfg,
|
||||
model: { mode: "required", id: model },
|
||||
profile: req.profile,
|
||||
timeoutMs: req.timeoutMs,
|
||||
agentDir: req.agentDir,
|
||||
authProfileStore: req.authStore,
|
||||
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"],
|
||||
isolation: "configured-transport",
|
||||
});
|
||||
return { text, model };
|
||||
}
|
||||
|
||||
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 runBoundedCodexAppServerTurn({
|
||||
config: req.cfg,
|
||||
model: { mode: "required", id: model },
|
||||
profile: req.profile,
|
||||
timeoutMs: req.timeoutMs,
|
||||
agentDir: req.agentDir,
|
||||
authProfileStore: req.authStore,
|
||||
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(),
|
||||
isolation: "configured-transport",
|
||||
});
|
||||
return normalizeStructuredExtractionResult({ text, model, provider: req.provider, req });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
4
extensions/codex/npm-shrinkwrap.json
generated
4
extensions/codex/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"dependencies": {
|
||||
"@openai/codex": "0.139.0",
|
||||
"typebox": "1.1.39",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.6.20-alpha.1",
|
||||
"version": "2026.6.8",
|
||||
"description": "OpenClaw Codex app-server harness and model provider plugin with a Codex-managed GPT catalog.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -34,10 +34,10 @@
|
||||
]
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.20-alpha.1"
|
||||
"pluginApi": ">=2026.6.8"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.20-alpha.1"
|
||||
"openclawVersion": "2026.6.8"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -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,60 +2,124 @@
|
||||
* Best-effort cleanup helpers for Codex app-server startup attempts and turns.
|
||||
*/
|
||||
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import {
|
||||
clearSharedCodexAppServerClientIfCurrent,
|
||||
clearSharedCodexAppServerClientIfCurrentAndUnclaimed,
|
||||
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;
|
||||
|
||||
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;
|
||||
/** 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";
|
||||
}
|
||||
closeable.close?.();
|
||||
}
|
||||
|
||||
export async function closeCodexStartupClientBestEffort(
|
||||
client: CodexAppServerClient | undefined,
|
||||
): Promise<void> {
|
||||
if (!client) {
|
||||
return;
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
const unclaimedSharedClient = clearSharedCodexAppServerClientIfCurrentAndUnclaimed(client);
|
||||
if (unclaimedSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
if (unclaimedSharedClient.found) {
|
||||
const retired = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retired?.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
|
||||
/** 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();
|
||||
}
|
||||
return;
|
||||
throw error;
|
||||
}
|
||||
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retiredSharedClient) {
|
||||
if (retiredSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
return;
|
||||
params.onRetry?.();
|
||||
if (!(await params.waitForActiveTurnCompletion())) {
|
||||
throw error;
|
||||
}
|
||||
await params.afterActiveTurnCompletion?.();
|
||||
return await params.startTurn();
|
||||
}
|
||||
if (clearSharedCodexAppServerClientIfCurrent(client)) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
|
||||
/** 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 },
|
||||
);
|
||||
}
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
|
||||
/** Sends a turn interrupt without blocking abort cleanup on app-server errors. */
|
||||
@@ -84,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.
|
||||
@@ -116,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,
|
||||
@@ -129,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -586,6 +586,51 @@ export function prependCodexOpenClawPromptContext(
|
||||
return [context?.trim(), deliverySection, promptSection].filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the surviving user-request portion of an input range after delivery
|
||||
* metadata has been relocated before the request.
|
||||
*/
|
||||
export function resolveCodexDeliveryHintPreservedInputRange(params: {
|
||||
prompt: string;
|
||||
promptInputRange: { start: number; end: number } | undefined;
|
||||
decoratedPrompt: string;
|
||||
}): { start: number; end: number } | undefined {
|
||||
const { prompt, promptInputRange, decoratedPrompt } = params;
|
||||
const { deliveryHint, prompt: promptWithoutDeliveryHint } = splitLeadingCodexDeliveryHint(prompt);
|
||||
if (
|
||||
!deliveryHint ||
|
||||
!promptInputRange ||
|
||||
promptInputRange.start < 0 ||
|
||||
promptInputRange.end < promptInputRange.start ||
|
||||
promptInputRange.end > prompt.length ||
|
||||
!decoratedPrompt.endsWith(promptWithoutDeliveryHint)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const promptWithoutDeliveryHintStart = prompt.length - promptWithoutDeliveryHint.length;
|
||||
const inputStart = Math.max(promptInputRange.start, promptWithoutDeliveryHintStart);
|
||||
const inputEnd = Math.max(
|
||||
inputStart,
|
||||
Math.min(
|
||||
promptInputRange.end,
|
||||
promptWithoutDeliveryHint.length + promptWithoutDeliveryHintStart,
|
||||
),
|
||||
);
|
||||
const decoratedPromptSuffixStart = decoratedPrompt.length - promptWithoutDeliveryHint.length;
|
||||
const requestHeader = "Current user request:\n";
|
||||
const requestHeaderStart = decoratedPromptSuffixStart - requestHeader.length;
|
||||
// Delivery metadata moves outside the request, so retain the remaining input
|
||||
// span rather than treating the original, now non-contiguous range as valid.
|
||||
return {
|
||||
start:
|
||||
inputStart === promptWithoutDeliveryHintStart &&
|
||||
decoratedPrompt.slice(requestHeaderStart, decoratedPromptSuffixStart) === requestHeader
|
||||
? requestHeaderStart
|
||||
: decoratedPromptSuffixStart + inputStart - promptWithoutDeliveryHintStart,
|
||||
end: decoratedPromptSuffixStart + inputEnd - promptWithoutDeliveryHintStart,
|
||||
};
|
||||
}
|
||||
|
||||
function splitLeadingCodexDeliveryHint(prompt: string): {
|
||||
deliveryHint?: string;
|
||||
prompt: string;
|
||||
|
||||
@@ -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 },
|
||||
@@ -125,10 +125,11 @@ function startThreadWithHarness(
|
||||
sandboxExecServerEnabled: false,
|
||||
sandbox: null,
|
||||
contextEngineProjection: undefined,
|
||||
startupTokenGuard: {},
|
||||
startupTimeoutMs,
|
||||
signal,
|
||||
onStartupTimeout: vi.fn(),
|
||||
spawnedBy: undefined,
|
||||
onThreadReserved: overrides?.onThreadReserved,
|
||||
});
|
||||
|
||||
return { harness, run };
|
||||
@@ -170,12 +171,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) {
|
||||
@@ -184,7 +186,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);
|
||||
@@ -194,25 +196,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,
|
||||
@@ -228,17 +262,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 () => {
|
||||
@@ -260,18 +293,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,
|
||||
@@ -282,11 +317,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 () => {
|
||||
@@ -311,45 +344,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,10 +11,15 @@ import {
|
||||
type resolveSandboxContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
|
||||
import { closeCodexStartupClientBestEffort } from "./attempt-client-cleanup.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 {
|
||||
@@ -52,16 +57,23 @@ import {
|
||||
releaseCodexSandboxExecServerEnvironment,
|
||||
type CodexSandboxExecEnvironment,
|
||||
} from "./sandbox-exec-server.js";
|
||||
import type { CodexAppServerBindingStore } from "./session-binding.js";
|
||||
import {
|
||||
clearSharedCodexAppServerClientIfCurrent,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
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 type { CodexNativeWebSearchSupport } from "./web-search.js";
|
||||
import {
|
||||
getCodexAppServerTurnRouter,
|
||||
type CodexAppServerTurnRouter,
|
||||
type CodexThreadRouteReservation,
|
||||
} from "./turn-router.js";
|
||||
|
||||
const CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS = 3;
|
||||
|
||||
@@ -69,14 +81,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>;
|
||||
};
|
||||
|
||||
@@ -85,7 +98,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;
|
||||
@@ -111,18 +125,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({
|
||||
@@ -133,10 +155,7 @@ export async function startCodexAttemptThread(params: {
|
||||
startupAbandonController.abort();
|
||||
await params.onStartupTimeout();
|
||||
await releaseStartupResourcesOnTimeout?.();
|
||||
releaseSharedClientLease?.();
|
||||
releaseSharedClientLease = undefined;
|
||||
await closeCodexStartupClientBestEffort(startupClientForAbandonedRequestCleanup);
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
await abandonStartupClient();
|
||||
},
|
||||
operation: async () => {
|
||||
const threadConfig = mergeCodexThreadConfigs(
|
||||
@@ -153,8 +172,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
|
||||
@@ -162,55 +182,48 @@ export async function startCodexAttemptThread(params: {
|
||||
.map((plugin) => plugin.configKey)
|
||||
.toSorted()
|
||||
: undefined;
|
||||
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 closeCodexStartupClientBestEffort(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,
|
||||
@@ -277,7 +290,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 (
|
||||
@@ -308,9 +320,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,
|
||||
@@ -332,7 +392,13 @@ export async function startCodexAttemptThread(params: {
|
||||
environmentSelection: startupEnvironmentSelection,
|
||||
appServerRuntimeFingerprint,
|
||||
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,
|
||||
@@ -356,57 +422,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 closeCodexStartupClientBestEffort(startupClient);
|
||||
} else if (
|
||||
shouldClearSharedClientAfterStartupRace(startupAttemptError) ||
|
||||
shouldClearSharedClientAfterStartupFailure({
|
||||
error: startupAttemptError,
|
||||
spawnedBy: params.spawnedBy,
|
||||
})
|
||||
) {
|
||||
if (startupClientForAbandonedRequestCleanup === startupClient) {
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
}
|
||||
await closeCodexStartupClientBestEffort(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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -421,18 +495,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),
|
||||
},
|
||||
);
|
||||
@@ -444,7 +513,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),
|
||||
},
|
||||
);
|
||||
@@ -453,32 +522,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 closeCodexStartupClientBestEffort(startupClientForAbandonedRequestCleanup);
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
} else if (
|
||||
shouldClearSharedClientAfterStartupRace(error) ||
|
||||
shouldClearSharedClientAfterStartupFailure({
|
||||
error,
|
||||
spawnedBy: params.spawnedBy,
|
||||
})
|
||||
) {
|
||||
releaseSharedClientLease?.();
|
||||
releaseSharedClientLease = undefined;
|
||||
await closeCodexStartupClientBestEffort(startupClientForAbandonedRequestCleanup);
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
const shouldAbandonStartupClient =
|
||||
params.signal.aborted || isIndeterminateCodexStartupFailure(error);
|
||||
if (shouldAbandonStartupClient) {
|
||||
await abandonStartupClient();
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -486,30 +544,13 @@ export async function startCodexAttemptThread(params: {
|
||||
}
|
||||
}
|
||||
|
||||
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,40 +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 as writeRawCodexAppServerBinding,
|
||||
} from "./session-binding.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
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,
|
||||
@@ -148,7 +164,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);
|
||||
@@ -164,13 +181,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,
|
||||
@@ -196,6 +222,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-"));
|
||||
});
|
||||
@@ -231,6 +258,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,
|
||||
@@ -238,7 +266,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(
|
||||
@@ -256,13 +283,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);
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
|
||||
import { readCodexNotificationItem } from "./attempt-notifications.js";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
import { readModelListResult } from "./models.js";
|
||||
@@ -27,6 +26,10 @@ import {
|
||||
type JsonObject,
|
||||
type JsonValue,
|
||||
} from "./protocol.js";
|
||||
import type {
|
||||
CodexAppServerClientLease,
|
||||
CodexAppServerClientLeaseFactory,
|
||||
} from "./shared-client.js";
|
||||
import { buildCodexRuntimeThreadConfig } from "./thread-lifecycle.js";
|
||||
|
||||
const CODEX_PRIVATE_STDIO_ARGS = ["app-server", "--listen", "stdio://"];
|
||||
@@ -46,7 +49,7 @@ const CODEX_PRIVATE_BOUNDED_THREAD_CONFIG: JsonObject = {
|
||||
|
||||
export type CodexBoundedTurnOptions = {
|
||||
pluginConfig?: unknown;
|
||||
clientFactory?: CodexAppServerClientFactory;
|
||||
clientFactory?: CodexAppServerClientLeaseFactory;
|
||||
};
|
||||
|
||||
export type CodexBoundedTurnResult = {
|
||||
@@ -118,11 +121,17 @@ async function runBoundedCodexAppServerTurnInWorkspace(
|
||||
const startOptions = workspace.codexHome
|
||||
? buildPrivateCodexAppServerStartOptions(appServer.start, workspace.codexHome)
|
||||
: appServer.start;
|
||||
const ownsClient = !params.options.clientFactory;
|
||||
let lease: CodexAppServerClientLease | undefined;
|
||||
const client = params.options.clientFactory
|
||||
? await params.options.clientFactory(startOptions, params.profile, agentDir, params.config, {
|
||||
? ((lease = await params.options.clientFactory({
|
||||
startOptions,
|
||||
timeoutMs,
|
||||
})
|
||||
authProfileId: params.profile,
|
||||
agentDir,
|
||||
authProfileStore: params.authProfileStore,
|
||||
config: params.config,
|
||||
})),
|
||||
lease.client)
|
||||
: await import("./shared-client.js").then(({ createIsolatedCodexAppServerClient }) =>
|
||||
createIsolatedCodexAppServerClient({
|
||||
startOptions,
|
||||
@@ -208,7 +217,9 @@ async function runBoundedCodexAppServerTurnInWorkspace(
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
params.signal?.removeEventListener("abort", abortFromCaller);
|
||||
if (ownsClient) {
|
||||
if (lease) {
|
||||
lease.release();
|
||||
} else {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +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;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
) => Promise<CodexAppServerClient>;
|
||||
|
||||
let sharedClientModulePromise: Promise<typeof import("./shared-client.js")> | null = null;
|
||||
|
||||
const loadSharedClientModule = async () => {
|
||||
sharedClientModulePromise ??= import("./shared-client.js");
|
||||
return await sharedClientModulePromise;
|
||||
};
|
||||
|
||||
/** 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,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
}),
|
||||
);
|
||||
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;
|
||||
|
||||
@@ -120,7 +123,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;
|
||||
@@ -241,11 +247,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);
|
||||
@@ -254,23 +276,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);
|
||||
}
|
||||
@@ -278,6 +314,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) => {
|
||||
@@ -291,6 +333,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)));
|
||||
@@ -315,18 +358,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);
|
||||
@@ -445,6 +476,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;
|
||||
@@ -522,7 +562,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 });
|
||||
});
|
||||
}
|
||||
@@ -540,11 +587,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();
|
||||
@@ -557,6 +647,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 {
|
||||
@@ -571,6 +672,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"
|
||||
@@ -586,6 +690,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 {
|
||||
@@ -200,7 +202,7 @@ describe("Codex app-server config", () => {
|
||||
},
|
||||
unix_sockets: {
|
||||
"/tmp/mock-proxy.sock": "allow",
|
||||
"/tmp/blocked.sock": "none",
|
||||
"/tmp/blocked.sock": "deny",
|
||||
},
|
||||
proxy_url: "http://127.0.0.1:3128",
|
||||
socks_url: "socks5h://127.0.0.1:8081",
|
||||
@@ -558,7 +560,6 @@ describe("Codex app-server config", () => {
|
||||
const switchedLocalModel = resolveCodexModelBackedReviewerPolicyContext({
|
||||
model: "lmstudio/local-model",
|
||||
bindingModel: "gpt-5.5",
|
||||
nativeAuthProfile: true,
|
||||
});
|
||||
expect(switchedLocalModel).toEqual({
|
||||
modelProvider: "lmstudio",
|
||||
@@ -745,6 +746,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: {
|
||||
@@ -942,8 +976,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';
|
||||
},
|
||||
});
|
||||
@@ -963,8 +997,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';
|
||||
},
|
||||
});
|
||||
|
||||
@@ -192,6 +192,11 @@ export type CodexAppServerRuntimeOptions = {
|
||||
networkProxy?: ResolvedCodexAppServerNetworkProxyConfig;
|
||||
};
|
||||
|
||||
export type CodexAppServerRuntimeResolution = {
|
||||
appServer: CodexAppServerRuntimeOptions;
|
||||
modelBackedReviewerAvailable: boolean;
|
||||
};
|
||||
|
||||
export type CodexModelBackedReviewerContext = {
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
@@ -332,7 +337,9 @@ const codexAppServerNetworkProxySchema = z
|
||||
baseProfile: z.enum(["read-only", "workspace"]).optional(),
|
||||
mode: z.enum(["limited", "full"]).optional(),
|
||||
domains: z.record(z.string(), codexAppServerNetworkProxyDomainPermissionSchema).optional(),
|
||||
unixSockets: z.record(z.string(), codexAppServerNetworkProxyUnixSocketPermissionSchema).optional(),
|
||||
unixSockets: z
|
||||
.record(z.string(), codexAppServerNetworkProxyUnixSocketPermissionSchema)
|
||||
.optional(),
|
||||
proxyUrl: z.string().trim().min(1).optional(),
|
||||
socksUrl: z.string().trim().min(1).optional(),
|
||||
enableSocks5: z.boolean().optional(),
|
||||
@@ -501,25 +508,34 @@ function resolveCodexPluginDestructivePolicy(policy: CodexPluginDestructivePolic
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -659,43 +675,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 } : {}),
|
||||
},
|
||||
connectionClass,
|
||||
remoteAppsSubstrate,
|
||||
...(remoteWorkspaceRoot ? { remoteWorkspaceRoot } : {}),
|
||||
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: resolvedSandbox,
|
||||
approvalsReviewer:
|
||||
forcedPolicy?.approvalsReviewer ??
|
||||
explicitApprovalsReviewer ??
|
||||
defaultPolicy?.approvalsReviewer ??
|
||||
(policyMode === "guardian" ? "auto_review" : "user"),
|
||||
...resolveCodexAppServerNetworkProxy(config.networkProxy, resolvedSandbox),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
},
|
||||
connectionClass,
|
||||
remoteAppsSubstrate,
|
||||
...(remoteWorkspaceRoot ? { remoteWorkspaceRoot } : {}),
|
||||
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: resolvedSandbox,
|
||||
approvalsReviewer:
|
||||
forcedPolicy?.approvalsReviewer ??
|
||||
explicitApprovalsReviewer ??
|
||||
defaultPolicy?.approvalsReviewer ??
|
||||
(policyMode === "guardian" ? "auto_review" : "user"),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
...resolveCodexAppServerNetworkProxy(config.networkProxy, resolvedSandbox),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -767,7 +786,6 @@ export function resolveCodexModelBackedReviewerPolicyContext(params: {
|
||||
model?: string;
|
||||
bindingModelProvider?: string;
|
||||
bindingModel?: string;
|
||||
nativeAuthProfile?: boolean;
|
||||
}): CodexModelBackedReviewerContext {
|
||||
const provider = params.provider?.trim();
|
||||
if (provider && provider.toLowerCase() !== "codex") {
|
||||
@@ -799,7 +817,7 @@ export function resolveCodexModelBackedReviewerPolicyContext(params: {
|
||||
};
|
||||
}
|
||||
return {
|
||||
modelProvider: params.nativeAuthProfile === true ? "openai" : undefined,
|
||||
modelProvider: undefined,
|
||||
model: params.model ?? params.bindingModel,
|
||||
};
|
||||
}
|
||||
@@ -866,6 +884,7 @@ export function codexAppServerStartOptionsKey(
|
||||
options: CodexAppServerStartOptions,
|
||||
params: {
|
||||
authProfileId?: string;
|
||||
authAccountCacheKey?: string;
|
||||
agentDir?: string;
|
||||
fallbackApiKeyCacheKey?: string;
|
||||
} = {},
|
||||
@@ -885,6 +904,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,
|
||||
});
|
||||
@@ -924,7 +944,7 @@ function resolveCodexAppServerNetworkProxy(
|
||||
enabled: true,
|
||||
mode: config.mode,
|
||||
domains: normalizeNetworkProxyPermissionMap(config.domains),
|
||||
unix_sockets: normalizeNetworkProxyPermissionMap(config.unixSockets),
|
||||
unix_sockets: normalizeNetworkProxyUnixSocketPermissionMap(config.unixSockets),
|
||||
proxy_url: readNonEmptyString(config.proxyUrl),
|
||||
socks_url: readNonEmptyString(config.socksUrl),
|
||||
enable_socks5: config.enableSocks5,
|
||||
@@ -979,6 +999,20 @@ export function fingerprintCodexAppServerNetworkProxyConfigPatch(configPatch: Js
|
||||
return createHash("sha256").update(stableStringifyJson(configPatch)).digest("hex");
|
||||
}
|
||||
|
||||
function normalizeNetworkProxyUnixSocketPermissionMap(
|
||||
value: Record<string, CodexAppServerNetworkProxyUnixSocketPermission> | undefined,
|
||||
): Record<string, "allow" | "deny"> | undefined {
|
||||
const normalized = normalizeNetworkProxyPermissionMap(value);
|
||||
return normalized
|
||||
? Object.fromEntries(
|
||||
Object.entries(normalized).map(([socketPath, permission]) => [
|
||||
socketPath,
|
||||
permission === "none" ? "deny" : permission,
|
||||
]),
|
||||
)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeNetworkProxyPermissionMap<TPermission extends string>(
|
||||
value: Record<string, TPermission> | undefined,
|
||||
): Record<string, TPermission> | undefined {
|
||||
|
||||
@@ -249,10 +249,64 @@ describe("projectContextEngineAssemblyForCodex", () => {
|
||||
// The user's actual request is the priority tail and must survive truncation.
|
||||
expect(fitted).toContain("Current user request:");
|
||||
expect(fitted.endsWith("q".repeat(40))).toBe(true);
|
||||
// The dropped older context is reported, not silently lost.
|
||||
// Current context still survives even when an earlier projection is dropped.
|
||||
expect(fitted).toContain("older context");
|
||||
// The dropped older content is reported, not silently lost.
|
||||
expect(fitted).toContain("[truncated ");
|
||||
});
|
||||
|
||||
it("keeps the current request and fitting hook context after projecting history", () => {
|
||||
const before = "OpenClaw assembled context for this turn:\n<conversation_context>\n";
|
||||
const context = `recent context ${"c".repeat(800)}`;
|
||||
const request = "\n</conversation_context>\n\nCurrent user request:\nkeep this request";
|
||||
const hookAppend = "\n\nhook context survives";
|
||||
const promptText = `${before}${context}${request}${hookAppend}`;
|
||||
const maxChars = 420;
|
||||
|
||||
const fitted = fitCodexProjectedContextForTurnStart({
|
||||
promptText,
|
||||
contextRange: { start: before.length, end: before.length + context.length },
|
||||
requestRange: {
|
||||
start: before.length + context.length,
|
||||
end: before.length + context.length + request.length,
|
||||
},
|
||||
maxChars,
|
||||
});
|
||||
|
||||
expect(fitted.length).toBeLessThanOrEqual(maxChars);
|
||||
expect(fitted).toContain("[truncated ");
|
||||
expect(fitted).toContain("Current user request:\nkeep this request");
|
||||
expect(fitted).toContain("hook context survives");
|
||||
});
|
||||
|
||||
it("keeps the original input when a hook appends context without a projection", () => {
|
||||
const prompt = "current prompt survives";
|
||||
const hookAppend = `\n\nhook context ${"h".repeat(800)}`;
|
||||
const maxChars = 420;
|
||||
|
||||
const fitted = fitCodexProjectedContextForTurnStart({
|
||||
promptText: `${prompt}${hookAppend}`,
|
||||
preservedRange: { start: 0, end: prompt.length },
|
||||
maxChars,
|
||||
});
|
||||
|
||||
expect(fitted.length).toBeLessThanOrEqual(maxChars);
|
||||
expect(fitted).toContain(prompt);
|
||||
expect(fitted).not.toContain("hook context");
|
||||
});
|
||||
|
||||
it("bounds hook output for an empty original input", () => {
|
||||
const maxChars = 420;
|
||||
const fitted = fitCodexProjectedContextForTurnStart({
|
||||
promptText: `hook context ${"h".repeat(800)} hook tail`,
|
||||
preservedRange: { start: 0, end: 0 },
|
||||
maxChars,
|
||||
});
|
||||
|
||||
expect(fitted.length).toBeLessThanOrEqual(maxChars);
|
||||
expect(fitted).toContain("hook tail");
|
||||
});
|
||||
|
||||
it("bounds output for a large request under the default Codex turn limit", () => {
|
||||
const maxChars = CODEX_TURN_START_TEXT_INPUT_MAX_CHARS;
|
||||
// A large assembled header prefix already over the cap forces the
|
||||
|
||||
@@ -121,6 +121,8 @@ export function resolveCodexContextEngineProjectionReserveTokens(params: {
|
||||
export function fitCodexProjectedContextForTurnStart(params: {
|
||||
promptText: string;
|
||||
contextRange?: CodexProjectedContextRange;
|
||||
requestRange?: CodexProjectedContextRange;
|
||||
preservedRange?: CodexProjectedContextRange;
|
||||
maxChars?: number;
|
||||
}): string {
|
||||
const maxChars =
|
||||
@@ -132,23 +134,63 @@ export function fitCodexProjectedContextForTurnStart(params: {
|
||||
}
|
||||
const range = normalizeProjectedContextRange(params.contextRange, params.promptText.length);
|
||||
if (!range) {
|
||||
return params.promptText;
|
||||
const preservedRange = normalizeProjectedContextRange(
|
||||
params.preservedRange,
|
||||
params.promptText.length,
|
||||
);
|
||||
if (!preservedRange) {
|
||||
return params.promptText;
|
||||
}
|
||||
const preservedText = params.promptText.slice(preservedRange.start, preservedRange.end);
|
||||
if (!preservedText) {
|
||||
return truncateOlderContext(params.promptText, maxChars);
|
||||
}
|
||||
if (preservedText.length >= maxChars) {
|
||||
return truncateOlderContext(preservedText, maxChars);
|
||||
}
|
||||
const beforeRange = params.promptText.slice(0, preservedRange.start);
|
||||
return `${truncateOlderContext(beforeRange, maxChars - preservedText.length)}${preservedText}`;
|
||||
}
|
||||
|
||||
const beforeContext = params.promptText.slice(0, range.start);
|
||||
const context = params.promptText.slice(range.start, range.end);
|
||||
const afterContext = params.promptText.slice(range.end);
|
||||
const requestRange = normalizeProjectedContextRange(
|
||||
params.requestRange,
|
||||
params.promptText.length,
|
||||
);
|
||||
if (
|
||||
requestRange &&
|
||||
requestRange.start >= range.end &&
|
||||
requestRange.end < params.promptText.length
|
||||
) {
|
||||
const request = params.promptText.slice(requestRange.start, requestRange.end);
|
||||
if (request.length >= maxChars) {
|
||||
return truncateOlderContext(request, maxChars);
|
||||
}
|
||||
const appendedContext = params.promptText.slice(requestRange.end);
|
||||
// Hook-appended context is newer than the projected history. Retain it
|
||||
// before trimming the projection, while the full current request remains
|
||||
// the hard boundary that must survive a bounded turn/start input.
|
||||
const fittedAppendedContext = truncateOlderContext(appendedContext, maxChars - request.length);
|
||||
const contextBudget = maxChars - request.length - fittedAppendedContext.length;
|
||||
const fittedContext = truncateOlderContext(context, contextBudget);
|
||||
const beforeContextBudget =
|
||||
maxChars - fittedContext.length - request.length - fittedAppendedContext.length;
|
||||
return `${truncateOlderContext(beforeContext, beforeContextBudget)}${fittedContext}${request}${fittedAppendedContext}`;
|
||||
}
|
||||
const contextBudget = maxChars - beforeContext.length - afterContext.length;
|
||||
if (contextBudget > 0) {
|
||||
const fittedContext = truncateOlderContext(context, contextBudget);
|
||||
return `${beforeContext}${fittedContext}${afterContext}`;
|
||||
}
|
||||
// The header plus the trailing user request already fill the limit, so the
|
||||
// older context drops entirely and the remaining text must still be bounded;
|
||||
// otherwise Codex app-server rejects the turn for exceeding
|
||||
// MAX_USER_INPUT_TEXT_CHARS. truncateOlderContext keeps the tail, preserving
|
||||
// the user's actual request over the older header text.
|
||||
return truncateOlderContext(`${beforeContext}${afterContext}`, maxChars);
|
||||
// Hook-added prefixes can make the non-context text exceed the limit. Keep
|
||||
// the current context tail before the user's request; dropping it would make
|
||||
// a duplicated earlier projection crowd out the newest assembled context.
|
||||
const afterContextText = truncateOlderContext(afterContext, maxChars);
|
||||
const contextBudgetAfterRequest = maxChars - afterContextText.length;
|
||||
const fittedContext = truncateOlderContext(context, contextBudgetAfterRequest);
|
||||
return `${fittedContext}${afterContextText}`;
|
||||
}
|
||||
|
||||
function normalizeProjectedContextRange(
|
||||
|
||||
@@ -11,11 +11,10 @@ import {
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addSandboxShellDynamicToolsIfAvailable,
|
||||
buildDynamicTools,
|
||||
filterCodexDynamicToolsForAllowlist,
|
||||
hasWildcardCodexToolsAllow,
|
||||
includeForcedCodexDynamicToolAllow,
|
||||
mapCodexAppServerRemoteWorkspacePath,
|
||||
prepareDynamicToolCatalog,
|
||||
resetOpenClawCodingToolsFactoryForTests,
|
||||
resolveCodexAppServerExecutionCwd,
|
||||
resolveOpenClawCodingToolsSessionKeys,
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
setOpenClawCodingToolsFactoryForTests,
|
||||
shouldEnableCodexAppServerNativeToolSurface,
|
||||
shouldForceMessageTool,
|
||||
type OpenClawCodingToolsFactory,
|
||||
} from "./dynamic-tool-build.js";
|
||||
import {
|
||||
filterCodexDynamicTools,
|
||||
@@ -106,13 +106,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,
|
||||
@@ -125,6 +125,7 @@ async function buildDynamicToolsForTest(
|
||||
onYieldDetected: () => undefined,
|
||||
...options,
|
||||
});
|
||||
return catalog.tools;
|
||||
}
|
||||
|
||||
describe("Codex app-server dynamic tool build", () => {
|
||||
@@ -227,197 +228,51 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes managed web_search when domain-restricted Codex hosted search is active", async () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.config = {
|
||||
tools: {
|
||||
web: {
|
||||
search: { openaiCodex: { allowedDomains: ["example.com"] } },
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
setOpenClawCodingToolsFactoryForTests(() => [
|
||||
createRuntimeDynamicTool("web_search"),
|
||||
createRuntimeDynamicTool("message"),
|
||||
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] : []),
|
||||
]);
|
||||
let webSearchAllowed = false;
|
||||
|
||||
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
|
||||
onWebSearchPolicyResolved: (allowed) => {
|
||||
webSearchAllowed = allowed;
|
||||
},
|
||||
});
|
||||
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(webSearchAllowed).toBe(true);
|
||||
});
|
||||
|
||||
it("reports hosted search denied when effective tool policy removes web_search", async () => {
|
||||
setOpenClawCodingToolsFactoryForTests(factory);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
|
||||
let webSearchAllowed = true;
|
||||
|
||||
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
|
||||
onWebSearchPolicyResolved: (allowed) => {
|
||||
webSearchAllowed = allowed;
|
||||
},
|
||||
});
|
||||
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(webSearchAllowed).toBe(false);
|
||||
});
|
||||
|
||||
it("separates persistent search policy from a runtime toolsAllow restriction", async () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.toolsAllow = ["message"];
|
||||
setOpenClawCodingToolsFactoryForTests(() => [
|
||||
createRuntimeDynamicTool("web_search"),
|
||||
createRuntimeDynamicTool("message"),
|
||||
]);
|
||||
let persistentWebSearchAllowed = false;
|
||||
let webSearchAllowed = true;
|
||||
|
||||
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
|
||||
onPersistentWebSearchPolicyResolved: (allowed) => {
|
||||
persistentWebSearchAllowed = allowed;
|
||||
},
|
||||
onWebSearchPolicyResolved: (allowed) => {
|
||||
webSearchAllowed = allowed;
|
||||
},
|
||||
});
|
||||
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(persistentWebSearchAllowed).toBe(true);
|
||||
expect(webSearchAllowed).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps persistent search denied when runtime toolsAllow also excludes it", async () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.toolsAllow = ["message"];
|
||||
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
|
||||
let persistentWebSearchAllowed = true;
|
||||
let webSearchAllowed = true;
|
||||
|
||||
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
|
||||
onPersistentWebSearchPolicyResolved: (allowed) => {
|
||||
persistentWebSearchAllowed = allowed;
|
||||
},
|
||||
onWebSearchPolicyResolved: (allowed) => {
|
||||
webSearchAllowed = allowed;
|
||||
},
|
||||
});
|
||||
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(persistentWebSearchAllowed).toBe(false);
|
||||
expect(webSearchAllowed).toBe(false);
|
||||
});
|
||||
|
||||
it("treats sender-scoped web_search denial as transient", async () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.senderId = "restricted-sender";
|
||||
params.config = {
|
||||
const runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.runtimePlan = {
|
||||
...runtimePlan,
|
||||
tools: {
|
||||
toolsBySender: {
|
||||
"id:restricted-sender": { deny: ["web_search"] },
|
||||
},
|
||||
normalize: (tools: Array<{ name: string }>) =>
|
||||
tools.filter((tool) => tool.name === "message"),
|
||||
logDiagnostics: () => undefined,
|
||||
},
|
||||
} as never;
|
||||
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
|
||||
let persistentWebSearchAllowed = false;
|
||||
let webSearchAllowed = true;
|
||||
} as unknown as NonNullable<EmbeddedRunAttemptParams["runtimePlan"]>;
|
||||
|
||||
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
|
||||
onPersistentWebSearchPolicyResolved: (allowed) => {
|
||||
persistentWebSearchAllowed = allowed;
|
||||
},
|
||||
onWebSearchPolicyResolved: (allowed) => {
|
||||
webSearchAllowed = allowed;
|
||||
},
|
||||
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(tools.map((tool) => tool.name)).toEqual(["message"]);
|
||||
expect(persistentWebSearchAllowed).toBe(true);
|
||||
expect(webSearchAllowed).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps persistent search denied when global and sender policy both deny it", async () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.senderId = "restricted-sender";
|
||||
params.config = {
|
||||
tools: {
|
||||
deny: ["web_search"],
|
||||
toolsBySender: {
|
||||
"id:restricted-sender": { deny: ["web_search"] },
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
|
||||
let persistentWebSearchAllowed = true;
|
||||
|
||||
await buildDynamicToolsForTest(params, workspaceDir, {
|
||||
onPersistentWebSearchPolicyResolved: (allowed) => {
|
||||
persistentWebSearchAllowed = allowed;
|
||||
},
|
||||
});
|
||||
|
||||
expect(persistentWebSearchAllowed).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps managed web_search when a managed provider is explicitly selected", async () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.config = {
|
||||
tools: {
|
||||
web: {
|
||||
search: { provider: "brave" },
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
setOpenClawCodingToolsFactoryForTests(() => [
|
||||
createRuntimeDynamicTool("web_search"),
|
||||
createRuntimeDynamicTool("message"),
|
||||
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",
|
||||
]);
|
||||
|
||||
const tools = await buildDynamicToolsForTest(params, workspaceDir);
|
||||
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["web_search", "message"]);
|
||||
});
|
||||
|
||||
it("keeps managed web_search when the active Codex provider lacks hosted search", async () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
setOpenClawCodingToolsFactoryForTests(() => [
|
||||
createRuntimeDynamicTool("web_search"),
|
||||
createRuntimeDynamicTool("message"),
|
||||
]);
|
||||
|
||||
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
|
||||
nativeProviderWebSearchSupport: "unsupported",
|
||||
});
|
||||
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["web_search", "message"]);
|
||||
});
|
||||
|
||||
it("applies additional Codex dynamic tool excludes without exposing Codex-native tools", () => {
|
||||
|
||||
@@ -46,6 +46,9 @@ type OpenClawExecOptions = NonNullable<OpenClawCodingToolsOptions["exec"]>;
|
||||
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"]>
|
||||
@@ -60,9 +63,7 @@ const CODEX_NATIVE_SANDBOX_TOOL_REQUIREMENTS = [
|
||||
"apply_patch",
|
||||
] as const;
|
||||
const CODEX_MEMORY_FLUSH_DYNAMIC_TOOL_ALLOW = new Set(["read", "write"]);
|
||||
const CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME = "node_exec";
|
||||
const CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME = "node_process";
|
||||
const CODEX_NODE_EXEC_HIDDEN_PARAMETER_NAMES = new Set(["host", "security", "ask", "node"]);
|
||||
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 = {
|
||||
@@ -78,9 +79,6 @@ export type DynamicToolBuildParams = {
|
||||
sessionAgentId: string;
|
||||
pluginConfig: CodexPluginConfig;
|
||||
profilerEnabled?: boolean;
|
||||
forceHeartbeatTool?: boolean;
|
||||
ignoreDisableMessageTool?: boolean;
|
||||
ignoreRuntimePlan?: boolean;
|
||||
onYieldDetected: () => void;
|
||||
onCodexAppServerEvent?: (event: CodexDynamicToolBuildEvent) => void;
|
||||
onPersistentWebSearchPolicyResolved?: (allowed: boolean) => void;
|
||||
@@ -143,6 +141,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;
|
||||
|
||||
@@ -204,26 +207,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;
|
||||
const messagePolicyParams = input.ignoreDisableMessageTool
|
||||
? { ...params, disableMessageTool: false }
|
||||
: params;
|
||||
if (params.disableTools) {
|
||||
input.onWebSearchPolicyResolved?.(false);
|
||||
return [];
|
||||
if (params.disableTools || !supportsModelTools(params.model)) {
|
||||
return { tools: [], registeredTools: [] };
|
||||
}
|
||||
if (!supportsModelTools(params.model)) {
|
||||
input.onPersistentWebSearchPolicyResolved?.(false);
|
||||
input.onWebSearchPolicyResolved?.(false);
|
||||
return [];
|
||||
}
|
||||
// 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 agentHarness = await import("openclaw/plugin-sdk/agent-harness");
|
||||
@@ -302,10 +321,10 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
requireExplicitMessageTarget:
|
||||
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
disableMessageTool: input.ignoreDisableMessageTool ? false : params.disableMessageTool,
|
||||
forceMessageTool: shouldForceMessageTool(messagePolicyParams),
|
||||
enableHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
|
||||
forceHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
|
||||
disableMessageTool: params.disableMessageTool,
|
||||
forceMessageTool: shouldForceMessageTool(params),
|
||||
enableHeartbeatTool: true,
|
||||
forceHeartbeatTool: true,
|
||||
onYield: (message) => {
|
||||
input.onYieldDetected();
|
||||
input.onCodexAppServerEvent?.({
|
||||
@@ -320,16 +339,30 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
allocateToolOutcomeOrdinal: params.allocateToolOutcomeOrdinal,
|
||||
});
|
||||
toolBuildStages.mark("create-openclaw-coding-tools");
|
||||
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [];
|
||||
const readableAllToolProjection = filterProviderNormalizableTools(allTools);
|
||||
preNormalizationDiagnostics.push(...readableAllToolProjection.diagnostics);
|
||||
const webSearchPlan = resolveCodexWebSearchPlan({
|
||||
config: params.config,
|
||||
disableTools: params.disableTools,
|
||||
nativeToolSurfaceEnabled: input.nativeToolSurfaceEnabled,
|
||||
nativeProviderWebSearchSupport: input.nativeProviderWebSearchSupport,
|
||||
});
|
||||
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)
|
||||
@@ -342,51 +375,18 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
input,
|
||||
nativeExecutionPolicy,
|
||||
);
|
||||
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");
|
||||
const webSearchPresent = visionFilteredTools.some((tool) => tool.name === "web_search");
|
||||
const webSearchPolicy = agentHarness.resolveWebSearchToolPolicy({
|
||||
config: params.config,
|
||||
modelProvider: params.model.provider,
|
||||
modelId: params.modelId,
|
||||
agentId: input.sessionAgentId,
|
||||
sessionKey: input.sandboxSessionKey,
|
||||
sandboxToolPolicy: input.sandbox?.tools,
|
||||
messageProvider: resolveCodexMessageToolProvider(params),
|
||||
agentAccountId: params.agentAccountId,
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
spawnedBy: params.spawnedBy,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
const senderScopedWebSearchRestriction =
|
||||
!webSearchPolicy.allowed && webSearchPolicy.persistentAllowed;
|
||||
const transientWebSearchRestriction =
|
||||
senderScopedWebSearchRestriction || isCodexMemoryFlushRun(params);
|
||||
const persistentCodexWebSearchSurface =
|
||||
params.config?.tools?.web?.search?.enabled !== false &&
|
||||
!(input.pluginConfig.codexDynamicToolsExclude ?? []).some(
|
||||
(name) => normalizeCodexDynamicToolName(name) === "web_search",
|
||||
);
|
||||
input.onPersistentWebSearchPolicyResolved?.(
|
||||
webSearchPresent ||
|
||||
(persistentCodexWebSearchSurface &&
|
||||
transientWebSearchRestriction &&
|
||||
webSearchPolicy.persistentAllowed),
|
||||
);
|
||||
const toolsAllow = includeForcedCodexDynamicToolAllow(params.toolsAllow, messagePolicyParams);
|
||||
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,
|
||||
@@ -395,17 +395,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");
|
||||
// Resolve policy before hiding the managed tool. Hosted search follows the
|
||||
// same effective policy, while only one search implementation is exposed.
|
||||
input.onWebSearchPolicyResolved?.(normalizedTools.some((tool) => tool.name === "web_search"));
|
||||
const exposedTools = webSearchPlan.suppressManagedWebSearch
|
||||
? normalizedTools.filter((tool) => tool.name !== "web_search")
|
||||
: normalizedTools;
|
||||
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`,
|
||||
{
|
||||
@@ -422,7 +419,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)}`,
|
||||
{
|
||||
@@ -435,9 +432,8 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
codexFilteredToolCount: codexFilteredTools.length,
|
||||
visionFilteredToolCount: visionFilteredTools.length,
|
||||
filteredToolCount: filteredTools.length,
|
||||
normalizedToolCount: exposedTools.length,
|
||||
forceHeartbeatTool: input.forceHeartbeatTool === true,
|
||||
ignoreRuntimePlan: input.ignoreRuntimePlan === true,
|
||||
normalizedToolCount: normalizedTools.length,
|
||||
ignoreRuntimePlan: options.ignoreRuntimePlan === true,
|
||||
nativeToolSurfaceEnabled: input.nativeToolSurfaceEnabled === true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -72,6 +72,12 @@ type CodexDynamicToolHookContext = {
|
||||
|
||||
type CodexToolResultHookContext = Omit<CodexDynamicToolHookContext, "config">;
|
||||
|
||||
type AgentToolResultObserver = (event: {
|
||||
toolName: string;
|
||||
result: unknown;
|
||||
isError: boolean;
|
||||
}) => void;
|
||||
|
||||
type ProjectedCodexDynamicTool = {
|
||||
tool: AnyAgentTool;
|
||||
name: string;
|
||||
@@ -108,8 +114,7 @@ export type CodexDynamicToolBridge = {
|
||||
params: CodexDynamicToolCallParams,
|
||||
options?: {
|
||||
signal?: AbortSignal;
|
||||
onAgentToolResult?: EmbeddedRunAttemptParams["onAgentToolResult"];
|
||||
toolCallOrdinal?: number;
|
||||
onAgentToolResult?: AgentToolResultObserver;
|
||||
},
|
||||
) => Promise<CodexDynamicToolCallResponse>;
|
||||
telemetry: {
|
||||
@@ -442,7 +447,7 @@ export function createCodexDynamicToolBridge(params: {
|
||||
}
|
||||
|
||||
function notifyAgentToolResult(
|
||||
observer: EmbeddedRunAttemptParams["onAgentToolResult"] | undefined,
|
||||
observer: AgentToolResultObserver | undefined,
|
||||
toolName: string,
|
||||
result: unknown,
|
||||
isError: boolean,
|
||||
|
||||
@@ -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) {
|
||||
@@ -863,10 +861,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: {
|
||||
@@ -887,10 +886,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: {
|
||||
@@ -914,9 +914,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",
|
||||
@@ -927,6 +926,9 @@ describe("CodexAppServerEventProjector", () => {
|
||||
rateLimitReachedType: "rate_limit_reached",
|
||||
},
|
||||
rateLimitsByLimitId: null,
|
||||
};
|
||||
const projector = await createProjector(undefined, {
|
||||
readRecentRateLimits: () => rateLimits,
|
||||
});
|
||||
|
||||
await projector.handleNotification(
|
||||
@@ -978,19 +980,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 =
|
||||
@@ -203,8 +184,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,
|
||||
@@ -241,11 +220,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;
|
||||
@@ -298,7 +272,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";
|
||||
@@ -709,9 +683,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;
|
||||
}
|
||||
@@ -782,7 +754,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";
|
||||
@@ -1689,7 +1661,7 @@ export class CodexAppServerEventProjector {
|
||||
formatCodexUsageLimitErrorMessage({
|
||||
message: error ? readString(error, "message") : undefined,
|
||||
codexErrorInfo: error?.codexErrorInfo,
|
||||
rateLimits: this.latestRateLimits ?? readRecentCodexRateLimits(),
|
||||
rateLimits: this.options.readRecentRateLimits?.(),
|
||||
}) ?? readCodexErrorNotificationMessage(params)
|
||||
);
|
||||
}
|
||||
@@ -1884,9 +1856,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 {
|
||||
@@ -1900,10 +1870,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;
|
||||
@@ -1993,21 +1959,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)) {
|
||||
@@ -2035,52 +1986,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;
|
||||
@@ -45,19 +50,17 @@ export function resolveCodexNativeExecutionPolicy(params: {
|
||||
const config = params.config ?? {};
|
||||
const sessionKey = params.sessionKey?.trim() || params.sessionId?.trim() || undefined;
|
||||
const agentId = resolvePolicyAgentId({ config, sessionKey, agentId: params.agentId });
|
||||
const canReadSessionEntry =
|
||||
params.readRuntimeSessionEntry &&
|
||||
shouldReadRuntimeSessionEntry({ config, sessionKey, agentId: params.agentId });
|
||||
const sessionEntry =
|
||||
params.sessionEntry ??
|
||||
(canReadSessionEntry && sessionKey
|
||||
? readRuntimeSessionEntryBestEffort({ sessionKey, agentId })
|
||||
(params.readRuntimeSessionEntry && sessionKey
|
||||
? readRuntimeSessionEntryBestEffort(config, sessionKey, agentId)
|
||||
: undefined);
|
||||
const sandboxAvailable =
|
||||
params.sandboxAvailable ??
|
||||
(sessionKey
|
||||
? resolveSandboxRuntimeStatus({
|
||||
cfg: config,
|
||||
agentId,
|
||||
sessionKey,
|
||||
}).sandboxed
|
||||
: false);
|
||||
@@ -230,16 +233,17 @@ function resolveEffectiveExecHost(params: {
|
||||
return params.requestedExecHost;
|
||||
}
|
||||
|
||||
function readRuntimeSessionEntryBestEffort(params: {
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
}): SessionEntry | undefined {
|
||||
function readRuntimeSessionEntryBestEffort(
|
||||
config: OpenClawConfig,
|
||||
sessionKey: string,
|
||||
agentId: string,
|
||||
): SessionEntry | undefined {
|
||||
try {
|
||||
return getSessionEntry({
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
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();
|
||||
|
||||
@@ -56,8 +56,8 @@ export class CodexNativeSubagentTaskMirror {
|
||||
}
|
||||
|
||||
markAuthoritativeCompletionExpected(childThreadId: string): void {
|
||||
// Local transcripts and V2 agent paths can supply the real result later.
|
||||
// Remote V1 lacks both and must keep collab-completed as its fallback.
|
||||
// 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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -139,6 +139,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;
|
||||
};
|
||||
@@ -146,7 +147,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 & {
|
||||
@@ -162,7 +166,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 & {
|
||||
@@ -207,11 +226,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[];
|
||||
};
|
||||
@@ -229,6 +247,7 @@ export type CodexThread = {
|
||||
threadSource?: string | null;
|
||||
agentNickname?: string | null;
|
||||
agentRole?: string | null;
|
||||
turns: CodexTurn[];
|
||||
};
|
||||
|
||||
export type CodexThreadStatus =
|
||||
@@ -564,6 +583,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;
|
||||
@@ -592,6 +612,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;
|
||||
@@ -604,6 +625,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);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import type { CodexAppServerRuntimeOptions } from "./config.js";
|
||||
import { resolveCodexProviderWebSearchSupport } from "./provider-capabilities.js";
|
||||
import type { CodexAppServerClientLeaseFactory } from "./shared-client.js";
|
||||
|
||||
const appServer = {
|
||||
start: {},
|
||||
@@ -13,12 +13,16 @@ function createClientFactory(webSearch: boolean | boolean[]) {
|
||||
const values = Array.isArray(webSearch) ? [...webSearch] : [webSearch];
|
||||
const request = vi.fn(async () => ({ webSearch: values.shift() ?? false }));
|
||||
const client = { request } as unknown as CodexAppServerClient;
|
||||
const clientFactory = vi.fn(async () => client) as unknown as CodexAppServerClientFactory;
|
||||
return { clientFactory, request };
|
||||
const release = vi.fn();
|
||||
const clientFactory = vi.fn(async () => ({
|
||||
client,
|
||||
release,
|
||||
})) as CodexAppServerClientLeaseFactory;
|
||||
return { clientFactory, release, request };
|
||||
}
|
||||
|
||||
function resolveSupport(
|
||||
clientFactory: CodexAppServerClientFactory,
|
||||
clientFactory: CodexAppServerClientLeaseFactory,
|
||||
modelProviderOverride?: string,
|
||||
) {
|
||||
return resolveCodexProviderWebSearchSupport({
|
||||
@@ -50,7 +54,7 @@ describe("resolveCodexProviderWebSearchSupport", () => {
|
||||
it("reports unknown support when app-server startup fails", async () => {
|
||||
const clientFactory = vi.fn(async () => {
|
||||
throw new Error("old app-server");
|
||||
}) as unknown as CodexAppServerClientFactory;
|
||||
}) as CodexAppServerClientLeaseFactory;
|
||||
|
||||
await expect(resolveSupport(clientFactory)).resolves.toBe("unknown");
|
||||
});
|
||||
@@ -60,10 +64,15 @@ describe("resolveCodexProviderWebSearchSupport", () => {
|
||||
throw new Error("transient rpc failure");
|
||||
});
|
||||
const client = { request } as unknown as CodexAppServerClient;
|
||||
const clientFactory = vi.fn(async () => client) as unknown as CodexAppServerClientFactory;
|
||||
const release = vi.fn();
|
||||
const clientFactory = vi.fn(async () => ({
|
||||
client,
|
||||
release,
|
||||
})) as CodexAppServerClientLeaseFactory;
|
||||
|
||||
await expect(resolveSupport(clientFactory)).resolves.toBe("unknown");
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps managed search when the configured provider reports no hosted support", async () => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import type { CodexAppServerRuntimeOptions } from "./config.js";
|
||||
import { releaseLeasedSharedCodexAppServerClient } from "./shared-client.js";
|
||||
import type {
|
||||
CodexAppServerClientLease,
|
||||
CodexAppServerClientLeaseFactory,
|
||||
} from "./shared-client.js";
|
||||
import type { CodexNativeWebSearchSupport } from "./web-search.js";
|
||||
|
||||
async function readConfiguredProviderWebSearchSupport(params: {
|
||||
@@ -45,7 +47,7 @@ export async function resolveCodexProviderWebSearchSupportForClient(params: {
|
||||
}
|
||||
|
||||
export async function resolveCodexProviderWebSearchSupport(params: {
|
||||
clientFactory: CodexAppServerClientFactory;
|
||||
clientFactory: CodexAppServerClientLeaseFactory;
|
||||
appServer: CodexAppServerRuntimeOptions;
|
||||
authProfileId: string | undefined;
|
||||
agentDir: string;
|
||||
@@ -53,17 +55,17 @@ export async function resolveCodexProviderWebSearchSupport(params: {
|
||||
modelProviderOverride: string | undefined;
|
||||
signal: AbortSignal;
|
||||
}): Promise<CodexNativeWebSearchSupport> {
|
||||
let client: CodexAppServerClient | undefined;
|
||||
let lease: CodexAppServerClientLease | undefined;
|
||||
try {
|
||||
client = await params.clientFactory(
|
||||
params.appServer.start,
|
||||
params.authProfileId,
|
||||
params.agentDir,
|
||||
params.config,
|
||||
{ timeoutMs: params.appServer.requestTimeoutMs },
|
||||
);
|
||||
lease = await params.clientFactory({
|
||||
startOptions: params.appServer.start,
|
||||
authProfileId: params.authProfileId,
|
||||
agentDir: params.agentDir,
|
||||
config: params.config,
|
||||
timeoutMs: params.appServer.requestTimeoutMs,
|
||||
});
|
||||
return await resolveCodexProviderWebSearchSupportForClient({
|
||||
client,
|
||||
client: lease.client,
|
||||
timeoutMs: params.appServer.requestTimeoutMs,
|
||||
modelProviderOverride: params.modelProviderOverride,
|
||||
signal: params.signal,
|
||||
@@ -71,8 +73,6 @@ export async function resolveCodexProviderWebSearchSupport(params: {
|
||||
} catch {
|
||||
return "unknown";
|
||||
} finally {
|
||||
if (client) {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
}
|
||||
lease?.release();
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user