mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-23 07:22:03 +08:00
Compare commits
7 Commits
codex/refa
...
release/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a116c0c19f | ||
|
|
e3ccf8743f | ||
|
|
d4c2fa7aed | ||
|
|
6c1041339d | ||
|
|
db54a3268b | ||
|
|
9750d887f5 | ||
|
|
fce586538a |
593
appcast.xml
593
appcast.xml
@@ -2,6 +2,472 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.6.9</title>
|
||||
<pubDate>Sun, 21 Jun 2026 03:38:20 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2606000990</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.6.9</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.6.9</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li><strong>Richer Telegram delivery:</strong> Telegram now sends rich HTML, preserves rich markdown and sticker paths, renders progress drafts and command output more faithfully, normalizes HTML tables safely, and keeps mentions and spooled handlers on the right delivery path. (#93286, #93164, #93124, #93364, #93130, #93088, #93281, #94891, #94856) Thanks @obviyus, @vincentkoc, @goutamadwant, @kesslerio, @NianJiuZst, @SweetSophia, @Marvinthebored, @aaajiao, @zhangqueping, and @jairrab.</li>
|
||||
<li><strong>More dependable agent recovery:</strong> retries, terminal outcomes, usage after compaction, session history repair, and reply reconciliation now keep more interrupted or partial turns moving toward a visible final result. (#92191, #93073, #93228, #93084, #93469, #93291, #90943) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @yetval, @sandieman2, and @vincentkoc.</li>
|
||||
<li><strong>A stronger Codex integration:</strong> Codex gains automatic plugin approvals, GPT-5.3 Spark OAuth routing, remote-node <code>exec</code> as a dynamic tool, and more reliable app-server teardown and terminal outcomes. (#92625, #89133, #93654, #91767, #93287) Thanks @kevinslin, @VACInc, @vincentkoc, @JPKay-AI, and @aliahnaf2013-max.</li>
|
||||
<li><strong>Standalone official provider plugins:</strong> external provider packages are now first-class npm releases, externally installed channel plugins load at Gateway startup, and StepFun is available from npm and ClawHub. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.</li>
|
||||
<li><strong>More capable web and native clients:</strong> the Control UI adds a session workspace rail and extension health, iOS adds Watch controls, and Android shows chat context. (#92856, #91952, #93387, #92837) Thanks @Solvely-Colin, @jalehman, @joshavant, and @Tosko4.</li>
|
||||
<li><strong>More useful search and skills:</strong> Codex Hosted Search is available, key-free search providers remain deliberate opt-ins, and ClawHub skill installs retain verified source provenance. (#93446, #93616, #93283, #93506) Thanks @fuller-stack-dev, @davemorin, @momothemage, @nmccready-tars, and @vincentkoc.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Providers and auth: add Codex Hosted Search, improve Gemini CLI OAuth behind proxies, and keep external provider onboarding on current choices and package metadata. (#93446, #92815) Thanks @fuller-stack-dev, @yetval, @EvetteYoung, and @vincentkoc.</li>
|
||||
<li>Plugins and installs: externalized official providers publish as independent npm packages, Gateway discovers installed channel plugins at startup, and StepFun installs from npm or ClawHub. (#93470) Thanks @sunlit-deng, @cxdnicole, and @vincentkoc.</li>
|
||||
<li>Dashboard and mobile: add a session workspace rail, plugin health in status, compact cron lists, and iOS Watch controls. (#92856, #91952, #93395, #93387) Thanks @Solvely-Colin, @jalehman, @yu-xin-c, @centralpc, @joshavant, and @vincentkoc.</li>
|
||||
<li>Codex, observability, and skills: add automatic plugin approvals and SecretRefs, preserve ClawHub skill provenance, add OpenTelemetry log export, and expose remote-node execution to Codex when a node is connected. (#92625, #94324, #93283, #94561, #93654) Thanks @kevinslin, @kevinlin-openai, @momothemage, @nmccready-tars, @jesse-merhi, @vincentkoc, and @JPKay-AI.</li>
|
||||
<li>QA and release engineering: QA scenarios now use YAML, with broader profile evidence and release coverage for the plugin and channel matrix. Thanks @vincentkoc.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Security and privacy: redact secrets from debug/config output, block internal HTTP session overrides, audit open-DM tool exposure, and retain plugin write ownership checks. (#93333, #88496, #93443, #92883, #93353) Thanks @Alix-007, @jason-allen-oneal, @coygeek, @RichardCao, @yu-xin-c, @cjg20ss, @eleqtrizit, and @vincentkoc.</li>
|
||||
<li>Agent and session runtime: retry thinking-only and empty post-tool turns, prevent duplicate hook execution, preserve pending subagent delivery, preserve fresh usage through compaction, and repair partial JSON/history artifacts. (#92191, #93073, #93009, #93084, #93469, #94349, #92383, #94257) Thanks @ai-hpc, @lml2468, @fuller-stack-dev, @zenglingbiao, @dertbv, @Hollychou924, @leno23, @de1tydev, @425072024, @wuwahe3, @drvoss, @vincentkoc, @sallyom, @oiGaDio, @Hidetsugu55, and @Nas01010101.</li>
|
||||
<li>Channels and replies: fix Telegram rich delivery, table rendering, action-error handling, and ingress recovery; preserve command progress detail across channel adapters; retain WhatsApp opening text after a media failure; keep Mattermost thread replies intact; and harden Discord action handling. (#93286, #93364, #93281, #93076, #93334, #93424, #93488, #94868, #94891, #94856, #94810, #93823) Thanks @obviyus, @NianJiuZst, @mcaxtr, @rushindrasinha, @amknight, @lzyyzznl, @darealgege, @vincentkoc, @zhangqueping, @jairrab, @ZOOWH, @parveshsaini, and @yetval.</li>
|
||||
<li>Storage and migrations: avoid SQLite WAL on network filesystems, clean reindex artifacts, keep setup state out of workspace dot-directories, and import default-agent auth profiles into SQLite. (#93454, #92891, #93182, #93295, #93520, #93156) Thanks @vincentkoc, @ZengWen-DT, @Zeng-wen, @potterdigital, @Alix-007, @Pick-cat, @sallyom, @1qh, and @Tazio7.</li>
|
||||
<li>Provider and model behavior: fix Gemini CLI proxy OAuth, restore Codex Spark OAuth routing, correct Bedrock embedding model IDs, and preserve configured defaults in embedded runs. (#92815, #89133, #93452, #93428) Thanks @yetval, @EvetteYoung, @VACInc, @LiuwqGit, @aleck31, @zenglingbiao, @danielgerlag, and @vincentkoc.</li>
|
||||
<li>CLI, TUI, and apps: accept global flags after subcommands, keep terminal output and activity indicators visible, preserve CJK IME composition, and refresh stale UI state. (#93455, #93460, #93006, #93427, #93498, #93606) Thanks @ooiuuii, @Alix-007, @ZengWen-DT, @Zeng-wen, @AlethiaQuizForge, @Zhaoqj2016, @liuhao1024, @BrianClaw1955, @vincentkoc, and @NicoBoom13.</li>
|
||||
<li>Operations and updates: harden official plugin recovery, restart managed Gateways after failed update handoff, keep safe cron delivery defaults, avoid Node-specific npm prefixes, and keep package validation paths reliable. (#93325, #92111, #93650, #94453, #91685) Thanks @vincentkoc, @yetval, @ofan, @yaanfpv, @jincheng-xydt, @sallyom, @davectr, and @nxmxbbd.</li>
|
||||
</ul>
|
||||
<h3>Complete contribution record</h3>
|
||||
This audited record covers the complete v2026.6.8..HEAD history: 422 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
|
||||
<h4>Pull requests</h4>
|
||||
<ul>
|
||||
<li><strong>PR #90463</strong> refactor: add session accessor seam with gateway consumer. Thanks @jalehman.</li>
|
||||
<li><strong>PR #88656</strong> Drop reasoning-only length turns from replay. Thanks @abel-zer0.</li>
|
||||
<li><strong>PR #92856</strong> feat(webui): add session workspace rail. Thanks @Solvely-Colin.</li>
|
||||
<li><strong>PR #92845</strong> docs(browser-control): document OPENCLAW_EAGER_BROWSER_CONTROL_SERVER requirement. Related #92841. Thanks @liuhao1024 and @jeugregg.</li>
|
||||
<li><strong>PR #82366</strong> fix: use passive periodic sqlite wal checkpoints. Related #81715. Thanks @honor2030 and @KrasimirKralev.</li>
|
||||
<li><strong>PR #92815</strong> fix(google): route Gemini CLI OAuth through the env proxy (#46184). Thanks @yetval and @EvetteYoung.</li>
|
||||
<li><strong>PR #91331</strong> fix(mattermost): merge progress preview lines by identity. Related #89761. Thanks @iloveleon19 and @leonthe8th and @vincentkoc.</li>
|
||||
<li><strong>PR #92909</strong> fix(tui): keep spinner active when toggling tools. Related #49763. Thanks @ZengWen-DT and @Zeng-wen and @vincentkoc and @CrimsonDump.</li>
|
||||
<li><strong>PR #92904</strong> fix(elevenlabs): use current TTS model ids. Thanks @vortexopenclaw and @vincentkoc.</li>
|
||||
<li><strong>PR #92642</strong> fix #86872: Subagent run reports success but fails to write output file. Thanks @zhangguiping-xydt and @vincentkoc and @zapper35.</li>
|
||||
<li><strong>PR #89122</strong> refactor: route command session reads through seam. Thanks @jalehman.</li>
|
||||
<li><strong>PR #90943</strong> fix(reply): deliver final reply when queued follow-up claims session; scope dedupe to routed thread. Thanks @sandieman2 and @vincentkoc.</li>
|
||||
<li><strong>PR #92894</strong> fix(skills): keep managed prompt paths readable. Related #92875. Thanks @kesslerio and @sallyom.</li>
|
||||
<li><strong>PR #39617</strong> fix: reload config in slash command routing so dmScope is respected. Related #39605. Thanks @Ciward.</li>
|
||||
<li><strong>PR #92191</strong> fix(agents): retry thinking-only errored turns. Related #91953. Thanks @ai-hpc and @lml2468.</li>
|
||||
<li><strong>PR #92891</strong> fix(memory): clean stale reindex temp files. Related #92874. Thanks @ZengWen-DT and @Zeng-wen and @vincentkoc and @potterdigital.</li>
|
||||
<li><strong>PR #93005</strong> Add OpenRouter Fusion guidance and prompt context. Related #92984. Thanks @sallyom.</li>
|
||||
<li><strong>PR #88792</strong> fix(state): harden sqlite path caching. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #93022</strong> fix(gateway): repair usage cost aggregation across agents. Thanks @luke-skywalker-open-claw and @stablegenius49.</li>
|
||||
<li><strong>PR #93020</strong> fix(telegram): cool down transient sendChatAction failures. Related #56096. Thanks @Boulea7 and @sumaiazaman and @Pick-cat and @cal-rufus.</li>
|
||||
<li><strong>PR #89160</strong> fix(agents): detect truncated API responses to prevent silent session hang. Related #89051. Thanks @joelnishanth and @ArthurusDent.</li>
|
||||
<li><strong>PR #93009</strong> fix(agents): make wrapToolWithBeforeToolCallHook idempotent to prevent double hook execution (fixes #92973). Thanks @zenglingbiao and @dertbv.</li>
|
||||
<li><strong>PR #92991</strong> fix(agents): tolerate missing attribution baseUrl. Related #92974. Thanks @samrusani and @Haderach-Ram.</li>
|
||||
<li><strong>PR #92913</strong> fix(opencode-go): register model catalog to fix context window detection. Related #92912. Thanks @kumaxs.</li>
|
||||
<li><strong>PR #89129</strong> refactor: route bundled plugin session callers through seam. Thanks @jalehman.</li>
|
||||
<li><strong>PR #93084</strong> fix(agents): preserve fresh usage after compaction. Related #50795. Thanks @Hollychou924 and @leno23 and @de1tydev and @425072024 and @vincentkoc and @wuwahe3.</li>
|
||||
<li><strong>PR #92869</strong> fix #90333: [Bug]: Discord image build aborts at step 66 — openclaw-build-messaging-plugins.py exits 1. Thanks @zhangguiping-xydt and @vincentkoc and @chriskosys.</li>
|
||||
<li><strong>PR #93011</strong> fix(gateway): accept file-only input on /v1/responses (parity with image-only). Thanks @yetval and @vincentkoc.</li>
|
||||
<li><strong>PR #92915</strong> Convert QA scenarios to YAML files. Thanks @RomneyDa.</li>
|
||||
<li><strong>PR #91767</strong> Fix one-shot Codex app-server teardown. Thanks @aliahnaf2013-max.</li>
|
||||
<li><strong>PR #92625</strong> feat(codex): add auto plugin approvals. Thanks @kevinslin.</li>
|
||||
<li><strong>PR #91587</strong> test(qa): add qa run --qa-profile and unified output summary/evidence. Thanks @RomneyDa.</li>
|
||||
<li><strong>PR #93104</strong> test(reply): seed channel fixtures for dedupe tests. Thanks @RomneyDa.</li>
|
||||
<li><strong>PR #93107</strong> test(reply): preserve telegram dedupe fallback. Thanks @RomneyDa.</li>
|
||||
<li><strong>PR #92954</strong> fix(memory): accept local default model path migration. Thanks @mushuiyu886 and @vincentkoc.</li>
|
||||
<li><strong>PR #90936</strong> fix(agents): do not misclassify client-disconnect abort as run timeout. Related #90764. Thanks @openperf and @reginaldomarcilon.</li>
|
||||
<li><strong>PR #90812</strong> fix(voice-call): preserve live Twilio streams in stale reaper. Related #79121. Thanks @Takhoffman and @sahibzada-allahyar and @donkeykong91.</li>
|
||||
<li><strong>PR #93094</strong> fix(whatsapp): bound socket operations. Thanks @mcaxtr.</li>
|
||||
<li><strong>PR #91629</strong> fix(scripts): add database-first legacy store guard. Related #91628. Thanks @galiniliev.</li>
|
||||
<li><strong>PR #93124</strong> fix(telegram): render progress drafts as rich previews. Thanks @Marvinthebored.</li>
|
||||
<li><strong>PR #93109</strong> test(qa): embed profile scorecard evidence. Thanks @RomneyDa.</li>
|
||||
<li><strong>PR #87298</strong> test: add temp directory helper guidance. Thanks @hxy91819.</li>
|
||||
<li><strong>PR #92318</strong> fix(cron): require explicit message target proof. Thanks @hxy91819.</li>
|
||||
<li><strong>PR #93137</strong> fix(imessage): honor disabled reply actions. Related #92142. Thanks @omarshahine and @dprev.</li>
|
||||
<li><strong>PR #93134</strong> fix(feishu): pass card_msg_content_type to get full card content (fixes #78289). Thanks @liuhao1024 and @vincentkoc and @longdoubled7.</li>
|
||||
<li><strong>PR #93138</strong> fix(agents): preserve literal current session resolution. Thanks @liuhao1024 and @vincentkoc.</li>
|
||||
<li><strong>PR #91225</strong> fix #83830: [Bug]: Dreaming diary repeats "first day" narrative every sweep — same early memories dominate snippets. Thanks @mushuiyu886 and @YinLiuLiu66.</li>
|
||||
<li><strong>PR #93153</strong> simplify QA evidence profile and mappings/coverage shape. Thanks @RomneyDa.</li>
|
||||
<li><strong>PR #93164</strong> fix(telegram): preserve rich markdown line breaks. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #93119</strong> fix: accept mixed source/dist bundled roots. Related #87730. Thanks @arkyu2077 and @vincentkoc and @jasonftl.</li>
|
||||
<li><strong>PR #93130</strong> fix(telegram): preserve sticker media paths. Related #83748. Thanks @goutamadwant and @vincentkoc and @aaajiao.</li>
|
||||
<li><strong>PR #93073</strong> fix(agents): retry empty post-tool final turns. Thanks @fuller-stack-dev.</li>
|
||||
<li><strong>PR #91784</strong> fix(voice-call): require realtime websocket path boundary. Thanks @jason-allen-oneal.</li>
|
||||
<li><strong>PR #89133</strong> Restore GPT-5.3 Codex Spark OAuth routing. Thanks @VACInc.</li>
|
||||
<li><strong>PR #91996</strong> refactor: prune unused iOS code. Thanks @zats.</li>
|
||||
<li><strong>PR #90231</strong> fix #69443: [Bug] Subagent RPC callback to WeChat session key routed to main session instead. Thanks @zhangguiping-xydt and @sliverp and @chen11221.</li>
|
||||
<li><strong>PR #89920</strong> fix(matrix): replace recovered command progress lines. Thanks @bdjben and @jesse-merhi.</li>
|
||||
<li><strong>PR #93159</strong> fix(tui): keep parent stdin paused after exit. Thanks @fuller-stack-dev.</li>
|
||||
<li><strong>PR #93201</strong> fix(auto-reply): clear pending-final state before honoring post-send abort (#89115). Thanks @amknight and @danashburn.</li>
|
||||
<li><strong>PR #93228</strong> fix(agents): replace prose terminal classifiers. Thanks @fuller-stack-dev.</li>
|
||||
<li><strong>PR #93231</strong> fix(status): correct pinned model clear hint. Thanks @hxy91819.</li>
|
||||
<li><strong>PR #92428</strong> fix(qqbot): keep markdown table chunks valid. Thanks @sliverp.</li>
|
||||
<li><strong>PR #93220</strong> fix(status): avoid stale session context windows. Thanks @hxy91819.</li>
|
||||
<li><strong>PR #91957</strong> perf(sessions): share one enumeration across archive retention sweeps. Thanks @amknight.</li>
|
||||
<li><strong>PR #93281</strong> fix(telegram): recover pid-reused ingress claims. Thanks @obviyus.</li>
|
||||
<li><strong>PR #93287</strong> fix(codex): preserve terminal outcome ordering.</li>
|
||||
<li><strong>PR #93182</strong> fix(memory): clean rollback-journal reindex temp sidecar on NFS stores. Thanks @Alix-007.</li>
|
||||
<li><strong>PR #93283</strong> Persist ClawHub skill install provenance. Related #92077. Thanks @momothemage and @nmccready-tars.</li>
|
||||
<li><strong>PR #88872</strong> fix: attribute spawned task runs to child agent. Related #66670. Thanks @Alix-007 and @Neomail2.</li>
|
||||
<li><strong>PR #92837</strong> fix(android): show live chat context usage. Thanks @Tosko4.</li>
|
||||
<li><strong>PR #93325</strong> fix(cli): harden official plugin recovery. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #93286</strong> feat(telegram): send rich messages as rich html. Thanks @obviyus.</li>
|
||||
<li><strong>PR #92910</strong> fix(memory-core): safely refresh qmd index during collection repair.</li>
|
||||
<li><strong>PR #93329</strong> fix(cli): allow zero Discord timeout duration. Related #93327. Thanks @rohitjavvadi.</li>
|
||||
<li><strong>PR #91625</strong> fix(cron): add cron edit --clear-model to clear a job's model override. Thanks @ly-wang19.</li>
|
||||
<li><strong>PR #91691</strong> [AI] fix(memory): prevent empty-string expectedModel in resolveMemory…. Thanks @xydt-tanshanshan.</li>
|
||||
<li><strong>PR #93006</strong> fix(tui): keep stderr visible when local shell stdout fills the output cap. Thanks @Alix-007.</li>
|
||||
<li><strong>PR #93001</strong> fix(daemon): prefer stderr over stale stdout in gateway restart diagnostics. Thanks @Alix-007.</li>
|
||||
<li><strong>PR #91117</strong> refactor: remove dead code and improve string concatenation. Thanks @Pommelle.</li>
|
||||
<li><strong>PR #90893</strong> fix(models): mask paste-token input in CLI auth prompt. Thanks @anurag-bg-neu.</li>
|
||||
<li><strong>PR #90571</strong> fix(configure): mask gateway password input in CLI wizard prompt. Thanks @anurag-bg-neu.</li>
|
||||
<li><strong>PR #91768</strong> fix(ios): respect chat header safe area. Thanks @zats.</li>
|
||||
<li><strong>PR #93245</strong> fix(cron): resolve lastRunStatus in cron list/show human output. Thanks @ly-wang19.</li>
|
||||
<li><strong>PR #78765</strong> fix(tui): avoid inserting spaces into long CJK text. Thanks @hpt.</li>
|
||||
<li><strong>PR #91776</strong> fix(ios): refresh permission rows after grants. Thanks @zats.</li>
|
||||
<li><strong>PR #92817</strong> fix(cron): trust agent output when channel is unresolved without explicit delivery. Related #90664. Thanks @fsdwen and @dertbv.</li>
|
||||
<li><strong>PR #93297</strong> fix(control-ui): respect agents.defaults.timeFormat for timestamps. Related #58147. Thanks @ZengWen-DT and @Zeng-wen and @TommoT2.</li>
|
||||
<li><strong>PR #93364</strong> Fix Telegram rich progress command output. Thanks @obviyus.</li>
|
||||
<li><strong>PR #91952</strong> feat(status): surface plugin health. Thanks @jalehman.</li>
|
||||
<li><strong>PR #75025</strong> fix(heartbeat): refresh stale Current time line on every helper call (#44993). Thanks @MoerAI and @mclee1975.</li>
|
||||
<li><strong>PR #90992</strong> docs(windows): fix WSL gateway-autostart recipe for WSL ≥ 2.6.1.0 idle-termination. Thanks @spencer2211.</li>
|
||||
<li><strong>PR #86544</strong> fix(cli): show Gemini CLI runtime auth status. Related #79585. Thanks @giodl73-repo and @fabricefoy.</li>
|
||||
<li><strong>PR #88945</strong> fix(plugins): serialize binding approval saves. Related #64065. Thanks @Alix-007 and @lihaokun.</li>
|
||||
<li><strong>PR #90115</strong> fix(gateway): pass managed inbound PDFs through chat.send. Related #90097. Thanks @harjothkhara and @joeykrug.</li>
|
||||
<li><strong>PR #74613</strong> docs(cli): add agent selector to CLI backend quick start. Related #68940. Thanks @vyctorbrzezowski and @drmarcopapa.</li>
|
||||
<li><strong>PR #89121</strong> refactor: add transcript reader seam. Thanks @jalehman.</li>
|
||||
<li><strong>PR #84434</strong> fix(cli): disable ScheduleWakeup/CronCreate in --print claude runs. Thanks @SkyWolfDreamer.</li>
|
||||
<li><strong>PR #66985</strong> fix(agents): resolve requestedNode to canonical ID before boundNode comparison. Related #87213. Thanks @mujiannan.</li>
|
||||
<li><strong>PR #91488</strong> fix(reply): project preflight compaction gate by next-input size on fresh tokens. Thanks @yetval.</li>
|
||||
<li><strong>PR #93353</strong> fix(plugins): require owner for plugin writes. Thanks @eleqtrizit.</li>
|
||||
<li><strong>PR #91499</strong> fix(cron): preserve scheduled turn tool policy [AI]. Thanks @mmaps.</li>
|
||||
<li><strong>PR #90412</strong> fix(sessions): cache warm transcript reads to avoid per-turn re-parse. Related #83943. Thanks @Alix-007 and @yyds-xxxx.</li>
|
||||
<li><strong>PR #93118</strong> fix(gateway): guard fast-path startup migrations. Related #93032. Thanks @openperf and @Haderach-Ram.</li>
|
||||
<li><strong>PR #93355</strong> fix(ci): verify performance workflow downloads. Thanks @eleqtrizit.</li>
|
||||
<li><strong>PR #93358</strong> fix(outbound): guard cross-context message mutations. Thanks @eleqtrizit.</li>
|
||||
<li><strong>PR #93362</strong> fix(flock): bind allow-always to wrapped command. Thanks @eleqtrizit.</li>
|
||||
<li><strong>PR #92578</strong> refactor(whatsapp): add inbound admission foundation. Thanks @mcaxtr.</li>
|
||||
<li><strong>PR #89547</strong> Control Telegram group history context. Thanks @mmaps.</li>
|
||||
<li><strong>PR #89201</strong> refactor: add transcript runtime identity contract. Thanks @jalehman.</li>
|
||||
<li><strong>PR #93357</strong> fix(plugins): enforce install policy in wrappers. Thanks @eleqtrizit.</li>
|
||||
<li><strong>PR #93156</strong> fix(doctor): import default-agent auth profiles into sqlite. Related #93145. Thanks @Pick-cat and @sallyom and @Tazio7.</li>
|
||||
<li><strong>PR #93179</strong> Add slim evidence mode for QA profile evidence. Thanks @RomneyDa.</li>
|
||||
<li><strong>PR #93349</strong> fix(control-ui): keep workboard card titles visible in overflowing columns (fixes #91717). Thanks @Pick-cat and @NicoBoom13.</li>
|
||||
<li><strong>PR #93324</strong> fix(cli): accept --no-color after subcommands. Thanks @ooiuuii.</li>
|
||||
<li><strong>PR #89621</strong> Return Google Chat thread metadata from message sends. Thanks @franco-viotti.</li>
|
||||
<li><strong>PR #82458</strong> fix(infra): drop duplicated "restart" word in restart-sentinel summary. Thanks @jameswniu.</li>
|
||||
<li><strong>PR #85471</strong> Suppress cron announce control replies. Related #85421. Thanks @TurboTheTurtle and @leatherneck-33.</li>
|
||||
<li><strong>PR #85316</strong> fix(auth): keep alias-compatible auth-profile overrides instead of clearing them. Thanks @SkyWolfDreamer.</li>
|
||||
<li><strong>PR #89260</strong> fix(doctor): separate platform-incompatible skills from missing requirements. Related #89232. Thanks @Alix-007 and @CameronWeller.</li>
|
||||
<li><strong>PR #90846</strong> fix(media): stop pruning media on write; let the configured timer do it. Thanks @lundog.</li>
|
||||
<li><strong>PR #88062</strong> fix(logging): avoid stalled warnings for active model calls. Thanks @litang9.</li>
|
||||
<li><strong>PR #93308</strong> fix(discord): reject malformed realtime consult calls. Thanks @khoek.</li>
|
||||
<li><strong>PR #93334</strong> fix(whatsapp): notify user when trailing media send fails instead of silent drop. Thanks @rushindrasinha.</li>
|
||||
<li><strong>PR #92575</strong> fix(sessions): preserve user behavior overrides across daily/idle rollover (#92562) [AI-assisted]. Thanks @harjothkhara and @civiltox.</li>
|
||||
<li><strong>PR #89124</strong> refactor: route auto-reply sessions through session seam. Thanks @jalehman.</li>
|
||||
<li><strong>PR #93431</strong> fix: stabilize transcript cache and CLI env isolation. Thanks @shakkernerd.</li>
|
||||
<li><strong>PR #93412</strong> fix(discord): suppress tool progress for message-tool replies. Thanks @mgunnin and @vincentkoc.</li>
|
||||
<li><strong>PR #93409</strong> fix(whatsapp): stop markdownToWhatsApp dropping code spans followed by a digit. Thanks @rushindrasinha.</li>
|
||||
<li><strong>PR #93295</strong> fix(memory): swap rollback-journal sidecar during atomic reindex. Thanks @Alix-007.</li>
|
||||
<li><strong>PR #93076</strong> fix(whatsapp): preserve auth on terminal disconnects. Thanks @mcaxtr.</li>
|
||||
<li><strong>PR #93435</strong> fix(agents): bound autoreview scope. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #93279</strong> fix(telegram): restore readable default text sends. Related #93263. Thanks @NianJiuZst and @SweetSophia.</li>
|
||||
<li><strong>PR #93429</strong> fix(line): cap carousel column text at 60 chars when a title or image is set. Thanks @harjothkhara and @vincentkoc.</li>
|
||||
<li><strong>PR #93428</strong> fix(agents): resolve configured default model in runEmbeddedAgent (fixes #93419). Thanks @zenglingbiao and @vincentkoc and @danielgerlag.</li>
|
||||
<li><strong>PR #93427</strong> fix(tui): show activity indicator for system-injected runs. Related #51825. Thanks @ZengWen-DT and @vincentkoc and @Zeng-wen and @AlethiaQuizForge.</li>
|
||||
<li><strong>PR #90003</strong> feat(policy): cover exec approvals artifact. Thanks @giodl73-repo.</li>
|
||||
<li><strong>PR #93448</strong> fix(guards): allow auth profile sqlite reader. Thanks @amknight.</li>
|
||||
<li><strong>PR #93424</strong> fix(mattermost): keep message tool replies in threads. Thanks @amknight and @vincentkoc.</li>
|
||||
<li><strong>PR #93418</strong> fix(telegram): forward Bot API 10.1 rich_message content to agent. Related #93410. Thanks @xzh-icenter and @vincentkoc and @0pen7ech.</li>
|
||||
<li><strong>PR #93175</strong> test(qa): taxonomy profiles: includeAllCategories for release profile, update some coverage. Thanks @RomneyDa.</li>
|
||||
<li><strong>PR #93456</strong> fix(agents): handle string assistant message content. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #93441</strong> fix(outbound): ignore schema-padded poll metadata on send. Related #43015. Thanks @weichengdeng and @charzhou.</li>
|
||||
<li><strong>PR #93443</strong> fix(gateway): block internal HTTP session overrides. Thanks @RichardCao.</li>
|
||||
<li><strong>PR #93454</strong> fix(sqlite): disable WAL on network filesystems. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #90275</strong> test: make install-safe-path symlink tests compatible with Windows. Thanks @aniruddhaadak80.</li>
|
||||
<li><strong>PR #93464</strong> fix(qa): suppress empty WhatsApp debug artifacts. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #90861</strong> fix(cli): preserve sessions_yield over MCP. Related #77426. Thanks @zhangguiping-xydt and @jarvisagimuspicard-hub.</li>
|
||||
<li><strong>PR #90946</strong> fix(infra): preserve inherited gateway PID across reparent during cleanup. Thanks @amittell.</li>
|
||||
<li><strong>PR #92220</strong> fix(media): extract large managed inbound PDFs via media-understanding. Related #90096, #90097. Thanks @amknight and @joeykrug.</li>
|
||||
<li><strong>PR #91208</strong> fix #91047: Plugin session-extension registry not pinned; sessions.pluginPatch fails after agent/subagent plugin-load churn. Thanks @mushuiyu886 and @teamadams.</li>
|
||||
<li><strong>PR #92111</strong> fix(update): restart managed gateway when update handoff fails after stop. Related #92088. Thanks @yetval and @ofan.</li>
|
||||
<li><strong>PR #93238</strong> fix(agents): honor disabled envelope timestamps at model boundary. Thanks @osolmaz.</li>
|
||||
<li><strong>PR #93343</strong> fix(codex): de-duplicate commentary notes across the raw response lane. Related #93296. Thanks @Marvinthebored and @Peetiegonzalez.</li>
|
||||
<li><strong>PR #93361</strong> fix(openshell): pin mirror remote mutations. Thanks @eleqtrizit.</li>
|
||||
<li><strong>PR #93354</strong> fix(discord): block cross-provider guild admin actions. Thanks @eleqtrizit.</li>
|
||||
<li><strong>PR #92178</strong> fix(gateway): normalize malformed paired access lists. Related #90654. Thanks @wangmiao0668000666 and @EmilioNicolas.</li>
|
||||
<li><strong>PR #85254</strong> perf(plugins): thread prepared manifestPlugins through runtime model-id normalize chain. Thanks @zeroaltitude.</li>
|
||||
<li><strong>PR #93489</strong> Add ClawHub content rights docs to sidebar. Thanks @Patrick-Erichsen.</li>
|
||||
<li><strong>PR #93466</strong> [AI] fix(feishu): guard against missing inbound in channelRuntime fallback. Thanks @xydt-tanshanshan.</li>
|
||||
<li><strong>PR #93460</strong> fix(cli): honor --log-level in route-first commands. Related #93457. Thanks @ooiuuii.</li>
|
||||
<li><strong>PR #93495</strong> fix(cron): clear delivery routing fields from cron edit. Thanks @ly-wang19 and @vincentkoc.</li>
|
||||
<li><strong>PR #93494</strong> docs: point PR landing at maintainer workflow. Thanks @fuller-stack-dev and @vincentkoc.</li>
|
||||
<li><strong>PR #93487</strong> fix(ui): add agent selector to skills page. Related #78553. Thanks @goutamadwant and @vincentkoc and @xiaobu1112.</li>
|
||||
<li><strong>PR #93488</strong> fix(discord): apply tool status emojis immediately to avoid override by thinking reactions. Related #92715. Thanks @lzyyzznl and @vincentkoc and @darealgege.</li>
|
||||
<li><strong>PR #93055</strong> fix(ui): restore provider usage pill in desktop chat composer [AI]. Thanks @harjothkhara.</li>
|
||||
<li><strong>PR #83156</strong> fix(matrix): accept bracketed display-name mentions. Related #83142. Thanks @wdx-agent-io and @wdongxv.</li>
|
||||
<li><strong>PR #93333</strong> fix(auto-reply): redact secrets in /debug show and /debug set output. Thanks @Alix-007.</li>
|
||||
<li><strong>PR #88496</strong> fix(auto-reply): redact secrets in config show output. Related #65623. Thanks @jason-allen-oneal and @coygeek.</li>
|
||||
<li><strong>PR #93105</strong> fix(doctor): repair null agents.list[].workspace values. Related #77718. Thanks @xydigit-sj and @slideshow-dingo.</li>
|
||||
<li><strong>PR #73923</strong> fix(ui): preserve gateway token during safe websocket url edits. Related #41545. Thanks @wsyjh8.</li>
|
||||
<li><strong>PR #88970</strong> fix #85871: [Bug]: Heartbeat scheduler silently fails to fire on 5.20 and all 5.x versions (regression from 4.23). Thanks @zhangguiping-xydt and @vincentkoc and @carlbjson.</li>
|
||||
<li><strong>PR #93511</strong> fix(imessage): normalize leading NUL echo-cache prefixes. Thanks @vincentkoc and @drvoss.</li>
|
||||
<li><strong>PR #92594</strong> [Bug]: ollama-cloud runtime fails DNS lookup for ai.ollama.com, while ollama/<model>:cloud works. Related #92391. Thanks @zhangguiping-xydt and @vincentkoc and @kvzsolt.</li>
|
||||
<li><strong>PR #93512</strong> build(docs): finish PowerShell-safe docs formatting. Related #44293. Thanks @vincentkoc and @yil337 and @aniruddhaadak80.</li>
|
||||
<li><strong>PR #93513</strong> fix(skills): refresh persisted snapshots after restart. Thanks @vincentkoc and @fif911 and @skadauke.</li>
|
||||
<li><strong>PR #93517</strong> fix(skills): quote skill-creator template description. Thanks @vincentkoc and @parubets.</li>
|
||||
<li><strong>PR #73976</strong> fix(memory): use per-keyword FTS search in hybrid mode #39484. Thanks @joshuakeithpa-sudo.</li>
|
||||
<li><strong>PR #93520</strong> fix(workspace): store setup state outside workspace dot-dir. Thanks @vincentkoc and @1qh.</li>
|
||||
<li><strong>PR #93521</strong> fix(onboard): skip Homebrew prompt on unsupported platforms. Related #68893. Thanks @vincentkoc and @yurivict.</li>
|
||||
<li><strong>PR #93522</strong> fix(feishu): send post mentions as native at elements. Thanks @vincentkoc and @gavin-ali and @YizukiAme and @Panniantong.</li>
|
||||
<li><strong>PR #93496</strong> fix(gateway): rotate already-stale generated transcript filename on /reset. Thanks @harjothkhara and @vincentkoc.</li>
|
||||
<li><strong>PR #93471</strong> fix(cron): preserve aborted isolated-run failure. Thanks @BhargavSatya and @vincentkoc.</li>
|
||||
<li><strong>PR #93473</strong> fix(memory): report skipped QMD embedding probe. Related #77645. Thanks @TurboTheTurtle and @vincentkoc and @aderius.</li>
|
||||
<li><strong>PR #93498</strong> fix(ui): preserve CJK IME composition. Related #86035. Thanks @Zhaoqj2016 and @vincentkoc.</li>
|
||||
<li><strong>PR #93088</strong> fix(telegram): bind bot mentions to assistant identity. Thanks @kesslerio and @vincentkoc.</li>
|
||||
<li><strong>PR #93499</strong> fix(nodes): return screen snapshots as media. Related #90126. Thanks @zenglingbiao and @vincentkoc and @JeffSteinbok.</li>
|
||||
<li><strong>PR #93506</strong> fix(skills): trust verified ClawHub source provenance. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #93525</strong> agents: notify chat exec empty-success completions. Thanks @vincentkoc and @wenkang-xie.</li>
|
||||
<li><strong>PR #93446</strong> feat: add Codex hosted web search. Thanks @fuller-stack-dev.</li>
|
||||
<li><strong>PR #92883</strong> fix(security): audit open dm tool exposure. Related #55612. Thanks @yu-xin-c and @vincentkoc and @cjg20ss.</li>
|
||||
<li><strong>PR #93476</strong> fix(mattermost): preserve Codex progress preview. Related #88766. Thanks @goutamadwant and @vincentkoc and @KelTech-Services.</li>
|
||||
<li><strong>PR #93395</strong> feat(cron): add compact list responses. Related #93366. Thanks @yu-xin-c and @vincentkoc and @centralpc.</li>
|
||||
<li><strong>PR #93527</strong> fix(cron): preserve model overrides for text payloads. Thanks @vincentkoc and @liaoandi.</li>
|
||||
<li><strong>PR #90487</strong> fix: harden ChatGPT Responses missing content-type streams. Thanks @anyech and @vincentkoc.</li>
|
||||
<li><strong>PR #93528</strong> fix(gateway): tolerate transient pre-hello clean closes. Thanks @vincentkoc and @ruanrrn.</li>
|
||||
<li><strong>PR #93529</strong> fix(auto-reply): allow message tool for group attachments. Related #43146. Thanks @vincentkoc and @Robcis.</li>
|
||||
<li><strong>PR #93291</strong> fix(reply): preserve pending thread evidence when reconciling partial send results. Thanks @yetval and @vincentkoc.</li>
|
||||
<li><strong>PR #90572</strong> fix(feishu): drop self-authored receive echoes. Thanks @baskduf.</li>
|
||||
<li><strong>PR #93455</strong> fix(cli): accept --log-level after subcommands. Thanks @ooiuuii and @vincentkoc.</li>
|
||||
<li><strong>PR #93452</strong> fix(bedrock): strip inference profile prefix from model ID in embedding adapter. Related #79212. Thanks @LiuwqGit and @vincentkoc and @aleck31.</li>
|
||||
<li><strong>PR #89799</strong> fix(cli): skip compile cache on early Node 24.x to avoid startup deadlock. Related #86550. Thanks @zhangguiping-xydt and @vincentkoc and @renyuliang000.</li>
|
||||
<li><strong>PR #93469</strong> fix(agents): drop partialJson streaming artifacts from session history repair. Thanks @drvoss and @vincentkoc.</li>
|
||||
<li><strong>PR #93463</strong> fix(codex): log app-server compaction completion. Related #83932. Thanks @goutamadwant and @vincentkoc and @aounakram.</li>
|
||||
<li><strong>PR #93562</strong> fix(tui): refresh after external session reset. Related #38966. Thanks @vincentkoc and @wsyjh8 and @yizhanzjz.</li>
|
||||
<li><strong>PR #93470</strong> fix(plugins): load externally-installed channel plugins at gateway startup. Related #93219. Thanks @sunlit-deng and @vincentkoc and @cxdnicole.</li>
|
||||
<li><strong>PR #88796</strong> fix(discord): resolve guildId from session channel for search actions. Related #88790. Thanks @SebTardif and @vincentkoc and @mugabuga.</li>
|
||||
<li><strong>PR #93194</strong> fix(agents): preserve prompt-released session metadata. Related #93193. Thanks @snowzlm.</li>
|
||||
<li><strong>PR #89483</strong> fix(gateway): project failed agent turns in chat history. Related #89197. Thanks @IWhatsskill and @vincentkoc and @yangiit.</li>
|
||||
<li><strong>PR #93434</strong> fix: avoid parent group allowlist false positive. Related #92684. Thanks @kingrubic and @vincentkoc and @motteman.</li>
|
||||
<li><strong>PR #93449</strong> fix(feishu): dedupe redelivered text by stable retry identity. Related #46778. Thanks @ZengWen-DT and @vincentkoc and @kingcuty.</li>
|
||||
<li><strong>PR #93407</strong> AGT-80 AGT-81 Fix Discord ingress ack ordering. Thanks @mgunnin and @vincentkoc.</li>
|
||||
<li><strong>PR #93439</strong> fix(agents): honor embedded run default model. Related #93419. Thanks @harjothkhara and @vincentkoc and @danielgerlag.</li>
|
||||
<li><strong>PR #93565</strong> fix(cli): summarize cleanup dry-run by label. Related #76826. Thanks @AgentArcLab and @vincentkoc and @renatomaluhy.</li>
|
||||
<li><strong>PR #93509</strong> fix(skills): clear orphaned idempotency pointer on corrupt-metadata re-begin. Thanks @Alix-007 and @vincentkoc.</li>
|
||||
<li><strong>PR #93274</strong> Clarify plugin channel config additional-property errors. Thanks @zhangguiping-xydt and @vincentkoc.</li>
|
||||
<li><strong>PR #93555</strong> fix(read): route text decoding through shared Windows codepage fallba…. Thanks @zhanxingxin1998 and @vincentkoc.</li>
|
||||
<li><strong>PR #93314</strong> fix(skills): preserve ClawHub origin provenance on readback. Thanks @Alix-007 and @vincentkoc.</li>
|
||||
<li><strong>PR #93573</strong> fix(acp): keep bridge sessions out of stale ACP classification [AI-assisted]. Related #38907. Thanks @eldar702 and @vincentkoc and @ninaopenclaw.</li>
|
||||
<li><strong>PR #93398</strong> fix(cron): emit isolated model usage diagnostics. Related #92338. Thanks @849261680 and @vincentkoc and @niks999.</li>
|
||||
<li><strong>PR #93367</strong> Fix SSH sandbox remote directory args. Related #93344. Thanks @dmorn and @vincentkoc.</li>
|
||||
<li><strong>PR #93574</strong> fix(feishu): suppress log noise for bot_p2p_chat_entered_v1 event [AI-assisted]. Related #42351. Thanks @eldar702 and @vincentkoc and @sunking0223.</li>
|
||||
<li><strong>PR #93269</strong> Fix tokenjuice bash results without details. Thanks @moeedahmed and @vincentkoc.</li>
|
||||
<li><strong>PR #93575</strong> fix(telegram): hydrate group reply-chain media into model context [AI-assisted]. Thanks @eldar702 and @vincentkoc.</li>
|
||||
<li><strong>PR #93261</strong> fix(plugins): resolve provider policy surface for plugin-owned CLI backends. Related #93259. Thanks @BitmapAsset and @vincentkoc.</li>
|
||||
<li><strong>PR #93303</strong> fix(whatsapp): bound stalled read-receipt socket operations. Thanks @Alix-007 and @vincentkoc.</li>
|
||||
<li><strong>PR #93242</strong> fix(mattermost): keep bare @mention with empty body instead of dropping it. Related #93205. Thanks @iloveleon19 and @vincentkoc.</li>
|
||||
<li><strong>PR #93606</strong> fix(ui): clear stale Talk error when session transitions to non-error state (fixes #88176). Thanks @liuhao1024 and @vincentkoc and @BrianClaw1955.</li>
|
||||
<li><strong>PR #93607</strong> perf(tasks): memoize reconcileInspectableTasks for same-tick calls (fixes #73531). Thanks @liuhao1024 and @vincentkoc and @slideshow-dingo.</li>
|
||||
<li><strong>PR #93612</strong> fix(gateway): compute sessions.usage aggregate totals from all sessions, not just the limited page (fixes #76496). Thanks @liuhao1024 and @vincentkoc and @bobsahur-robot.</li>
|
||||
<li><strong>PR #93615</strong> fix(telegram): recover lone active spooled handler on timeout (#84158). Thanks @0xghost42 and @vincentkoc and @crash2kx.</li>
|
||||
<li><strong>PR #93616</strong> Keep key-free web search providers opt-in. Thanks @davemorin and @vincentkoc.</li>
|
||||
<li><strong>PR #93298</strong> fix #93044: control-ui webchat double-renders agent replies when dmScope=main. Thanks @zhangguiping-xydt and @vincentkoc and @cfmilam.</li>
|
||||
<li><strong>PR #93618</strong> fix(feishu): filter temporary card-action-c-\* IDs from reply target to prevent Invalid open_message_id errors (fixes #56818). Thanks @liuhao1024 and @vincentkoc and @SwordImmortal.</li>
|
||||
<li><strong>PR #93387</strong> feat(ios): add watch action surface. Thanks @Solvely-Colin and @joshavant.</li>
|
||||
<li><strong>PR #93648</strong> fix(doctor): archive superseded plugin install index conflicts. Related #90418. Thanks @vincentkoc and @ramitrkar-hash.</li>
|
||||
<li><strong>PR #93649</strong> fix(qwen): place DashScope image prompts in user content. Related #92688. Thanks @vincentkoc and @Yachiyo404.</li>
|
||||
<li><strong>PR #93650</strong> fix(update): avoid per-Node npm prefixes during self-update. Related #80387. Thanks @vincentkoc and @yaanfpv.</li>
|
||||
<li><strong>PR #93653</strong> fix(skill-workshop): skip helper sessions during auto-capture. Thanks @vincentkoc and @zhangguiping-xydt.</li>
|
||||
<li><strong>PR #93654</strong> fix(codex): expose remote node exec as a Codex dynamic tool. Related #92141. Thanks @vincentkoc and @JPKay-AI.</li>
|
||||
<li><strong>PR #93662</strong> fix(discord): protect mention aliases in code fences. Thanks @vincentkoc and @rohitjavvadi.</li>
|
||||
<li><strong>PR #93663</strong> fix(clawdock): open dashboard on published port without starting deps. Related #77344. Thanks @vincentkoc and @dhoman.</li>
|
||||
<li><strong>PR #93670</strong> fix(browser): recover stale managed Chrome CDP listener. Related #41750. Thanks @vincentkoc and @rohitjavvadi and @kissman911.</li>
|
||||
<li><strong>PR #93672</strong> fix(commands): preserve multiline slash skill args. Related #79155. Thanks @vincentkoc and @web3blind.</li>
|
||||
<li><strong>PR #93674</strong> fix(browser): accept top-level act fields with nested requests. Related #38762. Thanks @vincentkoc and @angelusbr and @Lumos-789.</li>
|
||||
<li><strong>PR #93678</strong> fix(plugins): allow Dreaming sidecar through restrictive memory allowlists. Related #92536. Thanks @vincentkoc and @pradeep7127 and @resYuto.</li>
|
||||
<li><strong>PR #93306</strong> fix(status): ignore stale context after model switch. Thanks @hxy91819.</li>
|
||||
<li><strong>PR #93666</strong> fix(control-ui): copy code blocks over plain HTTP via clipboard fallback. Related #93628. Thanks @Pick-cat and @pjq2926.</li>
|
||||
<li><strong>PR #93629</strong> fix(reply): preserve unsent text-only finals after block pipeline streamed partial content (fixes #81078). Thanks @liuhao1024 and @Jackten.</li>
|
||||
<li><strong>PR #93690</strong> fix(telegram): dispatch MEDIA directives as attachments. Related #77702. Thanks @vincentkoc and @butttersbot.</li>
|
||||
<li><strong>PR #93693</strong> fix(gateway): ignore stale sudo scope for root user services. Related #81410. Thanks @vincentkoc and @Ericksza.</li>
|
||||
<li><strong>PR #93646</strong> fix(agents): return string assistant content in getLastAssistantText. Thanks @Alix-007 and @vincentkoc.</li>
|
||||
<li><strong>PR #93687</strong> fix(i18n): retain Codex error tails in logs. Thanks @hxy91819.</li>
|
||||
<li><strong>PR #93630</strong> fix(heartbeat): bootstrap plugin session targets. Thanks @ZengWen-DT and @vincentkoc.</li>
|
||||
<li><strong>PR #93658</strong> fix(wizard): preserve existing default model during setup auth choice [AI-assisted]. Related #64129. Thanks @ml12580 and @vegapunk9527.</li>
|
||||
<li><strong>PR #93671</strong> fix(respawn): rewrite pnpm versioned entry paths to stable wrapper (fixes #52313). Thanks @liuhao1024 and @vincentkoc and @RichardCao.</li>
|
||||
<li><strong>PR #93698</strong> Fix Telegram rich progress detail updates. Thanks @obviyus.</li>
|
||||
<li><strong>PR #93656</strong> fix(gateway): send approval route notices with write scope. Related #93563. Thanks @mushuiyu886 and @vincentkoc and @clawbot247-commits.</li>
|
||||
<li><strong>PR #93665</strong> fix(gateway): surface codex app-server returned failures. Thanks @litang9 and @vincentkoc.</li>
|
||||
<li><strong>PR #93727</strong> fix(context-engine): avoid turn-maintenance lane livelock. Related #77340. Thanks @vincentkoc and @baghvn and @Veda-openclaw.</li>
|
||||
<li><strong>PR #93681</strong> fix(llm): handle string assistant content on the OpenAI-compatible completion path. Thanks @Alix-007.</li>
|
||||
<li><strong>PR #93722</strong> chore(release): update appcast for 2026.6.8. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #93677</strong> fix(google-meet): declare realtime provider secret inputs. Related #81891. Thanks @goutamadwant and @vincentkoc and @chachi-max.</li>
|
||||
<li><strong>PR #92947</strong> fix(qqbot): deliver cron auto-TTS voice by trusting OpenClaw temp root. Related #92816. Thanks @ZengWen-DT and @Zeng-wen and @lewiswu1209.</li>
|
||||
<li><strong>PR #93679</strong> fix(whatsapp): extract GIF metadata and distinguish gifPlayback in media placeholders (fixes #49099). Thanks @liuhao1024 and @vincentkoc and @bugkill3r.</li>
|
||||
<li><strong>PR #93688</strong> fix(minimax): check base_resp envelope errors in TTS provider. Related #76904. Thanks @dwc1997 and @najef1979-code.</li>
|
||||
<li><strong>PR #93714</strong> fix: isolate async model resolution mock from sync mock in flaky test. Related #92117. Thanks @lsr911 and @wangwllu.</li>
|
||||
<li><strong>PR #93705</strong> test(macos): cover root command dispatch. Related #83879. Thanks @markoub and @vincentkoc and @davinci282828.</li>
|
||||
<li><strong>PR #93711</strong> Keep command text in progress drafts. Thanks @keshavbotagent and @vincentkoc.</li>
|
||||
<li><strong>PR #93712</strong> fix: scope assistant avatar override to agent ID. Related #90890. Thanks @lsr911 and @vincentkoc and @najef1979-code.</li>
|
||||
<li><strong>PR #93725</strong> fix(usage): prune stale usage cache temp files. Related #78939. Thanks @markoub and @Tramsrepus.</li>
|
||||
<li><strong>PR #93726</strong> fix(typing): start typing on reasoning deltas in thinking mode before visible text. Related #79681. Thanks @xialonglee and @novaflash82.</li>
|
||||
<li><strong>PR #93716</strong> fix(discord): propagate timeout through channel capabilities diagnostics. Related #77040. Thanks @xialonglee and @vincentkoc and @unicebondoc.</li>
|
||||
<li><strong>PR #93729</strong> fix(ollama): preserve configured API during discovery. Related #93710. Thanks @zhangguiping-xydt and @vincentkoc and @obnoxious2011-cmd.</li>
|
||||
<li><strong>PR #93719</strong> fix: pin plugin workspace dir for sessions.list to avoid O(rows) memo busting. Related #90814. Thanks @lsr911 and @vincentkoc and @k-l-lambda.</li>
|
||||
<li><strong>PR #93732</strong> fix(agents): preserve re-sent user prompt during compaction transcript rotation. Thanks @yetval.</li>
|
||||
<li><strong>PR #93738</strong> fix: break plugin registry type import cycle. Thanks @giodl73-repo.</li>
|
||||
<li><strong>PR #93740</strong> fix(sessions): release retained locks after takeover. Thanks @TurboTheTurtle.</li>
|
||||
<li><strong>PR #93745</strong> fix(usage): reject invalid explicit dates in usage RPC date parsing. Thanks @harjothkhara and @vincentkoc.</li>
|
||||
<li><strong>PR #93746</strong> fix(ui): populate realtime talk provider and transport options from talk.catalog. Thanks @shushushv and @vincentkoc.</li>
|
||||
<li><strong>PR #93751</strong> fix(ios): fix quick setup sheet layout design. Thanks @zats.</li>
|
||||
<li><strong>PR #93749</strong> fix(compaction): ignore stale persisted totalTokens in preflight gate. Thanks @yetval.</li>
|
||||
<li><strong>PR #93753</strong> fix: correct tautological uppercase check in tool description summarizer. Thanks @GautamKumarOffical.</li>
|
||||
<li><strong>PR #89123</strong> refactor: route transcript writers through session seam. Thanks @jalehman.</li>
|
||||
<li><strong>PR #93758</strong> feat(memory): apply outputDimensionality truncation to local GGUF embeddings (fixes #58765). Thanks @liuhao1024 and @vincentkoc and @losz5000.</li>
|
||||
<li><strong>PR #93754</strong> feat(inbound-meta): expose per-turn source modality. Related #50482. Thanks @liuhao1024 and @vincentkoc and @JTOrca.</li>
|
||||
<li><strong>PR #93767</strong> fix(reasoning-tags): strip MiniMax <code>mm:</code> namespaced reasoning tags. Thanks @DrHack1 and @vincentkoc.</li>
|
||||
<li><strong>PR #93772</strong> fix(feishu): recover CJK filenames from JSON file_name field (fixes #81103). Thanks @liuhao1024 and @vincentkoc and @pjuneye.</li>
|
||||
<li><strong>PR #93773</strong> fix(ui): scope Skill Workshop proposals to selected agent. Related #93760. Thanks @TurboTheTurtle and @vincentkoc and @hannesrudolph.</li>
|
||||
<li><strong>PR #88750</strong> feat(context-engine): pass runtime settings into lifecycle. Thanks @ragesaq and @jalehman.</li>
|
||||
<li><strong>PR #93763</strong> fix(agents): use neutral billing copy for subscription auth. Related #80877. Thanks @eldar702 and @vincentkoc and @22kyasue.</li>
|
||||
<li><strong>PR #93818</strong> List all ClawHub docs in sidebar. Thanks @Patrick-Erichsen.</li>
|
||||
<li><strong>PR #93779</strong> fix(webchat): skip textarea resize during IME composition to eliminate typing lag. Related #90800. Thanks @joelnishanth and @vincentkoc and @w10497-create.</li>
|
||||
<li><strong>PR #93786</strong> fix(plugins): treat refreshable catalogs as requiring runtime discovery (fixes #93775). Thanks @liuhao1024 and @St0rmz1.</li>
|
||||
<li><strong>PR #93791</strong> fix(memory): await search-sync before returning results to prevent stale index (fixes #52115). Thanks @liuhao1024 and @vincentkoc and @FicheallADa.</li>
|
||||
<li><strong>PR #93780</strong> fix(google): keep parallel Gemini tool responses in the turn after the model. Thanks @yetval and @vincentkoc.</li>
|
||||
<li><strong>PR #93789</strong> fix(agents): make lane suspension consistent across cooldown-precheck and embedded-runner paths. Related #93036. Thanks @joelnishanth and @vincentkoc and @kumaxs.</li>
|
||||
<li><strong>PR #93798</strong> fix(status): show 0 (not ?) for fresh-session context tokens. Related #93771. Thanks @Alix-007 and @vincentkoc and @anarchia-99.</li>
|
||||
<li><strong>PR #93810</strong> fix(cron): preserve startup overflow catch-up deferrals in start() maintenance pass. Thanks @yetval.</li>
|
||||
<li><strong>PR #93811</strong> Strip UTF-8 BOM when reading SKILL.md in quick_validate. Thanks @HrachShah.</li>
|
||||
<li><strong>PR #93803</strong> fix(ui): preserve WebChat visible messages across session switches. Related #80855. Thanks @LiuwqGit and @vincentkoc and @viagarsuker.</li>
|
||||
<li><strong>PR #93792</strong> fix(android): wait for node capability approval before onboarding. Thanks @Solvely-Colin and @vincentkoc.</li>
|
||||
<li><strong>PR #93796</strong> fix(feishu): paginate wiki node and space listing (#37626). Thanks @ZengWen-DT and @vincentkoc and @ritou11.</li>
|
||||
<li><strong>PR #93797</strong> fix(browser): use openTab return value to prevent wsUrl race in ensureTabAvailable (fixes #63343). Thanks @liuhao1024 and @vincentkoc and @OpenCodeEngineer.</li>
|
||||
<li><strong>PR #93806</strong> fix(reasoning-tags): strip MiniMax mm: tags on silent-reply and streaming paths missed by #93767. Thanks @Alix-007 and @vincentkoc.</li>
|
||||
<li><strong>PR #93691</strong> refactor: add gateway sessions.create lifecycle seam. Thanks @jalehman.</li>
|
||||
<li><strong>PR #88748</strong> fix(gemini): bridge OAuth profiles into CLI runtime. Related #88742. Thanks @jason-allen-oneal.</li>
|
||||
<li><strong>PR #93857</strong> fix(deps): remediate Dependabot alerts. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #93874</strong> fix(slack): recognize MiniMax mm: namespaced reasoning tags in monitor preview. Thanks @Alix-007.</li>
|
||||
<li><strong>PR #93832</strong> feat(providers): add ClawRouter managed proxy. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #93880</strong> fix(macos): preserve approvals migration data. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #93903</strong> fix(cron): reject invalid absolute timestamps. Thanks @Alix-007 and @vincentkoc.</li>
|
||||
<li><strong>PR #93879</strong> fix(update): use configured npm registry for update metadata. Related #79140. Thanks @vincentkoc and @sixerLiu.</li>
|
||||
<li><strong>PR #93924</strong> revert(providers): remove ClawRouter provider. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #93955</strong> fix(telegram): surface rich-message disabled state. Thanks @obviyus.</li>
|
||||
<li><strong>PR #93881</strong> fix(agents): route BTW through canonical Codex runtime. Related #88902. Thanks @vincentkoc and @TurboTheTurtle and @khalil-omer.</li>
|
||||
<li><strong>PR #90192</strong> fix(feishu): fetch quoted content before empty-message guard. Related #90177. Thanks @bladin and @sliverp and @lkxlaz.</li>
|
||||
<li><strong>PR #93237</strong> Fix Mattermost open DM validation. Thanks @amknight.</li>
|
||||
<li><strong>PR #93945</strong> feat(diagnostics): add SIEM security events. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #87487</strong> fix(cli): clarify mcp list registry scope. Related #65209. Thanks @Alix-007 and @slideshow-dingo.</li>
|
||||
<li><strong>PR #24661</strong> feat(cohere): add provider plugin. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #93532</strong> Expose verified ClawHub source in skill verify output. Thanks @momothemage.</li>
|
||||
<li><strong>PR #93538</strong> feat(codex): support app-server network proxy profiles. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #93938</strong> fix(telegram): guard UTF-16 surrogate pairs in outbound chunkers. Related #93921. Thanks @Nas01010101 and @vincentkoc.</li>
|
||||
<li><strong>PR #94104</strong> feat(agents): trace compaction summarization model calls. Thanks @amknight.</li>
|
||||
<li><strong>PR #94108</strong> Fix package Telegram temp root. Thanks @obviyus.</li>
|
||||
<li><strong>PR #94113</strong> Fix Telegram package output mount. Thanks @obviyus.</li>
|
||||
<li><strong>PR #89062</strong> feat(docker): support offline setup reruns. Related #70443. Thanks @Alix-007 and @safrano9999.</li>
|
||||
<li><strong>PR #93929</strong> fix(secrets): explicitly pass BWS_SERVER_URL to resolver for self-hosted instances. Related #93851. Thanks @Pandah97 and @vincentkoc and @AdoShan.</li>
|
||||
<li><strong>PR #90057</strong> Polish Workboard operations view. Thanks @fuller-stack-dev.</li>
|
||||
<li><strong>PR #89396</strong> fix(doctor): drop inert legacy cron notify when cron.webhook is unset. Related #44460. Thanks @Alix-007.</li>
|
||||
<li><strong>PR #94138</strong> fix(session): prevent stale finalizer from recreating deleted session rows. Related #40840. Thanks @xialonglee and @vincentkoc and @AL-knows.</li>
|
||||
<li><strong>PR #93739</strong> refactor: add session patch projection seam. Thanks @jalehman.</li>
|
||||
<li><strong>PR #94178</strong> fix(workspace): skip optional bootstrap files when workspace setup is already completed. Related #83593. Thanks @dwc1997 and @jsompis.</li>
|
||||
<li><strong>PR #93363</strong> fix(feishu): enforce account tool family gates. Thanks @eleqtrizit.</li>
|
||||
<li><strong>PR #93813</strong> fix(codex): keep message registered for internal turns. Related #93750. Thanks @jalehman and @hannesrudolph.</li>
|
||||
<li><strong>PR #93659</strong> refactor: add session reset delete lifecycle seam. Thanks @jalehman.</li>
|
||||
<li><strong>PR #93852</strong> ci(release): harden release controls. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #94203</strong> feat(codex): support remote app-server plugins. Thanks @kevinslin.</li>
|
||||
<li><strong>PR #94263</strong> chore: migrate claw-score skill. Thanks @RomneyDa and @kevinslin.</li>
|
||||
<li><strong>PR #93695</strong> refactor: add compact trim lifecycle seam. Thanks @jalehman.</li>
|
||||
<li><strong>PR #93114</strong> test: fold lifecycle and package proof into QA Lab. Thanks @RomneyDa.</li>
|
||||
<li><strong>PR #93181</strong> test: fold otel smoke into qa e2e. Thanks @RomneyDa.</li>
|
||||
<li><strong>PR #93178</strong> test: fold gateway smoke into qa e2e. Thanks @RomneyDa.</li>
|
||||
<li><strong>PR #94276</strong> qa-lab: support script-backed evidence scenarios. Thanks @Solvely-Colin and @RomneyDa.</li>
|
||||
<li><strong>PR #94282</strong> Support owner-qualified ClawHub skill installs. Thanks @Patrick-Erichsen.</li>
|
||||
<li><strong>PR #93704</strong> refactor: add session cleanup lifecycle seam. Thanks @jalehman.</li>
|
||||
<li><strong>PR #94296</strong> fix: require all taxonomy coverage ids for a feature - AND not OR. Thanks @RomneyDa.</li>
|
||||
<li><strong>PR #92016</strong> fix(plugins): compose live hook registry view for tool-call hooks. Related #91918. Thanks @amknight and @vokaplok.</li>
|
||||
<li><strong>PR #89596</strong> fix(policy): recognize declared tool allowlists. Thanks @giodl73-repo.</li>
|
||||
<li><strong>PR #93713</strong> fix: route deleted-agent session purge through lifecycle seam. Thanks @jalehman.</li>
|
||||
<li><strong>PR #84172</strong> fix(exec): rebuild command authorization on the Tree-sitter command planner. Thanks @jesse-merhi.</li>
|
||||
<li><strong>PR #94332</strong> docs: add ClawHub namespace claims to sidebar. Thanks @Patrick-Erichsen.</li>
|
||||
<li><strong>PR #86360</strong> fix(codex): honor bound agent exec host policy. Thanks @jesse-merhi.</li>
|
||||
<li><strong>PR #73162</strong> fix(slack): remove socket reconnect attempt cap so gateway stays connected indefinitely. Related #72808. Thanks @suboss87 and @tleyden.</li>
|
||||
<li><strong>PR #94156</strong> fix: expose OpenAI image quality and moderation CLI options. Thanks @lastguru-net and @fuller-stack-dev.</li>
|
||||
<li><strong>PR #94350</strong> feat: externalize GMI provider plugin. Thanks @Patrick-Erichsen and @vincentkoc.</li>
|
||||
<li><strong>PR #94543</strong> fix(gateway): bound config.get middleware results. Related #94265. Thanks @vincentkoc and @v-s-gusev.</li>
|
||||
<li><strong>PR #91409</strong> fix(update): run plugin convergence after RPC git updates. Thanks @masatohoshino.</li>
|
||||
<li><strong>PR #94556</strong> chore(extensions): bump tokenjuice to 0.8.1. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #94580</strong> fix(ci): stabilize update run gates.</li>
|
||||
<li><strong>PR #94394</strong> fix(infra): probe 127.0.0.1 in ensurePortAvailable to detect IPv4-only occupants. Related #94379. Thanks @Pandah97 and @wangwllu.</li>
|
||||
<li><strong>PR #94421</strong> fix(agents): preserve active compaction retries. Related #94391. Thanks @dexiosmb.</li>
|
||||
<li><strong>PR #94428</strong> fix(feishu): preserve replies before error finals. Related #94360. Thanks @xunx33.</li>
|
||||
<li><strong>PR #93735</strong> refactor: add restart recovery lifecycle seam. Thanks @jalehman.</li>
|
||||
<li><strong>PR #94591</strong> docs(release): backfill complete contribution records. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #94588</strong> fix(cron): retry isolated setup timeouts. Thanks @aaroneden.</li>
|
||||
<li><strong>PR #94082</strong> fix(cron): prevent lane timeout during long tool execution. Related #94033. Thanks @ajwan8998 and @JingWang-Star996.</li>
|
||||
<li><strong>PR #94551</strong> feat(firecrawl): add keyless scrape support. Thanks @vincentkoc and @developersdigest.</li>
|
||||
<li><strong>PR #94619</strong> test(ci): stabilize timeout-sensitive shards. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #94048</strong> fix(telegram): set richMessages default to false explicitly in schema. Related #93770, #93794. Thanks @Monkey-wusky and @obviyus and @Nardoa375 and @laurenceputra.</li>
|
||||
<li><strong>PR #94118</strong> [codex] Fix Telegram rich local Markdown link hrefs. Related #94117. Thanks @dankarization and @obviyus.</li>
|
||||
<li><strong>PR #94646</strong> refactor(sqlite): land database-first memory and proxy alignment. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #94658</strong> test(sqlite): use shared temp directory helper. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #92135</strong> fix(openai-embedding): preserve openai/ prefix for non-native base URLs. Related #92124. Thanks @xialonglee and @Kambrian.</li>
|
||||
<li><strong>PR #93737</strong> refactor: add session maintenance transaction seam. Thanks @jalehman.</li>
|
||||
<li><strong>PR #93685</strong> refactor(auto-reply): add lifecycle storage seams. Thanks @jalehman.</li>
|
||||
<li><strong>PR #94349</strong> fix(agents): preserve pending subagent completion announces. Related #93323. Thanks @sallyom and @oiGaDio.</li>
|
||||
<li><strong>PR #93174</strong> test: fold channel message flows into qa e2e. Thanks @RomneyDa.</li>
|
||||
<li><strong>PR #94093</strong> Prevent Codex thread rotation from losing next-step context. Thanks @VACInc.</li>
|
||||
<li><strong>PR #53920</strong> fix(scripts): avoid mutating tracked auth-monitor template during setup. Thanks @JackWuGlobal.</li>
|
||||
<li><strong>PR #94702</strong> Standardize QA coverage IDs on dotted names. Thanks @RomneyDa.</li>
|
||||
<li><strong>PR #81825</strong> fix(skills/1password): stop forcing tmux for desktop app auth (#52540). Thanks @koshaji and @tylerbittner.</li>
|
||||
<li><strong>PR #94725</strong> fix(doctor): warn on volatile SQLite state. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #88551</strong> fix(agents): skip auth gate for CLI-owned transport. Thanks @yu-xin-c.</li>
|
||||
<li><strong>PR #88581</strong> feat(commands): add /name to rename the current session from chat. Thanks @BSG2000.</li>
|
||||
<li><strong>PR #94324</strong> feat(codex): support app-server SecretRefs. Thanks @kevinlin-openai and @kevinslin.</li>
|
||||
<li><strong>PR #90882</strong> fix: add self-knowledge docs rule to system prompt. Related #90713. Thanks @SutraHsing.</li>
|
||||
<li><strong>PR #94684</strong> fix: #80507 show dry-run output for message send/poll. Thanks @lzyyzznl and @YB0y.</li>
|
||||
<li><strong>PR #93823</strong> fix(whatsapp): keep opening text chunk when first media fails on multi-chunk reply. Thanks @yetval.</li>
|
||||
<li><strong>PR #89203</strong> refactor: route SDK session compatibility through seam. Thanks @jalehman.</li>
|
||||
<li><strong>PR #94453</strong> fix: default cron runMode to "due" instead of "force" (#94270). Thanks @jincheng-xydt and @sallyom and @davectr.</li>
|
||||
<li><strong>PR #94746</strong> fix(note): prevent clack from re-breaking copy-sensitive tokens. Related #94730. Thanks @xzh-icenter and @berkgungor.</li>
|
||||
<li><strong>PR #89904</strong> refactor: route sdk session compatibility through accessor. Thanks @jalehman.</li>
|
||||
<li><strong>PR #86719</strong> fix(skills): retarget stale plugin skill symlinks. Related #85925. Thanks @stevenepalmer and @shakkernerd.</li>
|
||||
<li><strong>PR #94337</strong> fix(tui): show 0 not ? for fresh-session context tokens in footer. Thanks @mushuiyu886.</li>
|
||||
<li><strong>PR #94539</strong> fix(android): group settings by intent. Thanks @Tosko4.</li>
|
||||
<li><strong>PR #92383</strong> fix(gateway): never return an empty chat.history transcript. Thanks @Hidetsugu55.</li>
|
||||
<li><strong>PR #92574</strong> test(browser): cover action-input CLI request bodies. Related #83877. Thanks @yu-xin-c and @davinci282828.</li>
|
||||
<li><strong>PR #92873</strong> test(diffs): add viewerState, toolbar toggle, shadow root, and hydrateProps tests (fixes #83915). Thanks @liuhao1024 and @davinci282828.</li>
|
||||
<li><strong>PR #94257</strong> fix(sessions): preserve Media\* index alignment when reading user-turn fields. Thanks @Nas01010101.</li>
|
||||
<li><strong>PR #94756</strong> fix(codex): bound turn/start text when context budget is non-positive. Related #94748. Thanks @Nas01010101.</li>
|
||||
<li><strong>PR #94729</strong> fix(skills/trello): add curl to requires.bins to match body examples (fixes #94727). Thanks @liuhao1024 and @berkgungor.</li>
|
||||
<li><strong>PR #94790</strong> feat(slack): log INFO receipt for inbound app_mention events. Related #94691. Thanks @ZengWen-DT and @BryceMurray.</li>
|
||||
<li><strong>PR #81696</strong> fix: guard tool event callbacks (AI-assisted). Thanks @enjoylife1243.</li>
|
||||
<li><strong>PR #94809</strong> chore: forward-port alpha release fixes.</li>
|
||||
<li><strong>PR #94612</strong> fix(macos): open NSOpenPanel for embedded Control UI file inputs (#94468). Thanks @bbblending and @DINGDANGMAOUP.</li>
|
||||
<li><strong>PR #89806</strong> fix(feishu): avoid axios interceptor internals. Related #83913. Thanks @sweetcornna and @davinci282828.</li>
|
||||
<li><strong>PR #91923</strong> fix(ios): clean up notification settings state. Thanks @zats.</li>
|
||||
<li><strong>PR #91345</strong> fix: suggest close CLI commands. Related #83999. Thanks @glenn-agent and @HannesOberreiter.</li>
|
||||
<li><strong>PR #94561</strong> Add stdout diagnostics OTEL log exporter. Thanks @jesse-merhi.</li>
|
||||
<li><strong>PR #91013</strong> fix(gateway): ignore stale abort markers for fresh chat events. Related #91012. Thanks @nxmxbbd.</li>
|
||||
<li><strong>PR #89279</strong> fix(tasks): deliver ACP completions to bound Discord threads. Related #84022. Thanks @anyech and @h-mascot.</li>
|
||||
<li><strong>PR #91656</strong> test(cron): expand parseAbsoluteTimeMs test coverage to 39 cases. Related #91654. Thanks @SpecialLeon.</li>
|
||||
<li><strong>PR #94810</strong> fix(telegram): classify sendChatAction 401 by structured error_code, not bare substring match. Related #94787. Thanks @ZOOWH and @parveshsaini.</li>
|
||||
<li><strong>PR #94737</strong> fix(reply): clarify provider internal error copy. Thanks @snowzlmbot.</li>
|
||||
<li><strong>PR #94868</strong> fix(channels): preserve command progress detail. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #94891</strong> fix(telegram): send progress previews as html text. Thanks @obviyus.</li>
|
||||
<li><strong>PR #94683</strong> fix(outbound): keep direct-only targets out of group sessions. Related #92384. Thanks @scotthuang and @haiwei01.</li>
|
||||
<li><strong>PR #92477</strong> fix: migrate watch app to single-target app (Xcode 27+ compat). Thanks @zats and @joshavant.</li>
|
||||
<li><strong>PR #94812</strong> test(perf): compare saved CLI startup benchmarks. Thanks @FelixIsaac.</li>
|
||||
<li><strong>PR #94856</strong> fix(telegram): normalize all HTML tables before entity-escaping in rich messages. Related #94317. Thanks @zhangqueping and @jairrab.</li>
|
||||
<li><strong>PR #91685</strong> fix(cron): refuse keyless implicit isolated cron delivery inherited from shared agent-main bucket. Thanks @nxmxbbd.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.9/OpenClaw-2026.6.9.zip" length="56109134" type="application/octet-stream" sparkle:edSignature="LVSWrdPpUtbyfNCZUrMm+I0SHoJA/CU4HHQmHyup4IVoAAa5drdtyYaXmNQPk/OXclhgOBJrVKv7Vw+R1VXLCg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.6.8</title>
|
||||
<pubDate>Tue, 16 Jun 2026 17:17:20 +0000</pubDate>
|
||||
@@ -124,132 +590,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.5/OpenClaw-2026.6.5.zip" length="55725877" type="application/octet-stream" sparkle:edSignature="EKr7gCfpEVStis9HSADJk1CWYbmH2MHMqSgNfZvLbBFCBWmk3pjBJS6K2qkxkq5lIbTj4H+Lo7Iri6ip/xTGDA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.6.1</title>
|
||||
<pubDate>Wed, 03 Jun 2026 21:26:22 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026060190</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.6.1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.6.1</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Agents and CLI-backed runtimes recover more cleanly from interrupted tool calls, stale session bindings, compaction handoffs, and media delivery retries. (#88129, #88136, #88141, #88162, #88182)</li>
|
||||
<li>Channels and mobile delivery are steadier across Telegram, WhatsApp, iMessage, Slack, Discord, Microsoft Teams, Google Chat, Google Meet, and iOS realtime Talk. (#88096, #88105, #88183, #88231)</li>
|
||||
<li>Provider and plugin requests now bound more timers, retries, OAuth/device-code lifetimes, media downloads, local service probes, and generated-content polling paths before they can hang a run.</li>
|
||||
<li>Skills, session metadata, gateway runtime state, plugin metadata, memory watchers, and store writes do less repeated work on hot paths while keeping config, dispatch, and Linux file-watch behavior stable. (#89185, #89188, #85351) Thanks @RomneyDa and @NianJiuZst.</li>
|
||||
<li>Skills and plugin loading now handle stale disabled snapshots and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173) Thanks @zeus1959.</li>
|
||||
<li>Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)</li>
|
||||
<li>Skill Workshop now has a fuller Control UI flow with proposal lists, today actions, revision handoff, searchable file previews, review states, locale coverage, and reusable session routing.</li>
|
||||
<li>Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, keep drafts local while typing, clear the composer after sends, trace first-output latency, prioritize first connect, and expose calmer composer controls. (#88772, #88825, #88998, #89030, #89106) Thanks @vincentkoc and @sallyom.</li>
|
||||
<li>Provider coverage and model metadata now include MiniMax M3, account OAuth endpoints, Google/Vertex catalog fixes, OpenRouter SQLite model caching, Copilot Claude 1M capabilities, Foundry reasoning alignment, and OpenAI response replay guards. (#88480, #88512, #88851, #88860)</li>
|
||||
<li>iMessage monitor state, inbound queues, and plugin install ledgers moved toward SQLite-backed state so restarts and local monitors recover with less duplicate filesystem scanning. (#88794, #88797)</li>
|
||||
<li>Release, CI, Docker, E2E, plugin install, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, status polling, child workflow waits, docker package cleanup, quiet test stalls, and rollback snapshots so failures report bounded proof instead of stalling. (#88966) Thanks @RomneyDa.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Docs: add a dedicated Skill Workshop guide covering governed skill creation, reviewable proposals, CLI, Gateway, agent tool behavior, approval policy, support files, and recovery, and refresh the ClawHub showcase cards. (#88734) Thanks @shakkernerd and @vyctorbrzezowski.</li>
|
||||
<li>Skills: let the <code>skill_workshop</code> agent tool apply, reject, and quarantine explicit proposals through the guarded review flow. Thanks @shakkernerd.</li>
|
||||
<li>Skills: let proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.</li>
|
||||
<li>Skills: let pending proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.</li>
|
||||
<li>Skills: add Skill Workshop with pending proposals, CLI/Gateway review actions, rollback metadata, and the <code>skill_workshop</code> agent tool. Thanks @shakkernerd.</li>
|
||||
<li>Skill Workshop: add the Control UI navigation, styled dashboard, proposal today view, revision dialog, file preview modal, searchable preview files, reusable session handoff, and localized strings.</li>
|
||||
<li>Plugins: externalize Tokenjuice as the official <code>@openclaw/tokenjuice</code> plugin with npm and ClawHub publish metadata.</li>
|
||||
<li>Plugins: externalize the GitHub Copilot agent runtime as the official <code>@openclaw/copilot</code> plugin with npm and ClawHub publish metadata.</li>
|
||||
<li>iOS: add hosted push relay defaults, realtime Talk playback, and a guarded WebSocket ping path for more reliable mobile sessions. (#88096, #88105, #88231)</li>
|
||||
<li>iOS: support native iPad display layouts.</li>
|
||||
<li>Workboard: add orchestration primitives and agent coordination tools for multi-agent planning and run tracking. (#87469)</li>
|
||||
<li>Workboard: wire task-backed board runs and show task comments in the edit modal.</li>
|
||||
<li>Code mode: add internal namespaces for scoped agent/global sessions and exact namespace tool dispatch. (#88043)</li>
|
||||
<li>Code mode: add MCP API files and docs for code-mode integrations.</li>
|
||||
<li>Control UI: add a Dreaming-tab agent selector and propagate the selected agent through Dreaming status, diary, and diary actions. (#78748) Thanks @stevenepalmer.</li>
|
||||
<li>Control UI: add calmer chat composer controls, local draft typing state, and first-output latency instrumentation for active chat entry. (#88772, #88998) Thanks @vincentkoc.</li>
|
||||
<li>Plugins: add a SecretRef provider integration manifest contract and extract shared LLM core packages for provider/plugin reuse. (#82326, #88117)</li>
|
||||
<li>Plugins: persist the plugin install index in SQLite so installed package lookup survives reloads with less filesystem scanning. (#88794)</li>
|
||||
<li>Providers: add MiniMax M3 model support. (#88860)</li>
|
||||
<li>Doctor: add disk space health checks and stabilize post-upgrade JSON probes.</li>
|
||||
<li>Channels: store inbound queues in SQLite and migrate iMessage monitor state to SQLite-backed tracking. (#88797)</li>
|
||||
<li>Skills: add the core skills index and centralize skills runtime loading, status, filtering, and prompt formatting.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Release/CI/E2E: fail early when Crabbox sparse-sync full checkouts do not have enough local disk, with guidance for moving the sync root.</li>
|
||||
<li>Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time.</li>
|
||||
<li>Plugins: stop timed-out package-boundary prep steps by process group so descendant TypeScript/helper processes do not survive local check cleanup.</li>
|
||||
<li>Control UI: serve static assets asynchronously after safe-open checks so large UI files do not block Gateway request handling.</li>
|
||||
<li>Scripts/UI: forward direct wrapper SIGHUP shutdown to child processes so terminal hangups do not leave wrapped dev commands running.</li>
|
||||
<li>Gateway: return the post-expiration pending-work revision from node drains so reconnecting nodes do not observe stale queue revisions after expired items are pruned.</li>
|
||||
<li>Release/CI/E2E: keep temporary full-sync checkouts alive while slow Crabbox leases boot, so sparse worktree runs do not lose their sync source before file-list generation.</li>
|
||||
<li>Release/CI/E2E: normalize inherited Linux <code>C.UTF-8</code> locale settings before raw AWS macOS Crabbox bootstrap commands, avoiding macOS locale warnings during package-manager hydration.</li>
|
||||
<li>Release/CI/E2E: keep gateway watch regression checks from copying large static plugin assets inside the measured idle window.</li>
|
||||
<li>Update: keep core updates nonblocking when a missing external plugin repair download stalls, while still blocking installed active plugin payload smoke failures.</li>
|
||||
<li>Agents/providers: keep streaming tool-call argument parsing record-shaped when providers emit valid non-object JSON such as <code>null</code> or arrays.</li>
|
||||
<li>Release/CI/E2E: reset incremental log readers when watched log files rotate without shrinking, so same-size replacements do not hide new readiness or RPC lines.</li>
|
||||
<li>Talk: preserve explicit <code>null</code> payloads on controller-created turn and output-audio lifecycle events.</li>
|
||||
<li>Agents/TUI: keep local custom provider runs from loading plugin runtime and auth alias metadata when plugins are disabled.</li>
|
||||
<li>Agents/TUI: restore in-flight TUI run switch-back behavior, keep no-policy native hook fallback available, guard vanished workspaces, and keep lightweight isolated subagents lightweight.</li>
|
||||
<li>Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.</li>
|
||||
<li>Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.</li>
|
||||
<li>Agents/Codex: stream Codex app-server final-answer partials to live reply previews, preserve ACP metadata in SQLite, prefer real tool results over synthetic repair output, prevent aborted app-server turn handles from lingering, migrate legacy OpenAI Codex <code>lastGood</code> auth state, and preserve workspace/session metadata through ACP runtime refactors. (#88405, #88724, #88730) Thanks @vincentkoc.</li>
|
||||
<li>Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.</li>
|
||||
<li>Agents/Codex: surface Skill Workshop guidance in Codex app-server prompts when <code>skill_workshop</code> is available. Thanks @shakkernerd.</li>
|
||||
<li>Skill Workshop: restore and localize the Control UI board/today view switcher so review workflows keep their intended layout toggle across locales. Thanks @shakkernerd.</li>
|
||||
<li>Agents/auth: write auth profiles atomically, dispatch auth failures by type, add force re-login recovery, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state. (#89181) Thanks @RomneyDa.</li>
|
||||
<li>Skills: skip disabled skill env overrides from stale persisted snapshots so disabled skill <code>apiKey</code> SecretRefs cannot abort embedded or channel turns. (#79072, #79173) Thanks @zeus1959.</li>
|
||||
<li>Skill Workshop: render the Control UI tab from filtered navigation state and keep filtered fallback routing stable.</li>
|
||||
<li>CLI: avoid live catalog validation during <code>openclaw agents add</code>, so adding a secondary agent no longer depends on provider catalog availability. (#76284, #88314) Thanks @zhangguiping-xydt.</li>
|
||||
<li>CLI: keep <code>plugins list --json</code> on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.</li>
|
||||
<li>CLI/desktop: bridge WSL clipboard operations through the shell, recognize manual-update launchd jobs, and keep machine-readable startup output parseable during progress setup. (#88764, #88689) Thanks @alexzhu0.</li>
|
||||
<li>Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.</li>
|
||||
<li>Plugins: clarify plugin loader failure guidance so missing or incompatible plugin packages point operators at the right repair path.</li>
|
||||
<li>Plugins: preserve npm plugin roots after blocked installs, skip plugin-local <code>openclaw</code> peer symlinks during rollback snapshots, relink those peers after restore, isolate cached tool runtime siblings, and isolate web-provider factory failures so one bad plugin does not poison sibling runtime paths. (#77237, #88807)</li>
|
||||
<li>Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)</li>
|
||||
<li>Cron: keep update delivery validation scoped, harden restart state, and retire MCP runtimes on isolated cron cleanup.</li>
|
||||
<li>Memory: serialize QMD update/embed writes per store, reduce Linux watcher fan-out, retry transient FileProvider-backed reads, preserve phase signals on read errors, harden envelope metadata sanitization, reattach Linux native watchers when directories are recreated, and rewrite generated transcript paths on rollover so memory/search state survives concurrent gateway and CLI activity. (#66339, #85931, #89185, #89188, #85351) Thanks @openperf, @amittell, @RomneyDa, and @NianJiuZst.</li>
|
||||
<li>Memory: keep vector-disabled FTS indexes from resolving embedding providers during sync and search.</li>
|
||||
<li>Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.</li>
|
||||
<li>Providers: resolve Google defaults to <code>google-generative-ai</code>, register Vertex static catalog rows, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, forward Gemini stop sequences, strip Kimi-incompatible Anthropic cache markers, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512, #76612) Thanks @coder999999999, @BryanTegomoh, and @vliuyt.</li>
|
||||
<li>Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.</li>
|
||||
<li>Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.</li>
|
||||
<li>Agents/Codex: keep live session locks during cleanup, recover interrupted CLI tool transcripts, preserve Codex auth and compaction session identity, clear orphan tool state, cap app-server idle timers, and keep media completion delivery retryable. (#88129, #88136, #88141, #88162, #88182)</li>
|
||||
<li>Chat/UI: show Gateway chat failures as visible assistant messages in the Control UI instead of only setting an invisible error state.</li>
|
||||
<li>Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; and retry WhatsApp QR login 408 timeouts. (#88183)</li>
|
||||
<li>Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.</li>
|
||||
<li>Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.</li>
|
||||
<li>Release/CI/E2E: bound release candidate reads, beta smoke REST calls, plugin npm verification commands, changelog restore, cross-OS process groups, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Telegram credential timeouts, Control UI i18n and CLI startup metadata generation, Vitest routing, dependency guard admin approvals, child workflow failure detection, quiet Node test shard stalls, docker package cleanup, and mainline test flakes. (#88127, #88137, #88155, #88160, #88966) Thanks @RomneyDa.</li>
|
||||
<li>Release/CI/E2E: keep Kitchen Sink live plugin MCP probes resolving source-checkout workspace packages and align the live gauntlet with current Kitchen Sink diagnostics.</li>
|
||||
<li>Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.</li>
|
||||
<li>Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.</li>
|
||||
<li>Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.</li>
|
||||
<li>Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.</li>
|
||||
<li>Agents: accept hidden <code>sessions_send</code> body aliases before validation while keeping the model-facing <code>message</code> schema canonical. (#88229) Thanks @zhangguiping-xydt.</li>
|
||||
<li>Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, keep drafts local while typing, guard composer rerenders, honor Chromium executable overrides, and detect system Chromium for E2E. (#88998) Thanks @vincentkoc.</li>
|
||||
<li>Channels: stop schema-padded poll modifiers from turning normal <code>send</code> actions into invalid poll sends. (#89601) Thanks @codezz.</li>
|
||||
<li>Channels: preserve long Feishu streaming replies, send visible fallbacks when accepted Feishu turns produce no final reply, tolerate iMessage self-chat timestamp skew, preserve colon-prefixed slash commands in mention parsing, decode Nostr <code>npub</code> allowlists correctly, and suppress raw provider errors during channel delivery. (#87896)</li>
|
||||
<li>Config/status/doctor: skip unresolved shell references in state-dir dotenv files, resolve gateway auth secrets during deep status audits, respect explicit PI runtime policy, report runtime tool-schema errors, and keep post-upgrade JSON stable. (#88288)</li>
|
||||
<li>Gateway/session state: list commands from the Gateway plugin registry, harden MCP loopback tool schemas, hide phantom agent-store rows from <code>sessions.list</code>, make task persistence failures explicit, and carry session UUIDs on interactive dispatch events.</li>
|
||||
<li>Gateway/plugins: narrow plugin lookup memoization to the stable plugin/runtime inputs, avoiding repeated lookup work without mixing disabled or filtered plugin state.</li>
|
||||
<li>OpenAI/TTS: handle speed directives for OpenAI TTS voices. (#74089)</li>
|
||||
<li>CI/Crabbox: keep default runner capacity on the Azure credit-backed on-demand D4 lane with the Azure SSH port and a Git-independent full check job, so broad validation avoids low-priority spot quota stalls, hydrate port mismatches, non-Git hydrated workspaces, and stale AWS region hints.</li>
|
||||
<li>CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.</li>
|
||||
<li>CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.</li>
|
||||
<li>CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.</li>
|
||||
<li>CI/tooling: route package, release, and install helper edits to their owner tests so changed-test gates cover publish and installer script changes.</li>
|
||||
<li>CI/tooling: route shared script library edits through their owner tests so lock, process, safety, and scan helpers do not skip changed-test coverage.</li>
|
||||
<li>CI/tooling: skip expensive import-graph scans once a changed diff already requires broad fallback, keeping local changed-test planning fast while still collecting explicit owner tests.</li>
|
||||
<li>CI/tooling: route script edits through conventional owner tests when matching <code>test/scripts</code> or <code>src/scripts</code> coverage already exists.</li>
|
||||
<li>CI/tooling: honor option terminators in the memory FD repro script so follow-on arguments are not reparsed.</li>
|
||||
<li>Release/CI/E2E: assert plugin lifecycle runtime inspect output instead of only capturing it.</li>
|
||||
<li>Release/CI/E2E: make gateway-network prove the advertised health RPC and retry early WebSocket closes without burning full open timeouts.</li>
|
||||
<li>Release/CI/E2E: honor option terminators across release, Parallels smoke, plugin gauntlet, and extension-memory scripts.</li>
|
||||
<li>Release/CI/E2E: fail plugin gateway gauntlet QA chunks when the requested suite summary is missing or invalid.</li>
|
||||
<li>Performance: prebuild QA runtime probes with generated plugin assets but without CLI startup metadata.</li>
|
||||
<li>Performance: skip declaration bundling for runtime-only CLI startup and gateway watch build profiles.</li>
|
||||
<li>Performance: reuse prepared provider handles, strict tool schemas, gateway runtime metadata, session maintenance config, plugin metadata, bundled skill allowlists, package-local plugin artifacts, single-entry store writes, and validated/serialized session prompt blobs.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.1/OpenClaw-2026.6.1.zip" length="55062100" type="application/octet-stream" sparkle:edSignature="PVp8E2HBCvikB/0LCr36lFEyHPAzoFA2ScT6LW27FlzvP+m4r1AEuVN2UrtgWlpkGSsn4Eav0kPJe32u4ObNBw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -143,39 +143,12 @@ 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 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.
|
||||
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.
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,7 @@
|
||||
/** Doctor contract hooks for Codex config, state migration, and route ownership. */
|
||||
/**
|
||||
* Doctor contract hooks for Codex plugin config migrations and session-route
|
||||
* ownership warnings.
|
||||
*/
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { DoctorSessionRouteStateOwner } from "openclaw/plugin-sdk/runtime-doctor";
|
||||
|
||||
@@ -48,7 +51,9 @@ 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[];
|
||||
@@ -66,9 +71,10 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }):
|
||||
const nextConfig = structuredClone(cfg) as OpenClawConfig & {
|
||||
plugins?: Record<string, unknown>;
|
||||
};
|
||||
const nextPluginConfig = asRecord(
|
||||
asRecord(asRecord(asRecord(nextConfig.plugins)?.entries)?.codex)?.config,
|
||||
);
|
||||
const nextPlugins = asRecord(nextConfig.plugins);
|
||||
const nextEntries = asRecord(nextPlugins?.entries);
|
||||
const nextEntry = asRecord(nextEntries?.codex);
|
||||
const nextPluginConfig = asRecord(nextEntry?.config);
|
||||
if (!nextPluginConfig) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
@@ -115,5 +121,3 @@ export const sessionRouteStateOwners: DoctorSessionRouteStateOwner[] = [
|
||||
authProfilePrefixes: ["codex:", "codex-cli:", "openai-codex:"],
|
||||
},
|
||||
];
|
||||
|
||||
export { stateMigrations } from "./src/migration/session-binding-sidecars.js";
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
// 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({
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
});
|
||||
const harness = createCodexAppServerAgentHarness();
|
||||
|
||||
it("supports the canonical codex virtual provider", () => {
|
||||
expect(harness.supports({ provider: "codex", requestedRuntime: "codex" })).toEqual({
|
||||
@@ -49,149 +40,8 @@ describe("Codex agent harness supports()", () => {
|
||||
});
|
||||
|
||||
it("honors explicit provider id overrides", () => {
|
||||
const narrowHarness = createCodexAppServerAgentHarness({
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
providerIds: ["codex"],
|
||||
});
|
||||
const narrowHarness = createCodexAppServerAgentHarness({ 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,13 +7,11 @@ 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 = [
|
||||
@@ -39,14 +37,12 @@ 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) =>
|
||||
@@ -75,7 +71,6 @@ 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 },
|
||||
});
|
||||
@@ -83,7 +78,6 @@ 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 },
|
||||
});
|
||||
@@ -91,43 +85,20 @@ 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.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`,
|
||||
);
|
||||
}
|
||||
if (params.sessionFile) {
|
||||
const { clearCodexAppServerBinding } = await import("./src/app-server/session-binding.js");
|
||||
await clearCodexAppServerBinding(params.sessionFile);
|
||||
}
|
||||
},
|
||||
dispose: async () => {
|
||||
|
||||
@@ -4,30 +4,10 @@ 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,
|
||||
}));
|
||||
@@ -60,6 +40,7 @@ describe("codex plugin", () => {
|
||||
const registerProvider = vi.fn();
|
||||
const registerWebSearchProvider = vi.fn();
|
||||
const on = vi.fn();
|
||||
const onConversationBindingResolved = vi.fn();
|
||||
|
||||
plugin.register(
|
||||
createTestPluginApi({
|
||||
@@ -68,7 +49,7 @@ describe("codex plugin", () => {
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig: {},
|
||||
runtime: createCodexTestRuntime(),
|
||||
runtime: {} as never,
|
||||
registerAgentHarness,
|
||||
registerCommand,
|
||||
registerMediaUnderstandingProvider,
|
||||
@@ -76,6 +57,7 @@ describe("codex plugin", () => {
|
||||
registerProvider,
|
||||
registerWebSearchProvider,
|
||||
on,
|
||||
onConversationBindingResolved,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -85,6 +67,9 @@ 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");
|
||||
@@ -118,12 +103,33 @@ 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({
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
});
|
||||
const harness = createCodexAppServerAgentHarness();
|
||||
|
||||
expect(harness.deliveryDefaults?.sourceVisibleReplies).toBe("message_tool");
|
||||
expect(
|
||||
@@ -144,196 +150,8 @@ 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({
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
pluginConfig: { appServer: {} },
|
||||
});
|
||||
const harness = createCodexAppServerAgentHarness({ pluginConfig: { appServer: {} } });
|
||||
const result = { success: true };
|
||||
runCodexAppServerAttemptMock.mockResolvedValueOnce(result);
|
||||
|
||||
@@ -342,7 +160,6 @@ describe("codex plugin", () => {
|
||||
expect(runCodexAppServerAttemptMock).toHaveBeenCalledWith(
|
||||
{ prompt: "hello" },
|
||||
{
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
pluginConfig: { appServer: {} },
|
||||
nativeHookRelay: { enabled: true },
|
||||
},
|
||||
@@ -377,7 +194,11 @@ describe("codex plugin", () => {
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig: { codexPlugins: { enabled: false } },
|
||||
runtime: createCodexTestRuntime(() => liveConfig),
|
||||
runtime: {
|
||||
config: {
|
||||
current: () => liveConfig,
|
||||
},
|
||||
} as never,
|
||||
registerAgentHarness,
|
||||
registerCommand: vi.fn(),
|
||||
registerMediaUnderstandingProvider: vi.fn(),
|
||||
@@ -397,49 +218,14 @@ 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({
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
pluginConfig: { appServer: {} },
|
||||
});
|
||||
const harness = createCodexAppServerAgentHarness({ pluginConfig: { appServer: {} } });
|
||||
const runSideQuestion = harness["runSideQuestion"];
|
||||
const result = { text: "ok" };
|
||||
runCodexAppServerSideQuestionMock.mockResolvedValueOnce(result);
|
||||
@@ -452,7 +238,6 @@ describe("codex plugin", () => {
|
||||
expect(runCodexAppServerSideQuestionMock).toHaveBeenCalledWith(
|
||||
{ question: "btw" },
|
||||
{
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
pluginConfig: { appServer: {} },
|
||||
nativeHookRelay: { enabled: true },
|
||||
},
|
||||
|
||||
@@ -4,72 +4,48 @@
|
||||
*/
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation";
|
||||
import {
|
||||
resolveLivePluginConfigObject,
|
||||
resolvePluginConfigObject,
|
||||
} from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import { resolveLivePluginConfigObject } 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,
|
||||
} from "./src/node-cli-session-registration.js";
|
||||
listCodexCliSessionsOnNode,
|
||||
resumeCodexCliSessionOnNode,
|
||||
resolveCodexCliSessionForBindingOnNode,
|
||||
} from "./src/node-cli-sessions.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 runtimeConfigLoader = api.runtime.config?.current
|
||||
? () => api.runtime.config?.current() as OpenClawConfig
|
||||
: undefined;
|
||||
const resolveCurrentConfig = () => runtimeConfigLoader?.();
|
||||
const loadNodeCliSessions = () => import("./src/node-cli-sessions.js");
|
||||
const resolveCurrentConfig = () =>
|
||||
api.runtime.config?.current ? (api.runtime.config.current() as OpenClawConfig) : undefined;
|
||||
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(
|
||||
runtimeConfigLoader,
|
||||
resolveCurrentConfig,
|
||||
"codex",
|
||||
api.pluginConfig as Record<string, unknown>,
|
||||
);
|
||||
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.pluginConfig;
|
||||
api.registerAgentHarness(
|
||||
createCodexAppServerAgentHarness({
|
||||
bindingStore,
|
||||
resolveConfig: resolveCurrentConfig,
|
||||
resolvePluginConfig: resolveCurrentPluginConfig,
|
||||
}),
|
||||
createCodexAppServerAgentHarness({ resolvePluginConfig: resolveCurrentPluginConfig }),
|
||||
);
|
||||
api.registerProvider(buildCodexProvider({ pluginConfig: api.pluginConfig }));
|
||||
api.registerMediaUnderstandingProvider(
|
||||
buildCodexMediaUnderstandingProvider({ resolvePluginConfig: resolveCurrentPluginConfig }),
|
||||
buildCodexMediaUnderstandingProvider({ pluginConfig: api.pluginConfig }),
|
||||
);
|
||||
api.registerWebSearchProvider(
|
||||
createCodexWebSearchProvider({ resolvePluginConfig: resolveCurrentPluginConfig }),
|
||||
@@ -83,43 +59,43 @@ export default definePluginEntry({
|
||||
}
|
||||
api.registerCommand(
|
||||
createCodexCommand({
|
||||
resolvePluginConfig: resolveCurrentPluginConfig,
|
||||
pluginConfig: api.pluginConfig,
|
||||
deps: {
|
||||
bindingStore,
|
||||
listCodexCliSessionsOnNode: async (params) =>
|
||||
await (
|
||||
await loadNodeCliSessions()
|
||||
).listCodexCliSessionsOnNode({
|
||||
runtime: api.runtime,
|
||||
...params,
|
||||
}),
|
||||
resolveCodexCliSessionForBindingOnNode: async (params) =>
|
||||
await (
|
||||
await loadNodeCliSessions()
|
||||
).resolveCodexCliSessionForBindingOnNode({
|
||||
runtime: api.runtime,
|
||||
...params,
|
||||
}),
|
||||
listCodexCliSessionsOnNode: (params) =>
|
||||
listCodexCliSessionsOnNode({ runtime: api.runtime, ...params }),
|
||||
resolveCodexCliSessionForBindingOnNode: (params) =>
|
||||
resolveCodexCliSessionForBindingOnNode({ runtime: api.runtime, ...params }),
|
||||
codexPluginsManagementIo: {
|
||||
readConfig: () => {
|
||||
const current = (api.runtime.config?.current?.() ?? {}) as OpenClawConfig;
|
||||
const codexPlugins = resolvePluginConfigObject(current, "codex")?.codexPlugins;
|
||||
if (
|
||||
!codexPlugins ||
|
||||
typeof codexPlugins !== "object" ||
|
||||
Array.isArray(codexPlugins)
|
||||
) {
|
||||
const plugins = (current as Record<string, unknown>).plugins;
|
||||
if (!plugins || typeof plugins !== "object") {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
const block = codexPlugins as Record<string, unknown>;
|
||||
const declared = block.plugins;
|
||||
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;
|
||||
if (!declared || typeof declared !== "object") {
|
||||
return Promise.resolve({
|
||||
enabled: block.enabled === true,
|
||||
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
enabled: block.enabled === true,
|
||||
enabled: (codexPlugins as Record<string, unknown>).enabled === true,
|
||||
plugins: declared as Record<string, never>,
|
||||
});
|
||||
},
|
||||
@@ -129,12 +105,17 @@ 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>;
|
||||
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 ??= {};
|
||||
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>;
|
||||
update(codexPlugins as CodexPluginsConfigBlock);
|
||||
},
|
||||
});
|
||||
@@ -143,58 +124,14 @@ export default definePluginEntry({
|
||||
},
|
||||
}),
|
||||
);
|
||||
api.on("inbound_claim", async (event, ctx) => {
|
||||
const { handleCodexConversationInboundClaim } = await import("./src/conversation-binding.js");
|
||||
return await handleCodexConversationInboundClaim(event, ctx, {
|
||||
bindingStore,
|
||||
api.on("inbound_claim", (event, ctx) =>
|
||||
handleCodexConversationInboundClaim(event, ctx, {
|
||||
pluginConfig: resolveCurrentPluginConfig(),
|
||||
config: resolveCurrentConfig(),
|
||||
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 } : {}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
resumeCodexCliSessionOnNode: (params) =>
|
||||
resumeCodexCliSessionOnNode({ runtime: api.runtime, ...params }),
|
||||
}),
|
||||
);
|
||||
api.onConversationBindingResolved?.(handleCodexConversationBindingResolved);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,25 +2,8 @@
|
||||
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 { CodexAppServerRpcError, type CodexAppServerClient } from "./src/app-server/client.js";
|
||||
import 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(),
|
||||
@@ -102,15 +85,13 @@ 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 closeHandlers = new Set<() => void>();
|
||||
const requestHandlers = new Set<(request: { method: string }) => JsonValue | undefined>();
|
||||
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") {
|
||||
@@ -123,60 +104,51 @@ function createFakeClient(options?: {
|
||||
return threadStartResult();
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
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" },
|
||||
});
|
||||
if (options?.approvalRequestMethod) {
|
||||
for (const handler of requestHandlers) {
|
||||
const response = handler({ method: options.approvalRequestMethod });
|
||||
if (response !== undefined) {
|
||||
approvalResponses.push(response);
|
||||
}
|
||||
}
|
||||
return turnStartResult();
|
||||
}
|
||||
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,
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
} 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
willRetry: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
emitTurnNotifications();
|
||||
} 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return turnStartResult(
|
||||
options?.completeWithItems ? "completed" : "inProgress",
|
||||
options?.completeWithItems
|
||||
@@ -192,12 +164,6 @@ function createFakeClient(options?: {
|
||||
: [],
|
||||
);
|
||||
}
|
||||
if (method === "turn/interrupt" && options?.interruptError) {
|
||||
throw options.interruptError;
|
||||
}
|
||||
if (method === "thread/unsubscribe" && options?.unsubscribeError) {
|
||||
throw options.unsubscribeError;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -207,17 +173,14 @@ function createFakeClient(options?: {
|
||||
notifications.add(handler);
|
||||
return () => notifications.delete(handler);
|
||||
},
|
||||
addRequestHandler() {
|
||||
return () => undefined;
|
||||
},
|
||||
addCloseHandler(handler: () => void) {
|
||||
closeHandlers.add(handler);
|
||||
return () => closeHandlers.delete(handler);
|
||||
addRequestHandler(handler: (request: { method: string }) => JsonValue | undefined) {
|
||||
requestHandlers.add(handler);
|
||||
return () => requestHandlers.delete(handler);
|
||||
},
|
||||
close: vi.fn(),
|
||||
} as unknown as CodexAppServerClient;
|
||||
|
||||
return { client, requests };
|
||||
return { client, requests, approvalResponses };
|
||||
}
|
||||
|
||||
describe("codex media understanding provider", () => {
|
||||
@@ -229,9 +192,11 @@ 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 () => client);
|
||||
const clientFactory = vi.fn(
|
||||
async (_startOptions, _authProfileId, _agentDir, _config) => client,
|
||||
);
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(clientFactory),
|
||||
clientFactory,
|
||||
});
|
||||
const cfg = {
|
||||
auth: {
|
||||
@@ -254,33 +219,42 @@ describe("codex media understanding provider", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual({ text: "A red square.", model: "gpt-5.4" });
|
||||
expect(requests.map((entry) => entry.method)).toEqual([
|
||||
"model/list",
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
]);
|
||||
expect(clientFactory).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
"/tmp/openclaw-agent",
|
||||
cfg,
|
||||
expect.objectContaining({ timeoutMs: 30_000 }),
|
||||
{ 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/codex-media-home",
|
||||
approvalPolicy: "never",
|
||||
cwd: "/tmp/openclaw-agent",
|
||||
approvalPolicy: "on-request",
|
||||
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: EXPECTED_MEDIA_THREAD_CONFIG,
|
||||
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",
|
||||
},
|
||||
environments: [],
|
||||
dynamicTools: [],
|
||||
experimentalRawEvents: true,
|
||||
ephemeral: true,
|
||||
persistExtendedHistory: false,
|
||||
});
|
||||
expect(requests[2]?.params).toEqual({
|
||||
threadId: "thread-1",
|
||||
@@ -288,6 +262,9 @@ 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",
|
||||
});
|
||||
});
|
||||
@@ -295,12 +272,8 @@ 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({
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(clientFactory),
|
||||
});
|
||||
const cfg = {
|
||||
agents: { list: [{ id: "main", agentDir: "/tmp/openclaw-default-agent" }] },
|
||||
};
|
||||
const provider = buildCodexMediaUnderstandingProvider({ clientFactory });
|
||||
const cfg = {};
|
||||
|
||||
await provider.describeImage?.({
|
||||
buffer: Buffer.from("image-bytes"),
|
||||
@@ -313,16 +286,11 @@ describe("codex media understanding provider", () => {
|
||||
agentDir: " ",
|
||||
});
|
||||
|
||||
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" }),
|
||||
);
|
||||
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() }));
|
||||
});
|
||||
|
||||
it("preserves configured WebSocket transport for media turns", async () => {
|
||||
@@ -402,7 +370,7 @@ describe("codex media understanding provider", () => {
|
||||
try {
|
||||
const { client } = createFakeClient();
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
|
||||
clientFactory: async () => client,
|
||||
});
|
||||
|
||||
const result = await provider.describeImage?.({
|
||||
@@ -425,97 +393,33 @@ describe("codex media understanding provider", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("starts the media deadline before client acquisition", async () => {
|
||||
vi.useFakeTimers();
|
||||
it("declines approval requests during image understanding", async () => {
|
||||
const { client, approvalResponses } = createFakeClient({
|
||||
approvalRequestMethod: "item/permissions/requestApproval",
|
||||
});
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(
|
||||
async () => await new Promise<CodexAppServerClient>(() => {}),
|
||||
),
|
||||
clientFactory: async () => client,
|
||||
});
|
||||
const description = provider.describeImage?.({
|
||||
buffer: Buffer.from("image-bytes"),
|
||||
fileName: "image.png",
|
||||
mime: "image/png",
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
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",
|
||||
});
|
||||
|
||||
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 = {
|
||||
|
||||
await 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,
|
||||
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);
|
||||
expect(approvalResponses).toEqual([{ permissions: {}, scope: "turn" }]);
|
||||
});
|
||||
|
||||
it("extracts text from terminal turn items", async () => {
|
||||
const { client } = createFakeClient({ completeWithItems: true });
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
|
||||
clientFactory: async () => client,
|
||||
});
|
||||
|
||||
const result = await provider.describeImages?.({
|
||||
@@ -534,7 +438,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({
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
|
||||
clientFactory: async () => client,
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -555,7 +459,7 @@ describe("codex media understanding provider", () => {
|
||||
it("surfaces Codex app-server turn errors", async () => {
|
||||
const { client } = createFakeClient({ notifyError: "vision unavailable" });
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
|
||||
clientFactory: async () => client,
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -572,107 +476,12 @@ 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({
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
|
||||
clientFactory: async () => client,
|
||||
});
|
||||
|
||||
const result = await provider.extractStructured?.({
|
||||
@@ -713,21 +522,31 @@ 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/codex-media-home",
|
||||
approvalPolicy: "never",
|
||||
cwd: "/tmp/openclaw-agent",
|
||||
approvalPolicy: "on-request",
|
||||
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: EXPECTED_MEDIA_THREAD_CONFIG,
|
||||
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",
|
||||
},
|
||||
environments: [],
|
||||
dynamicTools: [],
|
||||
experimentalRawEvents: true,
|
||||
ephemeral: true,
|
||||
persistExtendedHistory: false,
|
||||
});
|
||||
const turnParams = requests[2]?.params as
|
||||
| {
|
||||
@@ -740,9 +559,9 @@ describe("codex media understanding provider", () => {
|
||||
}
|
||||
| undefined;
|
||||
expect(turnParams?.threadId).toBe("thread-1");
|
||||
expect(turnParams?.approvalPolicy).toBeUndefined();
|
||||
expect(turnParams?.model).toBeUndefined();
|
||||
expect(turnParams?.cwd).toBeUndefined();
|
||||
expect(turnParams?.approvalPolicy).toBe("on-request");
|
||||
expect(turnParams?.model).toBe("gpt-5.4");
|
||||
expect(turnParams?.cwd).toBe("/tmp/openclaw-agent");
|
||||
expect(turnParams?.effort).toBe("low");
|
||||
expect(turnParams?.input).toHaveLength(3);
|
||||
expect(turnParams?.input?.[0]?.type).toBe("text");
|
||||
@@ -765,7 +584,7 @@ describe("codex media understanding provider", () => {
|
||||
responseText: '{"summary":"only text"}',
|
||||
});
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
|
||||
clientFactory: async () => client,
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -785,7 +604,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({
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
|
||||
clientFactory: async () => client,
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -814,7 +633,7 @@ describe("codex media understanding provider", () => {
|
||||
responseText: '{"summary":123,"tags":["shape"]}',
|
||||
});
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(async () => client),
|
||||
clientFactory: async () => client,
|
||||
});
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -1,35 +1,216 @@
|
||||
/** Lazy registration facade for Codex-backed media understanding. */
|
||||
import type { MediaUnderstandingProvider } from "openclaw/plugin-sdk/media-understanding";
|
||||
/**
|
||||
* Codex-backed media understanding provider for bounded image description and
|
||||
* structured extraction turns.
|
||||
*/
|
||||
import {
|
||||
type JsonSchemaObject,
|
||||
validateJsonSchemaValue,
|
||||
} from "openclaw/plugin-sdk/json-schema-runtime";
|
||||
import type {
|
||||
ImagesDescriptionRequest,
|
||||
ImagesDescriptionResult,
|
||||
MediaUnderstandingProvider,
|
||||
StructuredExtractionRequest,
|
||||
StructuredExtractionResult,
|
||||
} from "openclaw/plugin-sdk/media-understanding";
|
||||
import { CODEX_PROVIDER_ID, FALLBACK_CODEX_MODELS } from "./provider-catalog.js";
|
||||
import type { CodexAppServerClientLeaseFactory } from "./src/app-server/shared-client.js";
|
||||
import {
|
||||
runBoundedCodexAppServerTurn,
|
||||
type CodexBoundedTurnOptions,
|
||||
} from "./src/app-server/bounded-turn.js";
|
||||
import type { CodexUserInput } from "./src/app-server/protocol.js";
|
||||
|
||||
const DEFAULT_CODEX_IMAGE_MODEL =
|
||||
FALLBACK_CODEX_MODELS.find((model) => model.inputModalities.includes("image"))?.id ??
|
||||
FALLBACK_CODEX_MODELS[0]?.id;
|
||||
const DEFAULT_CODEX_IMAGE_PROMPT = "Describe the image.";
|
||||
|
||||
/** Dependencies and plugin config for Codex media-understanding calls. */
|
||||
export type CodexMediaUnderstandingProviderOptions = {
|
||||
pluginConfig?: unknown;
|
||||
resolvePluginConfig?: () => unknown;
|
||||
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
|
||||
};
|
||||
export type CodexMediaUnderstandingProviderOptions = CodexBoundedTurnOptions;
|
||||
|
||||
/** Builds a provider whose app-server implementation loads on first use. */
|
||||
/**
|
||||
* Builds the media-understanding provider that delegates image tasks to an
|
||||
* isolated Codex app-server session.
|
||||
*/
|
||||
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 ({ 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),
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
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,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 { listAllCodexAppServerModels } from "./src/app-server/models.js";
|
||||
import type { listCodexAppServerModels } from "./src/app-server/models.js";
|
||||
import {
|
||||
createIsolatedCodexAppServerClient,
|
||||
leaseSharedCodexAppServerClient,
|
||||
getSharedCodexAppServerClient,
|
||||
resetSharedCodexAppServerClientForTests,
|
||||
} from "./src/app-server/shared-client.js";
|
||||
|
||||
@@ -26,8 +26,7 @@ function createFakeCodexClient(): CodexAppServerClient {
|
||||
return {
|
||||
initialize: vi.fn(async () => undefined),
|
||||
request: vi.fn(async () => ({ data: [] })),
|
||||
addNotificationHandler: vi.fn(() => () => undefined),
|
||||
addRequestHandler: vi.fn(() => () => undefined),
|
||||
setActiveSharedLeaseCountProviderForUnscopedNotifications: vi.fn(),
|
||||
addCloseHandler: vi.fn(() => () => undefined),
|
||||
close: vi.fn(),
|
||||
} as unknown as CodexAppServerClient;
|
||||
@@ -40,7 +39,7 @@ const TEST_CODEX_APP_SERVER_CONFIG = {
|
||||
};
|
||||
|
||||
async function listTestCodexAppServerModels(
|
||||
options: Parameters<typeof listAllCodexAppServerModels>[0] = {},
|
||||
options: Parameters<typeof listCodexAppServerModels>[0] = {},
|
||||
) {
|
||||
expect(options.sharedClient).toBe(false);
|
||||
const client = await createIsolatedCodexAppServerClient({
|
||||
@@ -184,33 +183,45 @@ describe("codex provider", () => {
|
||||
expect(resultProvider?.models.map((model) => model.id)).toEqual(["gpt-5.4"]);
|
||||
});
|
||||
|
||||
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: [],
|
||||
},
|
||||
],
|
||||
}));
|
||||
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: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
@@ -266,7 +277,7 @@ describe("codex provider", () => {
|
||||
.mockReturnValueOnce(activeClient)
|
||||
.mockReturnValueOnce(discoveryClient);
|
||||
|
||||
await leaseSharedCodexAppServerClient({
|
||||
await getSharedCodexAppServerClient({
|
||||
startOptions: {
|
||||
transport: "stdio",
|
||||
command: "/tmp/openclaw-test-codex",
|
||||
|
||||
@@ -18,11 +18,16 @@ import {
|
||||
CODEX_PROVIDER_ID,
|
||||
FALLBACK_CODEX_MODELS,
|
||||
} from "./provider-catalog.js";
|
||||
import type { CodexAppServerStartOptions } from "./src/app-server/config.js";
|
||||
import {
|
||||
type CodexAppServerStartOptions,
|
||||
readCodexPluginConfig,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
} 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";
|
||||
@@ -34,6 +39,7 @@ const codexCatalogLog = createSubsystemLogger("codex/catalog");
|
||||
type CodexModelLister = (options: {
|
||||
timeoutMs: number;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
sharedClient?: boolean;
|
||||
}) => Promise<CodexAppServerModelListResult>;
|
||||
@@ -117,11 +123,6 @@ 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,
|
||||
@@ -155,15 +156,13 @@ 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 ?? listAllCodexAppServerModelsLazy,
|
||||
listModels: options.listModels ?? listCodexAppServerModelsLazy,
|
||||
timeoutMs,
|
||||
startOptions: appServer.start,
|
||||
onDiscoveryFailure: options.onDiscoveryFailure,
|
||||
@@ -201,14 +200,22 @@ async function listModelsBestEffort(params: {
|
||||
onDiscoveryFailure?: (error: unknown) => void;
|
||||
}): Promise<CodexAppServerModel[]> {
|
||||
try {
|
||||
// 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);
|
||||
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;
|
||||
} catch (error) {
|
||||
params.onDiscoveryFailure?.(error);
|
||||
codexCatalogLog.debug("codex model discovery failed; using fallback catalog", {
|
||||
@@ -218,14 +225,15 @@ async function listModelsBestEffort(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function listAllCodexAppServerModelsLazy(options: {
|
||||
async function listCodexAppServerModelsLazy(options: {
|
||||
timeoutMs: number;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
sharedClient?: boolean;
|
||||
}): Promise<CodexAppServerModelListResult> {
|
||||
const { listAllCodexAppServerModels } = await import("./src/app-server/models.js");
|
||||
return listAllCodexAppServerModels(options);
|
||||
const { listCodexAppServerModels } = await import("./src/app-server/models.js");
|
||||
return listCodexAppServerModels(options);
|
||||
}
|
||||
|
||||
async function requestCodexAppServerRateLimitsLazy(options: {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Codex tests cover app server policy plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCodexAppServerForOpenClawToolPolicy } from "./app-server-policy.js";
|
||||
import {
|
||||
resolveCodexAppServerForModelProvider,
|
||||
resolveCodexAppServerForOpenClawToolPolicy,
|
||||
} from "./app-server-policy.js";
|
||||
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
|
||||
describe("Codex app-server policy", () => {
|
||||
@@ -66,4 +69,143 @@ 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,10 +2,11 @@
|
||||
* Policy promotion for Codex app-server runs that can safely use OpenClaw tool
|
||||
* approvals.
|
||||
*/
|
||||
import type {
|
||||
CodexAppServerRuntimeOptions,
|
||||
CodexPluginConfig,
|
||||
OpenClawExecPolicyForCodexAppServer,
|
||||
import {
|
||||
canUseCodexModelBackedApprovalsReviewerForModel,
|
||||
type CodexAppServerRuntimeOptions,
|
||||
type CodexPluginConfig,
|
||||
type OpenClawExecPolicyForCodexAppServer,
|
||||
} from "./config.js";
|
||||
|
||||
/**
|
||||
@@ -44,6 +45,35 @@ 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";
|
||||
}
|
||||
@@ -53,3 +83,12 @@ 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,7 +285,8 @@ function matchesCurrentTurn(
|
||||
if (!requestParams) {
|
||||
return false;
|
||||
}
|
||||
const requestThreadId = readString(requestParams, "threadId");
|
||||
const requestThreadId =
|
||||
readString(requestParams, "threadId") ?? readString(requestParams, "conversationId");
|
||||
const requestTurnId = readString(requestParams, "turnId");
|
||||
return requestThreadId === threadId && requestTurnId === turnId;
|
||||
}
|
||||
|
||||
@@ -2,41 +2,10 @@
|
||||
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 () => ({}));
|
||||
|
||||
@@ -53,58 +22,7 @@ describe("Codex app-server attempt client cleanup", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
it("swallows unsubscribe cleanup failures", async () => {
|
||||
const request = vi.fn(async () => {
|
||||
throw new Error("already gone");
|
||||
});
|
||||
@@ -114,7 +32,7 @@ describe("Codex app-server attempt client cleanup", () => {
|
||||
threadId: "thread-1",
|
||||
timeoutMs: 123,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"thread/unsubscribe",
|
||||
@@ -122,31 +40,4 @@ 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,124 +2,60 @@
|
||||
* Best-effort cleanup helpers for Codex app-server startup attempts and turns.
|
||||
*/
|
||||
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js";
|
||||
import { isJsonObject, readCodexThreadCreationResponseId } from "./protocol.js";
|
||||
import type { CodexAppServerClientLease } from "./shared-client.js";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import {
|
||||
clearSharedCodexAppServerClientIfCurrent,
|
||||
clearSharedCodexAppServerClientIfCurrentAndUnclaimed,
|
||||
retireSharedCodexAppServerClientIfCurrent,
|
||||
} from "./shared-client.js";
|
||||
|
||||
/** Timeout for best-effort app-server turn interruption during cleanup. */
|
||||
export const CODEX_APP_SERVER_INTERRUPT_TIMEOUT_MS = 5_000;
|
||||
/** Timeout for best-effort thread unsubscribe during cleanup. */
|
||||
export const CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** The connection's thread-subscription ownership can no longer be proven. */
|
||||
export class CodexAppServerUnsafeSubscriptionError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "CodexAppServerUnsafeSubscriptionError";
|
||||
async function closeClientAndWaitIfAvailable(client: CodexAppServerClient): Promise<void> {
|
||||
const closeable = client as {
|
||||
close?: CodexAppServerClient["close"];
|
||||
closeAndWait?: CodexAppServerClient["closeAndWait"];
|
||||
};
|
||||
if (typeof closeable.closeAndWait === "function") {
|
||||
await closeable.closeAndWait();
|
||||
return;
|
||||
}
|
||||
closeable.close?.();
|
||||
}
|
||||
|
||||
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}`,
|
||||
);
|
||||
export async function closeCodexStartupClientBestEffort(
|
||||
client: CodexAppServerClient | undefined,
|
||||
): Promise<void> {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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();
|
||||
const unclaimedSharedClient = clearSharedCodexAppServerClientIfCurrentAndUnclaimed(client);
|
||||
if (unclaimedSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
if (unclaimedSharedClient.found) {
|
||||
const retired = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retired?.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
throw error;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retiredSharedClient) {
|
||||
if (retiredSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
params.onRetry?.();
|
||||
if (!(await params.waitForActiveTurnCompletion())) {
|
||||
throw error;
|
||||
}
|
||||
await params.afterActiveTurnCompletion?.();
|
||||
return await params.startTurn();
|
||||
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 },
|
||||
);
|
||||
if (clearSharedCodexAppServerClientIfCurrent(client)) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
|
||||
/** Sends a turn interrupt without blocking abort cleanup on app-server errors. */
|
||||
@@ -148,56 +84,28 @@ export function interruptCodexTurnBestEffort(
|
||||
}
|
||||
}
|
||||
|
||||
/** Unsubscribes from a thread and reports whether wire cleanup was confirmed. */
|
||||
/** Unsubscribes from a thread while swallowing cleanup-only failures. */
|
||||
export async function unsubscribeCodexThreadBestEffort(
|
||||
client: CodexAppServerClient,
|
||||
params: {
|
||||
threadId: string;
|
||||
timeoutMs: number;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
): Promise<void> {
|
||||
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.
|
||||
@@ -208,9 +116,10 @@ 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,
|
||||
@@ -220,10 +129,28 @@ export async function retireCodexAppServerClientAfterTimedOutTurn(
|
||||
threadId: params.threadId,
|
||||
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
});
|
||||
await params.abandonClientLease();
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
embeddedAgentLog.warn("codex app-server client retired after timed-out turn", {
|
||||
threadId: params.threadId,
|
||||
turnId: params.turnId,
|
||||
reason: params.reason,
|
||||
detachedSharedClient,
|
||||
closedClient,
|
||||
activeSharedClientLeases: retiredSharedClient?.activeLeases ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
isFileChangePatchUpdatedNotification,
|
||||
isAssistantCommentaryCompletionNotification,
|
||||
isNativeToolProgressNotification,
|
||||
isNativeResponseStreamDeltaNotification,
|
||||
isPendingOpenClawDynamicToolCompletionNotification,
|
||||
isRawAssistantProgressNotification,
|
||||
isRawReasoningCompletionNotification,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
isReasoningProgressNotification,
|
||||
isReasoningItemCompletionNotification,
|
||||
isRetryableErrorNotification,
|
||||
isTurnNotification,
|
||||
readCodexNotificationItem,
|
||||
readNotificationItemId,
|
||||
shouldDisarmAssistantCompletionIdleWatch,
|
||||
@@ -23,7 +25,6 @@ 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 =
|
||||
@@ -69,7 +70,7 @@ export function isTerminalCodexTurnNotificationForTurn(params: {
|
||||
turnId: string;
|
||||
currentPromptTexts: string[];
|
||||
}): boolean {
|
||||
if (!isCodexNotificationForTurn(params.notification.params, params.threadId, params.turnId)) {
|
||||
if (!isTurnNotification(params.notification.params, params.threadId, params.turnId)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
@@ -104,15 +105,16 @@ export function applyCodexTurnNotificationState(params: {
|
||||
turnCrossedToolHandoff: boolean;
|
||||
} {
|
||||
const { notification, turnWatches } = params;
|
||||
const isCurrentTurnNotification = isCodexNotificationForTurn(
|
||||
const isCurrentTurnNotification = isTurnNotification(
|
||||
notification.params,
|
||||
params.threadId,
|
||||
params.turnId,
|
||||
);
|
||||
const isTurnCompletion = notification.method === "turn/completed" && isCurrentTurnNotification;
|
||||
const isNativeResponseStreamDelta = isNativeResponseStreamDeltaNotification(notification);
|
||||
let turnCrossedToolHandoff = params.turnCrossedToolHandoff;
|
||||
|
||||
if (isCurrentTurnNotification) {
|
||||
if (isCurrentTurnNotification && !isNativeResponseStreamDelta) {
|
||||
turnWatches.touchActivity(`notification:${notification.method}`, {
|
||||
details: describeNotificationActivity(notification),
|
||||
attemptProgress: true,
|
||||
@@ -248,6 +250,7 @@ export function applyCodexTurnNotificationState(params: {
|
||||
!turnWatches.isCompletionIdleWatchPinnedByTerminalError() &&
|
||||
notification.method !== "turn/completed" &&
|
||||
isCurrentTurnNotification &&
|
||||
!isNativeResponseStreamDelta &&
|
||||
!trackedDynamicToolCompletion &&
|
||||
!rawToolOutputCompletion &&
|
||||
!postToolProgressNeedsTerminalGuard &&
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
/**
|
||||
* 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,
|
||||
@@ -211,6 +216,13 @@ 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,
|
||||
@@ -265,9 +277,74 @@ 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 {
|
||||
return isJsonObject(value) && value.willRetry === true;
|
||||
if (!isJsonObject(value)) {
|
||||
return false;
|
||||
}
|
||||
return readBoolean(value, "willRetry") === true || readBoolean(value, "will_retry") === true;
|
||||
}
|
||||
|
||||
/** Returns true for terminal app-server thread status strings. */
|
||||
@@ -342,6 +419,10 @@ 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,16 +9,13 @@ 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 {
|
||||
resetCodexTestBindingStore,
|
||||
testCodexAppServerBindingStore,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
import {
|
||||
leaseSharedCodexAppServerClient,
|
||||
resetSharedCodexAppServerClientForTests,
|
||||
clearSharedCodexAppServerClient,
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
} from "./shared-client.js";
|
||||
import { createClientHarness, createCodexTestModel } from "./test-support.js";
|
||||
|
||||
@@ -88,10 +85,12 @@ 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();
|
||||
@@ -102,7 +101,8 @@ function startThreadWithHarness(
|
||||
const effectivePluginConfig = overrides?.pluginConfig ?? pluginConfig;
|
||||
|
||||
const run = startCodexAttemptThread({
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
attemptClientFactory:
|
||||
overrides?.attemptClientFactory?.(harness) ?? defaultLeasedCodexAppServerClientFactory,
|
||||
appServer: resolveCodexAppServerRuntimeOptions({ pluginConfig: effectivePluginConfig }),
|
||||
pluginConfig: effectivePluginConfig,
|
||||
computerUseConfig: effectivePluginConfig.computerUse ?? { enabled: false },
|
||||
@@ -125,11 +125,10 @@ function startThreadWithHarness(
|
||||
sandboxExecServerEnabled: false,
|
||||
sandbox: null,
|
||||
contextEngineProjection: undefined,
|
||||
startupTokenGuard: {},
|
||||
startupTimeoutMs,
|
||||
signal,
|
||||
onStartupTimeout: vi.fn(),
|
||||
onThreadReserved: overrides?.onThreadReserved,
|
||||
spawnedBy: undefined,
|
||||
});
|
||||
|
||||
return { harness, run };
|
||||
@@ -171,13 +170,12 @@ describe("startCodexAttemptThread", () => {
|
||||
vi.useRealTimers();
|
||||
vi.stubEnv("CODEX_API_KEY", "");
|
||||
vi.stubEnv("OPENAI_API_KEY", "");
|
||||
resetCodexTestBindingStore();
|
||||
resetSharedCodexAppServerClientForTests();
|
||||
clearSharedCodexAppServerClient();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
resetSharedCodexAppServerClientForTests();
|
||||
clearSharedCodexAppServerClient();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
for (const root of tempRoots) {
|
||||
@@ -186,7 +184,7 @@ describe("startCodexAttemptThread", () => {
|
||||
tempRoots.clear();
|
||||
});
|
||||
|
||||
it("keeps the shared app-server reusable after a structured startup rejection", async () => {
|
||||
it("clears the shared app-server when top-level thread startup fails with an app error", async () => {
|
||||
const { harness, run } = startThreadWithHarness(5_000);
|
||||
await answerInitialize(harness);
|
||||
const threadStart = await waitForThreadStart(harness);
|
||||
@@ -196,57 +194,25 @@ 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 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 () => {
|
||||
it("retires a failed startup client after another active lease releases", async () => {
|
||||
const retained = createClientHarness();
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(retained.client);
|
||||
const replacement = createClientHarness();
|
||||
const startSpy = vi
|
||||
.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(retained.client)
|
||||
.mockReturnValueOnce(replacement.client);
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
const paths = createAttemptPaths();
|
||||
|
||||
const retainedLeasePromise = leaseSharedCodexAppServerClient({
|
||||
const retainedLease = getLeasedSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
agentDir: paths.agentDir,
|
||||
preparedAuth: {},
|
||||
});
|
||||
await answerInitialize(retained);
|
||||
const retainedLease = await retainedLeasePromise;
|
||||
expect(retainedLease.client).toBe(retained.client);
|
||||
await expect(retainedLease).resolves.toBe(retained.client);
|
||||
|
||||
const { run } = startThreadWithHarness(5_000, new AbortController().signal, {
|
||||
harness: retained,
|
||||
@@ -262,16 +228,17 @@ describe("startCodexAttemptThread", () => {
|
||||
await expect(run).rejects.toThrow("Invalid bearer token");
|
||||
expect(retained.process.stdin.destroyed).toBe(false);
|
||||
|
||||
retainedLease.release();
|
||||
const nextLeasePromise = leaseSharedCodexAppServerClient({
|
||||
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
|
||||
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
|
||||
|
||||
const replacementLease = getLeasedSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
agentDir: paths.agentDir,
|
||||
preparedAuth: {},
|
||||
});
|
||||
const nextLease = await nextLeasePromise;
|
||||
expect(nextLease.client).toBe(retained.client);
|
||||
expect(startSpy).toHaveBeenCalledTimes(1);
|
||||
nextLease.release();
|
||||
await answerInitialize(replacement);
|
||||
await expect(replacementLease).resolves.toBe(replacement.client);
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
expect(releaseLeasedSharedCodexAppServerClient(replacement.client)).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the shared app-server when startup abandons an in-flight thread request", async () => {
|
||||
@@ -293,20 +260,18 @@ describe("startCodexAttemptThread", () => {
|
||||
expect(harness.stdinDestroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("retires abandoned thread startup even when another lease shares the client", async () => {
|
||||
it("aborts abandoned thread startup when another lease keeps the shared app-server alive", async () => {
|
||||
const retained = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(retained.client);
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
const paths = createAttemptPaths();
|
||||
|
||||
const retainedLeasePromise = leaseSharedCodexAppServerClient({
|
||||
const retainedLease = getLeasedSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
agentDir: paths.agentDir,
|
||||
preparedAuth: {},
|
||||
});
|
||||
await answerInitialize(retained);
|
||||
const retainedLease = await retainedLeasePromise;
|
||||
expect(retainedLease.client).toBe(retained.client);
|
||||
await expect(retainedLease).resolves.toBe(retained.client);
|
||||
|
||||
const { run } = startThreadWithHarness(100, new AbortController().signal, {
|
||||
harness: retained,
|
||||
@@ -317,9 +282,11 @@ describe("startCodexAttemptThread", () => {
|
||||
const threadStart = await waitForThreadStart(retained);
|
||||
|
||||
await rejected;
|
||||
expect(threadStart.id).toBeDefined();
|
||||
expect(retained.process.stdin.destroyed).toBe(true);
|
||||
retainedLease.release();
|
||||
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));
|
||||
});
|
||||
|
||||
it("closes the shared app-server when startup times out during initialize", async () => {
|
||||
@@ -344,37 +311,45 @@ describe("startCodexAttemptThread", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
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: {},
|
||||
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;
|
||||
});
|
||||
const { run } = startThreadWithHarness(100, new AbortController().signal, {
|
||||
harness,
|
||||
paths,
|
||||
skipStartSpy: true,
|
||||
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 rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
|
||||
|
||||
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);
|
||||
await rejected;
|
||||
await factoryDone;
|
||||
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
|
||||
interval: 1,
|
||||
timeout: 2_000,
|
||||
});
|
||||
|
||||
expect(startSpy).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
readHarnessMessages(harness.writes).some((write) => write.method === "thread/start"),
|
||||
).toBe(false);
|
||||
await peer.abandon();
|
||||
expect(harness.stdinDestroyed).toBe(true);
|
||||
expect(observedFactoryOptions?.onStartedClient).toBeTypeOf("function");
|
||||
expect(observedFactoryOptions?.abandonSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the shared app-server when cancellation abandons an in-flight thread request", async () => {
|
||||
|
||||
@@ -11,15 +11,10 @@ import {
|
||||
type resolveSandboxContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
|
||||
import {
|
||||
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
CodexAppServerUnsafeSubscriptionError,
|
||||
isCodexAppServerUnsafeSubscriptionError,
|
||||
unsubscribeCodexThreadBestEffort,
|
||||
} from "./attempt-client-cleanup.js";
|
||||
import { closeCodexStartupClientBestEffort } from "./attempt-client-cleanup.js";
|
||||
import { buildCodexPluginThreadConfigEligibilityLogData } from "./attempt-diagnostics.js";
|
||||
import { withCodexStartupTimeout } from "./attempt-timeouts.js";
|
||||
import { ensureCodexAppServerClientRuntime } from "./client-runtime.js";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import { isCodexAppServerConnectionClosedError, type CodexAppServerClient } from "./client.js";
|
||||
import { ensureCodexComputerUse } from "./computer-use.js";
|
||||
import {
|
||||
@@ -57,23 +52,16 @@ import {
|
||||
releaseCodexSandboxExecServerEnvironment,
|
||||
type CodexSandboxExecEnvironment,
|
||||
} from "./sandbox-exec-server.js";
|
||||
import type { CodexAppServerBindingStore } from "./session-binding.js";
|
||||
import {
|
||||
leaseSharedCodexAppServerClient,
|
||||
type CodexAppServerClientLease,
|
||||
type CodexAppServerClientLeaseFactory,
|
||||
clearSharedCodexAppServerClientIfCurrent,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
} from "./shared-client.js";
|
||||
import type { CodexAppServerStartupTokenGuard } from "./startup-binding.js";
|
||||
import {
|
||||
startOrResumeThread,
|
||||
type CodexAppServerThreadLifecycleBinding,
|
||||
type CodexContextEngineThreadBootstrapProjection,
|
||||
} from "./thread-lifecycle.js";
|
||||
import {
|
||||
getCodexAppServerTurnRouter,
|
||||
type CodexAppServerTurnRouter,
|
||||
type CodexThreadRouteReservation,
|
||||
} from "./turn-router.js";
|
||||
import type { CodexNativeWebSearchSupport } from "./web-search.js";
|
||||
|
||||
const CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS = 3;
|
||||
|
||||
@@ -81,15 +69,14 @@ type CodexSandboxContext = Awaited<ReturnType<typeof resolveSandboxContext>>;
|
||||
|
||||
/** Resources and bindings returned after a Codex attempt thread starts. */
|
||||
export type StartCodexAttemptThreadResult = {
|
||||
turnRouter: CodexAppServerTurnRouter;
|
||||
turnRoute: CodexThreadRouteReservation;
|
||||
client: CodexAppServerClient;
|
||||
thread: CodexAppServerThreadLifecycleBinding;
|
||||
pluginAppServer: CodexAppServerRuntimeOptions;
|
||||
sandboxEnvironment: CodexSandboxExecEnvironment | undefined;
|
||||
environmentSelection: CodexTurnEnvironmentParams[] | undefined;
|
||||
executionCwd: string;
|
||||
sandboxPolicy: CodexSandboxPolicy | undefined;
|
||||
clientLease: CodexAppServerClientLease;
|
||||
mcpElicitationDelegationRequired: boolean;
|
||||
releaseSharedClientLease: () => void;
|
||||
restartContextEngineCodexThread: () => Promise<CodexAppServerThreadLifecycleBinding>;
|
||||
};
|
||||
|
||||
@@ -98,8 +85,7 @@ export type StartCodexAttemptThreadResult = {
|
||||
* run loop must later release.
|
||||
*/
|
||||
export async function startCodexAttemptThread(params: {
|
||||
bindingStore: CodexAppServerBindingStore;
|
||||
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
|
||||
attemptClientFactory: CodexAppServerClientFactory;
|
||||
appServer: CodexAppServerRuntimeOptions;
|
||||
pluginConfig: CodexPluginConfig;
|
||||
computerUseConfig: CodexComputerUseConfig;
|
||||
@@ -125,26 +111,18 @@ export async function startCodexAttemptThread(params: {
|
||||
sandboxExecServerEnabled: boolean;
|
||||
sandbox: CodexSandboxContext;
|
||||
contextEngineProjection: CodexContextEngineThreadBootstrapProjection | undefined;
|
||||
expectedResumeThreadId?: string;
|
||||
startupTokenGuard: CodexAppServerStartupTokenGuard;
|
||||
startupTimeoutMs: number;
|
||||
signal: AbortSignal;
|
||||
onStartupTimeout: () => void | Promise<void>;
|
||||
onThreadReserved?: (client: CodexAppServerClient, threadId: string) => () => void;
|
||||
spawnedBy: EmbeddedRunAttemptParams["spawnedBy"];
|
||||
}): Promise<StartCodexAttemptThreadResult> {
|
||||
let mcpElicitationDelegationRequired = false;
|
||||
let sharedClientLease: CodexAppServerClientLease | undefined;
|
||||
let pluginAppServer = params.appServer;
|
||||
let releaseSharedClientLease: (() => void) | undefined;
|
||||
let startupClientForAbandonedRequestCleanup: CodexAppServerClient | 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({
|
||||
@@ -155,7 +133,10 @@ export async function startCodexAttemptThread(params: {
|
||||
startupAbandonController.abort();
|
||||
await params.onStartupTimeout();
|
||||
await releaseStartupResourcesOnTimeout?.();
|
||||
await abandonStartupClient();
|
||||
releaseSharedClientLease?.();
|
||||
releaseSharedClientLease = undefined;
|
||||
await closeCodexStartupClientBestEffort(startupClientForAbandonedRequestCleanup);
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
},
|
||||
operation: async () => {
|
||||
const threadConfig = mergeCodexThreadConfigs(
|
||||
@@ -172,9 +153,8 @@ export async function startCodexAttemptThread(params: {
|
||||
const resolvedPluginPolicy = pluginThreadConfigRequired
|
||||
? resolveCodexPluginsPolicy(pluginThreadConfigPluginConfig)
|
||||
: undefined;
|
||||
const computerUseMcpElicitationDelegationRequired =
|
||||
params.computerUseConfig.enabled === true;
|
||||
mcpElicitationDelegationRequired =
|
||||
const computerUseMcpElicitationDelegationRequired = params.computerUseConfig.enabled;
|
||||
const mcpElicitationDelegationRequired =
|
||||
resolvedPluginPolicy?.enabled === true || computerUseMcpElicitationDelegationRequired;
|
||||
const enabledPluginConfigKeys = resolvedPluginPolicy
|
||||
? resolvedPluginPolicy.pluginPolicies
|
||||
@@ -182,48 +162,55 @@ export async function startCodexAttemptThread(params: {
|
||||
.map((plugin) => plugin.configKey)
|
||||
.toSorted()
|
||||
: undefined;
|
||||
const pluginAppServer = mcpElicitationDelegationRequired
|
||||
pluginAppServer = mcpElicitationDelegationRequired
|
||||
? {
|
||||
...params.appServer,
|
||||
approvalPolicy: withMcpElicitationsApprovalPolicy(params.appServer.approvalPolicy),
|
||||
}
|
||||
: params.appServer;
|
||||
|
||||
let attemptedClientAbandoned = false;
|
||||
let attemptedClient: CodexAppServerClient | undefined;
|
||||
const startupAttempt = async () => {
|
||||
let startupClientLease: CodexAppServerClientLease | undefined;
|
||||
let clientWorkStarted = false;
|
||||
attemptedClientAbandoned = false;
|
||||
let startupClientLease: (() => void) | undefined;
|
||||
let startupClient: CodexAppServerClient | undefined;
|
||||
let startupAttemptError: unknown;
|
||||
let startupAttemptSucceeded = false;
|
||||
try {
|
||||
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,
|
||||
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,
|
||||
},
|
||||
abandonSignal: startupAbandonController.signal,
|
||||
});
|
||||
const activeStartupLease = startupClientLease;
|
||||
const activeStartupClient = activeStartupLease.client;
|
||||
sharedClientLease = startupClientLease;
|
||||
);
|
||||
const activeStartupClient = startupClient;
|
||||
let startupClientLeaseReleased = false;
|
||||
startupClientLease = () => {
|
||||
if (startupClientLeaseReleased) {
|
||||
return;
|
||||
}
|
||||
startupClientLeaseReleased = true;
|
||||
releaseLeasedSharedCodexAppServerClient(activeStartupClient);
|
||||
};
|
||||
releaseSharedClientLease = startupClientLease;
|
||||
attemptedClient = activeStartupClient;
|
||||
startupClientForAbandonedRequestCleanup = activeStartupClient;
|
||||
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,
|
||||
@@ -290,6 +277,7 @@ export async function startCodexAttemptThread(params: {
|
||||
: undefined;
|
||||
startupSandboxEnvironmentAcquired = Boolean(startupSandboxEnvironment);
|
||||
if (startupAbandonController.signal.aborted) {
|
||||
await releaseStartupSandboxEnvironment();
|
||||
throw new Error("codex app-server startup aborted");
|
||||
}
|
||||
if (
|
||||
@@ -320,57 +308,9 @@ export async function startCodexAttemptThread(params: {
|
||||
const startupSandboxPolicy = startupSandboxEnvironment
|
||||
? resolveCodexExternalSandboxPolicyForOpenClawSandbox(params.sandbox)
|
||||
: undefined;
|
||||
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 } = {},
|
||||
) =>
|
||||
const buildThreadLifecycleParams = (signal: AbortSignal) =>
|
||||
({
|
||||
client: activeStartupClient,
|
||||
abandonClient: activeStartupLease.abandon,
|
||||
bindingStore: params.bindingStore,
|
||||
params: params.buildAttemptParams(),
|
||||
agentId: params.sessionAgentId,
|
||||
cwd: startupExecutionCwd,
|
||||
@@ -392,13 +332,7 @@ 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,
|
||||
@@ -422,65 +356,57 @@ 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 {
|
||||
turnRouter,
|
||||
turnRoute: startupReservation.route,
|
||||
client: activeStartupClient,
|
||||
thread: startupThread,
|
||||
sandboxEnvironment: startupSandboxEnvironment,
|
||||
environmentSelection: startupEnvironmentSelection,
|
||||
executionCwd: startupExecutionCwd,
|
||||
sandboxPolicy: startupSandboxPolicy,
|
||||
restartContextEngineCodexThread: () =>
|
||||
startOrResumeThread(
|
||||
buildThreadLifecycleParams(params.signal, { freshStartOnly: true }),
|
||||
),
|
||||
startOrResumeThread(buildThreadLifecycleParams(params.signal)),
|
||||
};
|
||||
} catch (error) {
|
||||
await releaseStartupResources();
|
||||
await releaseStartupSandboxEnvironment();
|
||||
throw error;
|
||||
} finally {
|
||||
if (releaseStartupResourcesOnTimeout === releaseStartupResources) {
|
||||
if (releaseStartupResourcesOnTimeout === releaseStartupSandboxEnvironment) {
|
||||
releaseStartupResourcesOnTimeout = undefined;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (sharedClientLease === startupClientLease) {
|
||||
sharedClientLease = undefined;
|
||||
}
|
||||
const shouldAbandonStartupClient =
|
||||
clientWorkStarted &&
|
||||
(startupAbandoned ||
|
||||
params.signal.aborted ||
|
||||
isIndeterminateCodexStartupFailure(error));
|
||||
if (shouldAbandonStartupClient) {
|
||||
attemptedClientAbandoned = true;
|
||||
await startupClientLease?.abandon();
|
||||
} else {
|
||||
startupClientLease?.release();
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -495,13 +421,18 @@ 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,
|
||||
abandonedSharedClient: attemptedClientAbandoned,
|
||||
clearedSharedClient,
|
||||
error: formatErrorMessage(error),
|
||||
},
|
||||
);
|
||||
@@ -513,7 +444,7 @@ export async function startCodexAttemptThread(params: {
|
||||
attempt,
|
||||
nextAttempt: attempt + 1,
|
||||
maxAttempts: CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS,
|
||||
abandonedSharedClient: attemptedClientAbandoned,
|
||||
clearedSharedClient,
|
||||
error: formatErrorMessage(error),
|
||||
},
|
||||
);
|
||||
@@ -522,21 +453,32 @@ export async function startCodexAttemptThread(params: {
|
||||
throw new Error("codex app-server startup retry loop exited unexpectedly");
|
||||
},
|
||||
});
|
||||
const completedSharedClientLease = sharedClientLease;
|
||||
if (!completedSharedClientLease) {
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
if (!releaseSharedClientLease) {
|
||||
throw new Error("codex app-server startup succeeded without a shared client lease");
|
||||
}
|
||||
sharedClientLease = undefined;
|
||||
return {
|
||||
...startupResult,
|
||||
mcpElicitationDelegationRequired,
|
||||
clientLease: completedSharedClientLease,
|
||||
pluginAppServer,
|
||||
releaseSharedClientLease,
|
||||
};
|
||||
} catch (error) {
|
||||
const shouldAbandonStartupClient =
|
||||
params.signal.aborted || isIndeterminateCodexStartupFailure(error);
|
||||
if (shouldAbandonStartupClient) {
|
||||
await abandonStartupClient();
|
||||
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;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -544,13 +486,30 @@ export async function startCodexAttemptThread(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function isIndeterminateCodexStartupFailure(error: unknown): boolean {
|
||||
function shouldClearSharedClientAfterStartupAbandon(error: unknown): boolean {
|
||||
return (
|
||||
isCodexAppServerUnsafeSubscriptionError(error) ||
|
||||
isCodexAppServerConnectionClosedError(error) ||
|
||||
(error instanceof Error &&
|
||||
(error.message.endsWith(" timed out") ||
|
||||
error.message.endsWith(" aborted") ||
|
||||
error.message.includes("write EPIPE")))
|
||||
error instanceof Error &&
|
||||
(error.message === "codex app-server startup timed out" ||
|
||||
error.message === "codex app-server startup aborted")
|
||||
);
|
||||
}
|
||||
|
||||
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,39 +159,6 @@ 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");
|
||||
rejectOnce(timeoutError);
|
||||
timeoutCleanup = Promise.resolve()
|
||||
.then(() => params.onTimeout?.())
|
||||
.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
timeoutCleanup = Promise.resolve(params.onTimeout?.()).then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
void timeoutCleanup.finally(() => {
|
||||
rejectOnce(timeoutError!);
|
||||
});
|
||||
}, 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({
|
||||
getThreadId: () => "thread-1",
|
||||
threadId: "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: {
|
||||
getThreadId: () => string;
|
||||
threadId: string;
|
||||
signal: AbortSignal;
|
||||
getTurnId: () => string | undefined;
|
||||
isCompleted: () => boolean;
|
||||
@@ -79,7 +79,6 @@ 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) {
|
||||
@@ -228,7 +227,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
clearTerminalIdleTimer();
|
||||
const turnId = params.getTurnId();
|
||||
params.onRecordEvent("turn.assistant_completion_idle_release", {
|
||||
threadId: currentThreadId(),
|
||||
threadId: params.threadId,
|
||||
turnId,
|
||||
idleMs,
|
||||
timeoutMs: turnAssistantCompletionIdleTimeoutMs,
|
||||
@@ -237,7 +236,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
embeddedAgentLog.warn(
|
||||
"codex app-server turn released after completed assistant item without terminal event",
|
||||
{
|
||||
threadId: currentThreadId(),
|
||||
threadId: params.threadId,
|
||||
turnId,
|
||||
idleMs,
|
||||
timeoutMs: turnAssistantCompletionIdleTimeoutMs,
|
||||
@@ -246,7 +245,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
);
|
||||
if (turnId) {
|
||||
params.onInterruptTurn({
|
||||
threadId: currentThreadId(),
|
||||
threadId: params.threadId,
|
||||
turnId,
|
||||
timeoutMs: interruptTimeoutMs,
|
||||
});
|
||||
@@ -279,7 +278,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
params.onTimeout(timeout);
|
||||
params.onMarkTimedOut();
|
||||
params.onRecordEvent("turn.progress_idle_timeout", {
|
||||
threadId: currentThreadId(),
|
||||
threadId: params.threadId,
|
||||
turnId: params.getTurnId(),
|
||||
idleMs,
|
||||
timeoutMs: timeout.timeoutMs,
|
||||
@@ -287,7 +286,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
...timeout.details,
|
||||
});
|
||||
embeddedAgentLog.warn("codex app-server turn idle timed out waiting for progress", {
|
||||
threadId: currentThreadId(),
|
||||
threadId: params.threadId,
|
||||
turnId: params.getTurnId(),
|
||||
idleMs,
|
||||
timeoutMs: timeout.timeoutMs,
|
||||
@@ -332,7 +331,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
params.onTimeout(timeout);
|
||||
params.onMarkTimedOut();
|
||||
params.onRecordEvent("turn.completion_idle_timeout", {
|
||||
threadId: currentThreadId(),
|
||||
threadId: params.threadId,
|
||||
turnId: params.getTurnId(),
|
||||
idleMs,
|
||||
timeoutMs,
|
||||
@@ -340,7 +339,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
...timeout.details,
|
||||
});
|
||||
embeddedAgentLog.warn("codex app-server turn idle timed out waiting for completion", {
|
||||
threadId: currentThreadId(),
|
||||
threadId: params.threadId,
|
||||
turnId: params.getTurnId(),
|
||||
idleMs,
|
||||
timeoutMs,
|
||||
@@ -375,7 +374,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
params.onTimeout(timeout);
|
||||
params.onMarkTimedOut();
|
||||
params.onRecordEvent("turn.terminal_idle_timeout", {
|
||||
threadId: currentThreadId(),
|
||||
threadId: params.threadId,
|
||||
turnId: params.getTurnId(),
|
||||
idleMs,
|
||||
timeoutMs: timeout.timeoutMs,
|
||||
@@ -383,7 +382,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
...timeout.details,
|
||||
});
|
||||
embeddedAgentLog.warn("codex app-server turn idle timed out waiting for terminal event", {
|
||||
threadId: currentThreadId(),
|
||||
threadId: params.threadId,
|
||||
turnId: params.getTurnId(),
|
||||
idleMs,
|
||||
timeoutMs: timeout.timeoutMs,
|
||||
@@ -458,11 +457,9 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
details?: Record<string, unknown>;
|
||||
attemptProgress?: boolean;
|
||||
attemptTimeoutMs?: number;
|
||||
receivedAtMs?: number;
|
||||
},
|
||||
) => {
|
||||
const now = Date.now();
|
||||
completionLastActivityAt = Math.min(now, options?.receivedAtMs ?? now);
|
||||
completionLastActivityAt = Date.now();
|
||||
completionLastActivityReason = `notification:${method}`;
|
||||
if (options?.details !== undefined) {
|
||||
completionLastActivityDetails = options.details;
|
||||
|
||||
@@ -8,56 +8,40 @@ 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,
|
||||
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";
|
||||
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
|
||||
} from "./session-binding.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
|
||||
let codexAppServerClientLeaseFactoryForTest: CodexAppServerClientLeaseFactory | undefined;
|
||||
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
|
||||
|
||||
type RunCodexAppServerAttemptImplOptions = NonNullable<
|
||||
type RunCodexAppServerAttemptOptions = NonNullable<
|
||||
Parameters<typeof runCodexAppServerAttemptImpl>[1]
|
||||
>;
|
||||
type RunCodexAppServerAttemptOptions = Omit<RunCodexAppServerAttemptImplOptions, "bindingStore"> & {
|
||||
bindingStore?: RunCodexAppServerAttemptImplOptions["bindingStore"];
|
||||
};
|
||||
|
||||
function setCodexAppServerClientFactoryForTest(factory: CodexTestAppServerClientFactory): void {
|
||||
codexAppServerClientLeaseFactoryForTest = adaptCodexTestClientFactory(factory);
|
||||
function setCodexAppServerClientFactoryForTest(factory: CodexAppServerClientFactory): void {
|
||||
codexAppServerClientFactoryForTest = factory;
|
||||
}
|
||||
|
||||
function resetCodexAppServerClientFactoryForTest(): void {
|
||||
codexAppServerClientLeaseFactoryForTest = undefined;
|
||||
codexAppServerClientFactoryForTest = undefined;
|
||||
}
|
||||
|
||||
function runCodexAppServerAttempt(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
options: RunCodexAppServerAttemptOptions = {},
|
||||
) {
|
||||
const clientLeaseFactory = options.clientLeaseFactory ?? codexAppServerClientLeaseFactoryForTest;
|
||||
return runCodexAppServerAttemptImpl(params, {
|
||||
...options,
|
||||
bindingStore: options.bindingStore ?? testCodexAppServerBindingStore,
|
||||
...(clientLeaseFactory ? { clientLeaseFactory } : {}),
|
||||
});
|
||||
const clientFactory = options.clientFactory ?? codexAppServerClientFactoryForTest;
|
||||
return runCodexAppServerAttemptImpl(
|
||||
params,
|
||||
clientFactory ? { ...options, clientFactory } : options,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -164,8 +148,7 @@ function createCodexAuthProfileHarness(params: { startMethod: "thread/start" | "
|
||||
const seenAuthProfileIds: Array<string | undefined> = [];
|
||||
const seenAgentDirs: Array<string | undefined> = [];
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
const notificationHandlers = new Set<(notification: unknown) => Promise<void> | void>();
|
||||
const requestHandlers = new Set<(request: unknown) => unknown>();
|
||||
let notify: (notification: unknown) => Promise<void> = async () => undefined;
|
||||
setCodexAppServerClientFactoryForTest(async (_startOptions, authProfileId, agentDir) => {
|
||||
seenAuthProfileIds.push(authProfileId);
|
||||
seenAgentDirs.push(agentDir);
|
||||
@@ -181,22 +164,13 @@ function createCodexAuthProfileHarness(params: { startMethod: "thread/start" | "
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}),
|
||||
addNotificationHandler: (handler: (notification: unknown) => Promise<void> | void) => {
|
||||
notificationHandlers.add(handler);
|
||||
return () => notificationHandlers.delete(handler);
|
||||
addNotificationHandler: (handler: (notification: unknown) => Promise<void>) => {
|
||||
notify = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
addRequestHandler: (handler: (request: unknown) => unknown) => {
|
||||
requestHandlers.add(handler);
|
||||
return () => requestHandlers.delete(handler);
|
||||
},
|
||||
addCloseHandler: () => () => undefined,
|
||||
addRequestHandler: () => () => undefined,
|
||||
} as never;
|
||||
});
|
||||
const notify = async (notification: unknown) => {
|
||||
await Promise.all(
|
||||
[...notificationHandlers].map((handler) => Promise.resolve(handler(notification))),
|
||||
);
|
||||
};
|
||||
return {
|
||||
seenAuthProfileIds,
|
||||
seenAgentDirs,
|
||||
@@ -222,7 +196,6 @@ 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-"));
|
||||
});
|
||||
@@ -258,7 +231,6 @@ 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,
|
||||
@@ -266,6 +238,7 @@ 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(
|
||||
@@ -283,13 +256,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,6 +5,7 @@ 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";
|
||||
@@ -26,10 +27,6 @@ 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://"];
|
||||
@@ -49,7 +46,7 @@ const CODEX_PRIVATE_BOUNDED_THREAD_CONFIG: JsonObject = {
|
||||
|
||||
export type CodexBoundedTurnOptions = {
|
||||
pluginConfig?: unknown;
|
||||
clientFactory?: CodexAppServerClientLeaseFactory;
|
||||
clientFactory?: CodexAppServerClientFactory;
|
||||
};
|
||||
|
||||
export type CodexBoundedTurnResult = {
|
||||
@@ -121,17 +118,11 @@ async function runBoundedCodexAppServerTurnInWorkspace(
|
||||
const startOptions = workspace.codexHome
|
||||
? buildPrivateCodexAppServerStartOptions(appServer.start, workspace.codexHome)
|
||||
: appServer.start;
|
||||
let lease: CodexAppServerClientLease | undefined;
|
||||
const ownsClient = !params.options.clientFactory;
|
||||
const client = params.options.clientFactory
|
||||
? ((lease = await params.options.clientFactory({
|
||||
startOptions,
|
||||
? await params.options.clientFactory(startOptions, params.profile, agentDir, params.config, {
|
||||
timeoutMs,
|
||||
authProfileId: params.profile,
|
||||
agentDir,
|
||||
authProfileStore: params.authProfileStore,
|
||||
config: params.config,
|
||||
})),
|
||||
lease.client)
|
||||
})
|
||||
: await import("./shared-client.js").then(({ createIsolatedCodexAppServerClient }) =>
|
||||
createIsolatedCodexAppServerClient({
|
||||
startOptions,
|
||||
@@ -217,9 +208,7 @@ async function runBoundedCodexAppServerTurnInWorkspace(
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
params.signal?.removeEventListener("abort", abortFromCaller);
|
||||
if (lease) {
|
||||
lease.release();
|
||||
} else {
|
||||
if (ownsClient) {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
50
extensions/codex/src/app-server/client-factory.ts
Normal file
50
extensions/codex/src/app-server/client-factory.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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,
|
||||
}),
|
||||
);
|
||||
@@ -1,78 +0,0 @@
|
||||
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" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
/** 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,78 +50,6 @@ 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);
|
||||
@@ -142,9 +70,9 @@ describe("CodexAppServerClient", () => {
|
||||
expect(outbound.params?.nested).toEqual(["lowend", "emoji 🙈 ok"]);
|
||||
harness.send({
|
||||
id: JSON.parse(harness.writes[0] ?? "{}").id,
|
||||
result: { thread: { id: "thread-1" } },
|
||||
result: { threadId: "thread-1" },
|
||||
});
|
||||
await expect(request).resolves.toEqual({ thread: { id: "thread-1" } });
|
||||
await expect(request).resolves.toEqual({ threadId: "thread-1" });
|
||||
});
|
||||
|
||||
it("logs a redacted preview for malformed app-server messages", async () => {
|
||||
@@ -212,30 +140,6 @@ 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);
|
||||
@@ -316,95 +220,6 @@ 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({
|
||||
@@ -701,26 +516,6 @@ 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);
|
||||
@@ -738,41 +533,6 @@ 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,7 +12,6 @@ import {
|
||||
type CodexInitializeParams,
|
||||
type CodexInitializeResponse,
|
||||
isRpcResponse,
|
||||
readCodexThreadCreationResponseId,
|
||||
type CodexServerNotification,
|
||||
type JsonValue,
|
||||
type RpcMessage,
|
||||
@@ -35,8 +34,6 @@ 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;
|
||||
|
||||
@@ -123,10 +120,7 @@ export class CodexAppServerClient {
|
||||
private readonly requestHandlers = new Set<CodexServerRequestHandler>();
|
||||
private readonly notificationHandlers = new Set<CodexServerNotificationHandler>();
|
||||
private readonly closeHandlers = new Set<(client: CodexAppServerClient) => void>();
|
||||
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 activeSharedLeaseCountProvider: (() => number | undefined) | undefined;
|
||||
private nextId = 1;
|
||||
private initialized = false;
|
||||
private closed = false;
|
||||
@@ -247,27 +241,11 @@ 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);
|
||||
@@ -276,37 +254,23 @@ export class CodexAppServerClient {
|
||||
cleanupAbort?.();
|
||||
cleanupAbort = undefined;
|
||||
};
|
||||
const rejectPending = (error: Error, rememberLateThreadCreation = false) => {
|
||||
const rejectPending = (error: Error) => {
|
||||
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`), true),
|
||||
() => rejectPending(new Error(`${method} timed out`)),
|
||||
Math.max(100, options.timeoutMs),
|
||||
);
|
||||
timeout.unref?.();
|
||||
}
|
||||
if (options.signal) {
|
||||
const abortListener = () => rejectPending(new Error(`${method} aborted`), requestWritten);
|
||||
const abortListener = () => rejectPending(new Error(`${method} aborted`));
|
||||
options.signal.addEventListener("abort", abortListener, { once: true });
|
||||
cleanupAbort = () => options.signal?.removeEventListener("abort", abortListener);
|
||||
}
|
||||
@@ -314,12 +278,6 @@ 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) => {
|
||||
@@ -333,7 +291,6 @@ export class CodexAppServerClient {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
requestWritten = true;
|
||||
this.writeMessage(message, (error) => rejectPending(error));
|
||||
} catch (error) {
|
||||
rejectPending(error instanceof Error ? error : new Error(String(error)));
|
||||
@@ -358,6 +315,18 @@ 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);
|
||||
@@ -476,15 +445,6 @@ 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;
|
||||
@@ -562,14 +522,7 @@ export class CodexAppServerClient {
|
||||
|
||||
private handleNotification(notification: CodexServerNotification): void {
|
||||
for (const handler of this.notificationHandlers) {
|
||||
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) => {
|
||||
Promise.resolve(handler(notification)).catch((error: unknown) => {
|
||||
embeddedAgentLog.warn("codex app-server notification handler failed", { error });
|
||||
});
|
||||
}
|
||||
@@ -587,54 +540,11 @@ 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();
|
||||
@@ -647,17 +557,6 @@ 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 {
|
||||
@@ -672,9 +571,6 @@ 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"
|
||||
@@ -690,12 +586,6 @@ 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,396 +7,145 @@ import {
|
||||
type EmbeddedAgentCompactResult,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
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";
|
||||
defaultLeasedCodexAppServerClientFactory,
|
||||
type CodexAppServerClientFactory,
|
||||
} from "./client-factory.js";
|
||||
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
|
||||
import type { JsonObject } from "./protocol.js";
|
||||
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
|
||||
import {
|
||||
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
|
||||
sessionBindingIdentity,
|
||||
type CodexAppServerBindingIdentity,
|
||||
type CodexAppServerBindingStore,
|
||||
readCodexAppServerBinding,
|
||||
withCodexAppServerBindingLock,
|
||||
writeCodexAppServerBinding,
|
||||
type CodexAppServerThreadBinding,
|
||||
} from "./session-binding.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";
|
||||
import { releaseLeasedSharedCodexAppServerClient } from "./shared-client.js";
|
||||
|
||||
const warnedIgnoredCompactionOverrides = new Set<string>();
|
||||
type CodexAppServerCompactOptions = {
|
||||
bindingStore: CodexAppServerBindingStore;
|
||||
pluginConfig?: unknown;
|
||||
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
|
||||
clientFactory?: CodexAppServerClientFactory;
|
||||
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", {
|
||||
@@ -423,7 +172,6 @@ async function compactCodexNativeThread(
|
||||
}
|
||||
const nativeExecutionBlock = resolveCodexNativeExecutionBlock({
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sandboxSessionKey ?? params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
surface: "native compaction",
|
||||
@@ -431,20 +179,17 @@ async function compactCodexNativeThread(
|
||||
if (nativeExecutionBlock) {
|
||||
return { ok: false, compacted: false, reason: nativeExecutionBlock };
|
||||
}
|
||||
const bindingIdentity: CodexAppServerBindingIdentity = sessionBindingIdentity({
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
|
||||
const initialBinding = await readCodexAppServerBinding(params.sessionFile, {
|
||||
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",
|
||||
});
|
||||
}
|
||||
const binding = initialBinding;
|
||||
let binding = initialBinding;
|
||||
const requestedAuthProfileId = params.authProfileId?.trim() || undefined;
|
||||
if (
|
||||
requestedAuthProfileId &&
|
||||
@@ -455,42 +200,85 @@ 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" };
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
const shouldReleaseDefaultLease = !options.clientFactory;
|
||||
const clientFactory = options.clientFactory ?? defaultLeasedCodexAppServerClientFactory;
|
||||
const client = await clientFactory(
|
||||
appServer.start,
|
||||
requestedAuthProfileId ?? binding.authProfileId,
|
||||
params.agentDir,
|
||||
params.config,
|
||||
);
|
||||
try {
|
||||
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",
|
||||
);
|
||||
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,
|
||||
});
|
||||
}
|
||||
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,
|
||||
@@ -509,6 +297,10 @@ async function compactCodexNativeThread(
|
||||
compacted: false,
|
||||
reason: formatCompactionError(error),
|
||||
};
|
||||
} finally {
|
||||
if (shouldReleaseDefaultLease) {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
}
|
||||
}
|
||||
const resultDetails: JsonObject = {
|
||||
backend: "codex-app-server",
|
||||
@@ -534,25 +326,6 @@ 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: {
|
||||
@@ -609,7 +382,39 @@ function failedCodexThreadBindingCompactionResult(
|
||||
};
|
||||
}
|
||||
|
||||
function isSameNativeTurnBinding(
|
||||
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(
|
||||
current: CodexAppServerThreadBinding,
|
||||
expected: CodexAppServerThreadBinding,
|
||||
): boolean {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// 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 {
|
||||
@@ -202,7 +200,7 @@ describe("Codex app-server config", () => {
|
||||
},
|
||||
unix_sockets: {
|
||||
"/tmp/mock-proxy.sock": "allow",
|
||||
"/tmp/blocked.sock": "deny",
|
||||
"/tmp/blocked.sock": "none",
|
||||
},
|
||||
proxy_url: "http://127.0.0.1:3128",
|
||||
socks_url: "socks5h://127.0.0.1:8081",
|
||||
@@ -560,6 +558,7 @@ describe("Codex app-server config", () => {
|
||||
const switchedLocalModel = resolveCodexModelBackedReviewerPolicyContext({
|
||||
model: "lmstudio/local-model",
|
||||
bindingModel: "gpt-5.5",
|
||||
nativeAuthProfile: true,
|
||||
});
|
||||
expect(switchedLocalModel).toEqual({
|
||||
modelProvider: "lmstudio",
|
||||
@@ -746,39 +745,6 @@ 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: {
|
||||
@@ -976,8 +942,8 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
env: {},
|
||||
modelProvider: "openai",
|
||||
requirementsPath: "/custom/codex/requirements.toml",
|
||||
readRequirementsFile: (requirementsPath) => {
|
||||
readPaths.push(requirementsPath);
|
||||
readRequirementsFile: (path) => {
|
||||
readPaths.push(path);
|
||||
return 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n';
|
||||
},
|
||||
});
|
||||
@@ -997,8 +963,8 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
env: { ProgramData: "D:\\ManagedData" },
|
||||
modelProvider: "openai",
|
||||
platform: "win32",
|
||||
readRequirementsFile: (requirementsPath) => {
|
||||
readPaths.push(requirementsPath);
|
||||
readRequirementsFile: (path) => {
|
||||
readPaths.push(path);
|
||||
return 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n';
|
||||
},
|
||||
});
|
||||
|
||||
@@ -192,11 +192,6 @@ export type CodexAppServerRuntimeOptions = {
|
||||
networkProxy?: ResolvedCodexAppServerNetworkProxyConfig;
|
||||
};
|
||||
|
||||
export type CodexAppServerRuntimeResolution = {
|
||||
appServer: CodexAppServerRuntimeOptions;
|
||||
modelBackedReviewerAvailable: boolean;
|
||||
};
|
||||
|
||||
export type CodexModelBackedReviewerContext = {
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
@@ -337,9 +332,7 @@ 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(),
|
||||
@@ -508,34 +501,25 @@ 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: CodexAppServerRuntimeParams = {},
|
||||
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;
|
||||
} = {},
|
||||
): 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);
|
||||
@@ -675,46 +659,43 @@ export function resolveCodexAppServerRuntime(
|
||||
: "implicit";
|
||||
|
||||
return {
|
||||
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 } : {}),
|
||||
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"),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
...resolveCodexAppServerNetworkProxy(config.networkProxy, resolvedSandbox),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -786,6 +767,7 @@ export function resolveCodexModelBackedReviewerPolicyContext(params: {
|
||||
model?: string;
|
||||
bindingModelProvider?: string;
|
||||
bindingModel?: string;
|
||||
nativeAuthProfile?: boolean;
|
||||
}): CodexModelBackedReviewerContext {
|
||||
const provider = params.provider?.trim();
|
||||
if (provider && provider.toLowerCase() !== "codex") {
|
||||
@@ -817,7 +799,7 @@ export function resolveCodexModelBackedReviewerPolicyContext(params: {
|
||||
};
|
||||
}
|
||||
return {
|
||||
modelProvider: undefined,
|
||||
modelProvider: params.nativeAuthProfile === true ? "openai" : undefined,
|
||||
model: params.model ?? params.bindingModel,
|
||||
};
|
||||
}
|
||||
@@ -884,7 +866,6 @@ export function codexAppServerStartOptionsKey(
|
||||
options: CodexAppServerStartOptions,
|
||||
params: {
|
||||
authProfileId?: string;
|
||||
authAccountCacheKey?: string;
|
||||
agentDir?: string;
|
||||
fallbackApiKeyCacheKey?: string;
|
||||
} = {},
|
||||
@@ -904,7 +885,6 @@ 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,
|
||||
});
|
||||
@@ -944,7 +924,7 @@ function resolveCodexAppServerNetworkProxy(
|
||||
enabled: true,
|
||||
mode: config.mode,
|
||||
domains: normalizeNetworkProxyPermissionMap(config.domains),
|
||||
unix_sockets: normalizeNetworkProxyUnixSocketPermissionMap(config.unixSockets),
|
||||
unix_sockets: normalizeNetworkProxyPermissionMap(config.unixSockets),
|
||||
proxy_url: readNonEmptyString(config.proxyUrl),
|
||||
socks_url: readNonEmptyString(config.socksUrl),
|
||||
enable_socks5: config.enableSocks5,
|
||||
@@ -999,20 +979,6 @@ 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 {
|
||||
|
||||
@@ -11,10 +11,11 @@ import {
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addSandboxShellDynamicToolsIfAvailable,
|
||||
buildDynamicTools,
|
||||
filterCodexDynamicToolsForAllowlist,
|
||||
hasWildcardCodexToolsAllow,
|
||||
includeForcedCodexDynamicToolAllow,
|
||||
prepareDynamicToolCatalog,
|
||||
mapCodexAppServerRemoteWorkspacePath,
|
||||
resetOpenClawCodingToolsFactoryForTests,
|
||||
resolveCodexAppServerExecutionCwd,
|
||||
resolveOpenClawCodingToolsSessionKeys,
|
||||
@@ -22,7 +23,6 @@ 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 prepareDynamicToolCatalog>[0]> = {},
|
||||
options: Partial<Parameters<typeof buildDynamicTools>[0]> = {},
|
||||
) {
|
||||
const sandboxSessionKey = params.sessionKey;
|
||||
if (!sandboxSessionKey) {
|
||||
throw new Error("createParams must provide a sessionKey for Codex dynamic tool tests.");
|
||||
}
|
||||
const catalog = await prepareDynamicToolCatalog({
|
||||
return buildDynamicTools({
|
||||
params,
|
||||
resolvedWorkspace: workspaceDir,
|
||||
effectiveWorkspace: workspaceDir,
|
||||
@@ -125,7 +125,6 @@ async function buildDynamicToolsForTest(
|
||||
onYieldDetected: () => undefined,
|
||||
...options,
|
||||
});
|
||||
return catalog.tools;
|
||||
}
|
||||
|
||||
describe("Codex app-server dynamic tool build", () => {
|
||||
@@ -228,51 +227,197 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("prepares runtime and durable tool views from one OpenClaw catalog", async () => {
|
||||
const messageTool = createRuntimeDynamicTool("message");
|
||||
const webSearchTool = createRuntimeDynamicTool("web_search");
|
||||
const heartbeatTool = createRuntimeDynamicTool("heartbeat_respond");
|
||||
const factory = vi.fn<OpenClawCodingToolsFactory>((options) => [
|
||||
messageTool,
|
||||
webSearchTool,
|
||||
...(options?.enableHeartbeatTool ? [heartbeatTool] : []),
|
||||
]);
|
||||
setOpenClawCodingToolsFactoryForTests(factory);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
it("removes managed web_search when domain-restricted Codex hosted search is active", async () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
|
||||
params.disableTools = false;
|
||||
const runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.runtimePlan = {
|
||||
...runtimePlan,
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
params.config = {
|
||||
tools: {
|
||||
normalize: (tools: Array<{ name: string }>) =>
|
||||
tools.filter((tool) => tool.name === "message"),
|
||||
logDiagnostics: () => undefined,
|
||||
web: {
|
||||
search: { openaiCodex: { allowedDomains: ["example.com"] } },
|
||||
},
|
||||
},
|
||||
} as unknown as NonNullable<EmbeddedRunAttemptParams["runtimePlan"]>;
|
||||
} as never;
|
||||
setOpenClawCodingToolsFactoryForTests(() => [
|
||||
createRuntimeDynamicTool("web_search"),
|
||||
createRuntimeDynamicTool("message"),
|
||||
]);
|
||||
let webSearchAllowed = false;
|
||||
|
||||
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,
|
||||
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
|
||||
onWebSearchPolicyResolved: (allowed) => {
|
||||
webSearchAllowed = allowed;
|
||||
},
|
||||
});
|
||||
|
||||
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",
|
||||
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 () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(path.join(tempDir, "session.jsonl"), 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 = {
|
||||
tools: {
|
||||
toolsBySender: {
|
||||
"id:restricted-sender": { deny: ["web_search"] },
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
setOpenClawCodingToolsFactoryForTests(() => [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 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"),
|
||||
]);
|
||||
|
||||
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,9 +46,6 @@ 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"]>
|
||||
@@ -63,7 +60,9 @@ const CODEX_NATIVE_SANDBOX_TOOL_REQUIREMENTS = [
|
||||
"apply_patch",
|
||||
] as const;
|
||||
const CODEX_MEMORY_FLUSH_DYNAMIC_TOOL_ALLOW = new Set(["read", "write"]);
|
||||
const CODEX_HEARTBEAT_DYNAMIC_TOOL_NAME = "heartbeat_respond";
|
||||
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"]);
|
||||
|
||||
/** Runtime inputs needed to derive the exact Codex dynamic tool surface for a turn. */
|
||||
export type DynamicToolBuildParams = {
|
||||
@@ -79,6 +78,9 @@ 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;
|
||||
@@ -141,11 +143,6 @@ 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;
|
||||
|
||||
@@ -207,42 +204,26 @@ export function formatCodexDynamicToolBuildStageSummary(
|
||||
: "none";
|
||||
}
|
||||
|
||||
/** Builds the turn-visible and durable registration views from one OpenClaw tool catalog. */
|
||||
export async function prepareDynamicToolCatalog(input: DynamicToolBuildParams): Promise<{
|
||||
tools: OpenClawDynamicTool[];
|
||||
registeredTools: OpenClawDynamicTool[];
|
||||
}> {
|
||||
/** Builds, filters, and normalizes Codex-compatible runtime tools for a single turn. */
|
||||
export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
const { params } = input;
|
||||
if (params.disableTools || !supportsModelTools(params.model)) {
|
||||
return { tools: [], registeredTools: [] };
|
||||
const messagePolicyParams = input.ignoreDisableMessageTool
|
||||
? { ...params, disableMessageTool: false }
|
||||
: params;
|
||||
if (params.disableTools) {
|
||||
input.onWebSearchPolicyResolved?.(false);
|
||||
return [];
|
||||
}
|
||||
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");
|
||||
@@ -321,10 +302,10 @@ async function buildOpenClawDynamicToolSource(
|
||||
requireExplicitMessageTarget:
|
||||
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
disableMessageTool: params.disableMessageTool,
|
||||
forceMessageTool: shouldForceMessageTool(params),
|
||||
enableHeartbeatTool: true,
|
||||
forceHeartbeatTool: true,
|
||||
disableMessageTool: input.ignoreDisableMessageTool ? false : params.disableMessageTool,
|
||||
forceMessageTool: shouldForceMessageTool(messagePolicyParams),
|
||||
enableHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
|
||||
forceHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
|
||||
onYield: (message) => {
|
||||
input.onYieldDetected();
|
||||
input.onCodexAppServerEvent?.({
|
||||
@@ -339,30 +320,16 @@ async function buildOpenClawDynamicToolSource(
|
||||
allocateToolOutcomeOrdinal: params.allocateToolOutcomeOrdinal,
|
||||
});
|
||||
toolBuildStages.mark("create-openclaw-coding-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 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];
|
||||
const codexFilteredTools = addNodeShellDynamicToolsIfNeeded(
|
||||
addSandboxShellDynamicToolsIfAvailable(
|
||||
isCodexMemoryFlushRun(params)
|
||||
@@ -375,18 +342,51 @@ function projectDynamicTools(
|
||||
input,
|
||||
nativeExecutionPolicy,
|
||||
);
|
||||
markStage("codex-filtering");
|
||||
const modelHasVision = params.model.input?.includes("image") ?? false;
|
||||
toolBuildStages.mark("codex-filtering");
|
||||
const visionFilteredTools = filterToolsForVisionInputs(codexFilteredTools, {
|
||||
modelHasVision,
|
||||
hasInboundImages: (params.images?.length ?? 0) > 0,
|
||||
});
|
||||
markStage("vision-filtering");
|
||||
const toolsAllow = includeForcedCodexDynamicToolAllow(params.toolsAllow, params);
|
||||
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);
|
||||
const filteredTools = filterCodexDynamicToolsForAllowlist(visionFilteredTools, toolsAllow);
|
||||
markStage("allowlist-filter");
|
||||
toolBuildStages.mark("allowlist-filter");
|
||||
const normalizedTools = normalizeAgentRuntimeTools({
|
||||
runtimePlan: options.ignoreRuntimePlan ? undefined : params.runtimePlan,
|
||||
runtimePlan: input.ignoreRuntimePlan ? undefined : params.runtimePlan,
|
||||
tools: filteredTools,
|
||||
provider: params.provider,
|
||||
config: params.config,
|
||||
@@ -395,14 +395,17 @@ function projectDynamicTools(
|
||||
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),
|
||||
});
|
||||
markStage("runtime-normalization");
|
||||
if (options.reportDiagnostics !== false && preNormalizationDiagnostics.length > 0) {
|
||||
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) {
|
||||
embeddedAgentLog.warn(
|
||||
`codex app-server quarantined ${preNormalizationDiagnostics.length} unsupported runtime tool schema${preNormalizationDiagnostics.length === 1 ? "" : "s"} before dynamic tool registration`,
|
||||
{
|
||||
@@ -419,7 +422,7 @@ function projectDynamicTools(
|
||||
}
|
||||
const summary = toolBuildStages.snapshot();
|
||||
if (shouldWarnCodexDynamicToolBuildStageSummary(summary)) {
|
||||
const phase = options.phase ?? "runtime-tools";
|
||||
const phase = input.forceHeartbeatTool ? "registered-tools" : "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)}`,
|
||||
{
|
||||
@@ -432,8 +435,9 @@ function projectDynamicTools(
|
||||
codexFilteredToolCount: codexFilteredTools.length,
|
||||
visionFilteredToolCount: visionFilteredTools.length,
|
||||
filteredToolCount: filteredTools.length,
|
||||
normalizedToolCount: normalizedTools.length,
|
||||
ignoreRuntimePlan: options.ignoreRuntimePlan === true,
|
||||
normalizedToolCount: exposedTools.length,
|
||||
forceHeartbeatTool: input.forceHeartbeatTool === true,
|
||||
ignoreRuntimePlan: input.ignoreRuntimePlan === true,
|
||||
nativeToolSurfaceEnabled: input.nativeToolSurfaceEnabled === true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -72,12 +72,6 @@ type CodexDynamicToolHookContext = {
|
||||
|
||||
type CodexToolResultHookContext = Omit<CodexDynamicToolHookContext, "config">;
|
||||
|
||||
type AgentToolResultObserver = (event: {
|
||||
toolName: string;
|
||||
result: unknown;
|
||||
isError: boolean;
|
||||
}) => void;
|
||||
|
||||
type ProjectedCodexDynamicTool = {
|
||||
tool: AnyAgentTool;
|
||||
name: string;
|
||||
@@ -114,7 +108,8 @@ export type CodexDynamicToolBridge = {
|
||||
params: CodexDynamicToolCallParams,
|
||||
options?: {
|
||||
signal?: AbortSignal;
|
||||
onAgentToolResult?: AgentToolResultObserver;
|
||||
onAgentToolResult?: EmbeddedRunAttemptParams["onAgentToolResult"];
|
||||
toolCallOrdinal?: number;
|
||||
},
|
||||
) => Promise<CodexDynamicToolCallResponse>;
|
||||
telemetry: {
|
||||
@@ -447,7 +442,7 @@ export function createCodexDynamicToolBridge(params: {
|
||||
}
|
||||
|
||||
function notifyAgentToolResult(
|
||||
observer: AgentToolResultObserver | undefined,
|
||||
observer: EmbeddedRunAttemptParams["onAgentToolResult"] | undefined,
|
||||
toolName: string,
|
||||
result: unknown,
|
||||
isError: boolean,
|
||||
|
||||
@@ -24,6 +24,7 @@ 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";
|
||||
@@ -107,6 +108,7 @@ afterEach(async () => {
|
||||
resetAgentEventsForTest();
|
||||
resetDiagnosticEventsForTest();
|
||||
resetGlobalHookRunner();
|
||||
resetCodexRateLimitCacheForTests();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
for (const tempDir of tempDirs) {
|
||||
@@ -861,11 +863,10 @@ 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: {
|
||||
@@ -886,11 +887,10 @@ 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,8 +914,9 @@ 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;
|
||||
const rateLimits = {
|
||||
rememberCodexRateLimits({
|
||||
rateLimits: {
|
||||
limitId: "codex",
|
||||
limitName: "Codex",
|
||||
@@ -926,9 +927,6 @@ describe("CodexAppServerEventProjector", () => {
|
||||
rateLimitReachedType: "rate_limit_reached",
|
||||
},
|
||||
rateLimitsByLimitId: null,
|
||||
};
|
||||
const projector = await createProjector(undefined, {
|
||||
readRecentRateLimits: () => rateLimits,
|
||||
});
|
||||
|
||||
await projector.handleNotification(
|
||||
@@ -980,19 +978,19 @@ describe("CodexAppServerEventProjector", () => {
|
||||
expect(result.promptErrorSource).toBe("prompt");
|
||||
});
|
||||
|
||||
it("normalizes current app-server token usage", async () => {
|
||||
it("normalizes snake_case current token usage fields", async () => {
|
||||
const projector = await createProjector();
|
||||
|
||||
await projector.handleNotification(agentMessageDelta("done"));
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("thread/tokenUsage/updated", {
|
||||
tokenUsage: {
|
||||
total: { totalTokens: 1_000_000 },
|
||||
last: {
|
||||
totalTokens: 17,
|
||||
inputTokens: 8,
|
||||
cachedInputTokens: 3,
|
||||
outputTokens: 9,
|
||||
total: { total_tokens: 1_000_000 },
|
||||
last_token_usage: {
|
||||
total_tokens: 17,
|
||||
input_tokens: 8,
|
||||
cached_input_tokens: 3,
|
||||
output_tokens: 9,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -26,7 +26,10 @@ 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 { isCodexNotificationForTurn } from "./notification-correlation.js";
|
||||
import {
|
||||
readCodexNotificationThreadId,
|
||||
readCodexNotificationTurnId,
|
||||
} from "./notification-correlation.js";
|
||||
import { readCodexTurn } from "./protocol-validators.js";
|
||||
import {
|
||||
isJsonObject,
|
||||
@@ -37,6 +40,7 @@ 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 {
|
||||
@@ -61,7 +65,6 @@ export type CodexAppServerToolTelemetry = {
|
||||
|
||||
export type CodexAppServerEventProjectorOptions = {
|
||||
nativePostToolUseRelayEnabled?: boolean;
|
||||
readRecentRateLimits?: () => JsonValue | undefined;
|
||||
trajectoryRecorder?: CodexTrajectoryRecorder | null;
|
||||
};
|
||||
|
||||
@@ -89,6 +92,22 @@ 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 =
|
||||
@@ -184,6 +203,8 @@ 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,
|
||||
@@ -220,6 +241,11 @@ 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;
|
||||
@@ -272,7 +298,7 @@ export class CodexAppServerEventProjector {
|
||||
await this.handleRawResponseItemCompleted(params);
|
||||
break;
|
||||
case "error":
|
||||
if (params.willRetry === true) {
|
||||
if (readBooleanAlias(params, ["willRetry", "will_retry"]) === true) {
|
||||
break;
|
||||
}
|
||||
this.promptError = this.formatCodexErrorMessage(params) ?? "codex app-server error";
|
||||
@@ -683,7 +709,9 @@ export class CodexAppServerEventProjector {
|
||||
|
||||
private handleTokenUsage(params: JsonObject): void {
|
||||
const tokenUsage = isJsonObject(params.tokenUsage) ? params.tokenUsage : undefined;
|
||||
const current = tokenUsage && isJsonObject(tokenUsage.last) ? tokenUsage.last : undefined;
|
||||
const current =
|
||||
(tokenUsage ? readFirstJsonObject(tokenUsage, CURRENT_TOKEN_USAGE_KEYS) : undefined) ??
|
||||
readFirstJsonObject(params, CURRENT_TOKEN_USAGE_KEYS);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
@@ -754,7 +782,7 @@ export class CodexAppServerEventProjector {
|
||||
formatCodexUsageLimitErrorMessage({
|
||||
message: turn.error?.message,
|
||||
codexErrorInfo: turn.error?.codexErrorInfo as JsonValue | null | undefined,
|
||||
rateLimits: this.options.readRecentRateLimits?.(),
|
||||
rateLimits: this.latestRateLimits ?? readRecentCodexRateLimits(),
|
||||
}) ??
|
||||
turn.error?.message ??
|
||||
"codex app-server turn failed";
|
||||
@@ -1661,7 +1689,7 @@ export class CodexAppServerEventProjector {
|
||||
formatCodexUsageLimitErrorMessage({
|
||||
message: error ? readString(error, "message") : undefined,
|
||||
codexErrorInfo: error?.codexErrorInfo,
|
||||
rateLimits: this.options.readRecentRateLimits?.(),
|
||||
rateLimits: this.latestRateLimits ?? readRecentCodexRateLimits(),
|
||||
}) ?? readCodexErrorNotificationMessage(params)
|
||||
);
|
||||
}
|
||||
@@ -1856,7 +1884,9 @@ export class CodexAppServerEventProjector {
|
||||
}
|
||||
|
||||
private isNotificationForTurn(params: JsonObject): boolean {
|
||||
return isCodexNotificationForTurn(params, this.threadId, this.turnId);
|
||||
const threadId = readCodexNotificationThreadId(params);
|
||||
const turnId = readNotificationTurnId(params);
|
||||
return threadId === this.threadId && turnId === this.turnId;
|
||||
}
|
||||
|
||||
private isHookNotificationForCurrentThread(params: JsonObject): boolean {
|
||||
@@ -1870,6 +1900,10 @@ 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;
|
||||
@@ -1959,6 +1993,21 @@ 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)) {
|
||||
@@ -1986,19 +2035,52 @@ 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 = readNumber(record, "inputTokens");
|
||||
const cacheRead = readNumber(record, "cachedInputTokens");
|
||||
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 input =
|
||||
promptTotalInput !== undefined && cacheRead !== undefined
|
||||
? Math.max(0, promptTotalInput - cacheRead)
|
||||
: promptTotalInput;
|
||||
: (promptTotalInput ?? readNumber(record, "input"));
|
||||
|
||||
return normalizeUsage({
|
||||
input,
|
||||
output: readNumber(record, "outputTokens"),
|
||||
output: readNumberAlias(record, ["outputTokens", "output_tokens", "output"]),
|
||||
cacheRead,
|
||||
total: readNumber(record, "totalTokens"),
|
||||
cacheWrite: readNumberAlias(record, [
|
||||
"cacheWrite",
|
||||
"cache_write",
|
||||
"cacheCreationInputTokens",
|
||||
"cache_creation_input_tokens",
|
||||
]),
|
||||
total: readNumberAlias(record, ["totalTokens", "total_tokens", "total"]),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,6 @@ 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 = {
|
||||
@@ -40,11 +36,10 @@ export type CodexAppServerListModelsOptions = {
|
||||
includeHidden?: boolean;
|
||||
timeoutMs?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string | null;
|
||||
authProfileId?: string;
|
||||
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. */
|
||||
@@ -59,37 +54,27 @@ 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);
|
||||
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 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;
|
||||
}
|
||||
cursor = nextCursor;
|
||||
}
|
||||
return { models, nextCursor, truncated: true };
|
||||
return { models, nextCursor, truncated: true };
|
||||
});
|
||||
}
|
||||
|
||||
async function withCodexAppServerModelClient<T>(
|
||||
@@ -98,32 +83,33 @@ async function withCodexAppServerModelClient<T>(
|
||||
): Promise<T> {
|
||||
const timeoutMs = options.timeoutMs ?? 2500;
|
||||
const useSharedClient = options.sharedClient !== false;
|
||||
const clientLease = useSharedClient
|
||||
? await leaseSharedCodexAppServerClient({
|
||||
const {
|
||||
createIsolatedCodexAppServerClient,
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
} = await import("./shared-client.js");
|
||||
const client = useSharedClient
|
||||
? await getLeasedSharedCodexAppServerClient({
|
||||
startOptions: options.startOptions,
|
||||
timeoutMs,
|
||||
authProfileId: options.authProfileId,
|
||||
agentDir: options.agentDir,
|
||||
config: options.config,
|
||||
abandonSignal: options.signal,
|
||||
})
|
||||
: undefined;
|
||||
const client =
|
||||
clientLease?.client ??
|
||||
(await createIsolatedCodexAppServerClient({
|
||||
startOptions: options.startOptions,
|
||||
timeoutMs,
|
||||
authProfileId: options.authProfileId,
|
||||
agentDir: options.agentDir,
|
||||
config: options.config,
|
||||
}));
|
||||
: await createIsolatedCodexAppServerClient({
|
||||
startOptions: options.startOptions,
|
||||
timeoutMs,
|
||||
authProfileId: options.authProfileId,
|
||||
agentDir: options.agentDir,
|
||||
config: options.config,
|
||||
});
|
||||
try {
|
||||
return await run({ client, timeoutMs });
|
||||
} finally {
|
||||
if (useSharedClient) {
|
||||
clientLease?.release();
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
} else {
|
||||
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 });
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +125,7 @@ async function requestModelListPage(
|
||||
cursor: options.cursor ?? null,
|
||||
includeHidden: options.includeHidden ?? null,
|
||||
},
|
||||
{ timeoutMs: options.timeoutMs, signal: options.signal },
|
||||
{ timeoutMs: options.timeoutMs },
|
||||
);
|
||||
return readModelListResult(response);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,7 @@
|
||||
*/
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveSandboxRuntimeStatus } from "openclaw/plugin-sdk/sandbox";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveSessionStoreEntry,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
} from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { getSessionEntry, type SessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
|
||||
type ExecHost = "sandbox" | "gateway" | "node";
|
||||
type ExecTarget = "auto" | ExecHost;
|
||||
@@ -50,17 +45,19 @@ 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 ??
|
||||
(params.readRuntimeSessionEntry && sessionKey
|
||||
? readRuntimeSessionEntryBestEffort(config, sessionKey, agentId)
|
||||
(canReadSessionEntry && sessionKey
|
||||
? readRuntimeSessionEntryBestEffort({ sessionKey, agentId })
|
||||
: undefined);
|
||||
const sandboxAvailable =
|
||||
params.sandboxAvailable ??
|
||||
(sessionKey
|
||||
? resolveSandboxRuntimeStatus({
|
||||
cfg: config,
|
||||
agentId,
|
||||
sessionKey,
|
||||
}).sandboxed
|
||||
: false);
|
||||
@@ -233,17 +230,16 @@ function resolveEffectiveExecHost(params: {
|
||||
return params.requestedExecHost;
|
||||
}
|
||||
|
||||
function readRuntimeSessionEntryBestEffort(
|
||||
config: OpenClawConfig,
|
||||
sessionKey: string,
|
||||
agentId: string,
|
||||
): SessionEntry | undefined {
|
||||
function readRuntimeSessionEntryBestEffort(params: {
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
}): SessionEntry | undefined {
|
||||
try {
|
||||
const storePath = resolveStorePath(config.session?.store, { agentId });
|
||||
return resolveSessionStoreEntry({
|
||||
store: loadSessionStore(storePath, { skipCache: true }),
|
||||
sessionKey,
|
||||
}).existing;
|
||||
return getSessionEntry({
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
hydrateSkillPromptRefs: false,
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ 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. */
|
||||
@@ -23,6 +24,8 @@ 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;
|
||||
@@ -146,8 +149,9 @@ export function createCodexNativeHookRelay(params: {
|
||||
allowedEvents: params.events,
|
||||
ttlMs: resolveCodexNativeHookRelayTtlMs({
|
||||
explicitTtlMs: params.options?.ttlMs,
|
||||
operationBudgetMs:
|
||||
params.attemptTimeoutMs + params.startupTimeoutMs + params.turnStartTimeoutMs,
|
||||
attemptTimeoutMs: params.attemptTimeoutMs,
|
||||
startupTimeoutMs: params.startupTimeoutMs,
|
||||
turnStartTimeoutMs: params.turnStartTimeoutMs,
|
||||
}),
|
||||
signal: params.signal,
|
||||
command: {
|
||||
@@ -159,27 +163,38 @@ export function createCodexNativeHookRelay(params: {
|
||||
});
|
||||
}
|
||||
|
||||
/** Selects the native hook events Codex should install for this thread. */
|
||||
/** Selects the native hook events Codex should install for the current approval mode. */
|
||||
export function resolveCodexNativeHookRelayEvents(params: {
|
||||
configuredEvents?: readonly NativeHookRelayEvent[];
|
||||
appServer: Pick<CodexAppServerRuntimeOptions, "approvalPolicy">;
|
||||
}): readonly NativeHookRelayEvent[] {
|
||||
if (params.configuredEvents?.length) {
|
||||
return params.configuredEvents;
|
||||
}
|
||||
// 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;
|
||||
// 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;
|
||||
}
|
||||
|
||||
/** Derives the native hook relay TTL from the turn budget unless explicitly configured. */
|
||||
export function resolveCodexNativeHookRelayTtlMs(params: {
|
||||
explicitTtlMs: number | undefined;
|
||||
operationBudgetMs: number;
|
||||
attemptTimeoutMs: number;
|
||||
startupTimeoutMs: number;
|
||||
turnStartTimeoutMs: number;
|
||||
}): number {
|
||||
if (params.explicitTtlMs !== undefined) {
|
||||
return params.explicitTtlMs;
|
||||
}
|
||||
const relayBudgetMs = params.operationBudgetMs + CODEX_NATIVE_HOOK_RELAY_TTL_GRACE_MS;
|
||||
const relayBudgetMs =
|
||||
params.attemptTimeoutMs +
|
||||
params.startupTimeoutMs +
|
||||
params.turnStartTimeoutMs +
|
||||
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,7 +4,6 @@ import {
|
||||
extractCodexNativeSubagentCompletions,
|
||||
extractCodexNativeSubagentCompletionsFromText,
|
||||
} from "./native-subagent-notification.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
|
||||
function trustedInterAgentNotification(params: {
|
||||
agentPath: string;
|
||||
@@ -36,29 +35,6 @@ 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(
|
||||
@@ -160,26 +136,6 @@ 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({
|
||||
@@ -214,27 +170,6 @@ 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,12 +39,13 @@ export function extractCodexNativeSubagentCompletions(
|
||||
if (!item) {
|
||||
return [];
|
||||
}
|
||||
const communication = readTrustedInterAgentCommunication(item);
|
||||
if (!communication) {
|
||||
const text = readTrustedInterAgentCommunicationContent(item);
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
return extractCodexNativeSubagentCompletionsFromText(communication.content).filter(
|
||||
(completion) => completion.agentPath === communication.author,
|
||||
const author = readTrustedInterAgentCommunicationAuthor(item);
|
||||
return extractCodexNativeSubagentCompletionsFromText(text).filter(
|
||||
(completion) => completion.agentPath === author,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,21 +190,17 @@ function completedWithoutFinalAssistantMessage(): {
|
||||
};
|
||||
}
|
||||
|
||||
type TrustedInterAgentCommunication = {
|
||||
author: string;
|
||||
recipient: string;
|
||||
content: string;
|
||||
};
|
||||
function readTrustedInterAgentCommunicationContent(item: JsonObject): string | undefined {
|
||||
const communication = readTrustedInterAgentCommunication(item);
|
||||
return typeof communication?.content === "string" ? communication.content : 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;
|
||||
}
|
||||
function readTrustedInterAgentCommunicationAuthor(item: JsonObject): string | undefined {
|
||||
const communication = readTrustedInterAgentCommunication(item);
|
||||
return typeof communication?.author === "string" ? communication.author : undefined;
|
||||
}
|
||||
|
||||
function readTrustedInterAgentCommunication(item: JsonObject): JsonObject | undefined {
|
||||
if (
|
||||
readString(item, "type") !== "message" ||
|
||||
readString(item, "role") !== "assistant" ||
|
||||
@@ -211,7 +208,7 @@ function readTrustedInterAgentCommunication(
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const text = extractSingleTextPart(item, "output_text", "text");
|
||||
const text = extractSingleTextPart(item);
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -224,20 +221,18 @@ function readTrustedInterAgentCommunication(
|
||||
if (!isJsonObject(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
const author = typeof parsed.author === "string" ? parsed.author.trim() : "";
|
||||
const recipient = typeof parsed.recipient === "string" ? parsed.recipient.trim() : "";
|
||||
if (
|
||||
!author ||
|
||||
!recipient ||
|
||||
typeof parsed.author !== "string" ||
|
||||
typeof parsed.recipient !== "string" ||
|
||||
typeof parsed.content !== "string" ||
|
||||
parsed.trigger_turn !== false
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { author, recipient, content: parsed.content };
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function extractSingleTextPart(item: JsonObject, ...acceptedTypes: string[]): string | undefined {
|
||||
function extractSingleTextPart(item: JsonObject): string | undefined {
|
||||
const content = item.content;
|
||||
if (!Array.isArray(content) || content.length !== 1) {
|
||||
return undefined;
|
||||
@@ -247,7 +242,7 @@ function extractSingleTextPart(item: JsonObject, ...acceptedTypes: string[]): st
|
||||
return undefined;
|
||||
}
|
||||
const type = readString(entry, "type");
|
||||
if (!type || !acceptedTypes.includes(type)) {
|
||||
if (type !== "output_text" && type !== "text") {
|
||||
return undefined;
|
||||
}
|
||||
return readString(entry, "text")?.trim();
|
||||
|
||||
@@ -56,8 +56,8 @@ export class CodexNativeSubagentTaskMirror {
|
||||
}
|
||||
|
||||
markAuthoritativeCompletionExpected(childThreadId: string): void {
|
||||
// The monitor recovers the authoritative result through app-server history.
|
||||
// Keep collab completion as progress so it cannot finalize stale text first.
|
||||
// Local transcripts and V2 agent paths can supply the real result later.
|
||||
// Remote V1 lacks both and must keep collab-completed as its fallback.
|
||||
this.expectedAuthoritativeRunIds.add(codexNativeSubagentRunId(childThreadId));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,28 @@
|
||||
* Correlates Codex app-server notifications with the active thread/turn so
|
||||
* projectors can ignore global or stale events without losing diagnostics.
|
||||
*/
|
||||
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
|
||||
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;
|
||||
};
|
||||
|
||||
/** Returns true when a notification payload belongs to the exact active thread and turn. */
|
||||
export function isCodexNotificationForTurn(
|
||||
@@ -19,10 +40,9 @@ export function isCodexNotificationForTurn(
|
||||
);
|
||||
}
|
||||
|
||||
/** Reads a thread id from canonical top-level or nested thread payloads. */
|
||||
/** Reads a thread id from either top-level notification params or nested turn payloads. */
|
||||
export function readCodexNotificationThreadId(record: JsonObject): string | undefined {
|
||||
const thread = isJsonObject(record.thread) ? record.thread : undefined;
|
||||
return readString(record, "threadId") ?? (thread ? readString(thread, "id") : undefined);
|
||||
return readNestedTurnThreadId(record) ?? readString(record, "threadId");
|
||||
}
|
||||
|
||||
/** Reads a turn id from either top-level notification params or nested turn payloads. */
|
||||
@@ -30,11 +50,50 @@ 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;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
/** 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,6 +60,14 @@ 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,6 +8,7 @@ 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,
|
||||
@@ -17,6 +18,7 @@ import type {
|
||||
CodexThreadResumeResponse,
|
||||
CodexThreadStartResponse,
|
||||
CodexTurn,
|
||||
CodexTurnCompletedNotification,
|
||||
CodexTurnStartResponse,
|
||||
} from "./protocol.js";
|
||||
|
||||
@@ -219,6 +221,9 @@ const validateThreadResumeResponse = compileCodexSchema<CodexThreadResumeRespons
|
||||
);
|
||||
const validateThreadStartResponse =
|
||||
compileCodexSchema<CodexThreadStartResponse>(threadStartResponseSchema);
|
||||
const validateTurnCompletedNotification = compileCodexSchema<CodexTurnCompletedNotification>(
|
||||
turnCompletedNotificationSchema,
|
||||
);
|
||||
const validateTurnStartResponse =
|
||||
compileCodexSchema<CodexTurnStartResponse>(turnStartResponseSchema);
|
||||
|
||||
@@ -293,6 +298,19 @@ 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;
|
||||
@@ -357,6 +375,9 @@ 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;
|
||||
}
|
||||
@@ -371,6 +392,16 @@ 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,7 +139,6 @@ 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;
|
||||
};
|
||||
@@ -147,10 +146,7 @@ export type CodexThreadResumeParams = JsonObject & {
|
||||
export type CodexThreadStartResponse = {
|
||||
thread: CodexThread;
|
||||
model: string;
|
||||
modelProvider: string;
|
||||
approvalPolicy: string | JsonObject;
|
||||
approvalsReviewer: string;
|
||||
sandbox: CodexSandboxPolicy;
|
||||
modelProvider?: string | null;
|
||||
};
|
||||
|
||||
export type CodexThreadForkParams = CodexThreadStartParams & {
|
||||
@@ -166,22 +162,7 @@ export type CodexThreadForkResponse = CodexThreadStartResponse;
|
||||
export type CodexThreadResumeResponse = {
|
||||
thread: CodexThread;
|
||||
model: string;
|
||||
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[];
|
||||
};
|
||||
modelProvider?: string | null;
|
||||
};
|
||||
|
||||
export type CodexThreadInjectItemsParams = JsonObject & {
|
||||
@@ -226,10 +207,11 @@ export type CodexTurnStartResponse = {
|
||||
|
||||
export type CodexTurn = {
|
||||
id: string;
|
||||
threadId: string;
|
||||
status?: string;
|
||||
error?: CodexErrorNotification["error"];
|
||||
startedAt?: number | null;
|
||||
completedAt?: number | null;
|
||||
startedAt?: string | null;
|
||||
completedAt?: string | null;
|
||||
durationMs?: number | null;
|
||||
items: CodexThreadItem[];
|
||||
};
|
||||
@@ -247,7 +229,6 @@ export type CodexThread = {
|
||||
threadSource?: string | null;
|
||||
agentNickname?: string | null;
|
||||
agentRole?: string | null;
|
||||
turns: CodexTurn[];
|
||||
};
|
||||
|
||||
export type CodexThreadStatus =
|
||||
@@ -583,7 +564,6 @@ 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;
|
||||
@@ -612,7 +592,6 @@ type CodexAppServerRequestResultMap = {
|
||||
"thread/fork": CodexThreadForkResponse;
|
||||
"thread/inject_items": JsonValue;
|
||||
"thread/list": JsonValue;
|
||||
"thread/read": CodexThreadReadResponse;
|
||||
"thread/resume": CodexThreadResumeResponse;
|
||||
"thread/start": CodexThreadStartResponse;
|
||||
"thread/unsubscribe": JsonValue;
|
||||
@@ -625,14 +604,6 @@ 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,16 +13,12 @@ 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 release = vi.fn();
|
||||
const clientFactory = vi.fn(async () => ({
|
||||
client,
|
||||
release,
|
||||
})) as CodexAppServerClientLeaseFactory;
|
||||
return { clientFactory, release, request };
|
||||
const clientFactory = vi.fn(async () => client) as unknown as CodexAppServerClientFactory;
|
||||
return { clientFactory, request };
|
||||
}
|
||||
|
||||
function resolveSupport(
|
||||
clientFactory: CodexAppServerClientLeaseFactory,
|
||||
clientFactory: CodexAppServerClientFactory,
|
||||
modelProviderOverride?: string,
|
||||
) {
|
||||
return resolveCodexProviderWebSearchSupport({
|
||||
@@ -54,7 +50,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 CodexAppServerClientLeaseFactory;
|
||||
}) as unknown as CodexAppServerClientFactory;
|
||||
|
||||
await expect(resolveSupport(clientFactory)).resolves.toBe("unknown");
|
||||
});
|
||||
@@ -64,15 +60,10 @@ describe("resolveCodexProviderWebSearchSupport", () => {
|
||||
throw new Error("transient rpc failure");
|
||||
});
|
||||
const client = { request } as unknown as CodexAppServerClient;
|
||||
const release = vi.fn();
|
||||
const clientFactory = vi.fn(async () => ({
|
||||
client,
|
||||
release,
|
||||
})) as CodexAppServerClientLeaseFactory;
|
||||
const clientFactory = vi.fn(async () => client) as unknown as CodexAppServerClientFactory;
|
||||
|
||||
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,10 +1,8 @@
|
||||
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 type {
|
||||
CodexAppServerClientLease,
|
||||
CodexAppServerClientLeaseFactory,
|
||||
} from "./shared-client.js";
|
||||
import { releaseLeasedSharedCodexAppServerClient } from "./shared-client.js";
|
||||
import type { CodexNativeWebSearchSupport } from "./web-search.js";
|
||||
|
||||
async function readConfiguredProviderWebSearchSupport(params: {
|
||||
@@ -47,7 +45,7 @@ export async function resolveCodexProviderWebSearchSupportForClient(params: {
|
||||
}
|
||||
|
||||
export async function resolveCodexProviderWebSearchSupport(params: {
|
||||
clientFactory: CodexAppServerClientLeaseFactory;
|
||||
clientFactory: CodexAppServerClientFactory;
|
||||
appServer: CodexAppServerRuntimeOptions;
|
||||
authProfileId: string | undefined;
|
||||
agentDir: string;
|
||||
@@ -55,17 +53,17 @@ export async function resolveCodexProviderWebSearchSupport(params: {
|
||||
modelProviderOverride: string | undefined;
|
||||
signal: AbortSignal;
|
||||
}): Promise<CodexNativeWebSearchSupport> {
|
||||
let lease: CodexAppServerClientLease | undefined;
|
||||
let client: CodexAppServerClient | undefined;
|
||||
try {
|
||||
lease = await params.clientFactory({
|
||||
startOptions: params.appServer.start,
|
||||
authProfileId: params.authProfileId,
|
||||
agentDir: params.agentDir,
|
||||
config: params.config,
|
||||
timeoutMs: params.appServer.requestTimeoutMs,
|
||||
});
|
||||
client = await params.clientFactory(
|
||||
params.appServer.start,
|
||||
params.authProfileId,
|
||||
params.agentDir,
|
||||
params.config,
|
||||
{ timeoutMs: params.appServer.requestTimeoutMs },
|
||||
);
|
||||
return await resolveCodexProviderWebSearchSupportForClient({
|
||||
client: lease.client,
|
||||
client,
|
||||
timeoutMs: params.appServer.requestTimeoutMs,
|
||||
modelProviderOverride: params.modelProviderOverride,
|
||||
signal: params.signal,
|
||||
@@ -73,6 +71,8 @@ export async function resolveCodexProviderWebSearchSupport(params: {
|
||||
} catch {
|
||||
return "unknown";
|
||||
} finally {
|
||||
lease?.release();
|
||||
if (client) {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
// Codex tests cover physical-client rate-limit snapshot ownership and rolling merges.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import {
|
||||
mergeCodexRateLimitsUpdate,
|
||||
readCodexRateLimitsRevision,
|
||||
readRecentCodexRateLimits,
|
||||
rememberCodexRateLimitsRead,
|
||||
} from "./rate-limit-cache.js";
|
||||
|
||||
function clientIdentity(): CodexAppServerClient {
|
||||
return {} as unknown as CodexAppServerClient;
|
||||
}
|
||||
|
||||
describe("Codex rate-limit cache", () => {
|
||||
it("isolates snapshots by physical client", () => {
|
||||
const first = clientIdentity();
|
||||
const second = clientIdentity();
|
||||
expect(readCodexRateLimitsRevision(first)).toBe(0);
|
||||
rememberCodexRateLimitsRead(first, { rateLimits: { limitId: "first" } }, 100);
|
||||
rememberCodexRateLimitsRead(second, { rateLimits: { limitId: "second" } }, 200);
|
||||
expect(readCodexRateLimitsRevision(first, "first")).toBe(1);
|
||||
expect(readCodexRateLimitsRevision(second, "second")).toBe(1);
|
||||
|
||||
expect(readRecentCodexRateLimits(first, { nowMs: 250 })).toEqual({
|
||||
rateLimits: { limitId: "first" },
|
||||
});
|
||||
expect(readRecentCodexRateLimits(second, { nowMs: 250 })).toEqual({
|
||||
rateLimits: { limitId: "second" },
|
||||
});
|
||||
expect(readRecentCodexRateLimits(first, { nowMs: 301, maxAgeMs: 200 })).toBeUndefined();
|
||||
expect(readRecentCodexRateLimits(second, { nowMs: 301, maxAgeMs: 200 })).toEqual({
|
||||
rateLimits: { limitId: "second" },
|
||||
});
|
||||
});
|
||||
|
||||
it("merges sparse rolling updates without clearing account metadata", () => {
|
||||
const client = clientIdentity();
|
||||
const codexSnapshot = {
|
||||
limitId: "codex",
|
||||
limitName: "Codex",
|
||||
primary: { usedPercent: 10, windowDurationMins: 300, resetsAt: 1000 },
|
||||
secondary: { usedPercent: 20, windowDurationMins: 10_080, resetsAt: 2000 },
|
||||
credits: { hasCredits: true, unlimited: false, balance: "5" },
|
||||
individualLimit: {
|
||||
limit: "25000",
|
||||
used: "8000",
|
||||
remainingPercent: 68,
|
||||
resetsAt: 3000,
|
||||
},
|
||||
planType: "pro",
|
||||
rateLimitReachedType: "rate_limit_reached",
|
||||
};
|
||||
const otherSnapshot = {
|
||||
limitId: "codex_other",
|
||||
limitName: "Other",
|
||||
primary: { usedPercent: 30, windowDurationMins: 60, resetsAt: 4000 },
|
||||
secondary: null,
|
||||
credits: null,
|
||||
individualLimit: null,
|
||||
planType: "pro",
|
||||
rateLimitReachedType: null,
|
||||
};
|
||||
rememberCodexRateLimitsRead(client, {
|
||||
rateLimits: codexSnapshot,
|
||||
rateLimitsByLimitId: { codex: codexSnapshot, codex_other: otherSnapshot },
|
||||
});
|
||||
|
||||
mergeCodexRateLimitsUpdate(client, {
|
||||
rateLimits: {
|
||||
limitId: null,
|
||||
limitName: null,
|
||||
primary: { usedPercent: 90, windowDurationMins: 300, resetsAt: 5000 },
|
||||
secondary: null,
|
||||
credits: null,
|
||||
individualLimit: null,
|
||||
planType: null,
|
||||
rateLimitReachedType: null,
|
||||
},
|
||||
});
|
||||
mergeCodexRateLimitsUpdate(client, {
|
||||
rateLimits: {
|
||||
limitId: "codex_other",
|
||||
limitName: null,
|
||||
primary: { usedPercent: 75, windowDurationMins: 60, resetsAt: 6000 },
|
||||
secondary: null,
|
||||
credits: null,
|
||||
individualLimit: null,
|
||||
planType: null,
|
||||
rateLimitReachedType: null,
|
||||
},
|
||||
});
|
||||
expect(readCodexRateLimitsRevision(client)).toBe(2);
|
||||
expect(readCodexRateLimitsRevision(client, "codex_other")).toBe(2);
|
||||
|
||||
const mergedCodexSnapshot = {
|
||||
limitId: "codex",
|
||||
limitName: null,
|
||||
primary: { usedPercent: 90, windowDurationMins: 300, resetsAt: 5000 },
|
||||
secondary: null,
|
||||
credits: codexSnapshot.credits,
|
||||
individualLimit: codexSnapshot.individualLimit,
|
||||
planType: "pro",
|
||||
rateLimitReachedType: null,
|
||||
};
|
||||
const mergedOtherSnapshot = {
|
||||
limitId: "codex_other",
|
||||
limitName: null,
|
||||
primary: { usedPercent: 75, windowDurationMins: 60, resetsAt: 6000 },
|
||||
secondary: null,
|
||||
credits: codexSnapshot.credits,
|
||||
individualLimit: codexSnapshot.individualLimit,
|
||||
planType: "pro",
|
||||
rateLimitReachedType: null,
|
||||
};
|
||||
expect(readRecentCodexRateLimits(client)).toEqual({
|
||||
rateLimits: mergedCodexSnapshot,
|
||||
rateLimitsByLimitId: {
|
||||
codex: mergedCodexSnapshot,
|
||||
codex_other: mergedOtherSnapshot,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,166 +1,55 @@
|
||||
/** Client-owned Codex app-server rate-limit snapshots. */
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
|
||||
/**
|
||||
* Keeps the latest Codex app-server rate-limit payload in process-global state
|
||||
* so failure handling can enrich later usage-limit errors.
|
||||
*/
|
||||
import type { JsonValue } from "./protocol.js";
|
||||
|
||||
const DEFAULT_CODEX_RATE_LIMIT_CACHE_MAX_AGE_MS = 10 * 60_000;
|
||||
const SPARSE_ACCOUNT_METADATA_KEYS = ["credits", "individualLimit", "planType"] as const;
|
||||
const CODEX_RATE_LIMIT_CACHE_STATE = Symbol.for("openclaw.codexRateLimitCacheState");
|
||||
|
||||
type CodexRateLimitCacheState = {
|
||||
value: JsonValue;
|
||||
updatedAtMs: number;
|
||||
revisionsByLimitId: Record<string, number>;
|
||||
value?: JsonValue;
|
||||
updatedAtMs?: number;
|
||||
};
|
||||
|
||||
const rateLimitsByClient = new WeakMap<CodexAppServerClient, CodexRateLimitCacheState>();
|
||||
|
||||
/** Replaces one physical client's cache with an authoritative rate-limit read response. */
|
||||
export function rememberCodexRateLimitsRead(
|
||||
client: CodexAppServerClient,
|
||||
value: JsonValue | undefined,
|
||||
nowMs = Date.now(),
|
||||
): void {
|
||||
if (value !== undefined) {
|
||||
const currentState = rateLimitsByClient.get(client);
|
||||
const revisionsByLimitId = { ...currentState?.revisionsByLimitId };
|
||||
for (const limitId of readRateLimitIds(value)) {
|
||||
revisionsByLimitId[limitId] = (revisionsByLimitId[limitId] ?? 0) + 1;
|
||||
}
|
||||
rateLimitsByClient.set(client, {
|
||||
value,
|
||||
updatedAtMs: nowMs,
|
||||
revisionsByLimitId,
|
||||
});
|
||||
}
|
||||
function getCodexRateLimitCacheState(): CodexRateLimitCacheState {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
[CODEX_RATE_LIMIT_CACHE_STATE]?: CodexRateLimitCacheState;
|
||||
};
|
||||
globalState[CODEX_RATE_LIMIT_CACHE_STATE] ??= {};
|
||||
return globalState[CODEX_RATE_LIMIT_CACHE_STATE];
|
||||
}
|
||||
|
||||
/** Merges a sparse rolling notification into one physical client's latest read response. */
|
||||
export function mergeCodexRateLimitsUpdate(
|
||||
client: CodexAppServerClient,
|
||||
value: JsonValue | undefined,
|
||||
nowMs = Date.now(),
|
||||
): void {
|
||||
const update =
|
||||
isJsonObject(value) && isJsonObject(value.rateLimits) ? value.rateLimits : undefined;
|
||||
if (!update) {
|
||||
/** Stores a non-empty Codex rate-limit payload with its observation time. */
|
||||
export function rememberCodexRateLimits(value: JsonValue | undefined, nowMs = Date.now()): void {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
const currentState = rateLimitsByClient.get(client);
|
||||
const current = currentState?.value;
|
||||
const limitId = readLimitId(update);
|
||||
rateLimitsByClient.set(client, {
|
||||
value: mergeRateLimitUpdate(current, update),
|
||||
updatedAtMs: nowMs,
|
||||
revisionsByLimitId: {
|
||||
...currentState?.revisionsByLimitId,
|
||||
[limitId]: (currentState?.revisionsByLimitId[limitId] ?? 0) + 1,
|
||||
},
|
||||
});
|
||||
const state = getCodexRateLimitCacheState();
|
||||
state.value = value;
|
||||
state.updatedAtMs = nowMs;
|
||||
}
|
||||
|
||||
/** Per-limit marker used to trust only primary Codex updates from one turn startup. */
|
||||
export function readCodexRateLimitsRevision(
|
||||
client: CodexAppServerClient,
|
||||
limitId = "codex",
|
||||
): number {
|
||||
return rateLimitsByClient.get(client)?.revisionsByLimitId[limitId] ?? 0;
|
||||
}
|
||||
|
||||
/** Reads one physical client's cached rate-limit payload within the max-age window. */
|
||||
export function readRecentCodexRateLimits(
|
||||
client: CodexAppServerClient,
|
||||
options?: {
|
||||
nowMs?: number;
|
||||
maxAgeMs?: number;
|
||||
},
|
||||
): JsonValue | undefined {
|
||||
const state = rateLimitsByClient.get(client);
|
||||
if (!state) {
|
||||
/** Reads the cached Codex rate-limit payload when it is still within the max-age window. */
|
||||
export function readRecentCodexRateLimits(options?: {
|
||||
nowMs?: number;
|
||||
maxAgeMs?: number;
|
||||
}): JsonValue | undefined {
|
||||
const state = getCodexRateLimitCacheState();
|
||||
if (state.value === undefined || state.updatedAtMs === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const nowMs = options?.nowMs ?? Date.now();
|
||||
const maxAgeMs = options?.maxAgeMs ?? DEFAULT_CODEX_RATE_LIMIT_CACHE_MAX_AGE_MS;
|
||||
return maxAgeMs >= 0 && nowMs - state.updatedAtMs > maxAgeMs ? undefined : state.value;
|
||||
if (maxAgeMs >= 0 && nowMs - state.updatedAtMs > maxAgeMs) {
|
||||
return undefined;
|
||||
}
|
||||
return state.value;
|
||||
}
|
||||
|
||||
function mergeRateLimitUpdate(current: JsonValue | undefined, update: JsonObject): JsonObject {
|
||||
const currentEnvelope = isJsonObject(current) ? current : undefined;
|
||||
const currentPrimary =
|
||||
currentEnvelope && isJsonObject(currentEnvelope.rateLimits)
|
||||
? currentEnvelope.rateLimits
|
||||
: undefined;
|
||||
const currentByLimitId =
|
||||
currentEnvelope && isJsonObject(currentEnvelope.rateLimitsByLimitId)
|
||||
? currentEnvelope.rateLimitsByLimitId
|
||||
: undefined;
|
||||
const limitId = readLimitId(update);
|
||||
const currentPrimaryLimitId = currentPrimary ? readLimitId(currentPrimary) : undefined;
|
||||
const currentForLimit =
|
||||
(currentByLimitId && isJsonObject(currentByLimitId[limitId])
|
||||
? currentByLimitId[limitId]
|
||||
: undefined) ?? (currentPrimaryLimitId === limitId ? currentPrimary : undefined);
|
||||
const merged = mergeSparseSnapshot(
|
||||
isJsonObject(currentForLimit) ? currentForLimit : undefined,
|
||||
currentPrimary,
|
||||
update,
|
||||
limitId,
|
||||
);
|
||||
const nextPrimary =
|
||||
!currentPrimary || currentPrimaryLimitId === limitId ? merged : currentPrimary;
|
||||
let nextByLimitId: JsonObject | undefined;
|
||||
if (currentByLimitId) {
|
||||
nextByLimitId = { ...currentByLimitId, [limitId]: merged };
|
||||
} else if (currentPrimary && currentPrimaryLimitId && currentPrimaryLimitId !== limitId) {
|
||||
nextByLimitId = {
|
||||
[currentPrimaryLimitId]: currentPrimary,
|
||||
[limitId]: merged,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...currentEnvelope,
|
||||
rateLimits: nextPrimary,
|
||||
...(nextByLimitId ? { rateLimitsByLimitId: nextByLimitId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readRateLimitIds(value: JsonValue): string[] {
|
||||
if (!isJsonObject(value)) {
|
||||
return [];
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
if (isJsonObject(value.rateLimits)) {
|
||||
ids.add(readLimitId(value.rateLimits));
|
||||
}
|
||||
if (isJsonObject(value.rateLimitsByLimitId)) {
|
||||
for (const [key, snapshot] of Object.entries(value.rateLimitsByLimitId)) {
|
||||
const snapshotLimitId =
|
||||
isJsonObject(snapshot) && typeof snapshot.limitId === "string"
|
||||
? snapshot.limitId.trim()
|
||||
: "";
|
||||
ids.add(snapshotLimitId || key);
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
function mergeSparseSnapshot(
|
||||
current: JsonObject | undefined,
|
||||
accountFallback: JsonObject | undefined,
|
||||
update: JsonObject,
|
||||
limitId: string,
|
||||
): JsonObject {
|
||||
const merged: JsonObject = { ...update, limitId };
|
||||
// Rolling updates serialize unavailable account metadata as null. Preserve
|
||||
// only those sparse fields; window and reached-state nulls remain authoritative.
|
||||
for (const key of SPARSE_ACCOUNT_METADATA_KEYS) {
|
||||
const previous = current?.[key] ?? accountFallback?.[key];
|
||||
if (merged[key] == null && previous != null) {
|
||||
merged[key] = previous;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function readLimitId(snapshot: JsonObject): string {
|
||||
const value = snapshot.limitId;
|
||||
return typeof value === "string" && value.trim() ? value.trim() : "codex";
|
||||
/** Clears the process-global rate-limit cache for deterministic tests. */
|
||||
export function resetCodexRateLimitCacheForTests(): void {
|
||||
const state = getCodexRateLimitCacheState();
|
||||
state.value = undefined;
|
||||
state.updatedAtMs = undefined;
|
||||
}
|
||||
|
||||
@@ -1,80 +1,23 @@
|
||||
// Codex tests cover request plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import { readRecentCodexRateLimits } from "./rate-limit-cache.js";
|
||||
|
||||
const sharedClientMocks = vi.hoisted(() => ({
|
||||
abandon: vi.fn(async () => undefined),
|
||||
createIsolatedCodexAppServerClient: vi.fn(),
|
||||
getSharedCodexAppServerClient: vi.fn(),
|
||||
release: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./shared-client.js", () => ({
|
||||
...sharedClientMocks,
|
||||
leaseSharedCodexAppServerClient: async (...args: unknown[]) => {
|
||||
let settled = false;
|
||||
return {
|
||||
client: await sharedClientMocks.getSharedCodexAppServerClient(...args),
|
||||
release: () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
sharedClientMocks.release();
|
||||
}
|
||||
},
|
||||
abandon: async () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
await sharedClientMocks.abandon();
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
getLeasedSharedCodexAppServerClient: sharedClientMocks.getSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const { requestCodexAppServerJson } = await import("./request.js");
|
||||
|
||||
function resumeResponse(threadId: string) {
|
||||
return {
|
||||
thread: {
|
||||
id: threadId,
|
||||
sessionId: "session-1",
|
||||
forkedFromId: null,
|
||||
preview: "",
|
||||
ephemeral: false,
|
||||
modelProvider: "openai",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: "/repo",
|
||||
cliVersion: "0.139.0",
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
gitInfo: null,
|
||||
name: null,
|
||||
turns: [],
|
||||
},
|
||||
model: "gpt-5.5-codex",
|
||||
modelProvider: "openai",
|
||||
serviceTier: null,
|
||||
cwd: "/repo",
|
||||
instructionSources: [],
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: { type: "dangerFullAccess" },
|
||||
permissionProfile: null,
|
||||
reasoningEffort: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("requestCodexAppServerJson sandbox guard", () => {
|
||||
beforeEach(() => {
|
||||
sharedClientMocks.createIsolatedCodexAppServerClient.mockReset();
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockReset();
|
||||
sharedClientMocks.release.mockClear();
|
||||
sharedClientMocks.abandon.mockClear();
|
||||
});
|
||||
|
||||
it("fails closed before raw app-server bypass methods in sandboxed sessions", async () => {
|
||||
@@ -92,29 +35,6 @@ describe("requestCodexAppServerJson sandbox guard", () => {
|
||||
expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the explicit agent sandbox for globally scoped session keys", async () => {
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "command/exec",
|
||||
requestParams: { command: ["sh", "-lc", "id"] },
|
||||
config: {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", default: true, sandbox: { mode: "off" } },
|
||||
{ id: "work", sandbox: { mode: "all" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
agentId: "work",
|
||||
sessionKey: "global-session",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Codex-native app-server method `command/exec` is unavailable because OpenClaw sandboxing is active for this session.",
|
||||
);
|
||||
|
||||
expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed before raw app-server bypass methods when exec host=node is active", async () => {
|
||||
for (const method of ["command/exec", "process/spawn"]) {
|
||||
await expect(
|
||||
@@ -145,31 +65,7 @@ describe("requestCodexAppServerJson sandbox guard", () => {
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"thread/list",
|
||||
{ limit: 10 },
|
||||
expect.objectContaining({
|
||||
timeoutMs: expect.any(Number),
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("records full rate-limit reads on the physical control client", async () => {
|
||||
const snapshot = { rateLimits: { limitId: "codex", primary: { usedPercent: 12 } } };
|
||||
const client = {
|
||||
request: vi.fn(async () => snapshot),
|
||||
} as unknown as CodexAppServerClient;
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue(client);
|
||||
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "account/rateLimits/read",
|
||||
requestParams: undefined,
|
||||
}),
|
||||
).resolves.toEqual(snapshot);
|
||||
|
||||
expect(readRecentCodexRateLimits(client)).toEqual(snapshot);
|
||||
expect(request).toHaveBeenCalledWith("thread/list", { limit: 10 }, { timeoutMs: 60_000 });
|
||||
});
|
||||
|
||||
it("fails closed for config-level exec host=node even without a session key", async () => {
|
||||
@@ -213,125 +109,11 @@ describe("requestCodexAppServerJson sandbox guard", () => {
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"thread/list",
|
||||
{ limit: 10 },
|
||||
expect.objectContaining({
|
||||
timeoutMs: expect.any(Number),
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("unsubscribes owned resumes but abandons a mismatched response", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(resumeResponse("thread-1"))
|
||||
.mockResolvedValueOnce({})
|
||||
.mockRejectedValueOnce(new Error("resume response lost"))
|
||||
.mockResolvedValueOnce(resumeResponse("wrong-thread"));
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
|
||||
request,
|
||||
addNotificationHandler: vi.fn(() => () => undefined),
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "thread/resume",
|
||||
requestParams: { threadId: "thread-1" },
|
||||
}),
|
||||
).resolves.toMatchObject({ thread: { id: "thread-1" } });
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "thread/resume",
|
||||
requestParams: { threadId: "thread-2" },
|
||||
}),
|
||||
).rejects.toThrow("resume response lost");
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "thread/resume",
|
||||
requestParams: { threadId: "thread-3" },
|
||||
}),
|
||||
).rejects.toThrow("Codex thread/resume returned wrong-thread for thread-3");
|
||||
|
||||
expect(request.mock.calls.map(([method, params]) => [method, params])).toEqual([
|
||||
["thread/resume", { threadId: "thread-1" }],
|
||||
["thread/unsubscribe", { threadId: "thread-1" }],
|
||||
["thread/resume", { threadId: "thread-2" }],
|
||||
["thread/resume", { threadId: "thread-3" }],
|
||||
]);
|
||||
expect(sharedClientMocks.abandon).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not release a thread owner when the request deadline expires before resume", async () => {
|
||||
const request = vi.fn();
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
|
||||
const now = vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValue(10);
|
||||
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "thread/resume",
|
||||
requestParams: { threadId: "thread-1" },
|
||||
timeoutMs: 1,
|
||||
}),
|
||||
).rejects.toThrow("codex app-server thread/resume timed out");
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(sharedClientMocks.release).toHaveBeenCalledOnce();
|
||||
expect(sharedClientMocks.abandon).not.toHaveBeenCalled();
|
||||
now.mockRestore();
|
||||
});
|
||||
|
||||
it("retires an isolated client that resolves after the end-to-end deadline", async () => {
|
||||
let resolveClient!: (client: CodexAppServerClient) => void;
|
||||
sharedClientMocks.createIsolatedCodexAppServerClient.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<CodexAppServerClient>((resolve) => {
|
||||
resolveClient = resolve;
|
||||
}),
|
||||
);
|
||||
const request = vi.fn();
|
||||
const closeAndWait = vi.fn(async () => undefined);
|
||||
const response = requestCodexAppServerJson({
|
||||
method: "model/list",
|
||||
requestParams: { limit: 10 },
|
||||
isolated: true,
|
||||
timeoutMs: 5,
|
||||
});
|
||||
|
||||
await expect(response).rejects.toThrow("codex app-server model/list timed out");
|
||||
resolveClient({ request, closeAndWait } as unknown as CodexAppServerClient);
|
||||
await vi.waitFor(() => expect(closeAndWait).toHaveBeenCalledOnce());
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not let isolated teardown extend the caller deadline", async () => {
|
||||
const request = vi.fn(async () => ({ data: [] }));
|
||||
const closeAndWait = vi.fn(async () => await new Promise<void>(() => {}));
|
||||
sharedClientMocks.createIsolatedCodexAppServerClient.mockResolvedValue({
|
||||
request,
|
||||
closeAndWait,
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "model/list",
|
||||
requestParams: { limit: 10 },
|
||||
isolated: true,
|
||||
timeoutMs: 5,
|
||||
}),
|
||||
).rejects.toThrow("codex app-server model/list timed out");
|
||||
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
expect(closeAndWait).toHaveBeenCalledOnce();
|
||||
expect(request).toHaveBeenCalledWith("thread/list", { limit: 10 }, { timeoutMs: 60_000 });
|
||||
});
|
||||
|
||||
it("allows sandbox-pinned thread starts in sandboxed sessions", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ thread: { id: "thread-1" }, model: "gpt-5.5" })
|
||||
.mockResolvedValueOnce({});
|
||||
const request = vi.fn(async () => ({ thread: { id: "thread-1" }, model: "gpt-5.5" }));
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
|
||||
const params = {
|
||||
cwd: "/workspace",
|
||||
@@ -347,44 +129,7 @@ describe("requestCodexAppServerJson sandbox guard", () => {
|
||||
}),
|
||||
).resolves.toEqual({ thread: { id: "thread-1" }, model: "gpt-5.5" });
|
||||
|
||||
expect(request.mock.calls).toEqual([
|
||||
[
|
||||
"thread/start",
|
||||
params,
|
||||
expect.objectContaining({
|
||||
timeoutMs: expect.any(Number),
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
],
|
||||
["thread/unsubscribe", { threadId: "thread-1" }, { timeoutMs: 5_000 }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("unsubscribes one-shot shared thread forks", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ thread: { id: "child-thread" } })
|
||||
.mockResolvedValueOnce({});
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
|
||||
|
||||
await expect(
|
||||
requestCodexAppServerJson({
|
||||
method: "thread/fork",
|
||||
requestParams: { threadId: "parent-thread" },
|
||||
}),
|
||||
).resolves.toEqual({ thread: { id: "child-thread" } });
|
||||
|
||||
expect(request.mock.calls).toEqual([
|
||||
[
|
||||
"thread/fork",
|
||||
{ threadId: "parent-thread" },
|
||||
expect.objectContaining({
|
||||
timeoutMs: expect.any(Number),
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
],
|
||||
["thread/unsubscribe", { threadId: "child-thread" }, { timeoutMs: 5_000 }],
|
||||
]);
|
||||
expect(request).toHaveBeenCalledWith("thread/start", params, { timeoutMs: 60_000 });
|
||||
});
|
||||
|
||||
it("blocks thread starts with sandbox environments when exec host=node is active", async () => {
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
import {
|
||||
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
CodexAppServerUnsafeSubscriptionError,
|
||||
settleCodexAppServerClientLease,
|
||||
} from "./attempt-client-cleanup.js";
|
||||
/**
|
||||
* Sends typed JSON-RPC requests to the Codex app-server with sandbox guard
|
||||
* checks, shared-client leasing, and isolated-client shutdown handling.
|
||||
*/
|
||||
import type { resolveCodexAppServerAuthProfileIdForAgent } from "./auth-bridge.js";
|
||||
import type { CodexAppServerStartOptions } from "./config.js";
|
||||
import {
|
||||
isJsonObject,
|
||||
readCodexThreadCreationResponseId,
|
||||
type CodexAppServerRequestMethod,
|
||||
type CodexAppServerRequestParams,
|
||||
type CodexAppServerRequestResult,
|
||||
type CodexThreadResumeParams,
|
||||
type JsonValue,
|
||||
import type {
|
||||
CodexAppServerRequestMethod,
|
||||
CodexAppServerRequestParams,
|
||||
CodexAppServerRequestResult,
|
||||
JsonValue,
|
||||
} from "./protocol.js";
|
||||
import { rememberCodexRateLimitsRead } from "./rate-limit-cache.js";
|
||||
import { resolveCodexAppServerDirectSandboxBypassBlock } from "./sandbox-guard.js";
|
||||
import {
|
||||
createIsolatedCodexAppServerClient,
|
||||
leaseSharedCodexAppServerClient,
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
} from "./shared-client.js";
|
||||
import { resumeCodexAppServerThread } from "./thread-resume.js";
|
||||
import { withTimeout } from "./timeout.js";
|
||||
|
||||
/** Sends a typed Codex app-server request and returns the method-specific response shape. */
|
||||
@@ -34,7 +25,6 @@ export async function requestCodexAppServerJson<M extends CodexAppServerRequestM
|
||||
timeoutMs?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string | null;
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
sessionKey?: string;
|
||||
@@ -47,7 +37,6 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
timeoutMs?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string | null;
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
sessionKey?: string;
|
||||
@@ -60,7 +49,6 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
timeoutMs?: number;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
authProfileId?: string | null;
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
sessionKey?: string;
|
||||
@@ -71,7 +59,6 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
method: params.method,
|
||||
requestParams: params.requestParams,
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
@@ -79,112 +66,33 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
|
||||
throw new Error(sandboxBlock);
|
||||
}
|
||||
const timeoutMs = params.timeoutMs ?? 60_000;
|
||||
const timeoutMessage = `codex app-server ${params.method} timed out`;
|
||||
const abortController = new AbortController();
|
||||
const operation = (async () => {
|
||||
const startedAt = Date.now();
|
||||
const clientOptions = {
|
||||
startOptions: params.startOptions,
|
||||
timeoutMs,
|
||||
authProfileId: params.authProfileId,
|
||||
agentDir: params.agentDir,
|
||||
config: params.config,
|
||||
abandonSignal: abortController.signal,
|
||||
};
|
||||
const clientLease = params.isolated
|
||||
? undefined
|
||||
: await leaseSharedCodexAppServerClient(clientOptions);
|
||||
const client = clientLease?.client ?? (await createIsolatedCodexAppServerClient(clientOptions));
|
||||
const requestedThreadId =
|
||||
params.method === "thread/resume" && isJsonObject(params.requestParams)
|
||||
? typeof params.requestParams.threadId === "string"
|
||||
? params.requestParams.threadId
|
||||
: undefined
|
||||
: undefined;
|
||||
let subscribedThreadId: string | undefined;
|
||||
let abandonClient = false;
|
||||
try {
|
||||
abortController.signal.throwIfAborted();
|
||||
const requestTimeoutMs = remainingRequestTimeoutMs(startedAt, timeoutMs, params.method);
|
||||
let response: T;
|
||||
if (params.method === "thread/resume" && requestedThreadId) {
|
||||
subscribedThreadId = requestedThreadId;
|
||||
response = (await resumeCodexAppServerThread({
|
||||
client,
|
||||
abandonClient: clientLease
|
||||
? clientLease.abandon
|
||||
: async () =>
|
||||
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 }),
|
||||
request: params.requestParams as CodexThreadResumeParams,
|
||||
timeoutMs: requestTimeoutMs,
|
||||
signal: abortController.signal,
|
||||
})) as T;
|
||||
} else {
|
||||
response = await client.request<T>(params.method, params.requestParams, {
|
||||
timeoutMs: requestTimeoutMs,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
}
|
||||
if (params.method === "account/rateLimits/read") {
|
||||
rememberCodexRateLimitsRead(client, response as JsonValue | undefined);
|
||||
}
|
||||
if (isThreadSubscriptionMethod(params.method)) {
|
||||
const returnedThreadId = readCodexThreadCreationResponseId(response);
|
||||
if (!returnedThreadId) {
|
||||
abandonClient = true;
|
||||
throw new CodexAppServerUnsafeSubscriptionError(
|
||||
`Codex ${params.method} response omitted its thread id`,
|
||||
);
|
||||
}
|
||||
if (params.method === "thread/resume") {
|
||||
if (!requestedThreadId) {
|
||||
abandonClient = true;
|
||||
throw new CodexAppServerUnsafeSubscriptionError(
|
||||
"Codex thread/resume succeeded without a requested thread id",
|
||||
);
|
||||
}
|
||||
return await withTimeout(
|
||||
(async () => {
|
||||
const client = await (
|
||||
params.isolated ? createIsolatedCodexAppServerClient : getLeasedSharedCodexAppServerClient
|
||||
)({
|
||||
startOptions: params.startOptions,
|
||||
timeoutMs,
|
||||
authProfileId: params.authProfileId,
|
||||
agentDir: params.agentDir,
|
||||
config: params.config,
|
||||
});
|
||||
try {
|
||||
return await client.request<T>(params.method, params.requestParams, { timeoutMs });
|
||||
} finally {
|
||||
if (params.isolated) {
|
||||
// Wait for the child to actually exit (with a SIGKILL fallback) so
|
||||
// the parent process doesn't hang on an orphaned codex app-server.
|
||||
// The stdio bin shim does not always propagate stdin EOF to the
|
||||
// underlying codex binary, so the unref'd close() path can leave
|
||||
// the child running and keep the parent's event loop alive.
|
||||
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 });
|
||||
} else {
|
||||
subscribedThreadId = returnedThreadId;
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
abandonClient ||= error instanceof CodexAppServerUnsafeSubscriptionError;
|
||||
throw error;
|
||||
} finally {
|
||||
if (params.isolated) {
|
||||
// Cleanup may outlive the caller's end-to-end deadline, but the outer
|
||||
// timeout aborts all work and returns without orphaning the child.
|
||||
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 });
|
||||
} else if (clientLease) {
|
||||
await settleCodexAppServerClientLease(clientLease, {
|
||||
threadId: subscribedThreadId,
|
||||
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
abandon: abandonClient,
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
try {
|
||||
return await withTimeout(operation, timeoutMs, timeoutMessage);
|
||||
} catch (error) {
|
||||
abortController.abort(error);
|
||||
void operation.catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function remainingRequestTimeoutMs(startedAt: number, timeoutMs: number, method: string): number {
|
||||
if (timeoutMs <= 0) {
|
||||
return timeoutMs;
|
||||
}
|
||||
const remaining = timeoutMs - (Date.now() - startedAt);
|
||||
if (remaining <= 0) {
|
||||
throw new Error(`codex app-server ${method} timed out`);
|
||||
}
|
||||
return Math.max(1, remaining);
|
||||
}
|
||||
|
||||
function isThreadSubscriptionMethod(method: string): boolean {
|
||||
return method === "thread/start" || method === "thread/fork" || method === "thread/resume";
|
||||
})(),
|
||||
timeoutMs,
|
||||
`codex app-server ${params.method} timed out`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,29 +15,19 @@ import { clearPluginCommands } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { afterEach, beforeEach, expect, vi } from "vitest";
|
||||
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
|
||||
import type { CodexServerNotification, CodexThread } from "./protocol.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
import { resetCodexRateLimitCacheForTests } from "./rate-limit-cache.js";
|
||||
import {
|
||||
runCodexAppServerAttempt as runCodexAppServerAttemptImpl,
|
||||
testing,
|
||||
} from "./run-attempt.js";
|
||||
import { closeCodexSandboxExecServersForTests } from "./sandbox-exec-server.js";
|
||||
import {
|
||||
registerCodexTestSessionIdentity,
|
||||
resetCodexTestBindingStore,
|
||||
testCodexAppServerBindingStore,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
import type { CodexAppServerClientLeaseFactory } from "./shared-client.js";
|
||||
import {
|
||||
adaptCodexTestClientFactory,
|
||||
createCodexTestModel,
|
||||
type CodexTestAppServerClientFactory,
|
||||
} from "./test-support.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
|
||||
export let tempDir: string;
|
||||
let codexAppServerClientLeaseFactoryForTest: CodexAppServerClientLeaseFactory | undefined;
|
||||
const multiplexedTestClients = new WeakSet<CodexAppServerClient>();
|
||||
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
|
||||
export const fastWait = { interval: 1, timeout: 5_000 } as const;
|
||||
const appServerHarnessWait = { interval: 1, timeout: 120_000 } as const;
|
||||
const activeAppServerAttemptsForTest = new Set<{
|
||||
@@ -47,12 +37,9 @@ const activeAppServerAttemptsForTest = new Set<{
|
||||
sessionKey?: string;
|
||||
}>();
|
||||
|
||||
type RunCodexAppServerAttemptImplOptions = NonNullable<
|
||||
type RunCodexAppServerAttemptOptions = NonNullable<
|
||||
Parameters<typeof runCodexAppServerAttemptImpl>[1]
|
||||
>;
|
||||
type RunCodexAppServerAttemptOptions = Omit<RunCodexAppServerAttemptImplOptions, "bindingStore"> & {
|
||||
bindingStore?: RunCodexAppServerAttemptImplOptions["bindingStore"];
|
||||
};
|
||||
|
||||
export function queueActiveRunMessageForTest(
|
||||
...args: Parameters<typeof queueAgentHarnessMessage>
|
||||
@@ -60,66 +47,19 @@ export function queueActiveRunMessageForTest(
|
||||
return queueAgentHarnessMessage(...args);
|
||||
}
|
||||
|
||||
export function setCodexAppServerClientFactoryForTest(
|
||||
factory: CodexTestAppServerClientFactory,
|
||||
): void {
|
||||
codexAppServerClientLeaseFactoryForTest = adaptCodexTestClientFactory(async (...args) => {
|
||||
const client = await factory(...args);
|
||||
const testClient = client as unknown as {
|
||||
addCloseHandler?: (handler: () => void) => () => void;
|
||||
};
|
||||
// Narrow test doubles still need the client lifecycle hook installed by
|
||||
// the keyed router, even when the test never simulates transport closure.
|
||||
testClient.addCloseHandler ??= () => () => undefined;
|
||||
multiplexTestClientHandlers(client);
|
||||
return client;
|
||||
});
|
||||
}
|
||||
|
||||
function multiplexTestClientHandlers(client: CodexAppServerClient): void {
|
||||
if (multiplexedTestClients.has(client)) {
|
||||
return;
|
||||
}
|
||||
multiplexedTestClients.add(client);
|
||||
const notificationHandlers = new Set<
|
||||
Parameters<CodexAppServerClient["addNotificationHandler"]>[0]
|
||||
>();
|
||||
const requestHandlers = new Set<Parameters<CodexAppServerClient["addRequestHandler"]>[0]>();
|
||||
const addNotificationHandler = client.addNotificationHandler.bind(client);
|
||||
const addRequestHandler = client.addRequestHandler.bind(client);
|
||||
addNotificationHandler(async (notification) => {
|
||||
await Promise.all(
|
||||
[...notificationHandlers].map((handler) => Promise.resolve(handler(notification))),
|
||||
);
|
||||
});
|
||||
addRequestHandler(async (request) => {
|
||||
for (const handler of requestHandlers) {
|
||||
const result = await handler(request);
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
client.addNotificationHandler = (handler) => {
|
||||
notificationHandlers.add(handler);
|
||||
return () => notificationHandlers.delete(handler);
|
||||
};
|
||||
client.addRequestHandler = (handler) => {
|
||||
requestHandlers.add(handler);
|
||||
return () => requestHandlers.delete(handler);
|
||||
};
|
||||
export function setCodexAppServerClientFactoryForTest(factory: CodexAppServerClientFactory): void {
|
||||
codexAppServerClientFactoryForTest = factory;
|
||||
}
|
||||
|
||||
function resetCodexAppServerClientFactoryForTest(): void {
|
||||
codexAppServerClientLeaseFactoryForTest = undefined;
|
||||
codexAppServerClientFactoryForTest = undefined;
|
||||
}
|
||||
|
||||
export function runCodexAppServerAttempt(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
options: RunCodexAppServerAttemptOptions = {},
|
||||
) {
|
||||
const clientLeaseFactory = options.clientLeaseFactory ?? codexAppServerClientLeaseFactoryForTest;
|
||||
const clientFactory = options.clientFactory ?? codexAppServerClientFactoryForTest;
|
||||
const abortController = params.abortSignal ? undefined : new AbortController();
|
||||
const trackedParams = abortController
|
||||
? ({ ...params, abortSignal: abortController.signal } as EmbeddedRunAttemptParams)
|
||||
@@ -130,11 +70,10 @@ export function runCodexAppServerAttempt(
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
};
|
||||
const promise = runCodexAppServerAttemptImpl(trackedParams, {
|
||||
...options,
|
||||
bindingStore: options.bindingStore ?? testCodexAppServerBindingStore,
|
||||
...(clientLeaseFactory ? { clientLeaseFactory } : {}),
|
||||
}).finally(() => {
|
||||
const promise = runCodexAppServerAttemptImpl(
|
||||
trackedParams,
|
||||
clientFactory ? { ...options, clientFactory } : options,
|
||||
).finally(() => {
|
||||
activeAppServerAttemptsForTest.delete(entry);
|
||||
});
|
||||
entry.promise = promise;
|
||||
@@ -182,7 +121,6 @@ async function drainActiveAppServerAttemptsForTest(): Promise<void> {
|
||||
}
|
||||
|
||||
export function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
|
||||
registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1");
|
||||
return {
|
||||
prompt: "hello",
|
||||
sessionId: "session-1",
|
||||
@@ -300,7 +238,7 @@ export function threadStartResult(threadId = "thread-1") {
|
||||
agentRole: null,
|
||||
gitInfo: null,
|
||||
name: null,
|
||||
turns: [] as CodexThread["turns"],
|
||||
turns: [],
|
||||
},
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
@@ -363,73 +301,61 @@ export function createAppServerHarness(
|
||||
} = {},
|
||||
) {
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
const notificationHandlers = new Set<
|
||||
(notification: CodexServerNotification) => Promise<void> | void
|
||||
>();
|
||||
const serverRequestHandlers = new Set<AppServerRequestHandler>();
|
||||
let notifyHandler: ((notification: CodexServerNotification) => Promise<void>) | undefined;
|
||||
let handleServerRequest: AppServerRequestHandler | undefined;
|
||||
const closeHandlers = new Set<() => void>();
|
||||
const request = vi.fn(async (method: string, params?: unknown, requestOptions?: unknown) => {
|
||||
requests.push({ method, params });
|
||||
return requestImpl(method, params, requestOptions as { signal?: AbortSignal } | undefined);
|
||||
});
|
||||
|
||||
const client = {
|
||||
getServerVersion: () => "0.132.0",
|
||||
request,
|
||||
addNotificationHandler: (
|
||||
handler: (notification: CodexServerNotification) => Promise<void> | void,
|
||||
) => {
|
||||
notificationHandlers.add(handler);
|
||||
return () => notificationHandlers.delete(handler);
|
||||
},
|
||||
addRequestHandler: (handler: AppServerRequestHandler) => {
|
||||
serverRequestHandlers.add(handler);
|
||||
return () => serverRequestHandlers.delete(handler);
|
||||
},
|
||||
addCloseHandler: (handler: () => void) => {
|
||||
closeHandlers.add(handler);
|
||||
return () => closeHandlers.delete(handler);
|
||||
},
|
||||
} as unknown as CodexAppServerClient;
|
||||
setCodexAppServerClientFactoryForTest(async (_startOptions, authProfileId, agentDir) => {
|
||||
options.onStart?.(authProfileId, agentDir);
|
||||
return client;
|
||||
return {
|
||||
...mockClientRuntimeMethods(),
|
||||
request,
|
||||
addNotificationHandler: (
|
||||
handler: (notification: CodexServerNotification) => Promise<void>,
|
||||
) => {
|
||||
notifyHandler = handler;
|
||||
return () => {
|
||||
if (notifyHandler === handler) {
|
||||
notifyHandler = undefined;
|
||||
}
|
||||
};
|
||||
},
|
||||
addRequestHandler: (handler: AppServerRequestHandler) => {
|
||||
handleServerRequest = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
addCloseHandler: (handler: () => void) => {
|
||||
closeHandlers.add(handler);
|
||||
return () => closeHandlers.delete(handler);
|
||||
},
|
||||
} as never;
|
||||
});
|
||||
|
||||
const waitForServerRequestHandler = async () => {
|
||||
await vi.waitFor(() => expect(serverRequestHandlers.size).toBeGreaterThan(0), {
|
||||
await vi.waitFor(() => expect(handleServerRequest).toBeTypeOf("function"), {
|
||||
interval: 1,
|
||||
timeout: appServerHarnessWait.timeout,
|
||||
});
|
||||
return async (requestLocal: Parameters<AppServerRequestHandler>[0]) => {
|
||||
for (const handler of serverRequestHandlers) {
|
||||
const result = await handler(requestLocal);
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
return handleServerRequest!;
|
||||
};
|
||||
|
||||
const waitForNotificationHandler = async () => {
|
||||
await vi.waitFor(() => expect(notificationHandlers.size).toBeGreaterThan(0), {
|
||||
await vi.waitFor(() => expect(notifyHandler).toBeTypeOf("function"), {
|
||||
interval: 1,
|
||||
timeout: appServerHarnessWait.timeout,
|
||||
});
|
||||
return async (notification: CodexServerNotification) => {
|
||||
await Promise.all(
|
||||
[...notificationHandlers].map((handler) => Promise.resolve(handler(notification))),
|
||||
);
|
||||
};
|
||||
return notifyHandler!;
|
||||
};
|
||||
const sendNotification = async (notification: CodexServerNotification) => {
|
||||
const handler = await waitForNotificationHandler();
|
||||
const handler = notifyHandler ?? (await waitForNotificationHandler());
|
||||
await handler(notification);
|
||||
};
|
||||
|
||||
return {
|
||||
client,
|
||||
request,
|
||||
requests,
|
||||
waitForMethod: async (method: string, timeoutMs: number = appServerHarnessWait.timeout) => {
|
||||
@@ -502,10 +428,9 @@ export function createStartedThreadHarness(
|
||||
}
|
||||
|
||||
export function createResumeHarness() {
|
||||
return createAppServerHarness(async (method, params) => {
|
||||
return createAppServerHarness(async (method) => {
|
||||
if (method === "thread/resume") {
|
||||
const threadId = (params as { threadId?: unknown }).threadId;
|
||||
return threadStartResult(typeof threadId === "string" ? threadId : "thread-existing");
|
||||
return threadStartResult("thread-existing");
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return turnStartResult();
|
||||
@@ -589,7 +514,6 @@ export function setupRunAttemptTestHooks(): void {
|
||||
clearMemoryPluginState();
|
||||
resetAgentEventsForTest();
|
||||
resetDiagnosticEventsForTest();
|
||||
resetCodexTestBindingStore();
|
||||
vi.stubEnv("OPENCLAW_TRAJECTORY", "0");
|
||||
vi.stubEnv("CODEX_API_KEY", "");
|
||||
vi.stubEnv("OPENAI_API_KEY", "");
|
||||
@@ -603,6 +527,7 @@ export function setupRunAttemptTestHooks(): void {
|
||||
testing.resetOpenClawCodingToolsFactoryForTests();
|
||||
testing.resetEnsuredCodexWorkspaceDirsForTests();
|
||||
testing.clearPendingCodexNativeHookRelayUnregistersForTests();
|
||||
resetCodexRateLimitCacheForTests();
|
||||
nativeHookRelayTesting.clearNativeHookRelaysForTests();
|
||||
clearMemoryPluginState();
|
||||
clearPluginCommands();
|
||||
|
||||
@@ -7,35 +7,10 @@ import {
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CodexAppServerRpcError } from "./client.js";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
import { runCodexAppServerAttempt } from "./run-attempt.js";
|
||||
import { createCodexTestBindingStore } from "./session-binding.test-helpers.js";
|
||||
import {
|
||||
adaptCodexTestClientFactory,
|
||||
createCodexTestModel,
|
||||
type CodexTestAppServerClientFactory,
|
||||
} from "./test-support.js";
|
||||
|
||||
const configRuntimeMock = vi.hoisted(() => ({ rejectedProvider: undefined as string | undefined }));
|
||||
|
||||
vi.mock("./config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveCodexAppServerRuntime: (
|
||||
params: Parameters<typeof actual.resolveCodexAppServerRuntime>[0],
|
||||
) => {
|
||||
if (
|
||||
configRuntimeMock.rejectedProvider &&
|
||||
params?.modelProvider === configRuntimeMock.rejectedProvider
|
||||
) {
|
||||
throw new Error(`rejected active provider: ${params.modelProvider}`);
|
||||
}
|
||||
return actual.resolveCodexAppServerRuntime(params);
|
||||
},
|
||||
};
|
||||
});
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
@@ -130,7 +105,6 @@ describe("Codex app-server main thread cleanup", () => {
|
||||
vi.stubEnv("OPENCLAW_TRAJECTORY", "0");
|
||||
vi.stubEnv("CODEX_API_KEY", "");
|
||||
vi.stubEnv("OPENAI_API_KEY", "");
|
||||
configRuntimeMock.rejectedProvider = undefined;
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-run-cleanup-"));
|
||||
});
|
||||
|
||||
@@ -146,9 +120,7 @@ describe("Codex app-server main thread cleanup", () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
const notificationHandlers = new Set<
|
||||
(notification: CodexServerNotification) => Promise<void> | void
|
||||
>();
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
const request = vi.fn(async (method: string, params?: unknown) => {
|
||||
requests.push({ method, params });
|
||||
if (method === "thread/start") {
|
||||
@@ -160,37 +132,33 @@ describe("Codex app-server main thread cleanup", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const clientFactory: CodexTestAppServerClientFactory = async () => {
|
||||
const clientFactory: CodexAppServerClientFactory = async () => {
|
||||
return {
|
||||
...mockClientRuntimeMethods(),
|
||||
request,
|
||||
addNotificationHandler: (handler: (notification: CodexServerNotification) => void) => {
|
||||
notificationHandlers.add(handler);
|
||||
return () => notificationHandlers.delete(handler);
|
||||
addNotificationHandler: (handler: typeof notify) => {
|
||||
notify = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
addRequestHandler: () => () => undefined,
|
||||
addCloseHandler: () => () => undefined,
|
||||
} as never;
|
||||
};
|
||||
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
|
||||
bindingStore: createCodexTestBindingStore(),
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(clientFactory),
|
||||
clientFactory,
|
||||
});
|
||||
await vi.waitFor(() => expect(requests.map((entry) => entry.method)).toContain("turn/start"), {
|
||||
interval: 1,
|
||||
timeout: 5_000,
|
||||
});
|
||||
for (const handler of notificationHandlers) {
|
||||
await handler({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
turn: { id: "turn-1", status: "completed" },
|
||||
},
|
||||
});
|
||||
}
|
||||
await notify({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
turn: { id: "turn-1", status: "completed" },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await run;
|
||||
expect(result.aborted).toBe(false);
|
||||
@@ -221,28 +189,20 @@ describe("Codex app-server main thread cleanup", () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
const clientFactory: CodexTestAppServerClientFactory = async () => {
|
||||
const clientFactory: CodexAppServerClientFactory = async () => {
|
||||
return {
|
||||
...mockClientRuntimeMethods(),
|
||||
request,
|
||||
addNotificationHandler: () => () => undefined,
|
||||
addRequestHandler: () => () => undefined,
|
||||
addCloseHandler: () => () => undefined,
|
||||
} as never;
|
||||
};
|
||||
const abandon = vi.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
|
||||
bindingStore: createCodexTestBindingStore(),
|
||||
clientLeaseFactory: async () => ({
|
||||
client: await clientFactory(),
|
||||
release: () => undefined,
|
||||
abandon,
|
||||
}),
|
||||
clientFactory,
|
||||
}),
|
||||
).rejects.toThrow("turn start exploded");
|
||||
expect(abandon).toHaveBeenCalledOnce();
|
||||
expect(requests.map((entry) => entry.method)).toEqual([
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
@@ -254,162 +214,4 @@ describe("Codex app-server main thread cleanup", () => {
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("releases startup ownership when authoritative provider policy rejects", async () => {
|
||||
const sessionFile = path.join(tempDir, "session-policy-rejection.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace-policy-rejection");
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
const response = threadStartResult();
|
||||
return {
|
||||
...response,
|
||||
thread: { ...response.thread, modelProvider: "lmstudio" },
|
||||
model: "local-model",
|
||||
modelProvider: "lmstudio",
|
||||
};
|
||||
}
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
const release = vi.fn();
|
||||
const abandon = vi.fn(async () => undefined);
|
||||
configRuntimeMock.rejectedProvider = "lmstudio";
|
||||
|
||||
await expect(
|
||||
runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
|
||||
bindingStore: createCodexTestBindingStore(),
|
||||
clientLeaseFactory: async () => ({
|
||||
client: {
|
||||
request,
|
||||
addNotificationHandler: () => () => undefined,
|
||||
addRequestHandler: () => () => undefined,
|
||||
addCloseHandler: () => () => undefined,
|
||||
} as never,
|
||||
release,
|
||||
abandon,
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow("rejected active provider: lmstudio");
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "thread-1" },
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
expect(abandon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the main client reusable after a structured turn rejection", async () => {
|
||||
const sessionFile = path.join(tempDir, "session-rpc-rejection.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace-rpc-rejection");
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult();
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
throw new CodexAppServerRpcError({ code: -32000, message: "turn rejected" }, method);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const abandon = vi.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
|
||||
bindingStore: createCodexTestBindingStore(),
|
||||
clientLeaseFactory: async () => ({
|
||||
client: {
|
||||
request,
|
||||
addNotificationHandler: () => () => undefined,
|
||||
addRequestHandler: () => () => undefined,
|
||||
addCloseHandler: () => () => undefined,
|
||||
} as never,
|
||||
release: () => undefined,
|
||||
abandon,
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow("turn rejected");
|
||||
|
||||
expect(abandon).not.toHaveBeenCalled();
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "thread-1" },
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses one client router after each attempt releases its thread route", async () => {
|
||||
const sessionFile = path.join(tempDir, "session-reused.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace-reused");
|
||||
const bindingStore = createCodexTestBindingStore();
|
||||
const notificationHandlers = new Set<
|
||||
(notification: CodexServerNotification) => Promise<void> | void
|
||||
>();
|
||||
const requestHandlers = new Set<(request: unknown) => unknown>();
|
||||
let turnIndex = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start" || method === "thread/resume") {
|
||||
return threadStartResult();
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
turnIndex += 1;
|
||||
return turnStartResult(`turn-${turnIndex}`);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const addNotificationHandler = vi.fn(
|
||||
(handler: (notification: CodexServerNotification) => Promise<void> | void) => {
|
||||
notificationHandlers.add(handler);
|
||||
return () => notificationHandlers.delete(handler);
|
||||
},
|
||||
);
|
||||
const addRequestHandler = vi.fn((handler: (request: unknown) => unknown) => {
|
||||
requestHandlers.add(handler);
|
||||
return () => requestHandlers.delete(handler);
|
||||
});
|
||||
const client = {
|
||||
request,
|
||||
addNotificationHandler,
|
||||
addRequestHandler,
|
||||
addCloseHandler: () => () => undefined,
|
||||
};
|
||||
const clientFactory: CodexTestAppServerClientFactory = async () => client as never;
|
||||
|
||||
const runAttempt = async (turnId: string, expectedTurnStartCount: number) => {
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
|
||||
bindingStore,
|
||||
clientLeaseFactory: adaptCodexTestClientFactory(clientFactory),
|
||||
});
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(request.mock.calls.filter(([method]) => method === "turn/start")).toHaveLength(
|
||||
expectedTurnStartCount,
|
||||
),
|
||||
{ interval: 1, timeout: 5_000 },
|
||||
);
|
||||
for (const handler of notificationHandlers) {
|
||||
void handler({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId,
|
||||
turn: { id: turnId, threadId: "thread-1", status: "completed" },
|
||||
},
|
||||
});
|
||||
}
|
||||
return await run;
|
||||
};
|
||||
|
||||
await expect(runAttempt("turn-1", 1)).resolves.toMatchObject({ aborted: false });
|
||||
const notificationHandlerCount = addNotificationHandler.mock.calls.length;
|
||||
const requestHandlerCount = addRequestHandler.mock.calls.length;
|
||||
await expect(runAttempt("turn-2", 2)).resolves.toMatchObject({ aborted: false });
|
||||
|
||||
expect(addNotificationHandler).toHaveBeenCalledTimes(notificationHandlerCount);
|
||||
expect(addRequestHandler).toHaveBeenCalledTimes(requestHandlerCount);
|
||||
expect(notificationHandlers.size).toBe(notificationHandlerCount);
|
||||
expect(requestHandlers.size).toBe(requestHandlerCount);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,63 +17,43 @@ import { MESSAGE_TOOL_DELIVERY_HINTS } from "openclaw/plugin-sdk/message-tool-de
|
||||
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import { CODEX_TURN_START_TEXT_INPUT_MAX_CHARS } from "./context-engine-projection.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl } from "./run-attempt.js";
|
||||
import {
|
||||
clearCodexAppServerBinding,
|
||||
readCodexAppServerBinding,
|
||||
registerCodexTestSessionIdentity,
|
||||
resetCodexTestBindingStore,
|
||||
testCodexAppServerBindingStore,
|
||||
writeCodexAppServerBinding as writeStoredCodexAppServerBinding,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
import type { CodexAppServerClientLeaseFactory } from "./shared-client.js";
|
||||
import {
|
||||
adaptCodexTestClientFactory,
|
||||
createCodexTestModel,
|
||||
type CodexTestAppServerClientFactory,
|
||||
} from "./test-support.js";
|
||||
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
|
||||
} from "./session-binding.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
|
||||
let tempDir: string;
|
||||
let codexAppServerClientLeaseFactoryForTest: CodexAppServerClientLeaseFactory | undefined;
|
||||
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
|
||||
|
||||
type RunCodexAppServerAttemptImplOptions = NonNullable<
|
||||
type RunCodexAppServerAttemptOptions = NonNullable<
|
||||
Parameters<typeof runCodexAppServerAttemptImpl>[1]
|
||||
>;
|
||||
type RunCodexAppServerAttemptOptions = Omit<RunCodexAppServerAttemptImplOptions, "bindingStore"> & {
|
||||
bindingStore?: RunCodexAppServerAttemptImplOptions["bindingStore"];
|
||||
};
|
||||
|
||||
function setCodexAppServerClientFactoryForTest(factory: CodexTestAppServerClientFactory): void {
|
||||
codexAppServerClientLeaseFactoryForTest = adaptCodexTestClientFactory(factory);
|
||||
function setCodexAppServerClientFactoryForTest(factory: CodexAppServerClientFactory): void {
|
||||
codexAppServerClientFactoryForTest = factory;
|
||||
}
|
||||
|
||||
function resetCodexAppServerClientFactoryForTest(): void {
|
||||
codexAppServerClientLeaseFactoryForTest = undefined;
|
||||
}
|
||||
|
||||
async function writeCodexAppServerBinding(
|
||||
...args: Parameters<typeof writeStoredCodexAppServerBinding>
|
||||
): Promise<void> {
|
||||
registerCodexTestSessionIdentity(args[0], "session-1", "agent:main:session-1");
|
||||
await writeStoredCodexAppServerBinding(...args);
|
||||
codexAppServerClientFactoryForTest = undefined;
|
||||
}
|
||||
|
||||
function runCodexAppServerAttempt(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
options: RunCodexAppServerAttemptOptions = {},
|
||||
) {
|
||||
const clientLeaseFactory = options.clientLeaseFactory ?? codexAppServerClientLeaseFactoryForTest;
|
||||
return runCodexAppServerAttemptImpl(params, {
|
||||
...options,
|
||||
bindingStore: options.bindingStore ?? testCodexAppServerBindingStore,
|
||||
...(clientLeaseFactory ? { clientLeaseFactory } : {}),
|
||||
});
|
||||
const clientFactory = options.clientFactory ?? codexAppServerClientFactoryForTest;
|
||||
return runCodexAppServerAttemptImpl(
|
||||
params,
|
||||
clientFactory ? { ...options, clientFactory } : options,
|
||||
);
|
||||
}
|
||||
|
||||
function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
|
||||
registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1");
|
||||
return {
|
||||
prompt: "hello",
|
||||
sessionId: "session-1",
|
||||
@@ -223,10 +203,7 @@ function createStartedThreadHarness(
|
||||
requestImpl: (method: string, params: unknown) => Promise<unknown> = async () => undefined,
|
||||
) {
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
const notificationHandlers = new Set<
|
||||
(notification: CodexServerNotification) => Promise<void> | void
|
||||
>();
|
||||
const requestHandlers = new Set<(request: unknown) => unknown>();
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
const request = vi.fn(async (method: string, params?: unknown) => {
|
||||
requests.push({ method, params });
|
||||
const override = await requestImpl(method, params);
|
||||
@@ -242,37 +219,21 @@ function createStartedThreadHarness(
|
||||
return {};
|
||||
});
|
||||
|
||||
const client = {
|
||||
request,
|
||||
addNotificationHandler: (
|
||||
handler: (notification: CodexServerNotification) => Promise<void> | void,
|
||||
) => {
|
||||
notificationHandlers.add(handler);
|
||||
return () => notificationHandlers.delete(handler);
|
||||
},
|
||||
addRequestHandler: (handler: (request: unknown) => unknown) => {
|
||||
requestHandlers.add(handler);
|
||||
return () => requestHandlers.delete(handler);
|
||||
},
|
||||
addCloseHandler: () => () => undefined,
|
||||
} as unknown as CodexAppServerClient;
|
||||
setCodexAppServerClientFactoryForTest(async () => client);
|
||||
|
||||
const notify = async (notification: CodexServerNotification) => {
|
||||
await Promise.all(
|
||||
[...notificationHandlers].map((handler) => Promise.resolve(handler(notification))),
|
||||
);
|
||||
};
|
||||
setCodexAppServerClientFactoryForTest(
|
||||
async () =>
|
||||
({
|
||||
...mockClientRuntimeMethods(),
|
||||
request,
|
||||
addNotificationHandler: (handler: typeof notify) => {
|
||||
notify = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
addRequestHandler: () => () => undefined,
|
||||
}) as never,
|
||||
);
|
||||
|
||||
return {
|
||||
client,
|
||||
requests,
|
||||
async handleServerRequest(serverRequest: unknown) {
|
||||
const responses = await Promise.all(
|
||||
[...requestHandlers].map((handler) => Promise.resolve(handler(serverRequest))),
|
||||
);
|
||||
return responses[0];
|
||||
},
|
||||
async waitForMethod(method: string) {
|
||||
await vi.waitFor(() => expect(requests.map((entry) => entry.method)).toContain(method), {
|
||||
interval: 1,
|
||||
@@ -388,7 +349,6 @@ function getRequestInputTextAt(
|
||||
|
||||
describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
beforeEach(async () => {
|
||||
resetCodexTestBindingStore();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-context-engine-"));
|
||||
});
|
||||
|
||||
@@ -785,14 +745,13 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
await secondRun;
|
||||
});
|
||||
|
||||
it("resumes a matching thread-bootstrap binding without a native usage snapshot", async () => {
|
||||
it("resumes a matching thread-bootstrap binding even when the bootstrap turn exceeded the opt-in native byte guard", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-bootstrapped",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
@@ -806,6 +765,21 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(path.dirname(sessionFile), "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:main:session-1": {
|
||||
sessionFile,
|
||||
totalTokens: 12_000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-bootstrapped.jsonl"),
|
||||
"x".repeat(2_000),
|
||||
);
|
||||
const contextEngine = createContextEngine({
|
||||
assemble: vi.fn(async ({ prompt }) => ({
|
||||
messages: [
|
||||
@@ -817,11 +791,28 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
contextProjection: { mode: "thread_bootstrap" as const, epoch: "epoch-1" },
|
||||
})),
|
||||
});
|
||||
const harness = createStartedThreadHarness(async (method) =>
|
||||
method === "thread/resume" ? threadStartResult("thread-bootstrapped") : undefined,
|
||||
);
|
||||
const harness = createStartedThreadHarness(async (method) => {
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-bootstrapped");
|
||||
}
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-fresh");
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.agentDir = agentDir;
|
||||
params.contextEngine = contextEngine;
|
||||
params.config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: 1_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
@@ -839,263 +830,14 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
await run;
|
||||
});
|
||||
|
||||
it("projects assembled context when the binding changes during startup", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-old",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: "[]",
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint:
|
||||
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"projectionMaxChars":24000}',
|
||||
projection: {
|
||||
schemaVersion: 1,
|
||||
mode: "thread_bootstrap",
|
||||
epoch: "epoch-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
const contextEngine = createContextEngine({
|
||||
assemble: vi.fn(async ({ prompt }) => {
|
||||
// Simulate a concurrent /codex resume or reset after the initial read.
|
||||
await clearCodexAppServerBinding(sessionFile);
|
||||
return {
|
||||
messages: [
|
||||
assistantMessage("assembled startup context", 10),
|
||||
userMessage(prompt ?? "", 11),
|
||||
],
|
||||
estimatedTokens: 42,
|
||||
systemPromptAddition: "context-engine system",
|
||||
contextProjection: { mode: "thread_bootstrap" as const, epoch: "epoch-1" },
|
||||
};
|
||||
}),
|
||||
});
|
||||
const harness = createStartedThreadHarness(async (method) =>
|
||||
method === "thread/start" ? threadStartResult("thread-fresh") : undefined,
|
||||
);
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
expect(harness.requests.map((request) => request.method)).toEqual([
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
]);
|
||||
expectRequestInputTextContains(harness, "assembled startup context");
|
||||
await harness.completeTurn("completed", "thread-fresh");
|
||||
await run;
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-fresh",
|
||||
contextEngine: {
|
||||
projection: { mode: "thread_bootstrap", epoch: "epoch-1" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("awaits accepted-turn interruption and retires the client when it cannot be confirmed", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const contextEngine = createContextEngine({
|
||||
assemble: vi.fn(async ({ prompt }) => ({
|
||||
messages: [assistantMessage("projected context", 10), userMessage(prompt ?? "", 11)],
|
||||
estimatedTokens: 42,
|
||||
contextProjection: { mode: "thread_bootstrap" as const, epoch: "epoch-1" },
|
||||
})),
|
||||
});
|
||||
let rejectTurnInterrupt!: (reason?: unknown) => void;
|
||||
const turnInterrupt = new Promise<unknown>((_resolve, reject) => {
|
||||
rejectTurnInterrupt = reject;
|
||||
});
|
||||
const harness = createStartedThreadHarness(async (method) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-fresh");
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return turnStartResult("turn-fresh");
|
||||
}
|
||||
if (method === "turn/interrupt") {
|
||||
return await turnInterrupt;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
let releaseProjectionCommit!: () => void;
|
||||
let markProjectionCommitStarted!: () => void;
|
||||
const projectionCommitStarted = new Promise<void>((resolve) => {
|
||||
markProjectionCommitStarted = resolve;
|
||||
});
|
||||
const projectionCommitReleased = new Promise<void>((resolve) => {
|
||||
releaseProjectionCommit = resolve;
|
||||
});
|
||||
const bindingStore: RunCodexAppServerAttemptImplOptions["bindingStore"] = {
|
||||
...testCodexAppServerBindingStore,
|
||||
async mutate(identity, mutation) {
|
||||
if (
|
||||
mutation.kind === "patch" &&
|
||||
mutation.patch.contextEngine?.projection?.epoch === "epoch-1"
|
||||
) {
|
||||
markProjectionCommitStarted();
|
||||
await projectionCommitReleased;
|
||||
return false;
|
||||
}
|
||||
return await testCodexAppServerBindingStore.mutate(identity, mutation);
|
||||
},
|
||||
};
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
const onAgentEvent = vi.fn();
|
||||
params.onAgentEvent = onAgentEvent;
|
||||
const releaseClient = vi.fn();
|
||||
const abandonClient = vi.fn(async () => undefined);
|
||||
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
bindingStore,
|
||||
clientLeaseFactory: async () => ({
|
||||
client: harness.client,
|
||||
release: releaseClient,
|
||||
abandon: abandonClient,
|
||||
}),
|
||||
});
|
||||
await projectionCommitStarted;
|
||||
const bufferedToolRequest = harness.handleServerRequest({
|
||||
id: "tool-after-accepted-turn",
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "thread-fresh",
|
||||
turnId: "turn-fresh",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "python",
|
||||
arguments: { code: "print('must not run')" },
|
||||
},
|
||||
});
|
||||
releaseProjectionCommit();
|
||||
await harness.waitForMethod("turn/interrupt");
|
||||
const runSettled = vi.fn();
|
||||
void run.then(runSettled, runSettled);
|
||||
await new Promise((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expect(runSettled).not.toHaveBeenCalled();
|
||||
expect(abandonClient).not.toHaveBeenCalled();
|
||||
|
||||
rejectTurnInterrupt(new Error("interrupt response lost"));
|
||||
await expect(run).rejects.toThrow("binding changed before context projection commit");
|
||||
await expect(bufferedToolRequest).resolves.toBeUndefined();
|
||||
expect(abandonClient).toHaveBeenCalledOnce();
|
||||
expect(releaseClient).not.toHaveBeenCalled();
|
||||
expect(onAgentEvent).not.toHaveBeenCalledWith(expect.objectContaining({ stream: "tool" }));
|
||||
expect(
|
||||
harness.requests.some(
|
||||
(request) =>
|
||||
request.method === "turn/interrupt" &&
|
||||
(request.params as { threadId?: string; turnId?: string }).threadId === "thread-fresh" &&
|
||||
(request.params as { threadId?: string; turnId?: string }).turnId === "turn-fresh",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("invalidates the projection and preserves fresh usage after native compaction", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-bootstrapped",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
nativeContextUsage: { currentTokens: 220_000 },
|
||||
modelContextWindow: 258_400,
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint:
|
||||
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"projectionMaxChars":24000}',
|
||||
projection: {
|
||||
schemaVersion: 1,
|
||||
mode: "thread_bootstrap",
|
||||
epoch: "epoch-1",
|
||||
fingerprint: "fingerprint-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
const contextEngine = createContextEngine({
|
||||
assemble: vi.fn(async ({ prompt }) => ({
|
||||
messages: [userMessage(prompt ?? "", 11)],
|
||||
estimatedTokens: 42,
|
||||
systemPromptAddition: "context-engine system",
|
||||
contextProjection: {
|
||||
mode: "thread_bootstrap" as const,
|
||||
epoch: "epoch-1",
|
||||
fingerprint: "fingerprint-1",
|
||||
},
|
||||
})),
|
||||
});
|
||||
const harness = createStartedThreadHarness(async (method) =>
|
||||
method === "thread/resume" ? threadStartResult("thread-bootstrapped") : undefined,
|
||||
);
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await harness.notify({
|
||||
method: "item/started",
|
||||
params: {
|
||||
threadId: "thread-bootstrapped",
|
||||
turnId: "turn-1",
|
||||
item: { id: "compact-1", type: "contextCompaction" },
|
||||
},
|
||||
});
|
||||
const startedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(startedBinding).not.toHaveProperty("nativeContextUsage");
|
||||
expect(startedBinding?.contextEngine).not.toHaveProperty("projection");
|
||||
await harness.notify({
|
||||
method: "thread/tokenUsage/updated",
|
||||
params: {
|
||||
threadId: "thread-bootstrapped",
|
||||
turnId: "turn-1",
|
||||
tokenUsage: {
|
||||
total: { totalTokens: 900_000 },
|
||||
last: { totalTokens: 12_000 },
|
||||
modelContextWindow: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
await harness.notify({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: "thread-bootstrapped",
|
||||
turnId: "turn-1",
|
||||
item: { id: "compact-1", type: "contextCompaction" },
|
||||
},
|
||||
});
|
||||
const compactedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(compactedBinding).not.toHaveProperty("nativeContextUsage");
|
||||
await harness.completeTurn("completed", "thread-bootstrapped");
|
||||
await run;
|
||||
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.contextEngine?.projection).toBeUndefined();
|
||||
expect(binding?.nativeContextUsage).toEqual({
|
||||
currentTokens: 12_000,
|
||||
});
|
||||
expect(binding?.modelContextWindow).toBe(258_400);
|
||||
});
|
||||
|
||||
it("starts a fresh thread instead of resuming a token-pressured thread-bootstrap binding", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-bootstrapped",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: "[]",
|
||||
nativeContextUsage: { currentTokens: 241_198 },
|
||||
modelContextWindow: 258_400,
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
@@ -1108,6 +850,31 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(path.dirname(sessionFile), "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:main:session-1": {
|
||||
sessionFile,
|
||||
totalTokens: 12_000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-bootstrapped.jsonl"),
|
||||
`${JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
last_token_usage: {
|
||||
total_tokens: 241_198,
|
||||
},
|
||||
model_context_window: 258_400,
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
const contextEngine = createContextEngine({
|
||||
assemble: vi.fn(async ({ prompt }) => ({
|
||||
messages: [assistantMessage("reprojected context", 10), userMessage(prompt ?? "", 11)],
|
||||
@@ -1126,14 +893,13 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
return undefined;
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.agentDir = agentDir;
|
||||
params.contextEngine = contextEngine;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
expect(harness.requests.map((request) => request.method)).toEqual([
|
||||
"thread/resume",
|
||||
"thread/unsubscribe",
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
]);
|
||||
@@ -1148,6 +914,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("does not inject mirrored history when a stale thread-bootstrap binding has no active context engine", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
sessionManager.appendMessage(
|
||||
userMessage("previous stale-bootstrap request", Date.now()) as never,
|
||||
@@ -1159,7 +926,6 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
threadId: "thread-stale-bootstrap",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: "[]",
|
||||
nativeContextUsage: { currentTokens: 300_000 },
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
@@ -1172,6 +938,30 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(path.dirname(sessionFile), "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:main:session-1": {
|
||||
sessionFile,
|
||||
totalTokens: 12_000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-stale-bootstrap.jsonl"),
|
||||
`${JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
last_token_usage: {
|
||||
total_tokens: 300_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
const harness = createStartedThreadHarness(async (method) => {
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-stale-bootstrap");
|
||||
@@ -1182,6 +972,17 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
return undefined;
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.agentDir = agentDir;
|
||||
params.config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: "1mb",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
@@ -1329,7 +1130,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
sessionId: "session-1",
|
||||
threadId: "thread-new",
|
||||
engineId: "lossless-claw",
|
||||
projectionPending: true,
|
||||
epoch: "epoch-new",
|
||||
action: "rotated",
|
||||
}),
|
||||
);
|
||||
@@ -1341,8 +1142,6 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-old",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
@@ -1509,12 +1308,6 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
},
|
||||
});
|
||||
await run;
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-old",
|
||||
contextEngine: {
|
||||
projection: { mode: "thread_bootstrap", epoch: "epoch-1" },
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
restoreSandboxBackend();
|
||||
}
|
||||
@@ -1596,11 +1389,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-old",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
nativeContextUsage: { currentTokens: 120_000 },
|
||||
modelContextWindow: 258_400,
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
@@ -1647,13 +1436,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
throw new Error("Codex ran out of room in the model's context window");
|
||||
}
|
||||
if (method === "thread/start") {
|
||||
const response = threadStartResult("thread-fresh");
|
||||
return {
|
||||
...response,
|
||||
thread: { ...response.thread, modelProvider: "lmstudio" },
|
||||
model: "local-model",
|
||||
modelProvider: "lmstudio",
|
||||
};
|
||||
return threadStartResult("thread-fresh");
|
||||
}
|
||||
if (method === "turn/start" && request.threadId === "thread-fresh") {
|
||||
return turnStartResult("turn-fresh");
|
||||
@@ -1664,30 +1447,15 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
params.contextEngine = contextEngine;
|
||||
params.contextTokenBudget = 400_000;
|
||||
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
pluginConfig: { appServer: { mode: "guardian" } },
|
||||
});
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.requests.map((request) => request.method)).toEqual([
|
||||
"thread/resume",
|
||||
"turn/start",
|
||||
"thread/unsubscribe",
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
]),
|
||||
);
|
||||
await harness.notify({
|
||||
method: "thread/tokenUsage/updated",
|
||||
params: {
|
||||
threadId: "thread-fresh",
|
||||
turnId: "turn-fresh",
|
||||
tokenUsage: {
|
||||
total: { totalTokens: 900_000 },
|
||||
last: { totalTokens: 12_000 },
|
||||
modelContextWindow: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
await harness.notify({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
@@ -1703,36 +1471,15 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
const result = await run;
|
||||
|
||||
expect(result.assistantTexts).toContain("fresh answer");
|
||||
const turnStarts = harness.requests.filter((request) => request.method === "turn/start");
|
||||
expect(turnStarts[0]?.params).toMatchObject({ approvalsReviewer: "auto_review" });
|
||||
expect(turnStarts[1]?.params).toMatchObject({
|
||||
model: "local-model",
|
||||
approvalsReviewer: "user",
|
||||
approvalPolicy: "on-request",
|
||||
});
|
||||
expect(compact).not.toHaveBeenCalled();
|
||||
expect(assemble).toHaveBeenCalledTimes(1);
|
||||
const retryInputText = getRequestInputTextAt(harness, -1);
|
||||
expect(retryInputText).toContain("context epoch-before");
|
||||
expect(retryInputText).toContain("hello");
|
||||
expect(retryInputText).toBe("hello");
|
||||
expect(retryInputText).not.toContain("successor compacted context");
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding?.threadId).toBe("thread-fresh");
|
||||
expect(savedBinding?.contextEngine?.engineId).toBe("lossless-claw");
|
||||
expect(savedBinding?.contextEngine?.projection).toEqual({
|
||||
schemaVersion: 1,
|
||||
mode: "thread_bootstrap",
|
||||
epoch: "epoch-before",
|
||||
});
|
||||
expect(savedBinding?.nativeContextUsage).toEqual({
|
||||
currentTokens: 12_000,
|
||||
});
|
||||
expect(savedBinding?.modelContextWindow).toBeUndefined();
|
||||
expect(
|
||||
harness.requests
|
||||
.filter((request) => request.method === "thread/unsubscribe")
|
||||
.map((request) => request.params),
|
||||
).toEqual([{ threadId: "thread-old" }, { threadId: "thread-fresh" }]);
|
||||
expect(savedBinding?.contextEngine?.projection).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves a newer context-engine binding when a stale resumed thread overflows", async () => {
|
||||
@@ -2023,7 +1770,6 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
expect(harness.requests.map((request) => request.method)).toEqual([
|
||||
"thread/resume",
|
||||
"turn/start",
|
||||
"thread/unsubscribe",
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
]),
|
||||
|
||||
@@ -178,8 +178,6 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
phase?: string;
|
||||
startedAt?: number;
|
||||
text?: string;
|
||||
threadId?: string;
|
||||
turnId?: string;
|
||||
};
|
||||
stream: string;
|
||||
}>;
|
||||
@@ -187,11 +185,6 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
(event) => event.stream === "lifecycle" && event.data.phase === "start",
|
||||
);
|
||||
expect(typeof lifecycleStart?.data.startedAt).toBe("number");
|
||||
const turnAccepted = agentEvents.find(
|
||||
(event) =>
|
||||
event.stream === "codex_app_server.lifecycle" && event.data.phase === "turn_accepted",
|
||||
);
|
||||
expect(turnAccepted?.data).toMatchObject({ threadId: "thread-1", turnId: "turn-1" });
|
||||
const assistantEvent = agentEvents.find((event) => event.stream === "assistant");
|
||||
expect(assistantEvent?.data).toEqual({ text: "hello back" });
|
||||
const lifecycleEnd = agentEvents.find(
|
||||
@@ -203,16 +196,10 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
(event) => event.stream === "lifecycle" && event.data.phase === "start",
|
||||
);
|
||||
const assistantIndex = agentEvents.findIndex((event) => event.stream === "assistant");
|
||||
const acceptedIndex = agentEvents.findIndex(
|
||||
(event) =>
|
||||
event.stream === "codex_app_server.lifecycle" && event.data.phase === "turn_accepted",
|
||||
);
|
||||
const endIndex = agentEvents.findIndex(
|
||||
(event) => event.stream === "lifecycle" && event.data.phase === "end",
|
||||
);
|
||||
expect(startIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(acceptedIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(startIndex).toBeGreaterThan(acceptedIndex);
|
||||
expect(assistantIndex).toBeGreaterThan(startIndex);
|
||||
expect(endIndex).toBeGreaterThan(assistantIndex);
|
||||
const globalAssistantEvent = globalAgentEvents.find((event) => event.stream === "assistant");
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as approvalBridge from "./approval-bridge.js";
|
||||
import { CodexAppServerRpcError } from "./client.js";
|
||||
import {
|
||||
createParams,
|
||||
createResumeHarness,
|
||||
@@ -17,21 +16,12 @@ import {
|
||||
runCodexAppServerAttempt,
|
||||
setupRunAttemptTestHooks,
|
||||
tempDir,
|
||||
threadStartResult,
|
||||
} from "./run-attempt-test-harness.js";
|
||||
import { testing } from "./run-attempt.js";
|
||||
import {
|
||||
readCodexAppServerBinding,
|
||||
registerCodexTestSessionIdentity,
|
||||
writeCodexAppServerBinding as writeStoredCodexAppServerBinding,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
|
||||
async function writeCodexAppServerBinding(
|
||||
...args: Parameters<typeof writeStoredCodexAppServerBinding>
|
||||
): Promise<void> {
|
||||
registerCodexTestSessionIdentity(args[0], "session-1", "agent:main:session-1");
|
||||
await writeStoredCodexAppServerBinding(...args);
|
||||
}
|
||||
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
|
||||
} from "./session-binding.js";
|
||||
|
||||
setupRunAttemptTestHooks();
|
||||
|
||||
@@ -284,7 +274,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("installs policy-stable native hook relay events before thread policy is known", async () => {
|
||||
it("lets Codex app-server approval modes own native permission requests by default", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const harness = createStartedThreadHarness();
|
||||
@@ -305,16 +295,11 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
expect(Array.isArray(startConfig?.["hooks.PreToolUse"])).toBe(true);
|
||||
expect(startConfig?.["hooks.PostToolUse"]).toEqual([]);
|
||||
expect(startConfig?.["hooks.Stop"]).toEqual([]);
|
||||
const permissionRequestHooks = startConfig?.["hooks.PermissionRequest"] as
|
||||
| Array<{ hooks?: Array<{ command?: string }> }>
|
||||
| undefined;
|
||||
expect(permissionRequestHooks?.[0]?.hooks?.[0]?.command).toContain(
|
||||
"--event permission_request",
|
||||
);
|
||||
expect(startConfig).not.toHaveProperty("hooks.PermissionRequest");
|
||||
const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
|
||||
expect(
|
||||
nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)?.allowedEvents,
|
||||
).toEqual(["pre_tool_use", "post_tool_use", "permission_request", "before_agent_finalize"]);
|
||||
).toEqual(["pre_tool_use", "post_tool_use", "before_agent_finalize"]);
|
||||
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
@@ -322,68 +307,6 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defers permission hooks after Codex returns a provider with guarded policy", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const harness = createStartedThreadHarness(async (method) => {
|
||||
if (method !== "thread/start") {
|
||||
return undefined;
|
||||
}
|
||||
const response = threadStartResult();
|
||||
return {
|
||||
...response,
|
||||
thread: { ...response.thread, modelProvider: "lmstudio" },
|
||||
model: "local-model",
|
||||
modelProvider: "lmstudio",
|
||||
};
|
||||
});
|
||||
const approvalRequester = vi.fn(async () => "allow" as const);
|
||||
nativeHookRelayTesting.setNativeHookRelayPermissionApprovalRequesterForTests(approvalRequester);
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.modelId = "openai/gpt-5.4-codex";
|
||||
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "yolo",
|
||||
approvalsReviewer: "auto_review",
|
||||
},
|
||||
},
|
||||
nativeHookRelay: { enabled: true },
|
||||
});
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
const startRequest = harness.requests.find((request) => request.method === "thread/start");
|
||||
const startParams = startRequest?.params as
|
||||
| { approvalPolicy?: unknown; config?: Record<string, unknown> }
|
||||
| undefined;
|
||||
expect(startParams?.approvalPolicy).toBe("never");
|
||||
expect(Array.isArray(startParams?.config?.["hooks.PermissionRequest"])).toBe(true);
|
||||
const turnStartRequest = harness.requests.find((request) => request.method === "turn/start");
|
||||
expect(
|
||||
(turnStartRequest?.params as { approvalPolicy?: unknown } | undefined)?.approvalPolicy,
|
||||
).toBe("on-request");
|
||||
const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
|
||||
await expect(
|
||||
invokeNativeHookRelay({
|
||||
provider: "codex",
|
||||
relayId,
|
||||
event: "permission_request",
|
||||
rawPayload: {
|
||||
hook_event_name: "PermissionRequest",
|
||||
permission_mode: "default",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "git push" },
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({ stdout: "", stderr: "", exitCode: 0 });
|
||||
expect(approvalRequester).not.toHaveBeenCalled();
|
||||
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
testing.flushPendingCodexNativeHookRelayUnregistersForTests();
|
||||
});
|
||||
|
||||
it("preserves explicit native permission request relay events in app-server approval modes", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
@@ -531,7 +454,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
"run-2",
|
||||
);
|
||||
|
||||
await secondHarness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await secondHarness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
|
||||
await secondRun;
|
||||
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(firstRelayId)?.runId).toBe(
|
||||
"run-2",
|
||||
@@ -583,7 +506,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
expect(extractRelayIdFromThreadRequest(resumeRequest?.params)).toBe(firstRelayId);
|
||||
expect(extractGenerationFromThreadRequest(resumeRequest?.params)).toBe(firstGeneration);
|
||||
|
||||
await secondHarness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await secondHarness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
|
||||
await secondRun;
|
||||
testing.flushPendingCodexNativeHookRelayUnregistersForTests();
|
||||
});
|
||||
@@ -713,7 +636,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
});
|
||||
const harness = createStartedThreadHarness(async (method) => {
|
||||
if (method === "thread/resume") {
|
||||
throw new CodexAppServerRpcError({ code: -32000, message: "resume failed" }, method);
|
||||
throw new Error("resume failed");
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -37,10 +37,10 @@ import {
|
||||
} from "./run-attempt-test-harness.js";
|
||||
import { testing } from "./run-attempt.js";
|
||||
import {
|
||||
createCodexTestBindingStore,
|
||||
readCodexAppServerBinding,
|
||||
writeCodexAppServerBinding,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
resolveCodexAppServerBindingPath,
|
||||
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
|
||||
} from "./session-binding.js";
|
||||
|
||||
setupRunAttemptTestHooks();
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("createCodexAttemptTurnWatchController", () => {
|
||||
const onTimeout = vi.fn();
|
||||
const onAbort = vi.fn();
|
||||
const controller = createCodexAttemptTurnWatchController({
|
||||
getThreadId: () => "thread-1",
|
||||
threadId: "thread-1",
|
||||
signal: new AbortController().signal,
|
||||
getTurnId: () => "turn-1",
|
||||
isCompleted: () => false,
|
||||
@@ -100,7 +100,7 @@ describe("createCodexAttemptTurnWatchController", () => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
controller.noteNotificationReceived("item/fileChange/patchUpdated", {
|
||||
controller.noteNotificationReceived("response.output_text.delta", {
|
||||
attemptProgress: true,
|
||||
attemptTimeoutMs: 40,
|
||||
});
|
||||
@@ -113,7 +113,7 @@ describe("createCodexAttemptTurnWatchController", () => {
|
||||
expect.objectContaining({
|
||||
kind: "progress",
|
||||
timeoutMs: 40,
|
||||
lastActivityReason: "notification:item/fileChange/patchUpdated",
|
||||
lastActivityReason: "notification:response.output_text.delta",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
@@ -159,6 +159,8 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.timeoutMs = 200;
|
||||
const bindingPath = resolveCodexAppServerBindingPath(params.sessionFile);
|
||||
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
pluginConfig: { appServer: { turnCompletionIdleTimeoutMs: 5 } },
|
||||
postToolRawAssistantCompletionIdleTimeoutMs: 5,
|
||||
@@ -204,7 +206,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
),
|
||||
{ interval: 1 },
|
||||
);
|
||||
await expect(readCodexAppServerBinding(params.sessionFile)).resolves.toBeUndefined();
|
||||
await expect(fs.stat(bindingPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(queueActiveRunMessageForTest("session-1", "after timeout")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -422,7 +424,8 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
expect(result.promptTimeoutOutcome).toBeUndefined();
|
||||
});
|
||||
|
||||
it("unsubscribes the app-server client when the active turn goes idle past the attempt timeout", async () => {
|
||||
it("unsubscribes and closes the app-server client when the active turn goes idle past the attempt timeout", async () => {
|
||||
const close = vi.fn();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-1");
|
||||
@@ -440,6 +443,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
({
|
||||
...mockClientRuntimeMethods(),
|
||||
request,
|
||||
close,
|
||||
addNotificationHandler: () => () => undefined,
|
||||
addRequestHandler: () => () => undefined,
|
||||
}) as never,
|
||||
@@ -472,6 +476,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
expect(queueActiveRunMessageForTest("session-1", "after timeout")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1881,6 +1886,119 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
expect(completionWarnData?.timeoutMs).toBe(100);
|
||||
});
|
||||
|
||||
it("counts native response deltas as post-tool raw assistant activity", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
let handleRequest:
|
||||
| ((request: { id: string; method: string; params?: unknown }) => Promise<unknown>)
|
||||
| undefined;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-1");
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return turnStartResult("turn-1", "inProgress");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
setCodexAppServerClientFactoryForTest(
|
||||
async () =>
|
||||
({
|
||||
...mockClientRuntimeMethods(),
|
||||
request,
|
||||
addNotificationHandler: (handler: typeof notify) => {
|
||||
notify = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
addRequestHandler: (
|
||||
handler: (request: {
|
||||
id: string;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}) => Promise<unknown>,
|
||||
) => {
|
||||
handleRequest = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.timeoutMs = 60_000;
|
||||
|
||||
let settled = false;
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
turnCompletionIdleTimeoutMs: 500,
|
||||
turnAssistantCompletionIdleTimeoutMs: 5,
|
||||
postToolRawAssistantCompletionIdleTimeoutMs: 50,
|
||||
turnTerminalIdleTimeoutMs: 500,
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), fastWait);
|
||||
|
||||
const toolResult = (await handleRequest?.({
|
||||
id: "request-tool-1",
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: { action: "send", text: "already sent" },
|
||||
},
|
||||
})) as { success?: boolean };
|
||||
expect(toolResult.success).toBe(false);
|
||||
await notify({
|
||||
method: "rawResponseItem/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "raw-status-1",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "I'm writing a large patch now." }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 30);
|
||||
});
|
||||
// This covers the future-compatible path for raw response deltas if Codex
|
||||
// app-server exposes them directly; current Codex primarily emits
|
||||
// rawResponseItem/completed for the raw-event surface.
|
||||
await notify({
|
||||
method: "response.custom_tool_call_input.delta",
|
||||
params: {
|
||||
item_id: "ctc-large-edit-1",
|
||||
output_index: 0,
|
||||
delta: '{"cmd":"apply_patch","patch":"large chunk"}',
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 30);
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await notify({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
turn: { id: "turn-1", status: "completed" },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await run;
|
||||
expect(result.aborted).toBe(false);
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.promptError).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the post-tool guard armed for patch update snapshots", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
let handleRequest:
|
||||
@@ -1992,6 +2110,214 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
expect(completionWarnData?.lastNotificationMethod).toBe("item/fileChange/patchUpdated");
|
||||
});
|
||||
|
||||
it("keeps the post-tool guard armed for scoped native response deltas", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
let handleRequest:
|
||||
| ((request: { id: string; method: string; params?: unknown }) => Promise<unknown>)
|
||||
| undefined;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-1");
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return turnStartResult("turn-1", "inProgress");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
setCodexAppServerClientFactoryForTest(
|
||||
async () =>
|
||||
({
|
||||
...mockClientRuntimeMethods(),
|
||||
request,
|
||||
addNotificationHandler: (handler: typeof notify) => {
|
||||
notify = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
addRequestHandler: (
|
||||
handler: (request: {
|
||||
id: string;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}) => Promise<unknown>,
|
||||
) => {
|
||||
handleRequest = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session-scoped-delta-timeout.jsonl"),
|
||||
path.join(tempDir, "workspace-scoped-delta-timeout"),
|
||||
);
|
||||
params.timeoutMs = 2_000;
|
||||
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
turnCompletionIdleTimeoutMs: 500,
|
||||
turnAssistantCompletionIdleTimeoutMs: 5,
|
||||
postToolRawAssistantCompletionIdleTimeoutMs: 50,
|
||||
turnTerminalIdleTimeoutMs: 500,
|
||||
});
|
||||
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), fastWait);
|
||||
|
||||
await handleRequest?.({
|
||||
id: "request-tool-1",
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: { action: "send", text: "already sent" },
|
||||
},
|
||||
});
|
||||
await notify({
|
||||
method: "rawResponseItem/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "raw-status-1",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "I'm writing a large patch now." }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 30);
|
||||
});
|
||||
await notify({
|
||||
method: "response.custom_tool_call_input.delta",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item_id: "ctc-large-edit-1",
|
||||
output_index: 0,
|
||||
delta: '{"cmd":"apply_patch","patch":"large chunk"}',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await run;
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.promptError).toBe(
|
||||
"codex app-server turn idle timed out waiting for turn/completed",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores unscoped native response deltas while another turn leases the client", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
let handleRequest:
|
||||
| ((request: { id: string; method: string; params?: unknown }) => Promise<unknown>)
|
||||
| undefined;
|
||||
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-1");
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return turnStartResult("turn-1", "inProgress");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
setCodexAppServerClientFactoryForTest(
|
||||
async () =>
|
||||
({
|
||||
...mockClientRuntimeMethods(),
|
||||
request,
|
||||
getActiveSharedLeaseCountForUnscopedNotifications: () => 2,
|
||||
addNotificationHandler: (handler: typeof notify) => {
|
||||
notify = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
addRequestHandler: (
|
||||
handler: (request: {
|
||||
id: string;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}) => Promise<unknown>,
|
||||
) => {
|
||||
handleRequest = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.timeoutMs = 60_000;
|
||||
|
||||
let settled = false;
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
turnCompletionIdleTimeoutMs: 500,
|
||||
turnAssistantCompletionIdleTimeoutMs: 5,
|
||||
postToolRawAssistantCompletionIdleTimeoutMs: 80,
|
||||
turnTerminalIdleTimeoutMs: 500,
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), fastWait);
|
||||
|
||||
await handleRequest?.({
|
||||
id: "request-tool-1",
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: { action: "send", text: "already sent" },
|
||||
},
|
||||
});
|
||||
await notify({
|
||||
method: "rawResponseItem/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "raw-status-1",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "I'm writing a large patch now." }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 40);
|
||||
});
|
||||
await notify({
|
||||
method: "response.custom_tool_call_input.delta",
|
||||
params: {
|
||||
item_id: "foreign-large-edit-1",
|
||||
output_index: 0,
|
||||
delta: '{"cmd":"apply_patch","patch":"other turn"}',
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(settled).toBe(true), fastWait);
|
||||
|
||||
const result = await run;
|
||||
expect(result.aborted).toBe(true);
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.promptError).toBe(
|
||||
"codex app-server turn idle timed out waiting for turn/completed",
|
||||
);
|
||||
const completionWarnCall = warn.mock.calls.find(
|
||||
([message]) => message === "codex app-server turn idle timed out waiting for completion",
|
||||
);
|
||||
const completionWarnData = completionWarnCall?.[1] as
|
||||
| {
|
||||
lastActivityReason?: string;
|
||||
lastNotificationMethod?: string;
|
||||
}
|
||||
| undefined;
|
||||
expect(completionWarnData?.lastActivityReason).toBe("notification:rawResponseItem/completed");
|
||||
expect(completionWarnData?.lastNotificationMethod).toBe("rawResponseItem/completed");
|
||||
});
|
||||
|
||||
it("times out post-native-tool raw assistant progress after the post-tool timeout", async () => {
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
@@ -2692,47 +3018,6 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("retires a timed-out client even when binding cleanup fails", async () => {
|
||||
vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
|
||||
const baseBindingStore = createCodexTestBindingStore();
|
||||
const bindingStore = {
|
||||
...baseBindingStore,
|
||||
mutate: vi.fn(async (...args: Parameters<typeof baseBindingStore.mutate>) => {
|
||||
if (args[1].kind === "clear") {
|
||||
throw new Error("binding store unavailable");
|
||||
}
|
||||
return await baseBindingStore.mutate(...args);
|
||||
}),
|
||||
};
|
||||
const harness = createStartedThreadHarness();
|
||||
const release = vi.fn();
|
||||
const abandon = vi.fn(async () => undefined);
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session-retirement.jsonl"),
|
||||
path.join(tempDir, "workspace-retirement"),
|
||||
);
|
||||
params.timeoutMs = 200;
|
||||
|
||||
const result = await runCodexAppServerAttempt(params, {
|
||||
bindingStore,
|
||||
turnCompletionIdleTimeoutMs: 15,
|
||||
clientLeaseFactory: async () => ({ client: harness.client, release, abandon }),
|
||||
});
|
||||
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(bindingStore.mutate).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ kind: "clear", threadId: "thread-1" }),
|
||||
);
|
||||
expect(harness.request).toHaveBeenCalledWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "thread-1" },
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
expect(abandon).toHaveBeenCalledOnce();
|
||||
expect(release).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the thread binding after a completion-idle timeout so the next turn starts fresh", async () => {
|
||||
// Regression for openclaw#89974. The "user interrupted the previous turn on
|
||||
// purpose" wording is Codex's generic <turn_aborted> rollout marker, written
|
||||
@@ -2744,7 +3029,6 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
|
||||
const sessionFile = path.join(tempDir, "session-89974.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace-89974");
|
||||
const firstParams = createParams(sessionFile, workspaceDir);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
@@ -2755,6 +3039,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
|
||||
// Turn 1: resume an existing thread, then never deliver turn/completed.
|
||||
const firstHarness = createResumeHarness();
|
||||
const firstParams = createParams(sessionFile, workspaceDir);
|
||||
firstParams.timeoutMs = 200;
|
||||
const firstRun = runCodexAppServerAttempt(firstParams, { turnCompletionIdleTimeoutMs: 15 });
|
||||
await firstHarness.waitForMethod("turn/start");
|
||||
@@ -2796,9 +3081,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
const processing = harness.notify(notification);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(readRecentCodexRateLimits(harness.client)).toBeUndefined();
|
||||
expect(readRecentCodexRateLimits()).toBeUndefined();
|
||||
await processing;
|
||||
expect(readRecentCodexRateLimits(harness.client)).toEqual(notification.params);
|
||||
expect(readRecentCodexRateLimits()).toEqual(notification.params);
|
||||
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await expect(run).resolves.toMatchObject({ aborted: false, timedOut: false });
|
||||
@@ -3847,8 +4132,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
);
|
||||
|
||||
await harness.waitForMethod("turn/start");
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
const completed = harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
harness.close();
|
||||
await completed;
|
||||
|
||||
const result = await run;
|
||||
expect(result.promptError ?? undefined).toBeUndefined();
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// Codex tests cover run attempt.usage limits plugin behavior.
|
||||
import path from "node:path";
|
||||
import { saveAuthProfileStore } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readCodexRateLimitsRevision, rememberCodexRateLimitsRead } from "./rate-limit-cache.js";
|
||||
import { rememberCodexRateLimits } from "./rate-limit-cache.js";
|
||||
import {
|
||||
createParams,
|
||||
createStartedThreadHarness,
|
||||
@@ -26,11 +25,7 @@ describe("runCodexAppServerAttempt usage limits", () => {
|
||||
if (!harnessRef.current) {
|
||||
throw new Error("Expected Codex app-server harness to be initialized");
|
||||
}
|
||||
const revisionBeforeUpdate = readCodexRateLimitsRevision(harnessRef.current.client);
|
||||
await harnessRef.current.notify(rateLimitsUpdated(resetsAt));
|
||||
expect(readCodexRateLimitsRevision(harnessRef.current.client)).toBe(
|
||||
revisionBeforeUpdate + 1,
|
||||
);
|
||||
void harnessRef.current.notify(rateLimitsUpdated(resetsAt));
|
||||
throw Object.assign(new Error("You've reached your usage limit."), {
|
||||
data: { codexErrorInfo: "usageLimitExceeded" },
|
||||
});
|
||||
@@ -41,7 +36,6 @@ describe("runCodexAppServerAttempt usage limits", () => {
|
||||
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.authProfileId = authProfileId;
|
||||
params.agentDir = path.join(tempDir, "agents", "main", "agent");
|
||||
params.authProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
@@ -54,13 +48,11 @@ describe("runCodexAppServerAttempt usage limits", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
saveAuthProfileStore(params.authProfileStore, params.agentDir);
|
||||
|
||||
const result = await runCodexAppServerAttempt(params);
|
||||
expect(result.promptErrorSource).toBe("prompt");
|
||||
expect(result.promptError).toContain("You've reached your Codex subscription usage limit.");
|
||||
expect(result.promptError).toContain("Next reset in");
|
||||
expect(params.authProfileStore.usageStats?.[authProfileId]?.blockedUntil).toBe(resetsAt * 1000);
|
||||
});
|
||||
|
||||
it("uses a recent Codex rate-limit snapshot when turn/start omits reset details", async () => {
|
||||
@@ -68,15 +60,7 @@ describe("runCodexAppServerAttempt usage limits", () => {
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
|
||||
const authProfileId = "openai:work";
|
||||
const harness = createStartedThreadHarness(async (method) => {
|
||||
if (method === "turn/start") {
|
||||
throw Object.assign(new Error("You've reached your usage limit."), {
|
||||
data: { codexErrorInfo: "usageLimitExceeded" },
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
rememberCodexRateLimitsRead(harness.client, {
|
||||
rememberCodexRateLimits({
|
||||
rateLimits: {
|
||||
limitId: "codex",
|
||||
limitName: "Codex",
|
||||
@@ -88,6 +72,14 @@ describe("runCodexAppServerAttempt usage limits", () => {
|
||||
},
|
||||
rateLimitsByLimitId: null,
|
||||
});
|
||||
const harness = createStartedThreadHarness(async (method) => {
|
||||
if (method === "turn/start") {
|
||||
throw Object.assign(new Error("You've reached your usage limit."), {
|
||||
data: { codexErrorInfo: "usageLimitExceeded" },
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.authProfileId = authProfileId;
|
||||
@@ -114,62 +106,6 @@ describe("runCodexAppServerAttempt usage limits", () => {
|
||||
expect(params.authProfileStore.usageStats?.[authProfileId]?.blockedUntil).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not trust an unrelated in-turn rate-limit update for profile blocking", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
|
||||
const authProfileId = "openai:work";
|
||||
const harnessRef: { current?: ReturnType<typeof createStartedThreadHarness> } = {};
|
||||
const harness = createStartedThreadHarness(async (method) => {
|
||||
if (method === "turn/start") {
|
||||
if (!harnessRef.current) {
|
||||
throw new Error("Expected Codex app-server harness to be initialized");
|
||||
}
|
||||
await harnessRef.current.notify({
|
||||
method: "account/rateLimits/updated",
|
||||
params: {
|
||||
rateLimits: {
|
||||
limitId: "codex_other",
|
||||
primary: { usedPercent: 100, windowDurationMins: 60, resetsAt: resetsAt + 60 },
|
||||
rateLimitReachedType: "rate_limit_reached",
|
||||
},
|
||||
},
|
||||
});
|
||||
throw Object.assign(new Error("You've reached your usage limit."), {
|
||||
data: { codexErrorInfo: "usageLimitExceeded" },
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
harnessRef.current = harness;
|
||||
rememberCodexRateLimitsRead(harness.client, {
|
||||
rateLimits: {
|
||||
limitId: "codex",
|
||||
primary: { usedPercent: 100, windowDurationMins: 300, resetsAt },
|
||||
rateLimitReachedType: "rate_limit_reached",
|
||||
},
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.authProfileId = authProfileId;
|
||||
params.authProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[authProfileId]: {
|
||||
type: "oauth",
|
||||
provider: "openai",
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await runCodexAppServerAttempt(params);
|
||||
|
||||
expect(result.promptError).toContain("Next reset in");
|
||||
expect(params.authProfileStore.usageStats?.[authProfileId]?.blockedUntil).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refreshes Codex account rate limits when turn/start omits reset details", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/** Small runtime-only Codex thread config boundary shared by isolated turns. */
|
||||
import type { JsonObject } from "./protocol.js";
|
||||
|
||||
// Stream structured patch snapshots so large generated edits keep the turn active.
|
||||
const CODEX_CODE_MODE_THREAD_CONFIG: JsonObject = {
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
};
|
||||
|
||||
const CODEX_CODE_MODE_DISABLED_THREAD_CONFIG: JsonObject = {
|
||||
"features.code_mode": false,
|
||||
"features.code_mode_only": false,
|
||||
};
|
||||
|
||||
/** Applies native code-mode policy without loading the full thread lifecycle. */
|
||||
export function buildCodexRuntimeThreadConfig(
|
||||
config: JsonObject | undefined,
|
||||
options: { nativeCodeModeEnabled?: boolean; nativeCodeModeOnlyEnabled?: boolean } = {},
|
||||
): JsonObject {
|
||||
const codeModeConfig: JsonObject = {
|
||||
...CODEX_CODE_MODE_THREAD_CONFIG,
|
||||
"features.code_mode_only": options.nativeCodeModeOnlyEnabled === true,
|
||||
};
|
||||
if (options.nativeCodeModeEnabled === false) {
|
||||
const disabledConfig = { ...config, ...CODEX_CODE_MODE_DISABLED_THREAD_CONFIG };
|
||||
// Patch streaming belongs to native code mode; omit it when that tool surface is disabled.
|
||||
delete disabledConfig["features.apply_patch_streaming_events"];
|
||||
return disabledConfig;
|
||||
}
|
||||
if (options.nativeCodeModeOnlyEnabled === true) {
|
||||
return { ...codeModeConfig, ...config, "features.code_mode_only": true };
|
||||
}
|
||||
return { ...codeModeConfig, ...config };
|
||||
}
|
||||
@@ -74,7 +74,6 @@ export function resolveCodexAppServerDirectSandboxBypassBlock(params: {
|
||||
method: string;
|
||||
requestParams?: unknown;
|
||||
config?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
}): string | undefined {
|
||||
@@ -82,7 +81,6 @@ export function resolveCodexAppServerDirectSandboxBypassBlock(params: {
|
||||
if (NODE_EXEC_BLOCKED_CONTROL_PLANE_METHODS.has(params.method)) {
|
||||
const nodeExecBlock = resolveCodexNativeNodeExecBlock({
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
surface: `app-server method \`${params.method}\``,
|
||||
@@ -96,7 +94,6 @@ export function resolveCodexAppServerDirectSandboxBypassBlock(params: {
|
||||
}
|
||||
const nodeExecBlock = resolveCodexNativeNodeExecBlock({
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
surface: `app-server method \`${params.method}\``,
|
||||
@@ -110,7 +107,6 @@ export function resolveCodexAppServerDirectSandboxBypassBlock(params: {
|
||||
}
|
||||
const sandboxBlock = resolveCodexNativeSandboxBlock({
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
sessionKey,
|
||||
surface: `app-server method \`${params.method}\``,
|
||||
});
|
||||
@@ -129,7 +125,6 @@ export function resolveCodexAppServerDirectSandboxBypassBlock(params: {
|
||||
/** Resolves the generic native-execution block for sandboxed or node-hosted sessions. */
|
||||
export function resolveCodexNativeExecutionBlock(params: {
|
||||
config?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
agentId?: string;
|
||||
@@ -141,7 +136,6 @@ export function resolveCodexNativeExecutionBlock(params: {
|
||||
/** Returns a block message when native Codex execution cannot honor active sandboxing. */
|
||||
export function resolveCodexNativeSandboxBlock(params: {
|
||||
config?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
surface: string;
|
||||
@@ -152,7 +146,6 @@ export function resolveCodexNativeSandboxBlock(params: {
|
||||
}
|
||||
const runtime = resolveSandboxRuntimeStatus({
|
||||
cfg: params.config,
|
||||
agentId: params.agentId,
|
||||
sessionKey,
|
||||
});
|
||||
if (!runtime.sandboxed) {
|
||||
@@ -205,7 +198,6 @@ function formatCodexNativeSandboxBlock(params: { surface: string }): string {
|
||||
|
||||
function resolveCodexNativeNodeExecBlock(params: {
|
||||
config?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
agentId?: string;
|
||||
@@ -214,7 +206,6 @@ function resolveCodexNativeNodeExecBlock(params: {
|
||||
const sessionKey = params.sessionKey?.trim() || params.sessionId?.trim();
|
||||
const policy = resolveCodexNativeExecutionPolicy({
|
||||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
sessionKey,
|
||||
agentId: params.agentId,
|
||||
readRuntimeSessionEntry: Boolean(sessionKey),
|
||||
|
||||
@@ -10,31 +10,8 @@ import {
|
||||
} from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexThreadStartParams } from "./protocol.js";
|
||||
import {
|
||||
resetCodexTestBindingStore,
|
||||
registerCodexTestSessionIdentity,
|
||||
testCodexAppServerBindingStore,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
import { createCodexTestModel, ensureCodexTestClientNotificationSurface } from "./test-support.js";
|
||||
import { startOrResumeThread as startOrResumeThreadImpl } from "./thread-lifecycle.js";
|
||||
|
||||
function startOrResumeThread(
|
||||
params: Omit<Parameters<typeof startOrResumeThreadImpl>[0], "bindingStore" | "abandonClient"> & {
|
||||
abandonClient?: () => Promise<void>;
|
||||
},
|
||||
) {
|
||||
registerCodexTestSessionIdentity(
|
||||
params.params.sessionFile,
|
||||
params.params.sessionId,
|
||||
params.params.sessionKey,
|
||||
);
|
||||
return startOrResumeThreadImpl({
|
||||
...params,
|
||||
client: ensureCodexTestClientNotificationSurface(params.client),
|
||||
abandonClient: params.abandonClient ?? (async () => undefined),
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
});
|
||||
}
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
import { startOrResumeThread } from "./thread-lifecycle.js";
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
@@ -114,7 +91,6 @@ function threadStartResult(threadId = "thread-1", serviceTier: string | null = n
|
||||
|
||||
describe("Codex app-server dynamic tool schema boundary contract", () => {
|
||||
beforeEach(async () => {
|
||||
resetCodexTestBindingStore();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-schema-contract-"));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
/** Process-stable plugin-state metadata for Codex app-server bindings. */
|
||||
export const CODEX_APP_SERVER_BINDING_NAMESPACE = "app-server-thread-bindings";
|
||||
export const CODEX_APP_SERVER_BINDING_MAX_ENTRIES = 50_000;
|
||||
@@ -1,31 +0,0 @@
|
||||
/** Lazy store facade that keeps binding schema/auth code off plugin startup. */
|
||||
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import {
|
||||
CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
|
||||
CODEX_APP_SERVER_BINDING_NAMESPACE,
|
||||
} from "./session-binding-meta.js";
|
||||
import type { CodexAppServerBindingStore, StoredCodexAppServerBinding } from "./session-binding.js";
|
||||
|
||||
export { CODEX_APP_SERVER_BINDING_MAX_ENTRIES, CODEX_APP_SERVER_BINDING_NAMESPACE };
|
||||
export type { StoredCodexAppServerBinding } from "./session-binding.js";
|
||||
|
||||
/** Defers schema compilation and auth loading until the first binding operation. */
|
||||
export function createLazyCodexAppServerBindingStore(
|
||||
state: Pick<PluginStateSyncKeyedStore<StoredCodexAppServerBinding>, "lookup" | "update">,
|
||||
): CodexAppServerBindingStore {
|
||||
let resolved: Promise<CodexAppServerBindingStore> | undefined;
|
||||
const store = () =>
|
||||
(resolved ??= import("./session-binding.js").then(({ createCodexAppServerBindingStore }) =>
|
||||
createCodexAppServerBindingStore(state),
|
||||
));
|
||||
return {
|
||||
read: async (identity) => (await store()).read(identity),
|
||||
mutate: async (identity, mutation) => (await store()).mutate(identity, mutation),
|
||||
prepareSessionGenerationReclaim: async (identity) =>
|
||||
(await store()).prepareSessionGenerationReclaim(identity),
|
||||
adoptSessionGeneration: async (identity, previousSessionId) =>
|
||||
(await store()).adoptSessionGeneration(identity, previousSessionId),
|
||||
retireSessionGeneration: async (identity) => (await store()).retireSessionGeneration(identity),
|
||||
withLease: async (identity, run) => (await store()).withLease(identity, run),
|
||||
};
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
/** In-memory binding store helpers for Codex app-server tests. */
|
||||
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import {
|
||||
bindingStoreKey,
|
||||
createCodexAppServerBindingStore,
|
||||
type CodexAppServerBindingStore,
|
||||
type CodexAppServerThreadBinding,
|
||||
type StoredCodexAppServerBinding,
|
||||
} from "./session-binding.js";
|
||||
|
||||
export function createCodexTestBindingStateStore(): PluginStateSyncKeyedStore<StoredCodexAppServerBinding> {
|
||||
const values = new Map<string, StoredCodexAppServerBinding>();
|
||||
return {
|
||||
register(key, value) {
|
||||
values.set(key, value);
|
||||
},
|
||||
registerIfAbsent(key, value) {
|
||||
if (values.has(key)) {
|
||||
return false;
|
||||
}
|
||||
values.set(key, value);
|
||||
return true;
|
||||
},
|
||||
update(key, updateValue) {
|
||||
const next = updateValue(values.get(key));
|
||||
if (next === undefined) {
|
||||
return false;
|
||||
}
|
||||
values.set(key, next);
|
||||
return true;
|
||||
},
|
||||
lookup: (key) => values.get(key),
|
||||
consume(key) {
|
||||
const value = values.get(key);
|
||||
values.delete(key);
|
||||
return value;
|
||||
},
|
||||
delete: (key) => values.delete(key),
|
||||
entries: () => [...values].map(([key, value]) => ({ key, value, createdAt: 0 })),
|
||||
clear: () => values.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCodexTestBindingStore(): CodexAppServerBindingStore {
|
||||
return createCodexAppServerBindingStore(createCodexTestBindingStateStore());
|
||||
}
|
||||
|
||||
const sharedStateStore = createCodexTestBindingStateStore();
|
||||
export const testCodexAppServerBindingStore = createCodexAppServerBindingStore(sharedStateStore);
|
||||
const testSessionIdentities = new Map<
|
||||
string,
|
||||
{ agentId: string; sessionId: string; sessionKey?: string }
|
||||
>();
|
||||
|
||||
export function resetCodexTestBindingStore(): void {
|
||||
sharedStateStore.clear();
|
||||
testSessionIdentities.clear();
|
||||
}
|
||||
|
||||
export function registerCodexTestSessionIdentity(
|
||||
locator: string,
|
||||
sessionId: string,
|
||||
sessionKey?: string,
|
||||
agentId = "main",
|
||||
): void {
|
||||
testSessionIdentities.set(locator, {
|
||||
agentId,
|
||||
sessionId,
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function seedCodexTestBinding(locator: string, binding: CodexAppServerThreadBinding): void {
|
||||
sharedStateStore.register(bindingStoreKey(testIdentity(locator)), {
|
||||
version: 1,
|
||||
state: "active",
|
||||
binding,
|
||||
});
|
||||
}
|
||||
|
||||
function testIdentity(locator: string) {
|
||||
const identity = testSessionIdentities.get(locator);
|
||||
return {
|
||||
kind: "session" as const,
|
||||
agentId: identity?.agentId ?? "main",
|
||||
sessionId: identity?.sessionId ?? locator,
|
||||
...(identity?.sessionKey ? { sessionKey: identity.sessionKey } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readCodexAppServerBinding(
|
||||
sessionId: string,
|
||||
): Promise<CodexAppServerThreadBinding | undefined> {
|
||||
return await testCodexAppServerBindingStore.read(testIdentity(sessionId));
|
||||
}
|
||||
|
||||
export async function writeCodexAppServerBinding(
|
||||
sessionId: string,
|
||||
binding: CodexAppServerThreadBinding,
|
||||
): Promise<void> {
|
||||
await testCodexAppServerBindingStore.mutate(testIdentity(sessionId), { kind: "set", binding });
|
||||
}
|
||||
|
||||
export async function clearCodexAppServerBinding(sessionId: string): Promise<void> {
|
||||
await testCodexAppServerBindingStore.mutate(testIdentity(sessionId), { kind: "clear" });
|
||||
}
|
||||
|
||||
export async function clearCodexAppServerBindingForThread(
|
||||
sessionId: string,
|
||||
threadId: string,
|
||||
): Promise<boolean> {
|
||||
return await testCodexAppServerBindingStore.mutate(testIdentity(sessionId), {
|
||||
kind: "clear",
|
||||
threadId,
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,29 +1,21 @@
|
||||
// Codex tests cover shared client plugin behavior.
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocketServer, type RawData } from "ws";
|
||||
import type { resolveCodexAppServerAuthProfileIdForAgent } from "./auth-bridge.js";
|
||||
import { CodexAppServerClient, MIN_CODEX_APP_SERVER_VERSION } from "./client.js";
|
||||
import { codexAppServerStartOptionsKey } from "./config.js";
|
||||
import { createClientHarness } from "./test-support.js";
|
||||
|
||||
type AuthProfileResolverParams = Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0];
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
bridgeCodexAppServerStartOptions: vi.fn(async ({ startOptions }) => startOptions),
|
||||
applyCodexAppServerAuthProfile: vi.fn(
|
||||
async (_params?: {
|
||||
client?: CodexAppServerClient;
|
||||
agentDir?: string;
|
||||
authProfileId?: string;
|
||||
config?: unknown;
|
||||
}) => undefined,
|
||||
async (_params?: { agentDir?: string; authProfileId?: string; config?: unknown }) => undefined,
|
||||
),
|
||||
resolveCodexAppServerAuthProfileIdForAgent: vi.fn(
|
||||
(params?: AuthProfileResolverParams) => params?.authProfileId,
|
||||
(params?: { authProfileId?: string }) => params?.authProfileId,
|
||||
),
|
||||
resolveCodexAppServerAuthProfileStore: vi.fn(
|
||||
(params?: { authProfileStore?: unknown }) => params?.authProfileStore,
|
||||
),
|
||||
resolveCodexAppServerAuthAccountCacheKey: vi.fn(async () => "account:credential"),
|
||||
refreshCodexAppServerAuthTokens: vi.fn(async () => ({
|
||||
accessToken: "refreshed-access",
|
||||
chatgptAccountId: "refreshed-account",
|
||||
@@ -40,9 +32,8 @@ vi.mock("./auth-bridge.js", () => ({
|
||||
bridgeCodexAppServerStartOptions: mocks.bridgeCodexAppServerStartOptions,
|
||||
resolveCodexAppServerAuthProfileIdForAgent: mocks.resolveCodexAppServerAuthProfileIdForAgent,
|
||||
resolveCodexAppServerAuthProfileStore: mocks.resolveCodexAppServerAuthProfileStore,
|
||||
resolveCodexAppServerAuthAccountCacheKey: mocks.resolveCodexAppServerAuthAccountCacheKey,
|
||||
resolveCodexAppServerFallbackApiKeyCacheKey: mocks.resolveCodexAppServerFallbackApiKeyCacheKey,
|
||||
refreshCodexAppServerAuthTokens: mocks.refreshCodexAppServerAuthTokens,
|
||||
resolveCodexAppServerFallbackApiKeyCacheKey: mocks.resolveCodexAppServerFallbackApiKeyCacheKey,
|
||||
}));
|
||||
|
||||
vi.mock("./managed-binary.js", () => ({
|
||||
@@ -59,10 +50,16 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
|
||||
}));
|
||||
|
||||
let listCodexAppServerModels: typeof import("./models.js").listCodexAppServerModels;
|
||||
let clearSharedCodexAppServerClientAndWait: typeof import("./shared-client.js").clearSharedCodexAppServerClientAndWait;
|
||||
let clearSharedCodexAppServerClient: typeof import("./shared-client.js").clearSharedCodexAppServerClient;
|
||||
let clearSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").clearSharedCodexAppServerClientIfCurrent;
|
||||
let clearSharedCodexAppServerClientIfCurrentAndWait: typeof import("./shared-client.js").clearSharedCodexAppServerClientIfCurrentAndWait;
|
||||
let createIsolatedCodexAppServerClient: typeof import("./shared-client.js").createIsolatedCodexAppServerClient;
|
||||
let leaseSharedCodexAppServerClient: typeof import("./shared-client.js").leaseSharedCodexAppServerClient;
|
||||
let retainSharedCodexAppServerClient: typeof import("./shared-client.js").retainSharedCodexAppServerClient;
|
||||
let detachSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").detachSharedCodexAppServerClientIfCurrent;
|
||||
let getLeasedSharedCodexAppServerClient: typeof import("./shared-client.js").getLeasedSharedCodexAppServerClient;
|
||||
let getSharedCodexAppServerClient: typeof import("./shared-client.js").getSharedCodexAppServerClient;
|
||||
let retainSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").retainSharedCodexAppServerClientIfCurrent;
|
||||
let releaseLeasedSharedCodexAppServerClient: typeof import("./shared-client.js").releaseLeasedSharedCodexAppServerClient;
|
||||
let retireSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").retireSharedCodexAppServerClientIfCurrent;
|
||||
let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests;
|
||||
|
||||
async function sendInitializeResult(
|
||||
@@ -134,10 +131,16 @@ describe("shared Codex app-server client", () => {
|
||||
beforeAll(async () => {
|
||||
({ listCodexAppServerModels } = await import("./models.js"));
|
||||
({
|
||||
clearSharedCodexAppServerClientAndWait,
|
||||
clearSharedCodexAppServerClient,
|
||||
clearSharedCodexAppServerClientIfCurrent,
|
||||
clearSharedCodexAppServerClientIfCurrentAndWait,
|
||||
createIsolatedCodexAppServerClient,
|
||||
leaseSharedCodexAppServerClient,
|
||||
retainSharedCodexAppServerClient,
|
||||
detachSharedCodexAppServerClientIfCurrent,
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
getSharedCodexAppServerClient,
|
||||
retainSharedCodexAppServerClientIfCurrent,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
retireSharedCodexAppServerClientIfCurrent,
|
||||
resetSharedCodexAppServerClientForTests,
|
||||
} = await import("./shared-client.js"));
|
||||
});
|
||||
@@ -148,20 +151,17 @@ describe("shared Codex app-server client", () => {
|
||||
vi.useRealTimers();
|
||||
mocks.bridgeCodexAppServerStartOptions.mockClear();
|
||||
mocks.applyCodexAppServerAuthProfile.mockClear();
|
||||
mocks.applyCodexAppServerAuthProfile.mockImplementation(async () => undefined);
|
||||
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockClear();
|
||||
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockImplementation(
|
||||
(params?: AuthProfileResolverParams) => params?.authProfileId,
|
||||
(params?: { authProfileId?: string }) => params?.authProfileId,
|
||||
);
|
||||
mocks.resolveCodexAppServerAuthProfileStore.mockClear();
|
||||
mocks.resolveCodexAppServerAuthProfileStore.mockImplementation(
|
||||
(params?: { authProfileStore?: unknown }) => params?.authProfileStore,
|
||||
);
|
||||
mocks.resolveCodexAppServerAuthAccountCacheKey.mockClear();
|
||||
mocks.resolveCodexAppServerAuthAccountCacheKey.mockResolvedValue("account:credential");
|
||||
mocks.refreshCodexAppServerAuthTokens.mockClear();
|
||||
mocks.resolveCodexAppServerFallbackApiKeyCacheKey.mockClear();
|
||||
mocks.resolveCodexAppServerFallbackApiKeyCacheKey.mockReturnValue(undefined);
|
||||
mocks.refreshCodexAppServerAuthTokens.mockClear();
|
||||
mocks.resolveManagedCodexAppServerStartOptions.mockClear();
|
||||
mocks.resolveManagedCodexAppServerStartOptions.mockImplementation(
|
||||
async (startOptions) => startOptions,
|
||||
@@ -213,12 +213,11 @@ describe("shared Codex app-server client", () => {
|
||||
const abandonController = new AbortController();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
|
||||
const abandonedAcquire = leaseSharedCodexAppServerClient({
|
||||
const abandonedAcquire = getSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
abandonSignal: abandonController.signal,
|
||||
});
|
||||
const abandonedResult = abandonedAcquire.catch((error: unknown) => error);
|
||||
const activeAcquire = leaseSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
const activeAcquire = getSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
|
||||
|
||||
abandonController.abort();
|
||||
@@ -226,130 +225,9 @@ describe("shared Codex app-server client", () => {
|
||||
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
|
||||
await expect(abandonedResult).resolves.toBeInstanceOf(Error);
|
||||
const activeLease = await activeAcquire;
|
||||
expect(activeLease.client).toBe(harness.client);
|
||||
await expect(abandonedAcquire).resolves.toBe(harness.client);
|
||||
await expect(activeAcquire).resolves.toBe(harness.client);
|
||||
expect(harness.process.stdin.destroyed).toBe(false);
|
||||
activeLease.release();
|
||||
});
|
||||
|
||||
it("does not let one acquire timeout close startup owned by another caller", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
|
||||
const timedOutAcquire = leaseSharedCodexAppServerClient({ timeoutMs: 5 });
|
||||
const activeAcquire = leaseSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
|
||||
await expect(timedOutAcquire).rejects.toThrow("codex app-server initialize timed out");
|
||||
expect(harness.process.stdin.destroyed).toBe(false);
|
||||
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
const activeLease = await activeAcquire;
|
||||
expect(activeLease.client).toBe(harness.client);
|
||||
expect(harness.process.stdin.destroyed).toBe(false);
|
||||
activeLease.release();
|
||||
});
|
||||
|
||||
it("does not launch a client after its only acquire times out during preparation", async () => {
|
||||
let finishManagedResolution!: () => void;
|
||||
mocks.resolveManagedCodexAppServerStartOptions.mockImplementationOnce(async (startOptions) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
finishManagedResolution = resolve;
|
||||
});
|
||||
return startOptions;
|
||||
});
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start");
|
||||
const startOptions = {
|
||||
transport: "stdio" as const,
|
||||
command: "codex",
|
||||
commandSource: "managed" as const,
|
||||
args: ["app-server", "--listen", "stdio://"],
|
||||
headers: {},
|
||||
};
|
||||
|
||||
await expect(
|
||||
leaseSharedCodexAppServerClient({
|
||||
startOptions,
|
||||
authProfileId: "openai:work",
|
||||
timeoutMs: 5,
|
||||
}),
|
||||
).rejects.toThrow("codex app-server preparation timed out");
|
||||
finishManagedResolution();
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
|
||||
expect(startSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not launch an isolated client after preparation times out", async () => {
|
||||
let finishManagedResolution!: () => void;
|
||||
mocks.resolveManagedCodexAppServerStartOptions.mockImplementationOnce(async (startOptions) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
finishManagedResolution = resolve;
|
||||
});
|
||||
return startOptions;
|
||||
});
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start");
|
||||
|
||||
await expect(createIsolatedCodexAppServerClient({ timeoutMs: 5 })).rejects.toThrow(
|
||||
"codex app-server preparation timed out",
|
||||
);
|
||||
finishManagedResolution();
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
|
||||
expect(startSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not create a pool entry when abort wins after preparation resolves", async () => {
|
||||
const controller = new AbortController();
|
||||
const originalThrowIfAborted = controller.signal.throwIfAborted.bind(controller.signal);
|
||||
let checks = 0;
|
||||
vi.spyOn(controller.signal, "throwIfAborted").mockImplementation(() => {
|
||||
checks += 1;
|
||||
if (checks === 2) {
|
||||
controller.abort(new Error("aborted after preparation"));
|
||||
}
|
||||
originalThrowIfAborted();
|
||||
});
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start");
|
||||
|
||||
await expect(
|
||||
leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
abandonSignal: controller.signal,
|
||||
}),
|
||||
).rejects.toThrow("aborted after preparation");
|
||||
|
||||
expect(startSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not grant a lease when abort wins after initialization resolves", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
const controller = new AbortController();
|
||||
const originalThrowIfAborted = controller.signal.throwIfAborted.bind(controller.signal);
|
||||
let checks = 0;
|
||||
vi.spyOn(controller.signal, "throwIfAborted").mockImplementation(() => {
|
||||
checks += 1;
|
||||
if (checks === 4) {
|
||||
controller.abort(new Error("aborted after initialization"));
|
||||
}
|
||||
originalThrowIfAborted();
|
||||
});
|
||||
|
||||
const leasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
abandonSignal: controller.signal,
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
|
||||
await expect(leasePromise).rejects.toThrow("aborted after initialization");
|
||||
expect(harness.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("does not wait for isolated initialize after a timeout closes the client", async () => {
|
||||
@@ -362,20 +240,6 @@ describe("shared Codex app-server client", () => {
|
||||
expect(harness.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("bounds isolated auth application with the same startup deadline", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
mocks.applyCodexAppServerAuthProfile.mockImplementationOnce(
|
||||
async () => await new Promise<undefined>(() => {}),
|
||||
);
|
||||
|
||||
const clientPromise = createIsolatedCodexAppServerClient({ timeoutMs: 100 });
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
|
||||
await expect(clientPromise).rejects.toThrow("codex app-server initialize timed out");
|
||||
expect(harness.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("passes the selected auth profile through the bridge helper", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
@@ -483,41 +347,12 @@ describe("shared Codex app-server client", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("installs physical-client handlers before initialization completes", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
|
||||
const leasePromise = leaseSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
|
||||
harness.send({
|
||||
id: "refresh-during-initialize",
|
||||
method: "account/chatgptAuthTokens/refresh",
|
||||
params: { reason: "expired" },
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.writes.map((line) => JSON.parse(line) as unknown)).toContainEqual({
|
||||
id: "refresh-during-initialize",
|
||||
result: {
|
||||
accessToken: "refreshed-access",
|
||||
chatgptAccountId: "refreshed-account",
|
||||
chatgptPlanType: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
|
||||
const lease = await leasePromise;
|
||||
expect(lease.client).toBe(harness.client);
|
||||
expect(mocks.refreshCodexAppServerAuthTokens).toHaveBeenCalledTimes(1);
|
||||
lease.release();
|
||||
});
|
||||
|
||||
it("skips target auth resolution when native source auth is requested", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
const config = { auth: { order: { openai: ["openai:target"] } } };
|
||||
|
||||
const leasePromise = leaseSharedCodexAppServerClient({
|
||||
const clientPromise = getSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: null,
|
||||
agentDir: "/tmp/openclaw-target-agent",
|
||||
@@ -525,8 +360,7 @@ describe("shared Codex app-server client", () => {
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
|
||||
const lease = await leasePromise;
|
||||
expect(lease.client).toBe(harness.client);
|
||||
await expect(clientPromise).resolves.toBe(harness.client);
|
||||
expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).not.toHaveBeenCalled();
|
||||
const bridgeCall = bridgeStartOptionsCall();
|
||||
expect(bridgeCall.agentDir).toBe("/tmp/openclaw-target-agent");
|
||||
@@ -536,7 +370,6 @@ describe("shared Codex app-server client", () => {
|
||||
expect(applyCall.agentDir).toBe("/tmp/openclaw-target-agent");
|
||||
expect(applyCall.authProfileId).toBeNull();
|
||||
expect(applyCall.config).toBe(config);
|
||||
lease.release();
|
||||
});
|
||||
|
||||
it("resolves the configured implicit auth profile before sharing a client", async () => {
|
||||
@@ -555,6 +388,7 @@ describe("shared Codex app-server client", () => {
|
||||
await expect(listPromise).resolves.toEqual({ models: [] });
|
||||
const resolveCall = resolveAuthProfileCall();
|
||||
expect(resolveCall).toStrictEqual({
|
||||
authProfileId: undefined,
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
config,
|
||||
});
|
||||
@@ -566,32 +400,6 @@ describe("shared Codex app-server client", () => {
|
||||
expect(applyCall?.config).toBe(config);
|
||||
});
|
||||
|
||||
it("separates shared clients when implicit auth resolves to different profiles", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
const firstConfig = { auth: { order: { openai: ["openai:work"] } } };
|
||||
const secondConfig = { auth: { order: { openai: ["openai:personal"] } } };
|
||||
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockImplementation(
|
||||
({ config }: AuthProfileResolverParams = {}) => config?.auth?.order?.openai?.[0],
|
||||
);
|
||||
const startSpy = vi
|
||||
.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(first.client)
|
||||
.mockReturnValueOnce(second.client);
|
||||
|
||||
const firstList = listCodexAppServerModels({ timeoutMs: 1000, config: firstConfig });
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
const secondList = listCodexAppServerModels({ timeoutMs: 1000, config: secondConfig });
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses the selected agent dir for shared app-server auth bridging", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
@@ -613,6 +421,74 @@ describe("shared Codex app-server client", () => {
|
||||
expect(applyCall?.authProfileId).toBe("openai:work");
|
||||
});
|
||||
|
||||
it("migrates legacy singleton global state into the keyed registry", async () => {
|
||||
const legacy = createClientHarness();
|
||||
const next = createClientHarness();
|
||||
const startOptions = {
|
||||
transport: "websocket" as const,
|
||||
command: "codex",
|
||||
args: [],
|
||||
url: "ws://127.0.0.1:39175",
|
||||
authToken: "tok-legacy",
|
||||
headers: {},
|
||||
};
|
||||
const key = codexAppServerStartOptionsKey(startOptions, {
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
});
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
[key: symbol]: unknown;
|
||||
};
|
||||
globalState[Symbol.for("openclaw.codexAppServerClientState")] = {
|
||||
key,
|
||||
client: legacy.client,
|
||||
promise: Promise.resolve(legacy.client),
|
||||
};
|
||||
|
||||
await expect(getSharedCodexAppServerClient({ startOptions })).resolves.toBe(legacy.client);
|
||||
|
||||
legacy.client.close();
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(next.client);
|
||||
const list = listCodexAppServerModels({ timeoutMs: 1000, startOptions });
|
||||
await sendInitializeResult(next, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(next);
|
||||
|
||||
await expect(list).resolves.toEqual({ models: [] });
|
||||
expect(startSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves keyed shared-client state when adding lease metadata", async () => {
|
||||
const legacy = createClientHarness();
|
||||
const startOptions = {
|
||||
transport: "websocket" as const,
|
||||
command: "codex",
|
||||
args: [],
|
||||
url: "ws://127.0.0.1:39176",
|
||||
authToken: "tok-keyed",
|
||||
headers: {},
|
||||
};
|
||||
const key = codexAppServerStartOptionsKey(startOptions, {
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
});
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
[key: symbol]: unknown;
|
||||
};
|
||||
globalState[Symbol.for("openclaw.codexAppServerClientState")] = {
|
||||
clients: new Map([[key, { client: legacy.client, promise: Promise.resolve(legacy.client) }]]),
|
||||
};
|
||||
|
||||
await expect(getLeasedSharedCodexAppServerClient({ startOptions })).resolves.toBe(
|
||||
legacy.client,
|
||||
);
|
||||
expect(retireSharedCodexAppServerClientIfCurrent(legacy.client)).toEqual({
|
||||
activeLeases: 1,
|
||||
closed: false,
|
||||
});
|
||||
expect(legacy.process.stdin.destroyed).toBe(false);
|
||||
|
||||
expect(releaseLeasedSharedCodexAppServerClient(legacy.client)).toBe(true);
|
||||
expect(legacy.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an active shared client alive when another agent dir uses a different key", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
@@ -623,7 +499,6 @@ describe("shared Codex app-server client", () => {
|
||||
|
||||
const firstList = listCodexAppServerModels({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: null,
|
||||
agentDir: "/tmp/openclaw-agent-one",
|
||||
});
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
@@ -632,7 +507,6 @@ describe("shared Codex app-server client", () => {
|
||||
|
||||
const secondList = listCodexAppServerModels({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: null,
|
||||
agentDir: "/tmp/openclaw-agent-two",
|
||||
});
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
@@ -644,76 +518,6 @@ describe("shared Codex app-server client", () => {
|
||||
expect(second.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds idle shared clients and closes the least recently released process", async () => {
|
||||
const harnesses = Array.from({ length: 5 }, () => createClientHarness());
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start");
|
||||
for (const harness of harnesses) {
|
||||
startSpy.mockReturnValueOnce(harness.client);
|
||||
}
|
||||
|
||||
for (const [index, harness] of harnesses.slice(0, 4).entries()) {
|
||||
const leasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: null,
|
||||
agentDir: `/tmp/openclaw-agent-${index}`,
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
const lease = await leasePromise;
|
||||
lease.release();
|
||||
}
|
||||
const refreshed = await leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: null,
|
||||
agentDir: "/tmp/openclaw-agent-0",
|
||||
});
|
||||
refreshed.release();
|
||||
const newestLeasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: null,
|
||||
agentDir: "/tmp/openclaw-agent-4",
|
||||
});
|
||||
await sendInitializeResult(harnesses[4], "openclaw/0.125.0 (macOS; test)");
|
||||
(await newestLeasePromise).release();
|
||||
|
||||
expect(harnesses[0]?.process.stdin.destroyed).toBe(false);
|
||||
expect(harnesses[1]?.process.stdin.destroyed).toBe(true);
|
||||
for (const harness of harnesses.slice(2)) {
|
||||
expect(harness.process.stdin.destroyed).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not evict a client retained by detached background work", async () => {
|
||||
const retained = createClientHarness();
|
||||
const idle = Array.from({ length: 5 }, () => createClientHarness());
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start");
|
||||
for (const harness of [retained, ...idle]) {
|
||||
startSpy.mockReturnValueOnce(harness.client);
|
||||
}
|
||||
const retainedLeasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: null,
|
||||
agentDir: "/tmp/openclaw-retained-agent",
|
||||
});
|
||||
await sendInitializeResult(retained, "openclaw/0.125.0 (macOS; test)");
|
||||
const retainedLease = await retainedLeasePromise;
|
||||
const releaseRetention = retainSharedCodexAppServerClient(retained.client);
|
||||
retainedLease.release();
|
||||
|
||||
for (const [index, harness] of idle.entries()) {
|
||||
const leasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: null,
|
||||
agentDir: `/tmp/openclaw-idle-agent-${index}`,
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
(await leasePromise).release();
|
||||
}
|
||||
|
||||
expect(retained.process.stdin.destroyed).toBe(false);
|
||||
releaseRetention();
|
||||
expect(retained.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves the managed binary before bridging and spawning the shared client", async () => {
|
||||
const harness = createClientHarness();
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
@@ -739,28 +543,6 @@ describe("shared Codex app-server client", () => {
|
||||
expect(startCall?.commandSource).toBe("resolved-managed");
|
||||
});
|
||||
|
||||
it("resolves managed binary metadata once while refreshing credentials per acquire", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
|
||||
const firstLeasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
const firstLease = await firstLeasePromise;
|
||||
firstLease.release();
|
||||
|
||||
const secondLease = await leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
});
|
||||
secondLease.release();
|
||||
|
||||
expect(mocks.resolveManagedCodexAppServerStartOptions).toHaveBeenCalledOnce();
|
||||
expect(mocks.bridgeCodexAppServerStartOptions).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("starts an independent shared client when the bridged auth token changes", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
@@ -803,29 +585,7 @@ describe("shared Codex app-server client", () => {
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps native and fallback auth in separate shared scopes", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
const startSpy = vi
|
||||
.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(first.client)
|
||||
.mockReturnValueOnce(second.client);
|
||||
const firstList = listCodexAppServerModels({ timeoutMs: 1000, authProfileId: null });
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
expect(second.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("starts a new shared client when fallback api-key auth changes", async () => {
|
||||
it("starts an independent shared client when fallback api-key auth changes", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
const startSpy = vi
|
||||
@@ -851,37 +611,6 @@ describe("shared Codex app-server client", () => {
|
||||
expect(second.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("starts a new shared client when an explicit profile credential changes", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
const startSpy = vi
|
||||
.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(first.client)
|
||||
.mockReturnValueOnce(second.client);
|
||||
mocks.resolveCodexAppServerAuthAccountCacheKey
|
||||
.mockResolvedValueOnce("account:credential-1")
|
||||
.mockResolvedValueOnce("account:credential-2");
|
||||
|
||||
const firstList = listCodexAppServerModels({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
});
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
const secondList = listCodexAppServerModels({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
});
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let one shared-client failure tear down another keyed client", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
@@ -926,154 +655,128 @@ describe("shared Codex app-server client", () => {
|
||||
expect(second.process.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("abandons a matching shared client without disturbing its replacement", async () => {
|
||||
it("only clears the shared client that is still current", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(first.client)
|
||||
.mockReturnValueOnce(second.client);
|
||||
const firstCloseAndWait = vi.spyOn(first.client, "closeAndWait");
|
||||
|
||||
const firstLeasePromise = leaseSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
const firstLease = await firstLeasePromise;
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
await firstLease.abandon();
|
||||
expect(clearSharedCodexAppServerClientIfCurrent(first.client)).toBe(true);
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
|
||||
const secondLeasePromise = leaseSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
const secondLease = await secondLeasePromise;
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
await firstLease.abandon();
|
||||
firstLease.release();
|
||||
expect(firstCloseAndWait).toHaveBeenCalledTimes(1);
|
||||
expect(clearSharedCodexAppServerClientIfCurrent(first.client)).toBe(false);
|
||||
expect(second.process.kill).not.toHaveBeenCalled();
|
||||
await secondLease.abandon();
|
||||
expect(clearSharedCodexAppServerClientIfCurrent(second.client)).toBe(true);
|
||||
expect(second.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("closes an abandoned client without removing its idle replacement", async () => {
|
||||
const retired = createClientHarness();
|
||||
const replacement = createClientHarness();
|
||||
const otherIdle = Array.from({ length: 4 }, () => createClientHarness());
|
||||
const startSpy = vi.spyOn(CodexAppServerClient, "start");
|
||||
for (const harness of [retired, replacement, ...otherIdle]) {
|
||||
startSpy.mockReturnValueOnce(harness.client);
|
||||
}
|
||||
const sharedOptions = {
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
};
|
||||
const retiringLeasePromise = leaseSharedCodexAppServerClient(sharedOptions);
|
||||
const liveLeasePromise = leaseSharedCodexAppServerClient(sharedOptions);
|
||||
await sendInitializeResult(retired, "openclaw/0.125.0 (macOS; test)");
|
||||
const retiringLease = await retiringLeasePromise;
|
||||
const liveLease = await liveLeasePromise;
|
||||
it("can detach the current shared client without closing it", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(first.client)
|
||||
.mockReturnValueOnce(second.client);
|
||||
|
||||
await retiringLease.abandon();
|
||||
const replacementLeasePromise = leaseSharedCodexAppServerClient(sharedOptions);
|
||||
await sendInitializeResult(replacement, "openclaw/0.125.0 (macOS; test)");
|
||||
const replacementLease = await replacementLeasePromise;
|
||||
replacementLease.release();
|
||||
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
const releaseRetention = retainSharedCodexAppServerClient(retired.client);
|
||||
for (const [index, harness] of otherIdle.entries()) {
|
||||
const leasePromise = leaseSharedCodexAppServerClient({
|
||||
...sharedOptions,
|
||||
agentDir: `/tmp/openclaw-retired-idle-agent-${index}`,
|
||||
});
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
(await leasePromise).release();
|
||||
}
|
||||
expect(detachSharedCodexAppServerClientIfCurrent(first.client)).toBe(true);
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
|
||||
expect(replacement.process.stdin.destroyed).toBe(true);
|
||||
expect(retired.process.stdin.destroyed).toBe(true);
|
||||
releaseRetention();
|
||||
liveLease.release();
|
||||
expect(retired.process.stdin.destroyed).toBe(true);
|
||||
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
expect(detachSharedCodexAppServerClientIfCurrent(first.client)).toBe(false);
|
||||
first.client.close();
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
expect(second.process.kill).not.toHaveBeenCalled();
|
||||
expect(detachSharedCodexAppServerClientIfCurrent(second.client)).toBe(true);
|
||||
second.client.close();
|
||||
expect(second.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("settles each concurrent shared-client lease exactly once", async () => {
|
||||
it("closes a retired shared app-server after all active leases release", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(first.client)
|
||||
.mockReturnValueOnce(second.client);
|
||||
|
||||
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
const releaseFirst = retainSharedCodexAppServerClientIfCurrent(first.client);
|
||||
const releaseSecond = retainSharedCodexAppServerClientIfCurrent(first.client);
|
||||
expect(releaseFirst).toBeTypeOf("function");
|
||||
expect(releaseSecond).toBeTypeOf("function");
|
||||
expect(retireSharedCodexAppServerClientIfCurrent(first.client)).toEqual({
|
||||
activeLeases: 2,
|
||||
closed: false,
|
||||
});
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
|
||||
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
releaseFirst?.();
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
releaseSecond?.();
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
expect(second.process.kill).not.toHaveBeenCalled();
|
||||
expect(retireSharedCodexAppServerClientIfCurrent(second.client)).toEqual({
|
||||
activeLeases: 0,
|
||||
closed: true,
|
||||
});
|
||||
expect(second.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("leases shared app-server clients before returning concurrent acquirers", async () => {
|
||||
const first = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(first.client);
|
||||
const close = vi.spyOn(first.client, "close");
|
||||
const closeAndWait = vi.spyOn(first.client, "closeAndWait");
|
||||
|
||||
const firstLeasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
});
|
||||
const secondLeasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
});
|
||||
const firstLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
const secondLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
const firstLease = await firstLeasePromise;
|
||||
const secondLease = await secondLeasePromise;
|
||||
expect(firstLease.client).toBe(first.client);
|
||||
expect(secondLease.client).toBe(first.client);
|
||||
await expect(firstLease).resolves.toBe(first.client);
|
||||
await expect(secondLease).resolves.toBe(first.client);
|
||||
|
||||
expect(mocks.resolveManagedCodexAppServerStartOptions).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).not.toHaveBeenCalled();
|
||||
expect(mocks.bridgeCodexAppServerStartOptions).toHaveBeenCalledTimes(1);
|
||||
expect(retireSharedCodexAppServerClientIfCurrent(first.client)).toEqual({
|
||||
activeLeases: 2,
|
||||
closed: false,
|
||||
});
|
||||
expect(retireSharedCodexAppServerClientIfCurrent(first.client)).toEqual({
|
||||
activeLeases: 2,
|
||||
closed: false,
|
||||
});
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
|
||||
await firstLease.abandon();
|
||||
await firstLease.abandon();
|
||||
firstLease.release();
|
||||
expect(releaseLeasedSharedCodexAppServerClient(first.client)).toBe(true);
|
||||
expect(first.process.stdin.destroyed).toBe(false);
|
||||
expect(releaseLeasedSharedCodexAppServerClient(first.client)).toBe(true);
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(closeAndWait).toHaveBeenCalledTimes(1);
|
||||
await expect(firstLease.client.request("model/list", {})).rejects.toThrow();
|
||||
|
||||
secondLease.release();
|
||||
secondLease.release();
|
||||
await secondLease.abandon();
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(closeAndWait).toHaveBeenCalledTimes(1);
|
||||
expect(releaseLeasedSharedCodexAppServerClient(first.client)).toBe(false);
|
||||
});
|
||||
|
||||
it("waits for an already-detached client retirement during clear-all shutdown", async () => {
|
||||
const first = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(first.client);
|
||||
let finishRetirement!: () => void;
|
||||
const retirementBlocked = new Promise<void>((resolve) => {
|
||||
finishRetirement = resolve;
|
||||
});
|
||||
const closeAndWait = vi.spyOn(first.client, "closeAndWait").mockImplementation(async () => {
|
||||
first.client.close();
|
||||
await retirementBlocked;
|
||||
});
|
||||
const leasePromise = leaseSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
authProfileId: "openai:work",
|
||||
});
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
const lease = await leasePromise;
|
||||
const abandon = lease.abandon();
|
||||
await vi.waitFor(() => expect(closeAndWait).toHaveBeenCalledOnce());
|
||||
|
||||
let shutdownSettled = false;
|
||||
const shutdown = clearSharedCodexAppServerClientAndWait({
|
||||
exitTimeoutMs: 25,
|
||||
forceKillDelayMs: 5,
|
||||
}).then(() => {
|
||||
shutdownSettled = true;
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expect(shutdownSettled).toBe(false);
|
||||
|
||||
finishRetirement();
|
||||
await Promise.all([abandon, shutdown]);
|
||||
|
||||
expect(closeAndWait).toHaveBeenCalledTimes(1);
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("abandons only the client owned by the exact lease", async () => {
|
||||
it("waits only for the shared client that is still current", async () => {
|
||||
const first = createClientHarness();
|
||||
const second = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start")
|
||||
@@ -1082,28 +785,33 @@ describe("shared Codex app-server client", () => {
|
||||
const firstCloseAndWait = vi.spyOn(first.client, "closeAndWait");
|
||||
const secondCloseAndWait = vi.spyOn(second.client, "closeAndWait");
|
||||
|
||||
const firstLeasePromise = leaseSharedCodexAppServerClient({
|
||||
const firstList = listCodexAppServerModels({
|
||||
timeoutMs: 1000,
|
||||
agentDir: "/tmp/openclaw-agent-one",
|
||||
});
|
||||
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
|
||||
const firstLease = await firstLeasePromise;
|
||||
await sendEmptyModelList(first);
|
||||
await expect(firstList).resolves.toEqual({ models: [] });
|
||||
|
||||
const secondLeasePromise = leaseSharedCodexAppServerClient({
|
||||
const secondList = listCodexAppServerModels({
|
||||
timeoutMs: 1000,
|
||||
agentDir: "/tmp/openclaw-agent-two",
|
||||
});
|
||||
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
|
||||
const secondLease = await secondLeasePromise;
|
||||
await sendEmptyModelList(second);
|
||||
await expect(secondList).resolves.toEqual({ models: [] });
|
||||
|
||||
await firstLease.abandon();
|
||||
await expect(
|
||||
clearSharedCodexAppServerClientIfCurrentAndWait(first.client, {
|
||||
exitTimeoutMs: 25,
|
||||
forceKillDelayMs: 5,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(firstCloseAndWait).toHaveBeenCalledTimes(1);
|
||||
expect(secondCloseAndWait).not.toHaveBeenCalled();
|
||||
expect(first.process.stdin.destroyed).toBe(true);
|
||||
expect(second.process.stdin.destroyed).toBe(false);
|
||||
|
||||
await secondLease.abandon();
|
||||
});
|
||||
|
||||
it("uses a fresh websocket Authorization header after shared-client token rotation", async () => {
|
||||
@@ -1164,7 +872,7 @@ describe("shared Codex app-server client", () => {
|
||||
|
||||
expect(authHeaders).toEqual(["Bearer tok-first", "Bearer tok-second"]);
|
||||
} finally {
|
||||
resetSharedCodexAppServerClientForTests();
|
||||
clearSharedCodexAppServerClient();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,5 @@
|
||||
// Codex tests cover side question plugin behavior.
|
||||
import {
|
||||
invokeNativeHookRelay,
|
||||
nativeHookRelayTesting,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { nativeHookRelayTesting } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
onInternalDiagnosticEvent,
|
||||
resetDiagnosticEventsForTest,
|
||||
@@ -19,51 +16,30 @@ import type { CodexServerNotification, JsonObject, RpcRequest } from "./protocol
|
||||
const readCodexAppServerBindingMock = vi.fn();
|
||||
const isCodexAppServerNativeAuthProfileMock = vi.fn();
|
||||
const getSharedCodexAppServerClientMock = vi.fn();
|
||||
const releaseSharedCodexAppServerClientMock = vi.fn();
|
||||
const abandonSharedCodexAppServerClientMock = vi.fn(async () => undefined);
|
||||
const refreshCodexAppServerAuthTokensMock = vi.fn();
|
||||
const createOpenClawCodingToolsMock = vi.fn();
|
||||
const toolExecuteMock = vi.fn();
|
||||
const handleCodexAppServerApprovalRequestMock = vi.fn();
|
||||
const configRuntimeMock = vi.hoisted(() => ({ rejectedProvider: undefined as string | undefined }));
|
||||
const resolveCodexProviderWebSearchSupportForClientMock = vi.fn();
|
||||
|
||||
vi.mock("./config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveCodexAppServerRuntime: (
|
||||
params: Parameters<typeof actual.resolveCodexAppServerRuntime>[0],
|
||||
) => {
|
||||
if (
|
||||
configRuntimeMock.rejectedProvider &&
|
||||
params?.modelProvider === configRuntimeMock.rejectedProvider
|
||||
) {
|
||||
throw new Error(`rejected active provider: ${params.modelProvider}`);
|
||||
}
|
||||
return actual.resolveCodexAppServerRuntime(params);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./session-binding.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./session-binding.js")>();
|
||||
return {
|
||||
...actual,
|
||||
isCodexAppServerNativeAuthProfile: (...args: unknown[]) =>
|
||||
isCodexAppServerNativeAuthProfileMock(...args),
|
||||
resolveCodexAppServerBindingModelProvider: (params: { modelProvider?: string }) =>
|
||||
params.modelProvider?.trim() ||
|
||||
(isCodexAppServerNativeAuthProfileMock(params) ? "openai" : undefined),
|
||||
};
|
||||
});
|
||||
vi.mock("./session-binding.js", () => ({
|
||||
clearCodexAppServerBinding: vi.fn(),
|
||||
isCodexAppServerNativeAuthProfile: (...args: unknown[]) =>
|
||||
isCodexAppServerNativeAuthProfileMock(...args),
|
||||
readCodexAppServerBinding: (...args: unknown[]) => readCodexAppServerBindingMock(...args),
|
||||
writeCodexAppServerBinding: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./shared-client.js", () => ({
|
||||
getSharedCodexAppServerClient: (...args: unknown[]) => getSharedCodexAppServerClientMock(...args),
|
||||
leaseSharedCodexAppServerClient: async (...args: unknown[]) => ({
|
||||
client: await getSharedCodexAppServerClientMock(...args),
|
||||
release: releaseSharedCodexAppServerClientMock,
|
||||
abandon: abandonSharedCodexAppServerClientMock,
|
||||
}),
|
||||
getLeasedSharedCodexAppServerClient: (...args: unknown[]) =>
|
||||
getSharedCodexAppServerClientMock(...args),
|
||||
releaseLeasedSharedCodexAppServerClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./auth-bridge.js", () => ({
|
||||
refreshCodexAppServerAuthTokens: (...args: unknown[]) =>
|
||||
refreshCodexAppServerAuthTokensMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./approval-bridge.js", () => ({
|
||||
@@ -80,25 +56,7 @@ vi.mock("openclaw/plugin-sdk/agent-harness", () => ({
|
||||
createOpenClawCodingTools: (...args: unknown[]) => createOpenClawCodingToolsMock(...args),
|
||||
}));
|
||||
|
||||
const { resolveDynamicToolCallTimeoutMs } = await import("./dynamic-tool-execution.js");
|
||||
const { runCodexAppServerSideQuestion: runCodexAppServerSideQuestionImpl } =
|
||||
await import("./side-question.js");
|
||||
type SideQuestionOptions = Parameters<typeof runCodexAppServerSideQuestionImpl>[1];
|
||||
const bindingStore: SideQuestionOptions["bindingStore"] = {
|
||||
read: (...args) => readCodexAppServerBindingMock(...args),
|
||||
mutate: vi.fn(),
|
||||
prepareSessionGenerationReclaim: vi.fn(),
|
||||
adoptSessionGeneration: vi.fn(),
|
||||
retireSessionGeneration: vi.fn(),
|
||||
withLease: (_identity, run) => run(),
|
||||
};
|
||||
|
||||
function runCodexAppServerSideQuestion(
|
||||
params: Parameters<typeof runCodexAppServerSideQuestionImpl>[0],
|
||||
options: Omit<SideQuestionOptions, "bindingStore"> = {},
|
||||
) {
|
||||
return runCodexAppServerSideQuestionImpl(params, { ...options, bindingStore });
|
||||
}
|
||||
const { testing, runCodexAppServerSideQuestion } = await import("./side-question.js");
|
||||
|
||||
type ServerRequest = Required<Pick<RpcRequest, "id" | "method">> & {
|
||||
params?: RpcRequest["params"];
|
||||
@@ -113,7 +71,6 @@ type FakeClient = {
|
||||
request: ReturnType<typeof vi.fn<ClientRequest>>;
|
||||
addNotificationHandler: ReturnType<typeof vi.fn>;
|
||||
addRequestHandler: ReturnType<typeof vi.fn>;
|
||||
addCloseHandler: ReturnType<typeof vi.fn>;
|
||||
notifications: Array<(notification: CodexServerNotification) => void>;
|
||||
requests: Array<(request: ServerRequest) => unknown>;
|
||||
emit: (notification: CodexServerNotification) => void;
|
||||
@@ -145,7 +102,6 @@ function createFakeClient(): FakeClient {
|
||||
}
|
||||
};
|
||||
}),
|
||||
addCloseHandler: vi.fn(() => () => undefined),
|
||||
emit: (notification) => {
|
||||
for (const handler of notifications) {
|
||||
handler(notification);
|
||||
@@ -319,28 +275,6 @@ function agentDelta(threadId: string, turnId: string, delta: string): CodexServe
|
||||
};
|
||||
}
|
||||
|
||||
function agentCompleted(params: {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
itemId: string;
|
||||
text: string;
|
||||
phase?: string;
|
||||
}): CodexServerNotification {
|
||||
return {
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: params.threadId,
|
||||
turnId: params.turnId,
|
||||
item: {
|
||||
id: params.itemId,
|
||||
type: "agentMessage",
|
||||
text: params.text,
|
||||
...(params.phase ? { phase: params.phase } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function turnCompleted(threadId: string, turnId: string, text: string): CodexServerNotification {
|
||||
return {
|
||||
method: "turn/completed",
|
||||
@@ -348,6 +282,7 @@ function turnCompleted(threadId: string, turnId: string, text: string): CodexSer
|
||||
threadId,
|
||||
turn: {
|
||||
id: turnId,
|
||||
threadId,
|
||||
status: "completed",
|
||||
items: [{ id: "agent-1", type: "agentMessage", text }],
|
||||
error: null,
|
||||
@@ -359,11 +294,14 @@ function turnCompleted(threadId: string, turnId: string, text: string): CodexSer
|
||||
};
|
||||
}
|
||||
|
||||
function turnCompletedWithoutItems(threadId: string, turnId: string): CodexServerNotification {
|
||||
const notification = turnCompleted(threadId, turnId, "");
|
||||
const turn = (notification.params as JsonObject).turn as JsonObject;
|
||||
turn.items = [];
|
||||
return notification;
|
||||
function turnCompletedWithNestedThread(
|
||||
threadId: string,
|
||||
turnId: string,
|
||||
text: string,
|
||||
): CodexServerNotification {
|
||||
const notification = turnCompleted(threadId, turnId, text);
|
||||
const turn = (notification.params as JsonObject).turn;
|
||||
return { method: notification.method, params: { threadId: "parent-thread", turn } };
|
||||
}
|
||||
|
||||
function sideParams(overrides: Partial<Parameters<typeof runCodexAppServerSideQuestion>[0]> = {}) {
|
||||
@@ -451,12 +389,12 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
readCodexAppServerBindingMock.mockReset();
|
||||
isCodexAppServerNativeAuthProfileMock.mockReset();
|
||||
getSharedCodexAppServerClientMock.mockReset();
|
||||
releaseSharedCodexAppServerClientMock.mockReset();
|
||||
abandonSharedCodexAppServerClientMock.mockClear();
|
||||
refreshCodexAppServerAuthTokensMock.mockReset();
|
||||
createOpenClawCodingToolsMock.mockReset();
|
||||
toolExecuteMock.mockReset();
|
||||
handleCodexAppServerApprovalRequestMock.mockReset();
|
||||
configRuntimeMock.rejectedProvider = undefined;
|
||||
resolveCodexProviderWebSearchSupportForClientMock.mockReset();
|
||||
resolveCodexProviderWebSearchSupportForClientMock.mockResolvedValue("supported");
|
||||
|
||||
toolExecuteMock.mockResolvedValue({
|
||||
content: [{ type: "text", text: "tool output" }],
|
||||
@@ -485,10 +423,16 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
model: "gpt-5.5",
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
historyCoveredThrough: new Date(0).toISOString(),
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
});
|
||||
isCodexAppServerNativeAuthProfileMock.mockReturnValue(true);
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(createFakeClient());
|
||||
refreshCodexAppServerAuthTokensMock.mockResolvedValue({
|
||||
accessToken: "access-token",
|
||||
chatgptAccountId: "account-1",
|
||||
chatgptPlanType: "plus",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -521,7 +465,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
senderE164: "+15550001",
|
||||
senderIsOwner: true,
|
||||
}),
|
||||
{ pluginConfig: { appServer: { mode: "yolo", approvalsReviewer: "user" } } },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ text: "Side answer." });
|
||||
@@ -535,7 +478,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
"cwd",
|
||||
"developerInstructions",
|
||||
"ephemeral",
|
||||
"excludeTurns",
|
||||
"model",
|
||||
"personality",
|
||||
"sandbox",
|
||||
@@ -548,7 +490,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(forkParams?.approvalPolicy).toBe("on-request");
|
||||
expect(forkParams?.sandbox).toBe("workspace-write");
|
||||
expect(forkParams?.ephemeral).toBe(true);
|
||||
expect(forkParams?.excludeTurns).toBe(true);
|
||||
expect(forkParams?.threadSource).toBe("user");
|
||||
expect(forkParams?.approvalsReviewer).toBe("user");
|
||||
expect(forkParams?.cwd).toBe("/tmp/workspace");
|
||||
@@ -563,10 +504,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(forkParams?.developerInstructions).toContain(
|
||||
"Only instructions submitted after the side-conversation boundary are active.",
|
||||
);
|
||||
expect(forkCall?.[2]).toEqual({
|
||||
timeoutMs: 60_000,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(forkCall?.[2]).toEqual({ timeoutMs: 60_000, signal: undefined });
|
||||
|
||||
const injectCall = mockCall(client.request, 1);
|
||||
expect(injectCall?.[0]).toBe("thread/inject_items");
|
||||
@@ -577,10 +515,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(injectParams?.items).toHaveLength(1);
|
||||
expect(injectParams?.items?.[0]?.type).toBe("message");
|
||||
expect(injectParams?.items?.[0]?.role).toBe("user");
|
||||
expect(injectCall?.[2]).toEqual({
|
||||
timeoutMs: 60_000,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(injectCall?.[2]).toEqual({ timeoutMs: 60_000, signal: undefined });
|
||||
const injectedItem = injectParams?.items?.[0] as
|
||||
| { content?: Array<{ text?: string }> }
|
||||
| undefined;
|
||||
@@ -598,15 +533,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
threadId: "side-thread",
|
||||
input: [{ type: "text", text: "What changed?", text_elements: [] }],
|
||||
cwd: "/tmp/workspace",
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "user",
|
||||
sandboxPolicy: {
|
||||
type: "workspaceWrite",
|
||||
writableRoots: ["/tmp/workspace"],
|
||||
networkAccess: false,
|
||||
excludeTmpdirEnvVar: false,
|
||||
excludeSlashTmp: false,
|
||||
},
|
||||
model: "gpt-5.5",
|
||||
personality: "none",
|
||||
effort: null,
|
||||
@@ -619,8 +545,11 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 60_000, signal: expect.any(AbortSignal) },
|
||||
{ timeoutMs: 60_000, signal: undefined },
|
||||
]);
|
||||
const turnStartParams = turnStartCall?.[1] as Record<string, unknown> | undefined;
|
||||
expect(turnStartParams).not.toHaveProperty("approvalPolicy");
|
||||
expect(turnStartParams).not.toHaveProperty("sandboxPolicy");
|
||||
expect(client.request.mock.calls.at(-1)).toEqual([
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "side-thread" },
|
||||
@@ -656,136 +585,217 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(toolOptions).toHaveProperty("requireExplicitMessageTarget", true);
|
||||
});
|
||||
|
||||
it("serializes streamed deltas before an empty completed turn", async () => {
|
||||
const client = createFakeClient();
|
||||
let releaseAssistantStart!: () => void;
|
||||
const assistantStartPending = new Promise<void>((resolve) => {
|
||||
releaseAssistantStart = resolve;
|
||||
});
|
||||
const onAssistantMessageStart = vi.fn(() => assistantStartPending);
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
if (method === "thread/fork") {
|
||||
return threadResult("side-thread");
|
||||
}
|
||||
if (method === "thread/inject_items") {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
queueMicrotask(() => {
|
||||
client.emit(agentDelta("side-thread", "turn-1", "Side "));
|
||||
client.emit(agentDelta("side-thread", "turn-1", "answer."));
|
||||
client.emit(turnCompletedWithoutItems("side-thread", "turn-1"));
|
||||
});
|
||||
return turnStartResult("turn-1");
|
||||
}
|
||||
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`);
|
||||
});
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
it("disables hosted search when side-question sender policy removes managed web_search", async () => {
|
||||
createOpenClawCodingToolsMock.mockImplementation((options: { senderId?: string }) =>
|
||||
options.senderId === "restricted-sender"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: "web_search",
|
||||
description: "Search the web",
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: toolExecuteMock,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const result = runCodexAppServerSideQuestion(sideParams({ opts: { onAssistantMessageStart } }));
|
||||
await vi.waitFor(() => expect(onAssistantMessageStart).toHaveBeenCalledTimes(1));
|
||||
releaseAssistantStart();
|
||||
const { forkConfig } = await runSideQuestionWithManagedWebSearchCall(
|
||||
sideParams({ senderId: "restricted-sender" }),
|
||||
{ preserveToolFactory: true },
|
||||
);
|
||||
|
||||
await expect(result).resolves.toEqual({ text: "Side answer." });
|
||||
expect(forkConfig).toMatchObject({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the final completed item when the terminal turn omits items", async () => {
|
||||
const client = createFakeClient();
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
if (method === "thread/fork") {
|
||||
return threadResult("side-thread");
|
||||
}
|
||||
if (method === "thread/inject_items") {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
queueMicrotask(() => {
|
||||
client.emit(
|
||||
agentCompleted({
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
itemId: "commentary-1",
|
||||
text: "Still working.",
|
||||
phase: "commentary",
|
||||
}),
|
||||
);
|
||||
client.emit(
|
||||
agentCompleted({
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
itemId: "final-1",
|
||||
text: "Canonical side answer.",
|
||||
phase: "final_answer",
|
||||
}),
|
||||
);
|
||||
client.emit(turnCompletedWithoutItems("side-thread", "turn-1"));
|
||||
});
|
||||
return turnStartResult("turn-1");
|
||||
}
|
||||
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`);
|
||||
});
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
it.each([
|
||||
{ name: "deny all", toolsAllow: [] },
|
||||
{ name: "narrow allowlist", toolsAllow: ["message"] },
|
||||
])("rejects /btw before forking when effective toolsAllow is $name", async ({ toolsAllow }) => {
|
||||
await expect(
|
||||
runCodexAppServerSideQuestion(
|
||||
sideParams({
|
||||
messageChannel: "telegram",
|
||||
messageProvider: "telegram",
|
||||
senderId: "restricted-sender",
|
||||
toolsAllow,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"Codex-native /btw side-question mode is unavailable because the effective tool policy restricts Codex native tools for this session.",
|
||||
);
|
||||
|
||||
await expect(runCodexAppServerSideQuestion(sideParams())).resolves.toEqual({
|
||||
text: "Canonical side answer.",
|
||||
});
|
||||
expect(client.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
expect(getSharedCodexAppServerClientMock).not.toHaveBeenCalled();
|
||||
expect(resolveCodexProviderWebSearchSupportForClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases a pending notification flush when the side question is aborted", async () => {
|
||||
const client = createFakeClient();
|
||||
const abortController = new AbortController();
|
||||
const onAssistantMessageStart = vi.fn(() => new Promise<void>(() => {}));
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
if (method === "thread/fork") {
|
||||
return threadResult("side-thread");
|
||||
}
|
||||
if (method === "thread/inject_items") {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
queueMicrotask(() => client.emit(agentDelta("side-thread", "turn-1", "pending")));
|
||||
return turnStartResult("turn-1");
|
||||
}
|
||||
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`);
|
||||
});
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
const result = runCodexAppServerSideQuestion(
|
||||
it("applies native search restrictions to side forks and suppresses managed search", async () => {
|
||||
const { forkConfig, result, toolResponse } = await runSideQuestionWithManagedWebSearchCall(
|
||||
sideParams({
|
||||
opts: { abortSignal: abortController.signal, onAssistantMessageStart },
|
||||
cfg: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
openaiCodex: {
|
||||
allowedDomains: ["example.com"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(onAssistantMessageStart).toHaveBeenCalledTimes(1));
|
||||
|
||||
abortController.abort();
|
||||
|
||||
await expect(result).rejects.toThrow("Codex /btw was aborted.");
|
||||
expect(result).toEqual({ text: "Search answer." });
|
||||
expect(forkConfig).toMatchObject({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "cached",
|
||||
"tools.web_search.allowed_domains": ["example.com"],
|
||||
});
|
||||
expect(toolResponse).toEqual({
|
||||
success: false,
|
||||
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: web_search" }],
|
||||
});
|
||||
expect(toolExecuteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses one client router after each side question releases its route", async () => {
|
||||
it("preserves managed web_search while planning hosted search for Responses side questions", async () => {
|
||||
createOpenClawCodingToolsMock.mockImplementation(
|
||||
(options: { suppressManagedWebSearch?: boolean }) =>
|
||||
options.suppressManagedWebSearch === false
|
||||
? [
|
||||
{
|
||||
name: "web_search",
|
||||
description: "Search the web",
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: toolExecuteMock,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
const { forkConfig, toolResponse } = await runSideQuestionWithManagedWebSearchCall(
|
||||
sideParams({
|
||||
runtimeModel: {
|
||||
id: "gpt-5.5",
|
||||
provider: "openai",
|
||||
api: "openai-chatgpt-responses",
|
||||
} as never,
|
||||
}),
|
||||
{ preserveToolFactory: true },
|
||||
);
|
||||
|
||||
expect(forkConfig).toMatchObject({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "cached",
|
||||
});
|
||||
expect(toolResponse).toEqual({
|
||||
success: false,
|
||||
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: web_search" }],
|
||||
});
|
||||
expect(toolExecuteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables search for side forks when the configured provider lacks hosted search", async () => {
|
||||
resolveCodexProviderWebSearchSupportForClientMock.mockResolvedValue("unsupported");
|
||||
|
||||
const { forkConfig, result, toolResponse } = await runSideQuestionWithManagedWebSearchCall();
|
||||
|
||||
expect(result).toEqual({ text: "Search answer." });
|
||||
expect(forkConfig).toMatchObject({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
expect(toolResponse).toEqual({
|
||||
success: false,
|
||||
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: web_search" }],
|
||||
});
|
||||
expect(toolExecuteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables search for side forks when a managed provider is selected", async () => {
|
||||
const { forkConfig, result, toolResponse } = await runSideQuestionWithManagedWebSearchCall(
|
||||
sideParams({
|
||||
cfg: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "brave",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ text: "Search answer." });
|
||||
expect(forkConfig).toMatchObject({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
expect(toolResponse).toEqual({
|
||||
success: false,
|
||||
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: web_search" }],
|
||||
});
|
||||
expect(toolExecuteMock).not.toHaveBeenCalled();
|
||||
expect(resolveCodexProviderWebSearchSupportForClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables both search surfaces for side forks when web search is disabled", async () => {
|
||||
const { forkConfig, result, toolResponse } = await runSideQuestionWithManagedWebSearchCall(
|
||||
sideParams({
|
||||
cfg: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ text: "Search answer." });
|
||||
expect(forkConfig).toMatchObject({
|
||||
"features.standalone_web_search": false,
|
||||
web_search: "disabled",
|
||||
});
|
||||
expect(toolResponse).toEqual({
|
||||
success: false,
|
||||
contentItems: [{ type: "inputText", text: "Unknown OpenClaw tool: web_search" }],
|
||||
});
|
||||
expect(toolExecuteMock).not.toHaveBeenCalled();
|
||||
expect(resolveCodexProviderWebSearchSupportForClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns side-thread completions scoped by nested turn thread id", async () => {
|
||||
const client = createFakeClient();
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
if (method === "thread/fork") {
|
||||
return threadResult("side-thread");
|
||||
}
|
||||
if (method === "thread/inject_items") {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
queueMicrotask(() =>
|
||||
client.emit(turnCompletedWithNestedThread("side-thread", "turn-1", "Nested answer.")),
|
||||
);
|
||||
return turnStartResult("turn-1");
|
||||
}
|
||||
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`);
|
||||
});
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
|
||||
await expect(runCodexAppServerSideQuestion(sideParams())).resolves.toEqual({
|
||||
text: "Side answer.",
|
||||
});
|
||||
await expect(runCodexAppServerSideQuestion(sideParams())).resolves.toEqual({
|
||||
text: "Side answer.",
|
||||
});
|
||||
const result = await runCodexAppServerSideQuestion(sideParams());
|
||||
|
||||
expect(client.addNotificationHandler).toHaveBeenCalledTimes(1);
|
||||
expect(client.addRequestHandler).toHaveBeenCalledTimes(1);
|
||||
expect(client.notifications).toHaveLength(1);
|
||||
expect(client.requests).toHaveLength(1);
|
||||
expect(result).toEqual({ text: "Nested answer." });
|
||||
});
|
||||
|
||||
it("rejects /btw before forking when the current OpenClaw session is sandboxed", async () => {
|
||||
@@ -854,12 +864,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
sessionKey: "agent:main:session-1",
|
||||
runId: "run-side-1",
|
||||
channelId: "voice-room",
|
||||
allowedEvents: [
|
||||
"pre_tool_use",
|
||||
"post_tool_use",
|
||||
"permission_request",
|
||||
"before_agent_finalize",
|
||||
],
|
||||
allowedEvents: ["pre_tool_use", "post_tool_use", "before_agent_finalize"],
|
||||
});
|
||||
return threadResult("side-thread");
|
||||
}
|
||||
@@ -898,9 +903,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(config?.["features.hooks"]).toBe(true);
|
||||
expect(config?.["features.code_mode"]).toBe(true);
|
||||
expect(config?.["features.code_mode_only"]).toBe(false);
|
||||
expect(codexHookCommand(config, "hooks.PermissionRequest")?.command).toContain(
|
||||
"--event permission_request",
|
||||
);
|
||||
expect(config?.["hooks.PermissionRequest"]).toEqual([]);
|
||||
const preToolUseHooks = config?.["hooks.PreToolUse"] as
|
||||
| Array<{ hooks?: Array<{ command?: string; timeout?: number; type?: string }> }>
|
||||
| undefined;
|
||||
@@ -915,8 +918,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(preToolUseState?.enabled).toBe(true);
|
||||
expect(preToolUseState?.trusted_hash).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
const permissionRequestState = codexHookStateForEvent(hookState, "permission_request");
|
||||
expect(permissionRequestState?.enabled).toBe(true);
|
||||
expect(permissionRequestState?.trusted_hash).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
expect(permissionRequestState).toEqual({ enabled: false });
|
||||
const turnStartCall = client.request.mock.calls.find(([method]) => method === "turn/start");
|
||||
expect(turnStartCall?.[1]).not.toHaveProperty("config");
|
||||
expect(relayIdDuringFork).toBeDefined();
|
||||
@@ -1058,7 +1060,8 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
model: "gpt-5.5",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "workspace-write",
|
||||
historyCoveredThrough: new Date(0).toISOString(),
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
});
|
||||
const client = createFakeClient();
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
@@ -1160,7 +1163,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
enabled: true,
|
||||
profileName: "side-proxy",
|
||||
domains: { "api.openai.com": "allow" },
|
||||
unixSockets: { "/tmp/proxy.sock": "allow", "/tmp/blocked.sock": "none" },
|
||||
unixSockets: { "/tmp/proxy.sock": "allow" },
|
||||
allowUpstreamProxy: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
},
|
||||
@@ -1184,7 +1187,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
network: {
|
||||
enabled: true,
|
||||
domains: { "api.openai.com": "allow" },
|
||||
unix_sockets: { "/tmp/proxy.sock": "allow", "/tmp/blocked.sock": "deny" },
|
||||
unix_sockets: { "/tmp/proxy.sock": "allow" },
|
||||
allow_upstream_proxy: true,
|
||||
proxy_url: "http://127.0.0.1:3128",
|
||||
},
|
||||
@@ -1193,10 +1196,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
});
|
||||
expect(config?.["features.code_mode"]).toBe(true);
|
||||
expect(config?.["features.code_mode_only"]).toBe(false);
|
||||
const turnStartParams = client.request.mock.calls.find(
|
||||
([method]) => method === "turn/start",
|
||||
)?.[1] as Record<string, unknown> | undefined;
|
||||
expect(turnStartParams).not.toHaveProperty("sandboxPolicy");
|
||||
});
|
||||
|
||||
it("keeps Codex code-mode-only while disabling Guardian for provider-qualified local models", async () => {
|
||||
@@ -1211,7 +1210,8 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
model: "gpt-5.5",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
historyCoveredThrough: new Date(0).toISOString(),
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -1260,7 +1260,8 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
modelProvider: "lmstudio",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
historyCoveredThrough: new Date(0).toISOString(),
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -1291,125 +1292,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(config?.["features.code_mode_only"]).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the provider returned by Codex for side-turn policy", async () => {
|
||||
readCodexAppServerBindingMock.mockResolvedValue({
|
||||
schemaVersion: 1,
|
||||
threadId: "parent-thread",
|
||||
sessionFile: "/tmp/session-1.jsonl",
|
||||
cwd: "/tmp/workspace",
|
||||
authProfileId: "openai:work",
|
||||
model: "gpt-5.5",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
historyCoveredThrough: new Date(0).toISOString(),
|
||||
});
|
||||
const client = createFakeClient();
|
||||
let relayIdDuringFork: string | undefined;
|
||||
let permissionResponse: unknown;
|
||||
const approvalRequester = vi.fn(async () => "allow" as const);
|
||||
nativeHookRelayTesting.setNativeHookRelayPermissionApprovalRequesterForTests(approvalRequester);
|
||||
client.request.mockImplementation(async (method: string, requestParams: unknown) => {
|
||||
if (method === "thread/fork") {
|
||||
relayIdDuringFork = extractRelayIdFromThreadConfig(
|
||||
(requestParams as { config?: Record<string, unknown> }).config,
|
||||
);
|
||||
const response = threadResult("side-thread");
|
||||
return {
|
||||
...response,
|
||||
thread: { ...response.thread, modelProvider: "lmstudio" },
|
||||
model: "local-model",
|
||||
modelProvider: "lmstudio",
|
||||
};
|
||||
}
|
||||
if (method === "thread/inject_items") {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
permissionResponse = await invokeNativeHookRelay({
|
||||
provider: "codex",
|
||||
relayId: relayIdDuringFork,
|
||||
event: "permission_request",
|
||||
rawPayload: {
|
||||
hook_event_name: "PermissionRequest",
|
||||
permission_mode: "default",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "git push" },
|
||||
},
|
||||
});
|
||||
queueMicrotask(() => {
|
||||
client.emit(agentDelta("side-thread", "turn-1", "Side answer."));
|
||||
client.emit(turnCompleted("side-thread", "turn-1", "Side answer."));
|
||||
});
|
||||
return turnStartResult("turn-1");
|
||||
}
|
||||
if (method === "thread/unsubscribe" || method === "turn/interrupt") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`);
|
||||
});
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
|
||||
await expect(
|
||||
runCodexAppServerSideQuestion(sideParams({ provider: "codex", model: "gpt-5.5" }), {
|
||||
pluginConfig: { appServer: { mode: "yolo", approvalsReviewer: "auto_review" } },
|
||||
nativeHookRelay: { enabled: true },
|
||||
}),
|
||||
).resolves.toEqual({ text: "Side answer." });
|
||||
|
||||
const turnStartCall = client.request.mock.calls.find(([method]) => method === "turn/start");
|
||||
const turnStartParams = turnStartCall?.[1] as Record<string, unknown> | undefined;
|
||||
const forkCall = client.request.mock.calls.find(([method]) => method === "thread/fork");
|
||||
const forkParams = forkCall?.[1] as Record<string, unknown> | undefined;
|
||||
expect(forkParams?.approvalPolicy).toBe("never");
|
||||
expect(codexHookCommand(forkParams?.config, "hooks.PermissionRequest")?.command).toContain(
|
||||
"--event permission_request",
|
||||
);
|
||||
expect(turnStartParams).toMatchObject({
|
||||
model: "local-model",
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: "user",
|
||||
sandboxPolicy: {
|
||||
type: "workspaceWrite",
|
||||
writableRoots: ["/tmp/workspace"],
|
||||
networkAccess: false,
|
||||
},
|
||||
});
|
||||
expect(permissionResponse).toEqual({ stdout: "", stderr: "", exitCode: 0 });
|
||||
expect(approvalRequester).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unsubscribes a fork when authoritative provider policy rejects it", async () => {
|
||||
const client = createFakeClient();
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
if (method === "thread/fork") {
|
||||
const response = threadResult("side-thread");
|
||||
return {
|
||||
...response,
|
||||
thread: { ...response.thread, modelProvider: "lmstudio" },
|
||||
model: "local-model",
|
||||
modelProvider: "lmstudio",
|
||||
};
|
||||
}
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`);
|
||||
});
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
configRuntimeMock.rejectedProvider = "lmstudio";
|
||||
|
||||
await expect(runCodexAppServerSideQuestion(sideParams())).rejects.toThrow(
|
||||
"rejected active provider: lmstudio",
|
||||
);
|
||||
|
||||
expect(client.request).toHaveBeenCalledWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "side-thread" },
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
expect(releaseSharedCodexAppServerClientMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses bound local providers for side-thread model ids that contain slashes", async () => {
|
||||
const client = createFakeClient();
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
@@ -1423,7 +1305,8 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
modelProvider: "lmstudio",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
historyCoveredThrough: new Date(0).toISOString(),
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -1461,7 +1344,8 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
modelProvider: "lmstudio",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
historyCoveredThrough: new Date(0).toISOString(),
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -1483,7 +1367,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
|
||||
const forkParams = mockCall(client.request)[1] as Record<string, unknown> | undefined;
|
||||
expect(forkParams?.model).toBe("gpt-5.5");
|
||||
expect(forkParams?.modelProvider).toBe("openai");
|
||||
expect(forkParams).not.toHaveProperty("modelProvider");
|
||||
expect(forkParams?.approvalsReviewer).toBe("auto_review");
|
||||
});
|
||||
|
||||
@@ -1501,7 +1385,8 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
modelProvider: "lmstudio",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
historyCoveredThrough: new Date(0).toISOString(),
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -1523,7 +1408,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
const forkParams = mockCall(client.request)[1] as Record<string, unknown> | undefined;
|
||||
expect(forkParams?.model).toBe("gpt-5.5");
|
||||
expect(forkParams).not.toHaveProperty("modelProvider");
|
||||
expect(forkParams?.approvalsReviewer).toBe("user");
|
||||
expect(forkParams?.approvalsReviewer).toBe("auto_review");
|
||||
});
|
||||
|
||||
it("keeps native hook relays alive across side-thread startup and completion timeouts", async () => {
|
||||
@@ -1847,7 +1732,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
});
|
||||
|
||||
it("uses configured image generation timeout for side-thread image_generate calls", () => {
|
||||
const timeoutMs = resolveDynamicToolCallTimeoutMs({
|
||||
const timeoutMs = testing.resolveSideDynamicToolCallTimeoutMs({
|
||||
call: {
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
@@ -1869,7 +1754,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
});
|
||||
|
||||
it("uses a 120 second default for side-thread image_generate calls", () => {
|
||||
const timeoutMs = resolveDynamicToolCallTimeoutMs({
|
||||
const timeoutMs = testing.resolveSideDynamicToolCallTimeoutMs({
|
||||
call: {
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
@@ -1883,7 +1768,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
});
|
||||
|
||||
it("uses a 90 second default for generic side-thread dynamic tool calls", () => {
|
||||
const timeoutMs = resolveDynamicToolCallTimeoutMs({
|
||||
const timeoutMs = testing.resolveSideDynamicToolCallTimeoutMs({
|
||||
call: {
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
@@ -1897,7 +1782,7 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
expect(timeoutMs).toBe(90_000);
|
||||
});
|
||||
|
||||
it("does not add a turn route when side tool setup fails", async () => {
|
||||
it("cleans up notification handlers when side tool setup fails", async () => {
|
||||
const client = createFakeClient();
|
||||
createOpenClawCodingToolsMock.mockImplementation(() => {
|
||||
throw new Error("tool setup failed");
|
||||
@@ -1906,8 +1791,38 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
|
||||
await expect(runCodexAppServerSideQuestion(sideParams())).rejects.toThrow("tool setup failed");
|
||||
|
||||
expect(client.notifications).toHaveLength(1);
|
||||
expect(client.requests).toHaveLength(1);
|
||||
expect(client.notifications).toHaveLength(0);
|
||||
expect(client.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses the app-server auth refresh request handler while the side thread is active", async () => {
|
||||
const client = createFakeClient();
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
if (method === "thread/fork") {
|
||||
await client.requests[0]?.({
|
||||
id: 1,
|
||||
method: "account/chatgptAuthTokens/refresh",
|
||||
});
|
||||
return threadResult("side-thread");
|
||||
}
|
||||
if (method === "thread/inject_items") {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
queueMicrotask(() => client.emit(turnCompleted("side-thread", "turn-1", "Done.")));
|
||||
return turnStartResult("turn-1");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
|
||||
await runCodexAppServerSideQuestion(sideParams());
|
||||
|
||||
expect(refreshCodexAppServerAuthTokensMock).toHaveBeenCalledWith({
|
||||
agentDir: "/tmp/agent",
|
||||
authProfileId: "openai:work",
|
||||
config: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a clear setup error when there is no Codex parent thread", async () => {
|
||||
@@ -1932,23 +1847,6 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
await expect(runCodexAppServerSideQuestion(sideParams())).rejects.toThrow(
|
||||
"Codex /btw needs an active Codex thread. Send a normal message first, then try /btw again.",
|
||||
);
|
||||
expect(abandonSharedCodexAppServerClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves late fork cleanup to the client when the response times out", async () => {
|
||||
const client = createFakeClient();
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
if (method === "thread/fork") {
|
||||
throw new Error("thread/fork timed out");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
|
||||
await expect(runCodexAppServerSideQuestion(sideParams())).rejects.toThrow(
|
||||
"thread/fork timed out",
|
||||
);
|
||||
expect(abandonSharedCodexAppServerClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("interrupts and unsubscribes the ephemeral thread on abort", async () => {
|
||||
@@ -1986,39 +1884,4 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
[["thread/unsubscribe", { threadId: "side-thread" }, { timeoutMs: 60_000 }]],
|
||||
);
|
||||
});
|
||||
|
||||
it("retires the client when side-thread interruption is not confirmed", async () => {
|
||||
const controller = new AbortController();
|
||||
const client = createFakeClient();
|
||||
client.request.mockImplementation(async (method: string) => {
|
||||
if (method === "thread/fork") {
|
||||
return threadResult("side-thread");
|
||||
}
|
||||
if (method === "thread/inject_items") {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
queueMicrotask(() => controller.abort());
|
||||
return turnStartResult("turn-1");
|
||||
}
|
||||
if (method === "turn/interrupt") {
|
||||
throw new Error("interrupt timeout");
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`);
|
||||
});
|
||||
getSharedCodexAppServerClientMock.mockResolvedValue(client);
|
||||
|
||||
await expect(
|
||||
runCodexAppServerSideQuestion(
|
||||
sideParams({
|
||||
opts: { abortSignal: controller.signal },
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("Codex /btw was aborted.");
|
||||
expect(abandonSharedCodexAppServerClientMock).toHaveBeenCalledOnce();
|
||||
expect(releaseSharedCodexAppServerClientMock).not.toHaveBeenCalled();
|
||||
expect(client.request.mock.calls.some(([method]) => method === "thread/unsubscribe")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,67 +1,563 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CodexAppServerThreadBinding } from "./session-binding.js";
|
||||
import { shouldRotateCodexAppServerStartupBinding, testing } from "./startup-binding.js";
|
||||
|
||||
function binding(
|
||||
currentTokens?: number,
|
||||
modelContextWindow = 100_000,
|
||||
): CodexAppServerThreadBinding {
|
||||
return {
|
||||
threadId: "thread-1",
|
||||
cwd: "/workspace",
|
||||
...(currentTokens === undefined
|
||||
? {}
|
||||
: { nativeContextUsage: { currentTokens }, modelContextWindow }),
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRotate(
|
||||
current: CodexAppServerThreadBinding,
|
||||
overrides: Partial<Parameters<typeof shouldRotateCodexAppServerStartupBinding>[0]> = {},
|
||||
) {
|
||||
return shouldRotateCodexAppServerStartupBinding({
|
||||
binding: current,
|
||||
config: undefined,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
// Codex tests cover startup binding plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import { rotateOversizedCodexAppServerStartupBinding } from "./startup-binding.js";
|
||||
|
||||
describe("Codex app-server startup binding", () => {
|
||||
it("rotates at the last terminal native token fuse", () => {
|
||||
expect(shouldRotate(binding(80_000))).toBe(true);
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-startup-binding-"));
|
||||
});
|
||||
|
||||
it("reserves room for the projected turn", () => {
|
||||
expect(shouldRotate(binding(70_000), { projectedTurnTokens: 10_000 })).toBe(true);
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("uses the smaller prepared model and agent context windows", () => {
|
||||
expect(shouldRotate(binding(60_000, 200_000), { contextWindowTokens: 75_000 })).toBe(true);
|
||||
});
|
||||
async function writeExistingBinding(
|
||||
sessionFile: string,
|
||||
workspaceDir: string,
|
||||
overrides: Partial<Parameters<typeof writeCodexAppServerBinding>[1]> = {},
|
||||
) {
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps a thread without a terminal usage snapshot", () => {
|
||||
expect(shouldRotate(binding())).toBe(false);
|
||||
});
|
||||
async function writeSessionRecord(sessionFile: string, record: Record<string, unknown>) {
|
||||
await fs.mkdir(path.dirname(sessionFile), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(path.dirname(sessionFile), "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:main:session-1": {
|
||||
sessionFile,
|
||||
...record,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it("keeps a thread below the fuse", () => {
|
||||
expect(shouldRotate(binding(79_999))).toBe(false);
|
||||
});
|
||||
it("does not use a default byte limit when maxActiveTranscriptBytes is unset", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-existing.jsonl"),
|
||||
"x".repeat(2_000_000),
|
||||
);
|
||||
|
||||
it("honors configured reserve tokens and their floor", () => {
|
||||
expect(
|
||||
testing.resolveNativeThreadReserveTokens({
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: { reserveTokens: 5_000, reserveTokensFloor: 12_000 },
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never),
|
||||
).toBe(12_000);
|
||||
expect(
|
||||
testing.resolveNativeThreadTokenFuse({
|
||||
modelContextWindow: 100_000,
|
||||
reserveTokens: 12_000,
|
||||
}),
|
||||
).toBe(88_000);
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding?.threadId).toBe("thread-existing");
|
||||
});
|
||||
|
||||
it("reuses the session record cache while sessions.json is unchanged", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const sessionsJson = path.join(path.dirname(sessionFile), "sessions.json");
|
||||
const readFileSpy = vi.spyOn(fs, "readFile");
|
||||
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: undefined,
|
||||
});
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
}
|
||||
|
||||
const sessionStoreReads = readFileSpy.mock.calls.filter(
|
||||
([file]) => typeof file === "string" && file === sessionsJson,
|
||||
);
|
||||
expect(sessionStoreReads).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("checks native rollout token pressure under default compaction config", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-existing.jsonl"),
|
||||
`${JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
last_token_usage: {
|
||||
total_tokens: 241_198,
|
||||
},
|
||||
model_context_window: 258_400,
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: undefined,
|
||||
});
|
||||
|
||||
expect(binding).toBeUndefined();
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("caps the default native reserve so small context windows keep prompt budget", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 100 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-existing.jsonl"),
|
||||
`${JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
last_token_usage: {
|
||||
total_tokens: 100,
|
||||
},
|
||||
model_context_window: 16_000,
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: undefined,
|
||||
});
|
||||
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding?.threadId).toBe("thread-existing");
|
||||
});
|
||||
|
||||
it("honors shorthand byte units for native rollout limits", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(path.join(rolloutDir, "rollout-thread-existing.jsonl"), "x".repeat(2_000));
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: "1k",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(binding).toBeUndefined();
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("honors custom Codex home rollout files for native rollout limits", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
const codexHome = path.join(tempDir, "custom-codex-home");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(codexHome, "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(path.join(rolloutDir, "rollout-thread-existing.jsonl"), "x".repeat(2_000));
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
codexHome,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: 1_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(binding).toBeUndefined();
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses current rollout token usage before cumulative usage", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-existing.jsonl"),
|
||||
`${JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
total_token_usage: {
|
||||
total_tokens: 300_000,
|
||||
},
|
||||
last_token_usage: {
|
||||
total_tokens: 12_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: "1mb",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding?.threadId).toBe("thread-existing");
|
||||
});
|
||||
|
||||
it("ignores stale session token totals for native rollout rotation", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, {
|
||||
totalTokens: 300_000,
|
||||
totalTokensFresh: false,
|
||||
});
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-existing.jsonl"),
|
||||
`${JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
last_token_usage: {
|
||||
total_tokens: 12_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: "1mb",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding?.threadId).toBe("thread-existing");
|
||||
});
|
||||
|
||||
it("clears native rollouts at Codex's reported model context window", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
const rolloutFile = path.join(rolloutDir, "rollout-thread-existing.jsonl");
|
||||
await fs.writeFile(
|
||||
rolloutFile,
|
||||
[
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
last_token_usage: {
|
||||
total_tokens: 128_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
model_context_window: 128_000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
);
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: "1mb",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(binding).toBeUndefined();
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps native rollouts above the old guard when Codex still has context window headroom", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-existing.jsonl"),
|
||||
`${JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
last_token_usage: {
|
||||
total_tokens: 86_000,
|
||||
},
|
||||
model_context_window: 272_000,
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: "1mb",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding?.threadId).toBe("thread-existing");
|
||||
});
|
||||
|
||||
it("includes projected turn tokens in the native rollout pressure check", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-existing.jsonl"),
|
||||
`${JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
last_token_usage: {
|
||||
total_tokens: 220_000,
|
||||
},
|
||||
model_context_window: 258_400,
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: undefined,
|
||||
projectedTurnTokens: 30_000,
|
||||
});
|
||||
|
||||
expect(binding).toBeUndefined();
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the session context window when the native rollout omits its model window", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000, contextTokens: 258_400 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(rolloutDir, "rollout-thread-existing.jsonl"),
|
||||
`${JSON.stringify({
|
||||
payload: {
|
||||
type: "token_count",
|
||||
info: {
|
||||
last_token_usage: {
|
||||
total_tokens: 241_198,
|
||||
},
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: undefined,
|
||||
});
|
||||
|
||||
expect(binding).toBeUndefined();
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears byte-oversized rollouts before reading their contents", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
const rolloutFile = path.join(rolloutDir, "rollout-thread-existing.jsonl");
|
||||
await fs.writeFile(rolloutFile, "x".repeat(2_000));
|
||||
const openSpy = vi.spyOn(fs, "open");
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: 1_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(binding).toBeUndefined();
|
||||
expect(openSpy.mock.calls.some(([file]) => String(file) === rolloutFile)).toBe(false);
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears native rollouts at the configured byte limit", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
await fs.writeFile(path.join(rolloutDir, "rollout-thread-existing.jsonl"), "x".repeat(1_000));
|
||||
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: 1_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(binding).toBeUndefined();
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(savedBinding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,35 +1,258 @@
|
||||
/** Decides whether a terminal Codex usage snapshot leaves room for the next turn. */
|
||||
/**
|
||||
* Guards Codex app-server thread reuse during startup by rotating bindings when
|
||||
* native transcripts exceed byte or token budgets.
|
||||
*/
|
||||
import type { Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
embeddedAgentLog,
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { isJsonObject } from "./protocol.js";
|
||||
import type { CodexAppServerThreadBinding } from "./session-binding.js";
|
||||
import { resolveCodexAppServerHomeDir } from "./auth-bridge.js";
|
||||
import { isJsonObject, type JsonValue } from "./protocol.js";
|
||||
import { clearCodexAppServerBinding, type CodexAppServerThreadBinding } from "./session-binding.js";
|
||||
|
||||
const DEFAULT_NATIVE_THREAD_MAX_TOKENS = 300_000;
|
||||
const DEFAULT_NATIVE_THREAD_RESERVE_TOKENS = 20_000;
|
||||
const MIN_PROMPT_BUDGET_TOKENS = 8_000;
|
||||
const MIN_PROMPT_BUDGET_RATIO = 0.5;
|
||||
const PROJECTED_CHARS_PER_TOKEN = 4;
|
||||
|
||||
export type CodexAppServerStartupTokenGuard = {
|
||||
contextWindowTokens?: number;
|
||||
projectedTurnTokens?: number;
|
||||
// Codex owns proactive auto-compaction, but OpenClaw must not resume a native
|
||||
// thread that is already too close to the server-side window for the next turn.
|
||||
const CODEX_APP_SERVER_NATIVE_THREAD_FALLBACK_MAX_TOKENS = 300_000;
|
||||
const CODEX_APP_SERVER_NATIVE_THREAD_DEFAULT_RESERVE_TOKENS = 20_000;
|
||||
const CODEX_APP_SERVER_NATIVE_THREAD_MIN_PROMPT_BUDGET_TOKENS = 8_000;
|
||||
const CODEX_APP_SERVER_NATIVE_THREAD_MIN_PROMPT_BUDGET_RATIO = 0.5;
|
||||
const CODEX_APP_SERVER_BYTE_UNITS: Record<string, number> = {
|
||||
b: 1,
|
||||
k: 1024,
|
||||
kb: 1024,
|
||||
kib: 1024,
|
||||
m: 1024 * 1024,
|
||||
mb: 1024 * 1024,
|
||||
mib: 1024 * 1024,
|
||||
g: 1024 * 1024 * 1024,
|
||||
gb: 1024 * 1024 * 1024,
|
||||
gib: 1024 * 1024 * 1024,
|
||||
t: 1024 * 1024 * 1024 * 1024,
|
||||
tb: 1024 * 1024 * 1024 * 1024,
|
||||
tib: 1024 * 1024 * 1024 * 1024,
|
||||
};
|
||||
type CodexSessionRecordCacheEntry = {
|
||||
sessionsFile: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
record: (Record<string, unknown> & { sessionKey: string }) | undefined;
|
||||
};
|
||||
|
||||
/** Conservative prompt-size estimate used by both harness and bound turns. */
|
||||
export function estimateCodexAppServerProjectedTurnTokens(params: {
|
||||
prompt: string;
|
||||
developerInstructions?: string;
|
||||
}): number {
|
||||
const inputChars = params.prompt.length + (params.developerInstructions?.length ?? 0);
|
||||
return Math.max(1, Math.ceil(inputChars / PROJECTED_CHARS_PER_TOKEN));
|
||||
const codexSessionRecordCache = new Map<string, CodexSessionRecordCacheEntry>();
|
||||
|
||||
function parseCodexAppServerByteLimit(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*([a-z]+)?$/i);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const amount = Number(match[1]);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const unit = (match[2] ?? "b").toLowerCase();
|
||||
const multiplier = CODEX_APP_SERVER_BYTE_UNITS[unit];
|
||||
if (multiplier === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.max(1, Math.round(amount * multiplier));
|
||||
}
|
||||
|
||||
async function listCodexAppServerRolloutFilesForThread(
|
||||
agentDir: string,
|
||||
threadId: string,
|
||||
codexHome?: string,
|
||||
): Promise<Array<{ path: string; bytes: number }>> {
|
||||
const resolvedAgentDir = path.resolve(agentDir);
|
||||
const resolvedCodexHome = codexHome?.trim()
|
||||
? path.resolve(codexHome)
|
||||
: resolveCodexAppServerHomeDir(resolvedAgentDir);
|
||||
const roots = [
|
||||
path.join(resolvedCodexHome, "sessions"),
|
||||
path.join(resolveCodexAppServerHomeDir(resolvedAgentDir), "sessions"),
|
||||
path.join(resolvedAgentDir, "agent", "codex-home", "sessions"),
|
||||
path.join(path.dirname(resolvedAgentDir), "codex-home", "sessions"),
|
||||
];
|
||||
const files: Array<{ path: string; bytes: number }> = [];
|
||||
const visited = new Set<string>();
|
||||
for (const root of roots) {
|
||||
if (visited.has(root)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(root);
|
||||
const stack = [root];
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop();
|
||||
if (!dir) {
|
||||
continue;
|
||||
}
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const file = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(file);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith(".jsonl") || !entry.name.includes(threadId)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
files.push({ path: file, bytes: (await fs.stat(file)).size });
|
||||
} catch {
|
||||
// Ignore rollout files that disappeared while the guard was scanning.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function readCodexSessionRecordForSessionFile(
|
||||
sessionFile: string,
|
||||
): Promise<(Record<string, unknown> & { sessionKey: string }) | undefined> {
|
||||
const sessionsFile = path.join(path.dirname(sessionFile), "sessions.json");
|
||||
const resolvedSessionFile = path.resolve(sessionFile);
|
||||
let stat: Awaited<ReturnType<typeof fs.stat>>;
|
||||
try {
|
||||
stat = await fs.stat(sessionsFile);
|
||||
} catch {
|
||||
codexSessionRecordCache.delete(resolvedSessionFile);
|
||||
return undefined;
|
||||
}
|
||||
const cached = codexSessionRecordCache.get(resolvedSessionFile);
|
||||
if (
|
||||
cached?.sessionsFile === sessionsFile &&
|
||||
cached.mtimeMs === stat.mtimeMs &&
|
||||
cached.size === stat.size
|
||||
) {
|
||||
return cached.record;
|
||||
}
|
||||
let store: JsonValue | undefined;
|
||||
try {
|
||||
store = JSON.parse(await fs.readFile(sessionsFile, "utf8")) as JsonValue;
|
||||
} catch {
|
||||
codexSessionRecordCache.delete(resolvedSessionFile);
|
||||
return undefined;
|
||||
}
|
||||
if (!isJsonObject(store)) {
|
||||
codexSessionRecordCache.delete(resolvedSessionFile);
|
||||
return undefined;
|
||||
}
|
||||
let found: (Record<string, unknown> & { sessionKey: string }) | undefined;
|
||||
for (const [sessionKey, record] of Object.entries(store)) {
|
||||
if (!isJsonObject(record) || typeof record.sessionFile !== "string") {
|
||||
continue;
|
||||
}
|
||||
if (path.resolve(record.sessionFile) !== resolvedSessionFile) {
|
||||
continue;
|
||||
}
|
||||
found = { sessionKey, ...record };
|
||||
break;
|
||||
}
|
||||
codexSessionRecordCache.set(resolvedSessionFile, {
|
||||
sessionsFile,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
record: found,
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
type CodexAppServerRolloutTokenSnapshot = {
|
||||
totalTokens?: number;
|
||||
modelContextWindow?: number;
|
||||
};
|
||||
|
||||
async function readCodexAppServerRolloutTokenSnapshot(
|
||||
file: string,
|
||||
): Promise<CodexAppServerRolloutTokenSnapshot | undefined> {
|
||||
let handle: Awaited<ReturnType<typeof fs.open>>;
|
||||
try {
|
||||
handle = await fs.open(file, "r");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
let snapshot: CodexAppServerRolloutTokenSnapshot | undefined;
|
||||
try {
|
||||
for await (const line of handle.readLines()) {
|
||||
const lineSnapshot = readCodexAppServerRolloutTokenSnapshotLine(line);
|
||||
if (lineSnapshot !== undefined) {
|
||||
snapshot ??= {};
|
||||
if (lineSnapshot.totalTokens !== undefined) {
|
||||
snapshot.totalTokens = lineSnapshot.totalTokens;
|
||||
}
|
||||
if (lineSnapshot.modelContextWindow !== undefined) {
|
||||
snapshot.modelContextWindow = lineSnapshot.modelContextWindow;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function readCodexAppServerRolloutTokenSnapshotLine(
|
||||
line: string,
|
||||
): CodexAppServerRolloutTokenSnapshot | undefined {
|
||||
if (!line.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line) as JsonValue;
|
||||
const payload = isJsonObject(parsed) ? parsed.payload : undefined;
|
||||
const info =
|
||||
isJsonObject(payload) && payload.type === "token_count" && isJsonObject(payload.info)
|
||||
? payload.info
|
||||
: undefined;
|
||||
if (!info) {
|
||||
return undefined;
|
||||
}
|
||||
const usage = isJsonObject(info.last_token_usage)
|
||||
? info.last_token_usage
|
||||
: isJsonObject(info.total_token_usage)
|
||||
? info.total_token_usage
|
||||
: undefined;
|
||||
const value = usage?.total_tokens ?? usage?.totalTokens;
|
||||
const totalTokens = typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
const windowValue = info.model_context_window ?? info.modelContextWindow;
|
||||
const modelContextWindow =
|
||||
typeof windowValue === "number" && Number.isFinite(windowValue) && windowValue > 0
|
||||
? Math.floor(windowValue)
|
||||
: undefined;
|
||||
const snapshot: CodexAppServerRolloutTokenSnapshot = {};
|
||||
if (totalTokens !== undefined) {
|
||||
snapshot.totalTokens = totalTokens;
|
||||
}
|
||||
if (modelContextWindow !== undefined) {
|
||||
snapshot.modelContextWindow = modelContextWindow;
|
||||
}
|
||||
return snapshot.totalTokens !== undefined || snapshot.modelContextWindow !== undefined
|
||||
? snapshot
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function toNonNegativeInt(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
||||
? Math.floor(value)
|
||||
: undefined;
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function readCompactionConfig(config: EmbeddedRunAttemptParams["config"] | undefined) {
|
||||
@@ -38,28 +261,37 @@ function readCompactionConfig(config: EmbeddedRunAttemptParams["config"] | undef
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveNativeThreadReserveTokens(
|
||||
function resolveCodexAppServerNativeThreadReserveTokens(
|
||||
config: EmbeddedRunAttemptParams["config"] | undefined,
|
||||
): number {
|
||||
const compaction = readCompactionConfig(config);
|
||||
const reserveTokens = toNonNegativeInt(compaction?.reserveTokens);
|
||||
const reserveTokensFloor = toNonNegativeInt(compaction?.reserveTokensFloor);
|
||||
if (reserveTokens !== undefined) {
|
||||
return Math.max(reserveTokens, reserveTokensFloor ?? DEFAULT_NATIVE_THREAD_RESERVE_TOKENS);
|
||||
return Math.max(
|
||||
reserveTokens,
|
||||
reserveTokensFloor ?? CODEX_APP_SERVER_NATIVE_THREAD_DEFAULT_RESERVE_TOKENS,
|
||||
);
|
||||
}
|
||||
return reserveTokensFloor ?? DEFAULT_NATIVE_THREAD_RESERVE_TOKENS;
|
||||
return reserveTokensFloor ?? CODEX_APP_SERVER_NATIVE_THREAD_DEFAULT_RESERVE_TOKENS;
|
||||
}
|
||||
|
||||
function resolveNativeThreadTokenFuse(params: {
|
||||
modelContextWindow?: number;
|
||||
function resolveCodexAppServerNativeThreadTokenFuse(params: {
|
||||
modelContextWindow: number | undefined;
|
||||
reserveTokens: number;
|
||||
projectedTurnTokens?: number;
|
||||
}): number {
|
||||
const projectedTurnTokens = toNonNegativeInt(params.projectedTurnTokens) ?? 0;
|
||||
const contextWindow = params.modelContextWindow ?? DEFAULT_NATIVE_THREAD_MAX_TOKENS;
|
||||
const projectedTurnTokens =
|
||||
typeof params.projectedTurnTokens === "number" &&
|
||||
Number.isFinite(params.projectedTurnTokens) &&
|
||||
params.projectedTurnTokens > 0
|
||||
? Math.floor(params.projectedTurnTokens)
|
||||
: 0;
|
||||
const contextWindow =
|
||||
params.modelContextWindow ?? CODEX_APP_SERVER_NATIVE_THREAD_FALLBACK_MAX_TOKENS;
|
||||
const minPromptBudget = Math.min(
|
||||
MIN_PROMPT_BUDGET_TOKENS,
|
||||
Math.max(1, Math.floor(contextWindow * MIN_PROMPT_BUDGET_RATIO)),
|
||||
CODEX_APP_SERVER_NATIVE_THREAD_MIN_PROMPT_BUDGET_TOKENS,
|
||||
Math.max(1, Math.floor(contextWindow * CODEX_APP_SERVER_NATIVE_THREAD_MIN_PROMPT_BUDGET_RATIO)),
|
||||
);
|
||||
const effectiveReserveTokens = Math.min(
|
||||
params.reserveTokens,
|
||||
@@ -68,50 +300,141 @@ function resolveNativeThreadTokenFuse(params: {
|
||||
return Math.max(1, contextWindow - effectiveReserveTokens - projectedTurnTokens);
|
||||
}
|
||||
|
||||
function minPositive(values: Array<number | undefined>): number | undefined {
|
||||
const present = values.filter(
|
||||
(value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0,
|
||||
function maxFiniteNumber(values: Array<number | undefined>): number | undefined {
|
||||
const nums = values.filter(
|
||||
(value): value is number => typeof value === "number" && Number.isFinite(value),
|
||||
);
|
||||
return present.length > 0 ? Math.min(...present) : undefined;
|
||||
if (nums.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.max(...nums);
|
||||
}
|
||||
|
||||
/** Returns true when the last terminal usage snapshot leaves too little turn headroom. */
|
||||
export function shouldRotateCodexAppServerStartupBinding(params: {
|
||||
binding: CodexAppServerThreadBinding | undefined;
|
||||
config: EmbeddedRunAttemptParams["config"] | undefined;
|
||||
contextWindowTokens?: number;
|
||||
projectedTurnTokens?: number;
|
||||
}): boolean {
|
||||
const binding = params.binding;
|
||||
const currentTokens = binding?.nativeContextUsage?.currentTokens;
|
||||
if (!binding?.threadId || currentTokens === undefined) {
|
||||
return false;
|
||||
function minFiniteNumber(values: Array<number | undefined>): number | undefined {
|
||||
const nums = values.filter(
|
||||
(value): value is number => typeof value === "number" && Number.isFinite(value),
|
||||
);
|
||||
if (nums.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const modelContextWindow = minPositive([binding.modelContextWindow, params.contextWindowTokens]);
|
||||
const reserveTokens = resolveNativeThreadReserveTokens(params.config);
|
||||
const maxTokens = resolveNativeThreadTokenFuse({
|
||||
modelContextWindow,
|
||||
return Math.min(...nums);
|
||||
}
|
||||
|
||||
function hasContextEngineThreadBootstrapProjection(binding: CodexAppServerThreadBinding): boolean {
|
||||
return binding.contextEngine?.projection?.mode === "thread_bootstrap";
|
||||
}
|
||||
|
||||
/** Clears and drops a binding when the native Codex thread is too large to resume safely. */
|
||||
export async function rotateOversizedCodexAppServerStartupBinding(params: {
|
||||
binding: CodexAppServerThreadBinding | undefined;
|
||||
sessionFile: string;
|
||||
agentDir: string;
|
||||
codexHome?: string;
|
||||
config: EmbeddedRunAttemptParams["config"] | undefined;
|
||||
contextEngineActive?: boolean;
|
||||
projectedTurnTokens?: number;
|
||||
}): Promise<CodexAppServerThreadBinding | undefined> {
|
||||
const binding = params.binding;
|
||||
if (!binding?.threadId) {
|
||||
return binding;
|
||||
}
|
||||
const sessionRecord = await readCodexSessionRecordForSessionFile(params.sessionFile);
|
||||
const rolloutFiles = await listCodexAppServerRolloutFilesForThread(
|
||||
params.agentDir,
|
||||
binding.threadId,
|
||||
params.codexHome,
|
||||
);
|
||||
const compaction = readCompactionConfig(params.config);
|
||||
const shouldDeferByteGuard =
|
||||
compaction?.truncateAfterCompaction === true &&
|
||||
params.contextEngineActive === true &&
|
||||
hasContextEngineThreadBootstrapProjection(binding);
|
||||
if (compaction?.truncateAfterCompaction === true && !shouldDeferByteGuard) {
|
||||
const maxBytes = parseCodexAppServerByteLimit(compaction.maxActiveTranscriptBytes);
|
||||
if (maxBytes !== undefined) {
|
||||
const oversizedFiles = rolloutFiles.filter((file) => file.bytes >= maxBytes);
|
||||
if (oversizedFiles.length > 0) {
|
||||
embeddedAgentLog.warn(
|
||||
"codex app-server native transcript exceeded active byte limit; starting a fresh thread",
|
||||
{
|
||||
threadId: binding.threadId,
|
||||
maxBytes,
|
||||
files: oversizedFiles.map((file) => ({ path: file.path, bytes: file.bytes })),
|
||||
},
|
||||
);
|
||||
await clearCodexAppServerBinding(params.sessionFile);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
const nativeTokenSnapshots = await Promise.all(
|
||||
rolloutFiles.map(async (file) => readCodexAppServerRolloutTokenSnapshot(file.path)),
|
||||
);
|
||||
const nativeTokens = maxFiniteNumber(
|
||||
nativeTokenSnapshots.map((snapshot) => snapshot?.totalTokens),
|
||||
);
|
||||
const nativeModelContextWindow = maxFiniteNumber(
|
||||
nativeTokenSnapshots.map((snapshot) => snapshot?.modelContextWindow),
|
||||
);
|
||||
const sessionModelContextWindow =
|
||||
typeof sessionRecord?.contextTokens === "number" &&
|
||||
Number.isFinite(sessionRecord.contextTokens) &&
|
||||
sessionRecord.contextTokens > 0
|
||||
? Math.floor(sessionRecord.contextTokens)
|
||||
: undefined;
|
||||
const reserveTokens = resolveCodexAppServerNativeThreadReserveTokens(params.config);
|
||||
const maxTokens = resolveCodexAppServerNativeThreadTokenFuse({
|
||||
modelContextWindow: minFiniteNumber([nativeModelContextWindow, sessionModelContextWindow]),
|
||||
reserveTokens,
|
||||
projectedTurnTokens: params.projectedTurnTokens,
|
||||
});
|
||||
if (currentTokens < maxTokens) {
|
||||
return false;
|
||||
const sessionTokens =
|
||||
sessionRecord?.totalTokensFresh !== false &&
|
||||
typeof sessionRecord?.totalTokens === "number" &&
|
||||
Number.isFinite(sessionRecord.totalTokens)
|
||||
? sessionRecord.totalTokens
|
||||
: undefined;
|
||||
const tokenCount = maxFiniteNumber([sessionTokens, nativeTokens]);
|
||||
if (tokenCount !== undefined && tokenCount >= maxTokens) {
|
||||
embeddedAgentLog.warn(
|
||||
"codex app-server native transcript exceeded active token limit; starting a fresh thread",
|
||||
{
|
||||
threadId: binding.threadId,
|
||||
maxTokens,
|
||||
sessionKey: sessionRecord?.sessionKey,
|
||||
sessionTokens,
|
||||
nativeTokens,
|
||||
nativeModelContextWindow,
|
||||
sessionModelContextWindow,
|
||||
reserveTokens,
|
||||
projectedTurnTokens: params.projectedTurnTokens,
|
||||
},
|
||||
);
|
||||
await clearCodexAppServerBinding(params.sessionFile);
|
||||
return undefined;
|
||||
}
|
||||
embeddedAgentLog.warn(
|
||||
"codex app-server thread usage left too little prompt headroom; starting a fresh thread",
|
||||
{
|
||||
threadId: binding.threadId,
|
||||
currentTokens,
|
||||
maxTokens,
|
||||
modelContextWindow,
|
||||
reserveTokens,
|
||||
projectedTurnTokens: params.projectedTurnTokens,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
if (compaction?.truncateAfterCompaction !== true) {
|
||||
return binding;
|
||||
}
|
||||
if (shouldDeferByteGuard) {
|
||||
embeddedAgentLog.debug(
|
||||
"codex app-server deferring native transcript byte guard for context-engine thread bootstrap",
|
||||
{
|
||||
threadId: binding.threadId,
|
||||
engineId: binding.contextEngine?.engineId,
|
||||
epoch: binding.contextEngine?.projection?.epoch,
|
||||
fingerprint: binding.contextEngine?.projection?.fingerprint,
|
||||
},
|
||||
);
|
||||
return binding;
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
/** Internal sizing helpers exposed for startup-binding regression tests. */
|
||||
export const testing = {
|
||||
resolveNativeThreadReserveTokens,
|
||||
resolveNativeThreadTokenFuse,
|
||||
parseCodexAppServerByteLimit,
|
||||
readCodexAppServerRolloutTokenSnapshotLine,
|
||||
resolveCodexAppServerNativeThreadTokenFuse,
|
||||
resolveCodexAppServerNativeThreadReserveTokens,
|
||||
};
|
||||
|
||||
@@ -7,47 +7,6 @@ import { PassThrough, Writable } from "node:stream";
|
||||
import type { Model } from "openclaw/plugin-sdk/llm";
|
||||
import { vi } from "vitest";
|
||||
import { CodexAppServerClient } from "./client.js";
|
||||
import type {
|
||||
CodexAppServerClientLeaseFactory,
|
||||
CodexAppServerClientOptions,
|
||||
} from "./shared-client.js";
|
||||
|
||||
/** Naked-client injection contract confined to tests. */
|
||||
export type CodexTestAppServerClientFactory = (
|
||||
startOptions?: CodexAppServerClientOptions["startOptions"],
|
||||
authProfileId?: string,
|
||||
agentDir?: string,
|
||||
config?: CodexAppServerClientOptions["config"],
|
||||
options?: CodexAppServerClientOptions,
|
||||
) => Promise<CodexAppServerClient>;
|
||||
|
||||
/** Wraps a test client in the ownership contract required by production code. */
|
||||
export function adaptCodexTestClientFactory(
|
||||
factory: CodexTestAppServerClientFactory,
|
||||
): CodexAppServerClientLeaseFactory {
|
||||
return async (options) => ({
|
||||
client: await factory(
|
||||
options?.startOptions,
|
||||
options?.authProfileId ?? undefined,
|
||||
options?.agentDir,
|
||||
options?.config,
|
||||
options,
|
||||
),
|
||||
release: () => undefined,
|
||||
abandon: async () => undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Completes lightweight request-only test doubles with the notification contract. */
|
||||
export function ensureCodexTestClientNotificationSurface(
|
||||
client: CodexAppServerClient,
|
||||
): CodexAppServerClient {
|
||||
const surface = client as unknown as {
|
||||
addNotificationHandler?: CodexAppServerClient["addNotificationHandler"];
|
||||
};
|
||||
surface.addNotificationHandler ??= () => () => undefined;
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Builds a representative Codex-capable model fixture for app-server tests. */
|
||||
export function createCodexTestModel(provider = "openai", input = ["text"]): Model {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// Codex tests cover thread lifecycle.binding plugin behavior.
|
||||
import path from "node:path";
|
||||
import { saveSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CodexAppServerUnsafeSubscriptionError } from "./attempt-client-cleanup.js";
|
||||
import { CodexAppServerRpcError } from "./client.js";
|
||||
import type { CodexDynamicToolFunctionSpec } from "./protocol.js";
|
||||
import {
|
||||
createParams as createRunAttemptParams,
|
||||
setupRunAttemptTestHooks,
|
||||
@@ -12,38 +10,13 @@ import {
|
||||
} from "./run-attempt-test-harness.js";
|
||||
import {
|
||||
readCodexAppServerBinding,
|
||||
registerCodexTestSessionIdentity,
|
||||
testCodexAppServerBindingStore,
|
||||
writeCodexAppServerBinding as writeCodexAppServerBindingImpl,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
import { ensureCodexTestClientNotificationSurface } from "./test-support.js";
|
||||
import { startOrResumeThread as startOrResumeThreadImpl } from "./thread-lifecycle.js";
|
||||
|
||||
function startOrResumeThread(
|
||||
params: Omit<Parameters<typeof startOrResumeThreadImpl>[0], "bindingStore" | "abandonClient"> & {
|
||||
abandonClient?: () => Promise<void>;
|
||||
},
|
||||
) {
|
||||
registerCodexTestSessionIdentity(
|
||||
params.params.sessionFile,
|
||||
params.params.sessionId,
|
||||
params.params.sessionKey,
|
||||
);
|
||||
return startOrResumeThreadImpl({
|
||||
...params,
|
||||
client: ensureCodexTestClientNotificationSurface(params.client),
|
||||
abandonClient: params.abandonClient ?? (async () => undefined),
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
});
|
||||
}
|
||||
|
||||
async function writeCodexAppServerBinding(
|
||||
sessionFile: string,
|
||||
binding: Parameters<typeof writeCodexAppServerBindingImpl>[1],
|
||||
): Promise<void> {
|
||||
registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1");
|
||||
await writeCodexAppServerBindingImpl(sessionFile, binding);
|
||||
}
|
||||
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
|
||||
} from "./session-binding.js";
|
||||
import { fingerprintCodexAppServerNetworkProxyConfigPatch } from "./config.js";
|
||||
import {
|
||||
shouldRotateCodexAppServerBindingForRuntime,
|
||||
startOrResumeThread,
|
||||
} from "./thread-lifecycle.js";
|
||||
|
||||
function createThreadLifecycleAppServerOptions(): Parameters<
|
||||
typeof startOrResumeThread
|
||||
@@ -281,45 +254,33 @@ function createTwoCalendarAppPolicyContext() {
|
||||
setupRunAttemptTestHooks();
|
||||
|
||||
describe("Codex app-server thread lifecycle bindings", () => {
|
||||
it("reclaims an unloaded plugin's stale generation for the current session", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const storePath = path.join(tempDir, "sessions.json");
|
||||
const sessionKey = "agent:main:telegram:chat-1";
|
||||
registerCodexTestSessionIdentity(sessionFile, "session-old", sessionKey);
|
||||
await writeCodexAppServerBindingImpl(sessionFile, {
|
||||
threadId: "thread-old",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
await saveSessionStore(storePath, {
|
||||
[sessionKey]: { sessionId: "session-new", updatedAt: Date.now() },
|
||||
});
|
||||
const params = {
|
||||
...createParams(sessionFile, workspaceDir),
|
||||
sessionId: "session-new",
|
||||
sessionKey,
|
||||
config: { session: { store: storePath } },
|
||||
};
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-new");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
});
|
||||
|
||||
expect(binding).toMatchObject({ threadId: "thread-new", lifecycle: { action: "started" } });
|
||||
expect(request).not.toHaveBeenCalledWith("thread/resume", expect.anything(), expect.anything());
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-new",
|
||||
});
|
||||
it("rotates remote runtime bindings when the app-server fingerprint is missing or changed", () => {
|
||||
expect(
|
||||
shouldRotateCodexAppServerBindingForRuntime({
|
||||
connectionClass: "remote",
|
||||
current: "remote-runtime-v1",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldRotateCodexAppServerBindingForRuntime({
|
||||
connectionClass: "remote",
|
||||
current: "remote-runtime-v1",
|
||||
binding: "remote-runtime-v0",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldRotateCodexAppServerBindingForRuntime({
|
||||
connectionClass: "remote",
|
||||
current: "remote-runtime-v1",
|
||||
binding: "remote-runtime-v1",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldRotateCodexAppServerBindingForRuntime({
|
||||
connectionClass: "local-loopback",
|
||||
current: "local-runtime-v1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not write a binding when thread start resolves after abort", async () => {
|
||||
@@ -358,179 +319,6 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not release a resume subscription when the signal is already aborted", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
abortController.abort("test_abort");
|
||||
const request = vi.fn();
|
||||
const reserveResumeThread = vi.fn();
|
||||
|
||||
await expect(
|
||||
startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
signal: abortController.signal,
|
||||
reserveResumeThread,
|
||||
}),
|
||||
).rejects.toThrow("test_abort");
|
||||
|
||||
expect(reserveResumeThread).not.toHaveBeenCalled();
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unsubscribes an orphaned fresh thread when another binding wins the commit", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-winner",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
return threadStartResult("thread-orphan");
|
||||
}
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
}),
|
||||
).rejects.toThrow("binding changed while committing a fresh thread");
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "thread-orphan" },
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-winner",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks a fresh client unsafe when orphan cleanup cannot be confirmed", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-winner",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
return threadStartResult("thread-orphan");
|
||||
}
|
||||
if (method === "thread/unsubscribe") {
|
||||
throw new Error("unsubscribe failed");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(CodexAppServerUnsafeSubscriptionError);
|
||||
});
|
||||
|
||||
it("retires a fresh client when its subscription cannot be identified", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const abandonClient = vi.fn(async () => undefined);
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
startOrResumeThread({
|
||||
client: { request } as never,
|
||||
abandonClient,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(CodexAppServerUnsafeSubscriptionError);
|
||||
|
||||
expect(abandonClient).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("marks a resumed client unsafe when subscription cleanup cannot be confirmed", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/resume") {
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-winner",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
return threadStartResult("thread-existing");
|
||||
}
|
||||
if (method === "thread/unsubscribe") {
|
||||
throw new Error("unsubscribe failed");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(CodexAppServerUnsafeSubscriptionError);
|
||||
});
|
||||
|
||||
it("does not resume a binding during a fresh-only replacement", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-concurrent",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
const request = vi.fn();
|
||||
|
||||
await expect(
|
||||
startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
freshStartOnly: true,
|
||||
}),
|
||||
).rejects.toThrow("binding changed while starting a replacement thread");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resumes a bound Codex thread when only dynamic tool descriptions change", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
@@ -569,259 +357,45 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
|
||||
});
|
||||
|
||||
it("merges resume-owned fields without dropping preferences or a concurrent patch", async () => {
|
||||
it("sends legacy flat dynamic tools on thread start", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
approvalPolicy: "never",
|
||||
sandbox: "workspace-write",
|
||||
serviceTier: "flex",
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
nativeContextUsage: { currentTokens: 90_000 },
|
||||
modelContextWindow: 258_400,
|
||||
});
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method !== "thread/resume") {
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-flat-tools");
|
||||
}
|
||||
await testCodexAppServerBindingStore.mutate(
|
||||
{
|
||||
kind: "session",
|
||||
agentId: "main",
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
},
|
||||
{
|
||||
kind: "patch",
|
||||
threadId: "thread-existing",
|
||||
patch: { serviceTier: "priority" },
|
||||
},
|
||||
);
|
||||
return threadStartResult("thread-existing");
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
dynamicTools: [
|
||||
createMessageDynamicTool("Send a message."),
|
||||
createDeferredNamedDynamicTool("web_search"),
|
||||
],
|
||||
appServer,
|
||||
});
|
||||
|
||||
expect(binding).toMatchObject({
|
||||
threadId: "thread-existing",
|
||||
approvalPolicy: "never",
|
||||
sandbox: "workspace-write",
|
||||
serviceTier: "priority",
|
||||
lifecycle: { action: "resumed" },
|
||||
});
|
||||
const stored = await readCodexAppServerBinding(sessionFile);
|
||||
expect(stored).toMatchObject({
|
||||
approvalPolicy: "never",
|
||||
sandbox: "workspace-write",
|
||||
serviceTier: "priority",
|
||||
nativeContextUsage: { currentTokens: 90_000 },
|
||||
modelContextWindow: 258_400,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a resume when startup state was derived from a replaced binding", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-replaced",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
});
|
||||
const request = vi.fn();
|
||||
|
||||
await expect(
|
||||
startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
expectedResumeThreadId: "thread-startup",
|
||||
const startParams = request.mock.calls.find(([method]) => method === "thread/start")?.[1] as
|
||||
| { dynamicTools?: unknown[] }
|
||||
| undefined;
|
||||
expect(startParams?.dynamicTools).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "message",
|
||||
description: "Send a message.",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "web_search",
|
||||
namespace: "openclaw",
|
||||
deferLoading: true,
|
||||
}),
|
||||
).rejects.toThrow("Codex thread binding changed during startup");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rotates a model-changed binding before trusting its usage snapshot", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-old",
|
||||
modelProvider: "openai",
|
||||
nativeContextUsage: { currentTokens: 149_000 },
|
||||
modelContextWindow: 150_000,
|
||||
});
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method !== "thread/start") {
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}
|
||||
return threadStartResult("thread-fresh");
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
startupTokenGuard: { contextWindowTokens: 150_000, projectedTurnTokens: 1_000 },
|
||||
});
|
||||
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(binding).toMatchObject({
|
||||
threadId: "thread-fresh",
|
||||
model: "gpt-5.4-codex",
|
||||
lifecycle: { action: "started" },
|
||||
});
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-fresh",
|
||||
model: "gpt-5.4-codex",
|
||||
});
|
||||
});
|
||||
|
||||
it("rotates a terminal usage snapshot before resuming", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
nativeContextUsage: { currentTokens: 95_000 },
|
||||
modelContextWindow: 100_000,
|
||||
});
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-existing");
|
||||
}
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {};
|
||||
}
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-new");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
startupTokenGuard: { contextWindowTokens: 100_000, projectedTurnTokens: 1_000 },
|
||||
});
|
||||
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/resume",
|
||||
"thread/unsubscribe",
|
||||
"thread/start",
|
||||
]);
|
||||
expect(binding.threadId).toBe("thread-new");
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-new",
|
||||
});
|
||||
});
|
||||
|
||||
it("rotates an active resumed thread when its terminal snapshot is over the fuse", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
nativeContextUsage: { currentTokens: 95_000 },
|
||||
modelContextWindow: 100_000,
|
||||
});
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/resume") {
|
||||
const response = threadStartResult("thread-existing");
|
||||
return {
|
||||
...response,
|
||||
thread: {
|
||||
...response.thread,
|
||||
status: { type: "active", activeFlags: [] },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {};
|
||||
}
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-new");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
startupTokenGuard: { contextWindowTokens: 100_000, projectedTurnTokens: 1_000 },
|
||||
});
|
||||
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/resume",
|
||||
"thread/unsubscribe",
|
||||
"thread/start",
|
||||
]);
|
||||
expect(binding.threadId).toBe("thread-new");
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-new",
|
||||
});
|
||||
});
|
||||
|
||||
it("resumes without polling when no terminal usage snapshot exists", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
});
|
||||
const requests: unknown[] = [];
|
||||
const request = vi.fn(async (method: string, requestParams?: unknown) => {
|
||||
if (method !== "thread/resume") {
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}
|
||||
requests.push(requestParams);
|
||||
return threadStartResult("thread-existing");
|
||||
});
|
||||
const client = { request } as never;
|
||||
const binding = await startOrResumeThread({
|
||||
client,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
startupTokenGuard: { contextWindowTokens: 100_000, projectedTurnTokens: 1_000 },
|
||||
});
|
||||
|
||||
expect(requests).toEqual([
|
||||
expect.objectContaining({ threadId: "thread-existing", excludeTurns: true }),
|
||||
]);
|
||||
expect(binding.threadId).toBe("thread-existing");
|
||||
expect(startParams?.dynamicTools?.[0]).not.toHaveProperty("type");
|
||||
expect(startParams?.dynamicTools?.[1]).not.toHaveProperty("type");
|
||||
});
|
||||
|
||||
it("keeps the bound local provider when recoverable resume failure starts a fresh thread", async () => {
|
||||
@@ -830,7 +404,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "local-model-2",
|
||||
model: "local-model",
|
||||
modelProvider: "lmstudio",
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
@@ -841,10 +415,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
|
||||
if (method === "thread/resume") {
|
||||
throw new CodexAppServerRpcError(
|
||||
{ code: -32_000, message: "stale thread" },
|
||||
"thread/resume",
|
||||
);
|
||||
throw new Error("stale thread");
|
||||
}
|
||||
if (method === "thread/start") {
|
||||
const response = threadStartResult("thread-new");
|
||||
@@ -853,9 +424,6 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
response.thread.modelProvider = "lmstudio";
|
||||
return response;
|
||||
}
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
@@ -870,49 +438,13 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
const startParams = request.mock.calls.find(([method]) => method === "thread/start")?.[1] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/resume",
|
||||
"thread/unsubscribe",
|
||||
"thread/start",
|
||||
]);
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/resume", "thread/start"]);
|
||||
expect(startParams?.model).toBe("local-model-2");
|
||||
expect(startParams?.modelProvider).toBe("lmstudio");
|
||||
expect(binding.threadId).toBe("thread-new");
|
||||
expect(binding.modelProvider).toBe("lmstudio");
|
||||
});
|
||||
|
||||
it("rejects a mismatched resume without reusing the corrupted connection", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
const request = vi.fn(async (method: string, _requestParams?: unknown) => {
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-other");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
}),
|
||||
).rejects.toThrow("Codex thread/resume returned thread-other for thread-existing");
|
||||
|
||||
expect(request.mock.calls.map(([method, requestParams]) => [method, requestParams])).toEqual([
|
||||
["thread/resume", expect.objectContaining({ threadId: "thread-existing" })],
|
||||
]);
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-existing",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the bound local provider when stale fingerprints force a fresh thread", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
@@ -1550,6 +1082,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
@@ -1639,7 +1172,6 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(binding.threadId).toBe("thread-fresh");
|
||||
expect(binding.lifecycle).toEqual({
|
||||
action: "started",
|
||||
activeModelProvider: "openai",
|
||||
rotatedContextEngineBinding: true,
|
||||
});
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
@@ -1689,7 +1221,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
});
|
||||
|
||||
expect(binding.threadId).toBe("thread-existing");
|
||||
expect(binding.lifecycle).toEqual({ action: "resumed", activeModelProvider: "openai" });
|
||||
expect(binding.lifecycle).toEqual({ action: "resumed" });
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/resume"]);
|
||||
});
|
||||
|
||||
@@ -1729,7 +1261,6 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(binding.threadId).toBe("thread-fresh");
|
||||
expect(binding.lifecycle).toEqual({
|
||||
action: "started",
|
||||
activeModelProvider: "openai",
|
||||
rotatedContextEngineBinding: true,
|
||||
});
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
@@ -1786,7 +1317,6 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(binding.threadId).toBe("thread-fresh");
|
||||
expect(binding.lifecycle).toEqual({
|
||||
action: "started",
|
||||
activeModelProvider: "openai",
|
||||
rotatedContextEngineBinding: true,
|
||||
});
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
@@ -1968,7 +1498,6 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
const abandonClient = vi.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
startOrResumeThread({
|
||||
@@ -1977,12 +1506,10 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer,
|
||||
abandonClient,
|
||||
}),
|
||||
).rejects.toThrow("codex app-server client is closed");
|
||||
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/resume"]);
|
||||
expect(abandonClient).toHaveBeenCalledOnce();
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
});
|
||||
|
||||
@@ -5,12 +5,8 @@ import path from "node:path";
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js";
|
||||
import {
|
||||
resetCodexTestBindingStore,
|
||||
registerCodexTestSessionIdentity,
|
||||
testCodexAppServerBindingStore,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
import { createCodexTestModel, ensureCodexTestClientNotificationSurface } from "./test-support.js";
|
||||
import { fingerprintCodexAppServerNetworkProxyConfigPatch } from "./config.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
import {
|
||||
buildDeveloperInstructions,
|
||||
buildTurnCollaborationMode,
|
||||
@@ -22,28 +18,10 @@ import {
|
||||
resolveCodexAppServerThreadModelSelection,
|
||||
resolveReasoningEffort,
|
||||
shouldWarnCodexThreadLifecycleTimingSummary,
|
||||
startOrResumeThread as startOrResumeThreadImpl,
|
||||
startOrResumeThread,
|
||||
type CodexThreadLifecycleTimingLogger,
|
||||
} from "./thread-lifecycle.js";
|
||||
|
||||
function startOrResumeThread(
|
||||
params: Omit<Parameters<typeof startOrResumeThreadImpl>[0], "bindingStore" | "abandonClient"> & {
|
||||
abandonClient?: () => Promise<void>;
|
||||
},
|
||||
) {
|
||||
registerCodexTestSessionIdentity(
|
||||
params.params.sessionFile,
|
||||
params.params.sessionId,
|
||||
params.params.sessionKey,
|
||||
);
|
||||
return startOrResumeThreadImpl({
|
||||
...params,
|
||||
client: ensureCodexTestClientNotificationSurface(params.client),
|
||||
abandonClient: params.abandonClient ?? (async () => undefined),
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
});
|
||||
}
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
function createAttemptParams(params: {
|
||||
@@ -888,7 +866,6 @@ describe("Codex app-server turn params", () => {
|
||||
serviceTier: "flex",
|
||||
personality: "none",
|
||||
developerInstructions: resumeParams.developerInstructions,
|
||||
excludeTurns: true,
|
||||
persistExtendedHistory: true,
|
||||
});
|
||||
expect(resumeParams.developerInstructions).not.toContain(CODEX_GPT5_BEHAVIOR_CONTRACT);
|
||||
@@ -1149,7 +1126,6 @@ describe("Codex app-server model provider selection", () => {
|
||||
|
||||
describe("Codex app-server thread lifecycle timing", () => {
|
||||
beforeEach(async () => {
|
||||
resetCodexTestBindingStore();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-thread-lifecycle-"));
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,41 +5,8 @@ import path from "node:path";
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerRuntimeOptions } from "./config.js";
|
||||
import {
|
||||
readCodexAppServerBinding,
|
||||
registerCodexTestSessionIdentity,
|
||||
resetCodexTestBindingStore,
|
||||
testCodexAppServerBindingStore,
|
||||
writeCodexAppServerBinding as writeCodexAppServerBindingImpl,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
import { ensureCodexTestClientNotificationSurface } from "./test-support.js";
|
||||
import { startOrResumeThread as startOrResumeThreadImpl } from "./thread-lifecycle.js";
|
||||
|
||||
function startOrResumeThread(
|
||||
params: Omit<Parameters<typeof startOrResumeThreadImpl>[0], "bindingStore" | "abandonClient"> & {
|
||||
abandonClient?: () => Promise<void>;
|
||||
},
|
||||
) {
|
||||
registerCodexTestSessionIdentity(
|
||||
params.params.sessionFile,
|
||||
params.params.sessionId,
|
||||
params.params.sessionKey,
|
||||
);
|
||||
return startOrResumeThreadImpl({
|
||||
...params,
|
||||
client: ensureCodexTestClientNotificationSurface(params.client),
|
||||
abandonClient: params.abandonClient ?? (async () => undefined),
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
});
|
||||
}
|
||||
|
||||
async function writeCodexAppServerBinding(
|
||||
sessionFile: string,
|
||||
binding: Parameters<typeof writeCodexAppServerBindingImpl>[1],
|
||||
): Promise<void> {
|
||||
registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1");
|
||||
await writeCodexAppServerBindingImpl(sessionFile, binding);
|
||||
}
|
||||
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import { startOrResumeThread } from "./thread-lifecycle.js";
|
||||
|
||||
function threadStartResult(threadId = "thread-1"): Record<string, unknown> {
|
||||
return {
|
||||
@@ -125,7 +92,6 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
|
||||
let tempDir = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
resetCodexTestBindingStore();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-80814-"));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js";
|
||||
import { readCodexNativeContextUsage, resumeCodexAppServerThread } from "./thread-resume.js";
|
||||
|
||||
function resumeResponse(threadId: string, restoredTurns = 0) {
|
||||
return {
|
||||
thread: {
|
||||
id: threadId,
|
||||
sessionId: "session-1",
|
||||
forkedFromId: null,
|
||||
preview: "",
|
||||
ephemeral: false,
|
||||
modelProvider: "openai",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: "/repo",
|
||||
cliVersion: "0.139.0",
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
gitInfo: null,
|
||||
name: null,
|
||||
turns: Array.from({ length: restoredTurns }, (_, index) => ({
|
||||
id: `turn-${index}`,
|
||||
items: [],
|
||||
status: "completed",
|
||||
error: null,
|
||||
})),
|
||||
},
|
||||
model: "gpt-5.5-codex",
|
||||
modelProvider: "openai",
|
||||
serviceTier: null,
|
||||
cwd: "/repo",
|
||||
instructionSources: [],
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: { type: "dangerFullAccess" },
|
||||
permissionProfile: null,
|
||||
reasoningEffort: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createClient(requestImpl: (params: unknown) => unknown) {
|
||||
const request = vi.fn(async (_method: string, params: unknown) => await requestImpl(params));
|
||||
const client = { request } as unknown as CodexAppServerClient;
|
||||
return {
|
||||
client,
|
||||
request,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resumeCodexAppServerThread", () => {
|
||||
it("reads current-context usage instead of cumulative thread usage", () => {
|
||||
expect(
|
||||
readCodexNativeContextUsage({
|
||||
method: "thread/tokenUsage/updated",
|
||||
params: {
|
||||
tokenUsage: {
|
||||
total: { totalTokens: 900_000 },
|
||||
last: { totalTokens: 42_000 },
|
||||
modelContextWindow: 258_400,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({ currentTokens: 42_000, modelContextWindow: 258_400 });
|
||||
});
|
||||
|
||||
it("leaves a proven RPC rejection on the reusable client", async () => {
|
||||
const rejection = new CodexAppServerRpcError(
|
||||
{ code: -32_000, message: "thread not found" },
|
||||
"thread/resume",
|
||||
);
|
||||
const { client } = createClient(async () => {
|
||||
throw rejection;
|
||||
});
|
||||
const abandonClient = vi.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
resumeCodexAppServerThread({
|
||||
client,
|
||||
abandonClient,
|
||||
request: { threadId: "thread-1", excludeTurns: true },
|
||||
}),
|
||||
).rejects.toBe(rejection);
|
||||
expect(abandonClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retires the exact client when resume acceptance is indeterminate", async () => {
|
||||
const { client } = createClient(async () => {
|
||||
throw new Error("thread/resume timed out");
|
||||
});
|
||||
const abandonClient = vi.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
resumeCodexAppServerThread({
|
||||
client,
|
||||
abandonClient,
|
||||
request: { threadId: "thread-1", excludeTurns: true },
|
||||
}),
|
||||
).rejects.toThrow("thread/resume timed out");
|
||||
expect(abandonClient).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("retires the exact client when the response names another thread", async () => {
|
||||
const { client } = createClient(async () => resumeResponse("thread-2"));
|
||||
const abandonClient = vi.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
resumeCodexAppServerThread({
|
||||
client,
|
||||
abandonClient,
|
||||
request: { threadId: "thread-1", excludeTurns: true },
|
||||
}),
|
||||
).rejects.toThrow("returned thread-2 for thread-1");
|
||||
expect(abandonClient).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import {
|
||||
assertCodexThreadResumeSubscription,
|
||||
CodexAppServerUnsafeSubscriptionError,
|
||||
} from "./attempt-client-cleanup.js";
|
||||
/** Owns Codex thread/resume subscription safety. */
|
||||
import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js";
|
||||
import { assertCodexThreadResumeResponse } from "./protocol-validators.js";
|
||||
import {
|
||||
isJsonObject,
|
||||
type CodexServerNotification,
|
||||
type CodexThreadResumeParams,
|
||||
type CodexThreadResumeResponse,
|
||||
type JsonValue,
|
||||
} from "./protocol.js";
|
||||
|
||||
export type CodexNativeContextUsageUpdate = {
|
||||
currentTokens: number;
|
||||
modelContextWindow?: number;
|
||||
};
|
||||
|
||||
/** Resumes one thread and retires the physical client when acceptance is indeterminate. */
|
||||
export async function resumeCodexAppServerThread(params: {
|
||||
client: CodexAppServerClient;
|
||||
abandonClient: () => Promise<void>;
|
||||
request: CodexThreadResumeParams;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<CodexThreadResumeResponse> {
|
||||
const threadId = params.request.threadId;
|
||||
let response: CodexThreadResumeResponse;
|
||||
try {
|
||||
response = assertCodexThreadResumeResponse(
|
||||
await params.client.request("thread/resume", params.request, {
|
||||
...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs } : {}),
|
||||
...(params.signal ? { signal: params.signal } : {}),
|
||||
}),
|
||||
);
|
||||
assertCodexThreadResumeSubscription(threadId, response.thread.id);
|
||||
} catch (error) {
|
||||
if (error instanceof CodexAppServerRpcError) {
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await params.abandonClient();
|
||||
} catch (abandonError) {
|
||||
throw new CodexAppServerUnsafeSubscriptionError(
|
||||
`Codex thread/resume client could not be retired for ${threadId}`,
|
||||
{ cause: abandonError },
|
||||
);
|
||||
}
|
||||
if (error instanceof CodexAppServerUnsafeSubscriptionError) {
|
||||
throw error;
|
||||
}
|
||||
throw new CodexAppServerUnsafeSubscriptionError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Codex thread/resume outcome is indeterminate for ${threadId}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Reads the authoritative per-context usage carried by Codex notifications. */
|
||||
export function readCodexNativeContextUsage(
|
||||
notification: CodexServerNotification,
|
||||
): CodexNativeContextUsageUpdate | undefined {
|
||||
const params = isJsonObject(notification.params) ? notification.params : undefined;
|
||||
const tokenUsage = params && isJsonObject(params.tokenUsage) ? params.tokenUsage : undefined;
|
||||
const current = tokenUsage && isJsonObject(tokenUsage.last) ? tokenUsage.last : undefined;
|
||||
const currentTokens = current ? readNonNegativeFiniteNumber(current.totalTokens) : undefined;
|
||||
if (currentTokens === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const modelContextWindow = readPositiveFiniteNumber(tokenUsage?.modelContextWindow);
|
||||
return {
|
||||
currentTokens,
|
||||
...(modelContextWindow !== undefined ? { modelContextWindow } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readNonNegativeFiniteNumber(value: JsonValue | undefined): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
||||
? Math.floor(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readPositiveFiniteNumber(value: JsonValue | undefined): number | undefined {
|
||||
const number = readNonNegativeFiniteNumber(value);
|
||||
return number !== undefined && number > 0 ? number : undefined;
|
||||
}
|
||||
@@ -1,940 +0,0 @@
|
||||
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import type { JsonValue } from "./protocol.js";
|
||||
import { createClientHarness } from "./test-support.js";
|
||||
import { getCodexAppServerTurnRouter, type CodexAppServerServerRequest } from "./turn-router.js";
|
||||
|
||||
type ClientHarness = ReturnType<typeof createClientHarness>;
|
||||
|
||||
type WireResponse = {
|
||||
id: number | string;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
describe("CodexAppServerTurnRouter", () => {
|
||||
const clients: CodexAppServerClient[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const client of clients) {
|
||||
client.close();
|
||||
}
|
||||
clients.length = 0;
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createHarness(): ClientHarness {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
return harness;
|
||||
}
|
||||
|
||||
it("installs one request and notification handler per client", () => {
|
||||
const harness = createHarness();
|
||||
const addNotificationHandler = vi.spyOn(harness.client, "addNotificationHandler");
|
||||
const addRequestHandler = vi.spyOn(harness.client, "addRequestHandler");
|
||||
const addCloseHandler = vi.spyOn(harness.client, "addCloseHandler");
|
||||
|
||||
const first = getCodexAppServerTurnRouter(harness.client);
|
||||
const second = getCodexAppServerTurnRouter(harness.client);
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(addNotificationHandler).toHaveBeenCalledTimes(1);
|
||||
expect(addRequestHandler).toHaveBeenCalledTimes(1);
|
||||
expect(addCloseHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("routes concurrent traffic to the exact thread and turn", async () => {
|
||||
const harness = createHarness();
|
||||
const router = getCodexAppServerTurnRouter(harness.client);
|
||||
const firstNotifications = vi.fn();
|
||||
const secondNotifications = vi.fn();
|
||||
const firstRequests = vi.fn(() => ({ owner: "first" }));
|
||||
const secondRequests = vi.fn(() => ({ owner: "second" }));
|
||||
const first = router.reserveThread({
|
||||
threadId: "thread-1",
|
||||
onNotification: firstNotifications,
|
||||
onRequest: firstRequests,
|
||||
});
|
||||
const second = router.reserveThread({
|
||||
threadId: "thread-2",
|
||||
onNotification: secondNotifications,
|
||||
onRequest: secondRequests,
|
||||
});
|
||||
first.armTurn();
|
||||
second.armTurn();
|
||||
await Promise.all([first.bindTurn("turn-1"), second.bindTurn("turn-2")]);
|
||||
|
||||
harness.send({
|
||||
method: "item/agentMessage/delta",
|
||||
params: { threadId: "thread-2", turnId: "turn-2", delta: "right" },
|
||||
});
|
||||
harness.send({
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-2", turn: { id: "turn-2", items: [] } },
|
||||
});
|
||||
harness.send({
|
||||
method: "item/agentMessage/delta",
|
||||
params: { threadId: "thread-2", turnId: "turn-stale", delta: "wrong" },
|
||||
});
|
||||
harness.send({
|
||||
id: "request-2",
|
||||
method: "item/tool/call",
|
||||
params: { threadId: "thread-2", turnId: "turn-2", tool: "second" },
|
||||
});
|
||||
harness.send({
|
||||
id: "request-1",
|
||||
method: "item/tool/call",
|
||||
params: { threadId: "thread-1", turnId: "turn-1", tool: "first" },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(secondNotifications).toHaveBeenCalledTimes(2));
|
||||
const firstResponse = await waitForResponse(harness, "request-1");
|
||||
const secondResponse = await waitForResponse(harness, "request-2");
|
||||
|
||||
expect(firstNotifications).not.toHaveBeenCalled();
|
||||
expect(secondNotifications).toHaveBeenCalledWith(
|
||||
{
|
||||
method: "item/agentMessage/delta",
|
||||
params: { threadId: "thread-2", turnId: "turn-2", delta: "right" },
|
||||
},
|
||||
{ threadId: "thread-2", turnId: "turn-2" },
|
||||
);
|
||||
expect(secondNotifications).toHaveBeenCalledWith(
|
||||
{
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-2", turn: { id: "turn-2", items: [] } },
|
||||
},
|
||||
{ threadId: "thread-2", turnId: "turn-2" },
|
||||
);
|
||||
expect(firstRequests).toHaveBeenCalledTimes(1);
|
||||
expect(secondRequests).toHaveBeenCalledTimes(1);
|
||||
expect(firstResponse).toEqual({ id: "request-1", result: { owner: "first" } });
|
||||
expect(secondResponse).toEqual({ id: "request-2", result: { owner: "second" } });
|
||||
});
|
||||
|
||||
it("buffers pre-bind notifications in order and filters the bound turn", async () => {
|
||||
const harness = createHarness();
|
||||
const methods: string[] = [];
|
||||
const receivedMethods: string[] = [];
|
||||
const route = getCodexAppServerTurnRouter(harness.client).reserveThread({
|
||||
threadId: "thread-1",
|
||||
onNotificationReceived: (notification) => {
|
||||
receivedMethods.push(notification.method);
|
||||
},
|
||||
onNotification: async (notification) => {
|
||||
await Promise.resolve();
|
||||
methods.push(notification.method);
|
||||
},
|
||||
});
|
||||
route.armTurn();
|
||||
|
||||
harness.send({
|
||||
method: "thread/started",
|
||||
params: { thread: { id: "thread-1" } },
|
||||
});
|
||||
harness.send({
|
||||
method: "item/started",
|
||||
params: { threadId: "thread-1", turnId: "turn-1" },
|
||||
});
|
||||
harness.send({
|
||||
method: "thread/status/changed",
|
||||
params: { threadId: "thread-1", status: { type: "active" } },
|
||||
});
|
||||
harness.send({
|
||||
method: "item/completed",
|
||||
params: { threadId: "thread-1", turnId: "turn-stale" },
|
||||
});
|
||||
harness.send({
|
||||
method: "turn/started",
|
||||
params: { threadId: "thread-1", turnId: "turn-1" },
|
||||
});
|
||||
await settleInput();
|
||||
|
||||
expect(methods).toEqual([]);
|
||||
expect(receivedMethods).toEqual([]);
|
||||
await route.bindTurn("turn-1");
|
||||
|
||||
expect(receivedMethods).toEqual([
|
||||
"thread/started",
|
||||
"item/started",
|
||||
"thread/status/changed",
|
||||
"turn/started",
|
||||
]);
|
||||
expect(methods).toEqual([
|
||||
"thread/started",
|
||||
"item/started",
|
||||
"thread/status/changed",
|
||||
"turn/started",
|
||||
]);
|
||||
});
|
||||
|
||||
it("flushes prior notifications before releasing a bound request", async () => {
|
||||
const harness = createHarness();
|
||||
const events: string[] = [];
|
||||
let finishFirst!: () => void;
|
||||
const firstPending = new Promise<void>((resolve) => {
|
||||
finishFirst = resolve;
|
||||
});
|
||||
const route = getCodexAppServerTurnRouter(harness.client).reserveThread({
|
||||
threadId: "thread-ordered",
|
||||
onNotification: async (notification) => {
|
||||
events.push(`${notification.method}:start`);
|
||||
if (notification.method === "item/started") {
|
||||
await firstPending;
|
||||
}
|
||||
events.push(`${notification.method}:end`);
|
||||
},
|
||||
onRequest: () => {
|
||||
events.push("request");
|
||||
return { success: true, contentItems: [] };
|
||||
},
|
||||
});
|
||||
route.armTurn();
|
||||
harness.send({
|
||||
method: "item/started",
|
||||
params: { threadId: "thread-ordered", turnId: "turn-ordered" },
|
||||
});
|
||||
harness.send({
|
||||
method: "item/agentMessage/delta",
|
||||
params: { threadId: "thread-ordered", turnId: "turn-ordered", delta: "done" },
|
||||
});
|
||||
harness.send({
|
||||
id: "request-ordered",
|
||||
method: "item/tool/call",
|
||||
params: { threadId: "thread-ordered", turnId: "turn-ordered", tool: "message" },
|
||||
});
|
||||
|
||||
const binding = route.bindTurn("turn-ordered");
|
||||
await vi.waitFor(() => expect(events).toEqual(["item/started:start"]));
|
||||
expect(harness.writes).toEqual([]);
|
||||
|
||||
finishFirst();
|
||||
await binding;
|
||||
expect(await waitForResponse(harness, "request-ordered")).toEqual({
|
||||
id: "request-ordered",
|
||||
result: { success: true, contentItems: [] },
|
||||
});
|
||||
expect(events).toEqual([
|
||||
"item/started:start",
|
||||
"item/started:end",
|
||||
"item/agentMessage/delta:start",
|
||||
"item/agentMessage/delta:end",
|
||||
"request",
|
||||
]);
|
||||
});
|
||||
|
||||
it("records receipt synchronously and drains accepted work after release", async () => {
|
||||
const harness = createHarness();
|
||||
const events: string[] = [];
|
||||
let finishFirst!: () => void;
|
||||
const firstPending = new Promise<void>((resolve) => {
|
||||
finishFirst = resolve;
|
||||
});
|
||||
const route = getCodexAppServerTurnRouter(harness.client).reserveThread({
|
||||
threadId: "thread-receive",
|
||||
onNotificationReceived: (notification) => {
|
||||
events.push(`${notification.method}:received`);
|
||||
},
|
||||
onNotification: async (notification) => {
|
||||
events.push(`${notification.method}:start`);
|
||||
if (notification.method === "item/started") {
|
||||
await firstPending;
|
||||
}
|
||||
events.push(`${notification.method}:end`);
|
||||
},
|
||||
});
|
||||
harness.send({
|
||||
method: "item/started",
|
||||
params: { threadId: "thread-receive", turnId: "turn-receive" },
|
||||
});
|
||||
harness.send({
|
||||
method: "item/completed",
|
||||
params: { threadId: "thread-receive", turnId: "turn-receive" },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(events).toContain("item/started:start"));
|
||||
expect(events.slice(0, 3)).toEqual([
|
||||
"item/started:received",
|
||||
"item/completed:received",
|
||||
"item/started:start",
|
||||
]);
|
||||
|
||||
route.release();
|
||||
finishFirst();
|
||||
await route.drain();
|
||||
expect(events).toEqual([
|
||||
"item/started:received",
|
||||
"item/completed:received",
|
||||
"item/started:start",
|
||||
"item/started:end",
|
||||
"item/completed:start",
|
||||
"item/completed:end",
|
||||
]);
|
||||
});
|
||||
|
||||
it("releases routing waiters without waiting for an async notification", async () => {
|
||||
const harness = createHarness();
|
||||
let notificationStarted!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
notificationStarted = resolve;
|
||||
});
|
||||
const neverFinishes = new Promise<void>(() => {});
|
||||
const route = getCodexAppServerTurnRouter(harness.client).reserveThread({
|
||||
threadId: "thread-release-tail",
|
||||
onNotification: async () => {
|
||||
notificationStarted();
|
||||
await neverFinishes;
|
||||
},
|
||||
onRequest: () => ({ decision: "accept" }),
|
||||
});
|
||||
route.armTurn();
|
||||
harness.send({
|
||||
method: "item/started",
|
||||
params: { threadId: "thread-release-tail", turnId: "turn-release-tail" },
|
||||
});
|
||||
harness.send({
|
||||
id: "request-release-tail",
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "thread-release-tail",
|
||||
turnId: "turn-release-tail",
|
||||
itemId: "item-1",
|
||||
},
|
||||
});
|
||||
const binding = route.bindTurn("turn-release-tail");
|
||||
await started;
|
||||
|
||||
route.release();
|
||||
|
||||
await expect(binding).rejects.toThrow("thread route is released");
|
||||
expect(await waitForResponse(harness, "request-release-tail")).toEqual({
|
||||
id: "request-release-tail",
|
||||
result: { decision: "decline" },
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers open-route notifications while an armed route waits", async () => {
|
||||
const harness = createHarness();
|
||||
const router = getCodexAppServerTurnRouter(harness.client);
|
||||
const threadHandler = vi.fn();
|
||||
const turnHandler = vi.fn();
|
||||
router.reserveThread({
|
||||
threadId: "thread-live",
|
||||
onNotification: threadHandler,
|
||||
});
|
||||
const turnRoute = router.reserveThread({
|
||||
threadId: "thread-buffered",
|
||||
onNotification: turnHandler,
|
||||
});
|
||||
turnRoute.armTurn();
|
||||
|
||||
const liveNotification = {
|
||||
method: "thread/status/changed",
|
||||
params: { threadId: "thread-live", status: { type: "active" } },
|
||||
};
|
||||
const bufferedNotification = {
|
||||
method: "item/started",
|
||||
params: { threadId: "thread-buffered", turnId: "turn-buffered" },
|
||||
};
|
||||
harness.send(liveNotification);
|
||||
harness.send(bufferedNotification);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(threadHandler).toHaveBeenCalledWith(liveNotification, {
|
||||
threadId: "thread-live",
|
||||
}),
|
||||
);
|
||||
expect(turnHandler).not.toHaveBeenCalled();
|
||||
|
||||
await turnRoute.bindTurn("turn-buffered");
|
||||
expect(turnHandler).toHaveBeenCalledWith(bufferedNotification, {
|
||||
threadId: "thread-buffered",
|
||||
turnId: "turn-buffered",
|
||||
});
|
||||
});
|
||||
|
||||
it("holds dormant traffic until one-shot activation", async () => {
|
||||
const harness = createHarness();
|
||||
const events: string[] = [];
|
||||
const route = getCodexAppServerTurnRouter(harness.client).reserveThread({
|
||||
threadId: "thread-dormant",
|
||||
});
|
||||
route.armTurn();
|
||||
|
||||
harness.send({
|
||||
method: "thread/status/changed",
|
||||
params: { threadId: "thread-dormant", status: { type: "active" } },
|
||||
});
|
||||
harness.send({
|
||||
method: "item/started",
|
||||
params: { threadId: "thread-dormant", turnId: "turn-dormant" },
|
||||
});
|
||||
harness.send({
|
||||
id: "request-dormant-thread",
|
||||
method: "mcpServer/elicitation/request",
|
||||
params: { threadId: "thread-dormant", turnId: null },
|
||||
});
|
||||
harness.send({
|
||||
id: "request-dormant-turn",
|
||||
method: "item/tool/call",
|
||||
params: { threadId: "thread-dormant", turnId: "turn-dormant" },
|
||||
});
|
||||
await settleInput();
|
||||
|
||||
expect(events).toEqual([]);
|
||||
expect(harness.writes).toEqual([]);
|
||||
expect(route.signal.aborted).toBe(false);
|
||||
await expect(route.bindTurn("turn-dormant")).rejects.toThrow(
|
||||
"thread route must be activated before binding a turn",
|
||||
);
|
||||
await expect(route.activate({})).rejects.toThrow(
|
||||
"thread route requires a notification or request handler",
|
||||
);
|
||||
|
||||
await route.activate({
|
||||
onNotification: async (notification) => {
|
||||
await Promise.resolve();
|
||||
events.push(`notification:${notification.method}`);
|
||||
},
|
||||
onRequest: (request): JsonValue => {
|
||||
events.push(`request:${request.method}`);
|
||||
return request.method === "item/tool/call"
|
||||
? { success: true, contentItems: [] }
|
||||
: { action: "accept" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(events).toEqual([]);
|
||||
expect(harness.writes.map((line) => JSON.parse(line) as WireResponse)).not.toContainEqual(
|
||||
expect.objectContaining({ id: "request-dormant-turn" }),
|
||||
);
|
||||
|
||||
await route.bindTurn("turn-dormant");
|
||||
expect(events.slice(0, 2)).toEqual([
|
||||
"notification:thread/status/changed",
|
||||
"notification:item/started",
|
||||
]);
|
||||
expect(await waitForResponse(harness, "request-dormant-thread")).toEqual({
|
||||
id: "request-dormant-thread",
|
||||
result: { action: "accept" },
|
||||
});
|
||||
expect(await waitForResponse(harness, "request-dormant-turn")).toEqual({
|
||||
id: "request-dormant-turn",
|
||||
result: { success: true, contentItems: [] },
|
||||
});
|
||||
expect(events.at(-1)).toBe("request:item/tool/call");
|
||||
await expect(route.activate({ onRequest: vi.fn() })).rejects.toThrow(
|
||||
"thread route already activated",
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for binding before validating turn-scoped requests", async () => {
|
||||
const harness = createHarness();
|
||||
const router = getCodexAppServerTurnRouter(harness.client);
|
||||
const matchingHandler = vi.fn(() => ({ success: true, contentItems: [] }));
|
||||
const matchingRoute = router.reserveThread({
|
||||
threadId: "thread-match",
|
||||
onRequest: matchingHandler,
|
||||
});
|
||||
matchingRoute.armTurn();
|
||||
|
||||
harness.send({
|
||||
id: "request-match",
|
||||
method: "item/tool/call",
|
||||
params: { threadId: "thread-match", turnId: "turn-match", tool: "message" },
|
||||
});
|
||||
await settleInput();
|
||||
|
||||
expect(matchingHandler).not.toHaveBeenCalled();
|
||||
expect(harness.writes).toEqual([]);
|
||||
|
||||
await matchingRoute.bindTurn("turn-match");
|
||||
await expect(waitForResponse(harness, "request-match")).resolves.toEqual({
|
||||
id: "request-match",
|
||||
result: { success: true, contentItems: [] },
|
||||
});
|
||||
expect(matchingHandler).toHaveBeenCalledTimes(1);
|
||||
|
||||
const staleHandler = vi.fn(() => ({ success: true, contentItems: [] }));
|
||||
const staleRoute = router.reserveThread({
|
||||
threadId: "thread-stale",
|
||||
onRequest: staleHandler,
|
||||
});
|
||||
staleRoute.armTurn();
|
||||
harness.send({
|
||||
id: "request-stale",
|
||||
method: "item/tool/call",
|
||||
params: { threadId: "thread-stale", turnId: "turn-stale", tool: "message" },
|
||||
});
|
||||
await settleInput();
|
||||
|
||||
expect(staleHandler).not.toHaveBeenCalled();
|
||||
await staleRoute.bindTurn("turn-current");
|
||||
|
||||
expect(await waitForResponse(harness, "request-stale")).toEqual({
|
||||
id: "request-stale",
|
||||
result: {
|
||||
contentItems: [
|
||||
{
|
||||
type: "inputText",
|
||||
text: "OpenClaw did not register a handler for this app-server tool call.",
|
||||
},
|
||||
],
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
expect(staleHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes no-turn requests immediately after activation", async () => {
|
||||
const harness = createHarness();
|
||||
const handleRequest = (request: CodexAppServerServerRequest): JsonValue => {
|
||||
if (request.method === "execCommandApproval" || request.method === "applyPatchApproval") {
|
||||
return { decision: "approved" };
|
||||
}
|
||||
return { action: "accept", content: { answer: "yes" } };
|
||||
};
|
||||
const handler = vi.fn(handleRequest);
|
||||
const route = getCodexAppServerTurnRouter(harness.client).reserveThread({
|
||||
threadId: "thread-1",
|
||||
});
|
||||
|
||||
harness.send({
|
||||
id: "elicitation-1",
|
||||
method: "mcpServer/elicitation/request",
|
||||
params: { threadId: "thread-1", turnId: null, message: "Continue?" },
|
||||
});
|
||||
|
||||
await settleInput();
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(harness.writes).toEqual([]);
|
||||
|
||||
await route.activate({ onRequest: handler });
|
||||
|
||||
expect(await waitForResponse(harness, "elicitation-1")).toEqual({
|
||||
id: "elicitation-1",
|
||||
result: { action: "accept", content: { answer: "yes" } },
|
||||
});
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
route.release();
|
||||
});
|
||||
|
||||
it("keeps resumed-turn requests open until a new turn is armed", async () => {
|
||||
const harness = createHarness();
|
||||
const handler = vi.fn(() => undefined);
|
||||
const notificationHandler = vi.fn();
|
||||
const route = getCodexAppServerTurnRouter(harness.client).reserveThread({
|
||||
threadId: "thread-resumed",
|
||||
onRequest: handler,
|
||||
onNotification: notificationHandler,
|
||||
});
|
||||
|
||||
harness.send({
|
||||
id: "old-turn-request",
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: { threadId: "thread-resumed", turnId: "turn-old", itemId: "item-old" },
|
||||
});
|
||||
await expect(waitForResponse(harness, "old-turn-request")).resolves.toEqual({
|
||||
id: "old-turn-request",
|
||||
result: { decision: "decline" },
|
||||
});
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
route.armTurn();
|
||||
harness.send({
|
||||
id: "pending-turn-request",
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: { threadId: "thread-resumed", turnId: "turn-next", itemId: "item-next" },
|
||||
});
|
||||
const earlyError = {
|
||||
method: "error",
|
||||
params: { threadId: "thread-resumed", message: "turn start failed" },
|
||||
};
|
||||
harness.send(earlyError);
|
||||
await settleInput();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(notificationHandler).not.toHaveBeenCalled();
|
||||
|
||||
await route.cancelTurn();
|
||||
expect(notificationHandler).toHaveBeenCalledWith(earlyError, {
|
||||
threadId: "thread-resumed",
|
||||
});
|
||||
await expect(waitForResponse(harness, "pending-turn-request")).resolves.toEqual({
|
||||
id: "pending-turn-request",
|
||||
result: { decision: "decline" },
|
||||
});
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
|
||||
route.armTurn();
|
||||
await route.bindTurn("turn-final");
|
||||
route.release();
|
||||
});
|
||||
|
||||
it("consumes one native completion and clears stale completion when arming", async () => {
|
||||
const harness = createHarness();
|
||||
const route = getCodexAppServerTurnRouter(harness.client).reserveThread({
|
||||
threadId: "thread-native",
|
||||
onNotification: vi.fn(),
|
||||
});
|
||||
harness.send({
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-native", turn: { id: "turn-native", items: [] } },
|
||||
});
|
||||
await settleInput();
|
||||
|
||||
await expect(route.waitForTurnCompletion({ timeoutMs: 10 })).resolves.toBe(true);
|
||||
await expect(route.waitForTurnCompletion({ timeoutMs: 1 })).resolves.toBe(false);
|
||||
|
||||
harness.send({
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-native", turn: { id: "turn-stale", items: [] } },
|
||||
});
|
||||
await settleInput();
|
||||
route.armTurn();
|
||||
await expect(route.waitForTurnCompletion({ timeoutMs: 1 })).resolves.toBe(false);
|
||||
await route.cancelTurn();
|
||||
});
|
||||
|
||||
it("settles an active native-completion waiter on completion, abort, and release", async () => {
|
||||
const harness = createHarness();
|
||||
const route = getCodexAppServerTurnRouter(harness.client).reserveThread({
|
||||
threadId: "thread-native-wait",
|
||||
onNotification: vi.fn(),
|
||||
});
|
||||
|
||||
const completed = route.waitForTurnCompletion({ timeoutMs: 100 });
|
||||
harness.send({
|
||||
method: "turn/completed",
|
||||
params: { threadId: "thread-native-wait", turn: { id: "turn-native", items: [] } },
|
||||
});
|
||||
await expect(completed).resolves.toBe(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
const aborted = route.waitForTurnCompletion({ timeoutMs: 100, signal: controller.signal });
|
||||
controller.abort("test");
|
||||
await expect(aborted).resolves.toBe(false);
|
||||
|
||||
const released = route.waitForTurnCompletion({ timeoutMs: 100 });
|
||||
route.release();
|
||||
await expect(released).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("watches one exact native turn without reserving its thread", async () => {
|
||||
const harness = createHarness();
|
||||
const router = getCodexAppServerTurnRouter(harness.client);
|
||||
const watch = router.watchNativeTurnCompletion({
|
||||
threadId: "thread-native-watch",
|
||||
turnId: "turn-target",
|
||||
timeoutMs: 100,
|
||||
});
|
||||
const settled = vi.fn();
|
||||
void watch.completion.then(settled);
|
||||
|
||||
const route = router.reserveThread({
|
||||
threadId: "thread-native-watch",
|
||||
onNotification: vi.fn(),
|
||||
});
|
||||
route.release();
|
||||
harness.send({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-native-watch",
|
||||
turn: { id: "turn-other", status: "completed" },
|
||||
},
|
||||
});
|
||||
await settleInput();
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
|
||||
harness.send({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-native-watch",
|
||||
turn: { id: "turn-target", status: "completed" },
|
||||
},
|
||||
});
|
||||
await expect(watch.completion).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("treats an exact non-retry error as native turn termination", async () => {
|
||||
const harness = createHarness();
|
||||
const watch = getCodexAppServerTurnRouter(harness.client).watchNativeTurnCompletion({
|
||||
threadId: "thread-native-error",
|
||||
turnId: "turn-native-error",
|
||||
timeoutMs: 100,
|
||||
});
|
||||
const settled = vi.fn();
|
||||
void watch.completion.then(settled);
|
||||
|
||||
harness.send({
|
||||
method: "error",
|
||||
params: {
|
||||
threadId: "thread-native-error",
|
||||
turnId: "turn-native-error",
|
||||
error: { message: "retrying" },
|
||||
willRetry: true,
|
||||
},
|
||||
});
|
||||
await settleInput();
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
|
||||
harness.send({
|
||||
method: "error",
|
||||
params: {
|
||||
threadId: "thread-native-error",
|
||||
turnId: "turn-native-error",
|
||||
error: { message: "review setup failed" },
|
||||
willRetry: false,
|
||||
},
|
||||
});
|
||||
await expect(watch.completion).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("refreshes native turn idle timeout on exact progress", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createHarness();
|
||||
const watch = getCodexAppServerTurnRouter(harness.client).watchNativeTurnCompletion({
|
||||
threadId: "thread-native-progress",
|
||||
turnId: "turn-native-progress",
|
||||
timeoutMs: 1_000,
|
||||
});
|
||||
const settled = vi.fn();
|
||||
void watch.completion.then(settled);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(900);
|
||||
harness.send({
|
||||
method: "item/agentMessage/delta",
|
||||
params: {
|
||||
threadId: "thread-native-progress",
|
||||
turnId: "turn-native-progress",
|
||||
delta: "working",
|
||||
},
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(900);
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
|
||||
harness.send({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-native-progress",
|
||||
turn: { id: "turn-native-progress", status: "completed" },
|
||||
},
|
||||
});
|
||||
await expect(watch.completion).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("cancels a detached native-turn completion watch", async () => {
|
||||
const harness = createHarness();
|
||||
const watch = getCodexAppServerTurnRouter(harness.client).watchNativeTurnCompletion({
|
||||
threadId: "thread-native-cancel",
|
||||
turnId: "turn-native-cancel",
|
||||
timeoutMs: 100,
|
||||
});
|
||||
|
||||
watch.cancel();
|
||||
|
||||
await expect(watch.completion).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("settles detached native-turn watches on timeout and client close", async () => {
|
||||
const timeoutHarness = createHarness();
|
||||
const timedOut = getCodexAppServerTurnRouter(timeoutHarness.client).watchNativeTurnCompletion({
|
||||
threadId: "thread-native-timeout",
|
||||
turnId: "turn-native-timeout",
|
||||
timeoutMs: 1,
|
||||
});
|
||||
await expect(timedOut.completion).resolves.toBe(false);
|
||||
|
||||
const closeHarness = createHarness();
|
||||
const closed = getCodexAppServerTurnRouter(closeHarness.client).watchNativeTurnCompletion({
|
||||
threadId: "thread-native-close",
|
||||
turnId: "turn-native-close",
|
||||
timeoutMs: 100,
|
||||
});
|
||||
closeHarness.client.close();
|
||||
await expect(closed.completion).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("releases pending requests and removes routes on cleanup", async () => {
|
||||
const harness = createHarness();
|
||||
const router = getCodexAppServerTurnRouter(harness.client);
|
||||
const notificationHandler = vi.fn();
|
||||
const requestHandler = vi.fn(() => ({ decision: "accept" }));
|
||||
const route = router.reserveThread({
|
||||
threadId: "thread-release",
|
||||
onNotification: notificationHandler,
|
||||
onRequest: requestHandler,
|
||||
});
|
||||
route.armTurn();
|
||||
harness.send({
|
||||
id: "request-release",
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "thread-release",
|
||||
turnId: "turn-release",
|
||||
itemId: "item-1",
|
||||
},
|
||||
});
|
||||
await settleInput();
|
||||
|
||||
route.release();
|
||||
harness.send({
|
||||
method: "item/started",
|
||||
params: { threadId: "thread-release", turnId: "turn-release" },
|
||||
});
|
||||
|
||||
expect(await waitForResponse(harness, "request-release")).toEqual({
|
||||
id: "request-release",
|
||||
result: { decision: "decline" },
|
||||
});
|
||||
expect(notificationHandler).not.toHaveBeenCalled();
|
||||
expect(requestHandler).not.toHaveBeenCalled();
|
||||
|
||||
let finishActiveRequest!: (result: { decision: string }) => void;
|
||||
const activeResult = new Promise<{ decision: string }>((resolve) => {
|
||||
finishActiveRequest = resolve;
|
||||
});
|
||||
let failActiveRequest!: (error: Error) => void;
|
||||
const rejectedActiveResult = new Promise<{ decision: string }>((_resolve, reject) => {
|
||||
failActiveRequest = reject;
|
||||
});
|
||||
const activeHandler = vi.fn((request: { id: number | string }) =>
|
||||
request.id === "request-active-reject" ? rejectedActiveResult : activeResult,
|
||||
);
|
||||
const activeRoute = router.reserveThread({
|
||||
threadId: "thread-active",
|
||||
onRequest: activeHandler,
|
||||
});
|
||||
activeRoute.armTurn();
|
||||
await activeRoute.bindTurn("turn-active");
|
||||
harness.send({
|
||||
id: "request-active",
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "thread-active",
|
||||
turnId: "turn-active",
|
||||
itemId: "item-2",
|
||||
},
|
||||
});
|
||||
harness.send({
|
||||
id: "request-active-reject",
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "thread-active",
|
||||
turnId: "turn-active",
|
||||
itemId: "item-3",
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(activeHandler).toHaveBeenCalledTimes(2));
|
||||
|
||||
activeRoute.release();
|
||||
finishActiveRequest({ decision: "accept" });
|
||||
failActiveRequest(new Error("stale request failure"));
|
||||
|
||||
expect(await waitForResponse(harness, "request-active")).toEqual({
|
||||
id: "request-active",
|
||||
result: { decision: "decline" },
|
||||
});
|
||||
expect(await waitForResponse(harness, "request-active-reject")).toEqual({
|
||||
id: "request-active-reject",
|
||||
result: { decision: "decline" },
|
||||
});
|
||||
|
||||
const closingRoute = router.reserveThread({
|
||||
threadId: "thread-close",
|
||||
onRequest: requestHandler,
|
||||
});
|
||||
harness.client.close();
|
||||
|
||||
await expect(closingRoute.bindTurn("turn-close")).rejects.toThrow("turn router closed");
|
||||
expect(closingRoute.signal.aborted).toBe(true);
|
||||
expect(closingRoute.signal.reason).toEqual(new Error("codex app-server turn router closed"));
|
||||
expect(() =>
|
||||
router.reserveThread({ threadId: "thread-late", onRequest: requestHandler }),
|
||||
).toThrow("turn router is closed");
|
||||
});
|
||||
|
||||
it("releases dormant waiters and aborts the reservation", async () => {
|
||||
const harness = createHarness();
|
||||
const router = getCodexAppServerTurnRouter(harness.client);
|
||||
const route = router.reserveThread({ threadId: "thread-dormant-release" });
|
||||
harness.send({
|
||||
id: "request-dormant-release",
|
||||
method: "item/tool/call",
|
||||
params: { threadId: "thread-dormant-release", turnId: "turn-1" },
|
||||
});
|
||||
await settleInput();
|
||||
|
||||
route.release();
|
||||
|
||||
expect(route.signal.aborted).toBe(true);
|
||||
expect(route.signal.reason).toEqual(new Error("codex app-server thread route is released"));
|
||||
await expect(route.activate({ onRequest: vi.fn() })).rejects.toThrow(
|
||||
"thread route is released",
|
||||
);
|
||||
await expect(route.bindTurn("turn-1")).rejects.toThrow("thread route is released");
|
||||
expect(await waitForResponse(harness, "request-dormant-release")).toEqual({
|
||||
id: "request-dormant-release",
|
||||
result: {
|
||||
contentItems: [
|
||||
{
|
||||
type: "inputText",
|
||||
text: "OpenClaw did not register a handler for this app-server tool call.",
|
||||
},
|
||||
],
|
||||
success: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails and removes a route when its pre-bind buffer is full", async () => {
|
||||
vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
|
||||
const harness = createHarness();
|
||||
const router = getCodexAppServerTurnRouter(harness.client);
|
||||
const route = router.reserveThread({
|
||||
threadId: "thread-overflow",
|
||||
onNotification: vi.fn(),
|
||||
});
|
||||
route.armTurn();
|
||||
for (let index = 0; index <= 256; index += 1) {
|
||||
harness.send({
|
||||
method: "item/started",
|
||||
params: { threadId: "thread-overflow", turnId: "turn-overflow" },
|
||||
});
|
||||
}
|
||||
await settleInput();
|
||||
|
||||
await expect(route.bindTurn("turn-overflow")).rejects.toThrow(
|
||||
"pre-bind notification buffer exceeded 256 entries",
|
||||
);
|
||||
expect(() =>
|
||||
router.reserveThread({
|
||||
threadId: "thread-overflow",
|
||||
onNotification: vi.fn(),
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForResponse(harness: ClientHarness, id: number | string): Promise<WireResponse> {
|
||||
let response: WireResponse | undefined;
|
||||
await vi.waitFor(() => {
|
||||
response = harness.writes
|
||||
.map((write) => JSON.parse(write) as WireResponse)
|
||||
.find((candidate) => candidate.id === id);
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
if (!response) {
|
||||
throw new Error(`missing app-server response for ${id}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function settleInput(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
}
|
||||
@@ -1,592 +0,0 @@
|
||||
/** Keyed routing for all turn traffic on one shared Codex app-server client. */
|
||||
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import {
|
||||
readCodexNotificationThreadId,
|
||||
readCodexNotificationTurnId,
|
||||
} from "./notification-correlation.js";
|
||||
import {
|
||||
isJsonObject,
|
||||
type CodexServerNotification,
|
||||
type JsonValue,
|
||||
type RpcRequest,
|
||||
} from "./protocol.js";
|
||||
|
||||
const DEFAULT_PREBIND_NOTIFICATION_LIMIT = 256;
|
||||
export const CODEX_APP_SERVER_NATIVE_TURN_WAIT_TIMEOUT_MS = 30_000;
|
||||
|
||||
export type CodexAppServerServerRequest = Required<Pick<RpcRequest, "id" | "method">> & {
|
||||
params?: JsonValue;
|
||||
};
|
||||
export type CodexThreadRouteScope = {
|
||||
threadId: string;
|
||||
turnId?: string;
|
||||
};
|
||||
type CodexThreadRequestHandler = (
|
||||
request: CodexAppServerServerRequest,
|
||||
scope: CodexThreadRouteScope,
|
||||
) => Promise<JsonValue | undefined> | JsonValue | undefined;
|
||||
type CodexThreadNotificationHandler = (
|
||||
notification: CodexServerNotification,
|
||||
scope: CodexThreadRouteScope,
|
||||
) => Promise<void> | void;
|
||||
type CodexThreadNotificationReceivedHandler = (
|
||||
notification: CodexServerNotification,
|
||||
scope: CodexThreadRouteScope,
|
||||
receivedAtMs: number,
|
||||
) => void;
|
||||
type CodexThreadRouteHandlers = {
|
||||
onNotificationReceived?: CodexThreadNotificationReceivedHandler;
|
||||
onNotification?: CodexThreadNotificationHandler;
|
||||
onRequest?: CodexThreadRequestHandler;
|
||||
};
|
||||
|
||||
export type CodexThreadRouteReservation = {
|
||||
readonly threadId: string;
|
||||
readonly signal: AbortSignal;
|
||||
activate: (handlers: CodexThreadRouteHandlers) => Promise<void>;
|
||||
armTurn: () => void;
|
||||
bindTurn: (turnId: string) => Promise<void>;
|
||||
cancelTurn: () => Promise<void>;
|
||||
waitForTurnCompletion: (options: { timeoutMs: number; signal?: AbortSignal }) => Promise<boolean>;
|
||||
drain: () => Promise<void>;
|
||||
release: () => void;
|
||||
};
|
||||
|
||||
type RouteOptions = Partial<CodexThreadRouteHandlers> & {
|
||||
threadId: string;
|
||||
releaseOn?: AbortSignal;
|
||||
};
|
||||
|
||||
export type CodexAppServerTurnRouter = {
|
||||
reserveThread: (options: RouteOptions) => CodexThreadRouteReservation;
|
||||
watchNativeTurnCompletion: (options: {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
timeoutMs: number;
|
||||
}) => CodexNativeTurnCompletionWatch;
|
||||
};
|
||||
|
||||
export type CodexNativeTurnCompletionWatch = {
|
||||
completion: Promise<boolean>;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
type Deferred = { promise: Promise<void>; resolve: () => void };
|
||||
type PendingNotification = {
|
||||
notification: CodexServerNotification;
|
||||
receivedAtMs: number;
|
||||
scope: CodexThreadRouteScope;
|
||||
};
|
||||
type Route = {
|
||||
threadId: string;
|
||||
controller: AbortController;
|
||||
handlers?: CodexThreadRouteHandlers;
|
||||
released?: Error;
|
||||
ended: Deferred;
|
||||
activated: Deferred;
|
||||
gate: "open" | "armed" | "bound";
|
||||
binding?: Deferred;
|
||||
turnId?: string;
|
||||
pending: PendingNotification[];
|
||||
notificationTail: Promise<void>;
|
||||
nativeTurnCompleted: boolean;
|
||||
nativeTurnCompletion?: Deferred;
|
||||
detachReleaseOn?: () => void;
|
||||
};
|
||||
type NativeTurnCompletionWatcher = {
|
||||
turnId: string;
|
||||
finish: (completed: boolean) => void;
|
||||
touch: () => void;
|
||||
};
|
||||
|
||||
const routers = new WeakMap<CodexAppServerClient, ClientTurnRouter>();
|
||||
|
||||
/** Returns the sole router installed on a physical app-server client. */
|
||||
export function getCodexAppServerTurnRouter(
|
||||
client: CodexAppServerClient,
|
||||
): CodexAppServerTurnRouter {
|
||||
const existing = routers.get(client);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const router = new ClientTurnRouter(client);
|
||||
routers.set(client, router);
|
||||
return router;
|
||||
}
|
||||
|
||||
class ClientTurnRouter implements CodexAppServerTurnRouter {
|
||||
private readonly routes = new Map<string, Route>();
|
||||
private readonly nativeTurnCompletionWatchers = new Map<
|
||||
string,
|
||||
Set<NativeTurnCompletionWatcher>
|
||||
>();
|
||||
private disposed = false;
|
||||
|
||||
constructor(client: CodexAppServerClient) {
|
||||
client.addNotificationHandler((notification) => this.routeNotification(notification));
|
||||
client.addRequestHandler((request) => this.routeRequest(request));
|
||||
client.addCloseHandler(() => this.dispose());
|
||||
}
|
||||
|
||||
reserveThread(options: RouteOptions): CodexThreadRouteReservation {
|
||||
this.assertActive();
|
||||
const threadId = requireId(options.threadId, "thread id");
|
||||
if (this.routes.has(threadId)) {
|
||||
throw new Error(`codex app-server thread route already reserved: ${threadId}`);
|
||||
}
|
||||
const route: Route = {
|
||||
threadId,
|
||||
controller: new AbortController(),
|
||||
ended: deferred(),
|
||||
activated: deferred(),
|
||||
gate: "open",
|
||||
pending: [],
|
||||
notificationTail: Promise.resolve(),
|
||||
nativeTurnCompleted: false,
|
||||
};
|
||||
this.routes.set(threadId, route);
|
||||
if (options.onNotification || options.onRequest) {
|
||||
this.activateNow(route, options);
|
||||
}
|
||||
const releaseOn = options.releaseOn;
|
||||
if (releaseOn) {
|
||||
const release = () => this.release(route, abortReason(releaseOn));
|
||||
releaseOn.addEventListener("abort", release, { once: true });
|
||||
route.detachReleaseOn = () => releaseOn.removeEventListener("abort", release);
|
||||
if (releaseOn.aborted) {
|
||||
release();
|
||||
}
|
||||
}
|
||||
return {
|
||||
threadId,
|
||||
signal: route.controller.signal,
|
||||
activate: (handlers) => this.activate(route, handlers),
|
||||
armTurn: () => this.armTurn(route),
|
||||
bindTurn: (turnId) => this.bindTurn(route, turnId),
|
||||
cancelTurn: () => this.cancelTurn(route),
|
||||
waitForTurnCompletion: (waitOptions) => this.waitForTurnCompletion(route, waitOptions),
|
||||
drain: () => this.drainNotifications(route),
|
||||
release: () => this.release(route),
|
||||
};
|
||||
}
|
||||
|
||||
watchNativeTurnCompletion(options: {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
timeoutMs: number;
|
||||
}): CodexNativeTurnCompletionWatch {
|
||||
this.assertActive();
|
||||
const threadId = requireId(options.threadId, "thread id");
|
||||
const turnId = requireId(options.turnId, "turn id");
|
||||
let settle!: (completed: boolean) => void;
|
||||
const completion = new Promise<boolean>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
const watchers =
|
||||
this.nativeTurnCompletionWatchers.get(threadId) ?? new Set<NativeTurnCompletionWatcher>();
|
||||
this.nativeTurnCompletionWatchers.set(threadId, watchers);
|
||||
let settled = false;
|
||||
const finish = (completed: boolean) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
watchers.delete(watcher);
|
||||
if (watchers.size === 0) {
|
||||
this.nativeTurnCompletionWatchers.delete(threadId);
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
settle(completed);
|
||||
};
|
||||
const touch = () => {
|
||||
timeout.refresh();
|
||||
};
|
||||
const watcher = { turnId, finish, touch };
|
||||
watchers.add(watcher);
|
||||
const timeout = setTimeout(() => finish(false), Math.max(1, options.timeoutMs));
|
||||
timeout.unref?.();
|
||||
return { completion, cancel: () => finish(false) };
|
||||
}
|
||||
|
||||
private dispose(): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.disposed = true;
|
||||
for (const route of this.routes.values()) {
|
||||
this.release(route, new Error("codex app-server turn router closed"));
|
||||
}
|
||||
for (const watchers of this.nativeTurnCompletionWatchers.values()) {
|
||||
for (const watcher of watchers) {
|
||||
watcher.finish(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async activate(route: Route, handlers: CodexThreadRouteHandlers): Promise<void> {
|
||||
this.assertRoute(route);
|
||||
this.activateNow(route, handlers);
|
||||
await this.waitForNotifications(route);
|
||||
this.assertRoute(route);
|
||||
}
|
||||
|
||||
private activateNow(route: Route, handlers: CodexThreadRouteHandlers): void {
|
||||
if (route.handlers) {
|
||||
throw new Error(`codex app-server thread route already activated: ${route.threadId}`);
|
||||
}
|
||||
this.assertRoute(route);
|
||||
if (!handlers.onNotification && !handlers.onRequest) {
|
||||
throw new Error("codex app-server thread route requires a notification or request handler");
|
||||
}
|
||||
route.handlers = handlers;
|
||||
if (!handlers.onNotification) {
|
||||
route.pending.length = 0;
|
||||
} else if (route.gate !== "armed") {
|
||||
this.flushNotifications(route);
|
||||
}
|
||||
route.activated.resolve();
|
||||
}
|
||||
|
||||
private armTurn(route: Route): void {
|
||||
this.assertRoute(route);
|
||||
if (route.gate !== "open") {
|
||||
throw new Error(`codex app-server thread route cannot arm from ${route.gate}`);
|
||||
}
|
||||
route.gate = "armed";
|
||||
route.nativeTurnCompleted = false;
|
||||
route.binding = deferred();
|
||||
}
|
||||
|
||||
private async cancelTurn(route: Route): Promise<void> {
|
||||
if (route.released || route.gate !== "armed") {
|
||||
return;
|
||||
}
|
||||
route.gate = "open";
|
||||
route.binding?.resolve();
|
||||
route.binding = undefined;
|
||||
this.flushNotifications(route);
|
||||
await this.waitForNotifications(route);
|
||||
this.assertRoute(route);
|
||||
}
|
||||
|
||||
private async bindTurn(route: Route, turnIdInput: string): Promise<void> {
|
||||
this.assertRoute(route);
|
||||
if (!route.handlers) {
|
||||
throw new Error("codex app-server thread route must be activated before binding a turn");
|
||||
}
|
||||
if (route.gate !== "armed") {
|
||||
throw new Error(`codex app-server thread route cannot bind from ${route.gate}`);
|
||||
}
|
||||
const turnId = requireId(turnIdInput, "turn id");
|
||||
route.gate = "bound";
|
||||
route.turnId = turnId;
|
||||
this.flushNotifications(route);
|
||||
route.binding?.resolve();
|
||||
await this.waitForNotifications(route);
|
||||
this.assertRoute(route);
|
||||
}
|
||||
|
||||
private routeNotification(notification: CodexServerNotification): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
const scope = readScope(notification.params);
|
||||
const watchers = scope.threadId
|
||||
? this.nativeTurnCompletionWatchers.get(scope.threadId)
|
||||
: undefined;
|
||||
const route = scope.threadId ? this.routes.get(scope.threadId) : undefined;
|
||||
if (!watchers && !route) {
|
||||
return;
|
||||
}
|
||||
const terminal = isCodexTerminalTurnNotification(notification);
|
||||
if (scope.turnId && watchers) {
|
||||
for (const watcher of watchers) {
|
||||
if (watcher.turnId === scope.turnId) {
|
||||
if (terminal) {
|
||||
watcher.finish(true);
|
||||
} else {
|
||||
watcher.touch();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!route) {
|
||||
return;
|
||||
}
|
||||
const routeScope: CodexThreadRouteScope = {
|
||||
threadId: route.threadId,
|
||||
...(scope.turnId ? { turnId: scope.turnId } : {}),
|
||||
};
|
||||
const receivedAtMs = Date.now();
|
||||
if (route.gate !== "bound" && terminal) {
|
||||
if (route.nativeTurnCompletion) {
|
||||
route.nativeTurnCompletion.resolve();
|
||||
} else {
|
||||
route.nativeTurnCompleted = true;
|
||||
}
|
||||
}
|
||||
if (!route.handlers) {
|
||||
this.bufferNotification(route, notification, routeScope, receivedAtMs);
|
||||
return;
|
||||
}
|
||||
const handler = route.handlers.onNotification;
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
if (route.gate === "bound" && scope.turnId && scope.turnId !== route.turnId) {
|
||||
return;
|
||||
}
|
||||
if (route.gate === "armed") {
|
||||
this.bufferNotification(route, notification, routeScope, receivedAtMs);
|
||||
return;
|
||||
}
|
||||
route.handlers.onNotificationReceived?.(notification, routeScope, receivedAtMs);
|
||||
this.enqueueNotification(route, handler, notification, routeScope);
|
||||
}
|
||||
|
||||
private async routeRequest(request: CodexAppServerServerRequest): Promise<JsonValue | undefined> {
|
||||
if (this.disposed) {
|
||||
return undefined;
|
||||
}
|
||||
const scope = readScope(request.params);
|
||||
if (!scope.threadId) {
|
||||
return undefined;
|
||||
}
|
||||
const route = this.routes.get(scope.threadId);
|
||||
if (!route || route.released) {
|
||||
return undefined;
|
||||
}
|
||||
if (!route.handlers) {
|
||||
await route.activated.promise;
|
||||
}
|
||||
if (route.released || !route.handlers) {
|
||||
return undefined;
|
||||
}
|
||||
const handler = route.handlers.onRequest;
|
||||
if (!handler) {
|
||||
return undefined;
|
||||
}
|
||||
// Open routes service a resumed native turn. Arming starts the handoff to a
|
||||
// new OpenClaw turn, whose requests must wait for its accepted turn id.
|
||||
while (route.gate === "armed") {
|
||||
await route.binding?.promise;
|
||||
if (route.released) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (route.gate === "bound") {
|
||||
if (scope.turnId && scope.turnId !== route.turnId) {
|
||||
return undefined;
|
||||
}
|
||||
if (route.released) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
await this.waitForNotifications(route);
|
||||
if (route.released) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const result = await handler(request, {
|
||||
threadId: scope.threadId,
|
||||
...(scope.turnId ? { turnId: scope.turnId } : {}),
|
||||
});
|
||||
return route.released ? undefined : result;
|
||||
} catch (error) {
|
||||
if (route.released) {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private flushNotifications(route: Route): void {
|
||||
const handler = route.handlers?.onNotification;
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
for (const pending of route.pending.splice(0)) {
|
||||
if (
|
||||
!pending.scope.turnId ||
|
||||
route.gate !== "bound" ||
|
||||
pending.scope.turnId === route.turnId
|
||||
) {
|
||||
route.handlers?.onNotificationReceived?.(
|
||||
pending.notification,
|
||||
pending.scope,
|
||||
pending.receivedAtMs,
|
||||
);
|
||||
this.enqueueNotification(route, handler, pending.notification, pending.scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bufferNotification(
|
||||
route: Route,
|
||||
notification: CodexServerNotification,
|
||||
scope: CodexThreadRouteScope,
|
||||
receivedAtMs: number,
|
||||
): void {
|
||||
if (route.pending.length < DEFAULT_PREBIND_NOTIFICATION_LIMIT) {
|
||||
route.pending.push({ notification, receivedAtMs, scope });
|
||||
return;
|
||||
}
|
||||
const error = new Error(
|
||||
`codex app-server pre-bind notification buffer exceeded ${DEFAULT_PREBIND_NOTIFICATION_LIMIT} entries for thread ${route.threadId}`,
|
||||
);
|
||||
embeddedAgentLog.warn(error.message);
|
||||
this.release(route, error);
|
||||
}
|
||||
|
||||
private enqueueNotification(
|
||||
route: Route,
|
||||
handler: CodexThreadNotificationHandler,
|
||||
notification: CodexServerNotification,
|
||||
scope: CodexThreadRouteScope,
|
||||
): void {
|
||||
if (route.released) {
|
||||
return;
|
||||
}
|
||||
route.notificationTail = route.notificationTail
|
||||
.then(() => handler(notification, scope))
|
||||
.catch((error: unknown) => {
|
||||
if (!route.released) {
|
||||
embeddedAgentLog.warn("codex app-server keyed notification handler failed", {
|
||||
method: notification.method,
|
||||
threadId: route.threadId,
|
||||
turnId: route.turnId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async waitForNotifications(route: Route): Promise<void> {
|
||||
await Promise.race([route.notificationTail, route.ended.promise]);
|
||||
}
|
||||
|
||||
private async drainNotifications(route: Route): Promise<void> {
|
||||
await route.notificationTail;
|
||||
}
|
||||
|
||||
private async waitForTurnCompletion(
|
||||
route: Route,
|
||||
options: { timeoutMs: number; signal?: AbortSignal },
|
||||
): Promise<boolean> {
|
||||
this.assertRoute(route);
|
||||
if (route.nativeTurnCompleted) {
|
||||
route.nativeTurnCompleted = false;
|
||||
return true;
|
||||
}
|
||||
if (route.nativeTurnCompletion) {
|
||||
throw new Error(`codex app-server turn completion wait already active: ${route.threadId}`);
|
||||
}
|
||||
const completion = deferred();
|
||||
route.nativeTurnCompletion = completion;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let removeAbort: (() => void) | undefined;
|
||||
const timedOut = new Promise<boolean>((resolve) => {
|
||||
timeout = setTimeout(() => resolve(false), Math.max(1, options.timeoutMs));
|
||||
});
|
||||
const aborted = new Promise<boolean>((resolve) => {
|
||||
const signal = options.signal;
|
||||
if (!signal) {
|
||||
return;
|
||||
}
|
||||
const onAbort = () => resolve(false);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
removeAbort = () => signal.removeEventListener("abort", onAbort);
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
}
|
||||
});
|
||||
try {
|
||||
return await Promise.race([
|
||||
completion.promise.then(() => true),
|
||||
route.ended.promise.then(() => false),
|
||||
timedOut,
|
||||
aborted,
|
||||
]);
|
||||
} finally {
|
||||
if (route.nativeTurnCompletion === completion) {
|
||||
route.nativeTurnCompletion = undefined;
|
||||
}
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
removeAbort?.();
|
||||
}
|
||||
}
|
||||
|
||||
private release(route: Route, error = new Error("codex app-server thread route is released")) {
|
||||
if (route.released) {
|
||||
return;
|
||||
}
|
||||
route.released = error;
|
||||
route.pending.length = 0;
|
||||
route.ended.resolve();
|
||||
route.activated.resolve();
|
||||
route.binding?.resolve();
|
||||
route.detachReleaseOn?.();
|
||||
route.controller.abort(error);
|
||||
if (this.routes.get(route.threadId) === route) {
|
||||
this.routes.delete(route.threadId);
|
||||
}
|
||||
}
|
||||
|
||||
private assertActive(): void {
|
||||
if (this.disposed) {
|
||||
throw new Error("codex app-server turn router is closed");
|
||||
}
|
||||
}
|
||||
|
||||
private assertRoute(route: Route): void {
|
||||
if (route.released) {
|
||||
throw route.released;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True after Codex will not continue the exact turn. */
|
||||
export function isCodexTerminalTurnNotification(notification: CodexServerNotification): boolean {
|
||||
if (notification.method === "turn/completed") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
notification.method === "error" &&
|
||||
isJsonObject(notification.params) &&
|
||||
notification.params.willRetry === false
|
||||
);
|
||||
}
|
||||
|
||||
function deferred(): Deferred {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function abortReason(signal: AbortSignal): Error {
|
||||
return signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error(String(signal.reason ?? "codex app-server thread route aborted"));
|
||||
}
|
||||
|
||||
function readScope(value: JsonValue | undefined) {
|
||||
if (!isJsonObject(value)) {
|
||||
return {};
|
||||
}
|
||||
const threadId = readCodexNotificationThreadId(value);
|
||||
const turnId = readCodexNotificationTurnId(value);
|
||||
return { ...(threadId ? { threadId } : {}), ...(turnId ? { turnId } : {}) };
|
||||
}
|
||||
|
||||
function requireId(value: string, label: string): string {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
throw new Error(`codex app-server ${label} must not be empty`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -16,11 +16,7 @@ import {
|
||||
type JsonObject,
|
||||
type JsonValue,
|
||||
} from "./protocol.js";
|
||||
import {
|
||||
readCodexRateLimitsRevision,
|
||||
readRecentCodexRateLimits,
|
||||
rememberCodexRateLimitsRead,
|
||||
} from "./rate-limit-cache.js";
|
||||
import { readRecentCodexRateLimits, rememberCodexRateLimits } from "./rate-limit-cache.js";
|
||||
import {
|
||||
formatCodexUsageLimitErrorMessage,
|
||||
resolveCodexUsageLimitResetAtMs,
|
||||
@@ -77,19 +73,13 @@ export async function markCodexAuthProfileBlockedFromRateLimits(params: {
|
||||
export async function formatCodexTurnStartUsageLimitError(params: {
|
||||
client: CodexAppServerClient;
|
||||
error: unknown;
|
||||
errorNotification?: CodexServerNotification;
|
||||
rateLimitsRevisionBeforeTurnStart?: number;
|
||||
pendingNotifications: CodexServerNotification[];
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<CodexUsageLimitErrorResult | undefined> {
|
||||
return refreshCodexUsageLimitError({
|
||||
client: params.client,
|
||||
source: readCodexTurnStartUsageLimitErrorSource(
|
||||
params.client,
|
||||
params.error,
|
||||
params.errorNotification,
|
||||
params.rateLimitsRevisionBeforeTurnStart,
|
||||
),
|
||||
source: readCodexTurnStartUsageLimitErrorSource(params.error, params.pendingNotifications),
|
||||
timeoutMs: params.timeoutMs,
|
||||
signal: params.signal,
|
||||
});
|
||||
@@ -111,7 +101,7 @@ export async function refreshCodexUsageLimitPromptError(params: {
|
||||
source: {
|
||||
message: params.message,
|
||||
codexErrorInfo: "usageLimitExceeded",
|
||||
rateLimits: readRecentCodexRateLimits(params.client),
|
||||
rateLimits: readRecentCodexRateLimits(),
|
||||
},
|
||||
timeoutMs: params.timeoutMs,
|
||||
signal: params.signal,
|
||||
@@ -173,7 +163,7 @@ async function readCodexRateLimitsFromAppServerForUsageLimitError(params: {
|
||||
timeoutMs: resolveCodexUsageLimitRateLimitRefreshTimeoutMs(params.timeoutMs),
|
||||
signal: params.signal,
|
||||
});
|
||||
rememberCodexRateLimitsRead(params.client, rateLimits);
|
||||
rememberCodexRateLimits(rateLimits);
|
||||
return rateLimits;
|
||||
} catch (error) {
|
||||
embeddedAgentLog.debug("codex app-server rate-limit refresh failed after usage-limit error", {
|
||||
@@ -191,39 +181,54 @@ function resolveCodexUsageLimitRateLimitRefreshTimeoutMs(timeoutMs: number | und
|
||||
}
|
||||
|
||||
function readCodexTurnStartUsageLimitErrorSource(
|
||||
client: CodexAppServerClient,
|
||||
error: unknown,
|
||||
errorNotification: CodexServerNotification | undefined,
|
||||
rateLimitsRevisionBeforeTurnStart: number | undefined,
|
||||
pendingNotifications: CodexServerNotification[],
|
||||
): CodexUsageLimitErrorSource {
|
||||
const notificationError = readCodexErrorNotification(errorNotification);
|
||||
const notificationError = readLatestCodexErrorNotification(pendingNotifications);
|
||||
const notificationRateLimits = readLatestRateLimitNotificationPayload(pendingNotifications);
|
||||
const errorPayload = readCodexErrorPayload(error);
|
||||
const rateLimits = errorPayload.rateLimits ?? readRecentCodexRateLimits(client);
|
||||
const cacheUpdatedDuringTurnStart =
|
||||
rateLimitsRevisionBeforeTurnStart !== undefined &&
|
||||
readCodexRateLimitsRevision(client) > rateLimitsRevisionBeforeTurnStart;
|
||||
const rateLimits =
|
||||
notificationRateLimits ?? errorPayload.rateLimits ?? readRecentCodexRateLimits();
|
||||
return {
|
||||
message: notificationError?.message ?? errorPayload.message ?? formatErrorMessage(error),
|
||||
codexErrorInfo: notificationError?.codexErrorInfo ?? errorPayload.codexErrorInfo,
|
||||
rateLimits,
|
||||
rateLimitsTrustedForProfile:
|
||||
errorPayload.rateLimits !== undefined || cacheUpdatedDuringTurnStart,
|
||||
notificationRateLimits !== undefined || errorPayload.rateLimits !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function readCodexErrorNotification(
|
||||
notification: CodexServerNotification | undefined,
|
||||
): { message?: string; codexErrorInfo?: JsonValue | null } | undefined {
|
||||
if (notification?.method !== "error" || !isJsonObject(notification.params)) {
|
||||
return undefined;
|
||||
function readLatestRateLimitNotificationPayload(
|
||||
notifications: CodexServerNotification[],
|
||||
): JsonValue | undefined {
|
||||
for (let index = notifications.length - 1; index >= 0; index -= 1) {
|
||||
const notification = notifications[index];
|
||||
if (notification?.method === "account/rateLimits/updated") {
|
||||
rememberCodexRateLimits(notification.params);
|
||||
return notification.params;
|
||||
}
|
||||
}
|
||||
const error = notification.params.error;
|
||||
return isJsonObject(error)
|
||||
? {
|
||||
message: readString(error, "message"),
|
||||
codexErrorInfo: error.codexErrorInfo,
|
||||
}
|
||||
: undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readLatestCodexErrorNotification(
|
||||
notifications: CodexServerNotification[],
|
||||
): { message?: string; codexErrorInfo?: JsonValue | null } | undefined {
|
||||
for (let index = notifications.length - 1; index >= 0; index -= 1) {
|
||||
const notification = notifications[index];
|
||||
if (notification?.method !== "error" || !isJsonObject(notification.params)) {
|
||||
continue;
|
||||
}
|
||||
const error = notification.params.error;
|
||||
if (!isJsonObject(error)) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
message: readString(error, "message"),
|
||||
codexErrorInfo: error.codexErrorInfo,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readCodexErrorPayload(error: unknown): {
|
||||
@@ -241,6 +246,9 @@ function readCodexErrorPayload(error: unknown): {
|
||||
}
|
||||
const nestedError = isJsonObject(data.error) ? data.error : data;
|
||||
const rateLimits = nestedError.rateLimits ?? data.rateLimits;
|
||||
if (rateLimits !== undefined) {
|
||||
rememberCodexRateLimits(rateLimits);
|
||||
}
|
||||
return {
|
||||
message: readString(nestedError, "message") ?? message,
|
||||
codexErrorInfo: nestedError.codexErrorInfo,
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { normalizeUniqueStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { CODEX_CONTROL_METHODS, type CodexControlMethod } from "./app-server/capabilities.js";
|
||||
import { isJsonObject, type JsonObject, type JsonValue } from "./app-server/protocol.js";
|
||||
import { rememberCodexRateLimits } from "./app-server/rate-limit-cache.js";
|
||||
import {
|
||||
summarizeCodexAccountUsage,
|
||||
type CodexAccountUsageSummary,
|
||||
@@ -56,13 +57,9 @@ export async function readCodexAccountAuthOverview(params: {
|
||||
safeCodexControlRequest: SafeCodexControlRequest;
|
||||
account: SafeValue<JsonValue | undefined>;
|
||||
limits: SafeValue<JsonValue | undefined>;
|
||||
scope: Pick<
|
||||
CodexControlRequestOptions,
|
||||
"agentDir" | "authProfileId" | "sessionId" | "sessionKey"
|
||||
>;
|
||||
}): Promise<CodexAccountAuthOverview | undefined> {
|
||||
const config = params.ctx.config;
|
||||
const agentDir = params.scope.agentDir ?? resolveDefaultAgentDir(config);
|
||||
const agentDir = resolveDefaultAgentDir(config);
|
||||
const store = ensureAuthProfileStore(agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
config,
|
||||
@@ -320,10 +317,6 @@ async function readSubscriptionUsage(params: {
|
||||
config: AuthProfileOrderConfig;
|
||||
subscriptionProfileId: string;
|
||||
now: number;
|
||||
scope: Pick<
|
||||
CodexControlRequestOptions,
|
||||
"agentDir" | "authProfileId" | "sessionId" | "sessionKey"
|
||||
>;
|
||||
}): Promise<CodexAccountUsageSummary | undefined> {
|
||||
const limits = await params.safeCodexControlRequest(
|
||||
params.pluginConfig,
|
||||
@@ -331,7 +324,6 @@ async function readSubscriptionUsage(params: {
|
||||
undefined,
|
||||
{
|
||||
config: params.config,
|
||||
...params.scope,
|
||||
authProfileId: params.subscriptionProfileId,
|
||||
isolated: true,
|
||||
},
|
||||
@@ -339,6 +331,7 @@ async function readSubscriptionUsage(params: {
|
||||
if (!limits.ok) {
|
||||
return undefined;
|
||||
}
|
||||
rememberCodexRateLimits(limits.value);
|
||||
return summarizeCodexAccountUsage(limits.value, params.now);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,6 @@ type AuthProfileOrderConfig = Parameters<
|
||||
export type CodexControlRequestOptions = {
|
||||
config?: AuthProfileOrderConfig;
|
||||
authProfileId?: string;
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
@@ -35,10 +34,6 @@ export function requestOptions(
|
||||
pluginConfig: unknown,
|
||||
limit: number,
|
||||
config?: AuthProfileOrderConfig,
|
||||
scope: Pick<
|
||||
CodexControlRequestOptions,
|
||||
"agentDir" | "agentId" | "authProfileId" | "sessionId" | "sessionKey"
|
||||
> = {},
|
||||
) {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
return {
|
||||
@@ -46,7 +41,6 @@ export function requestOptions(
|
||||
timeoutMs: runtime.requestTimeoutMs,
|
||||
startOptions: runtime.start,
|
||||
config,
|
||||
...scope,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,7 +63,7 @@ export async function codexControlRequest(
|
||||
method: CodexControlMethod,
|
||||
requestParams?: unknown,
|
||||
options: CodexControlRequestOptions = {},
|
||||
): Promise<unknown> {
|
||||
) {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
return await requestCodexAppServerJson({
|
||||
method,
|
||||
@@ -77,7 +71,6 @@ export async function codexControlRequest(
|
||||
timeoutMs: runtime.requestTimeoutMs,
|
||||
startOptions: runtime.start,
|
||||
config: options.config,
|
||||
agentId: options.agentId,
|
||||
sessionKey: options.sessionKey,
|
||||
sessionId: options.sessionId,
|
||||
authProfileId: options.authProfileId,
|
||||
@@ -103,7 +96,7 @@ export async function safeCodexControlRequest(
|
||||
method: CodexControlMethod,
|
||||
requestParams?: unknown,
|
||||
options: CodexControlRequestOptions = {},
|
||||
): Promise<SafeValue<unknown>> {
|
||||
) {
|
||||
return await safeValue(
|
||||
async () =>
|
||||
await codexControlRequest(pluginConfig, method, requestParams as JsonValue, options),
|
||||
@@ -114,51 +107,32 @@ async function safeCodexModelList(
|
||||
pluginConfig: unknown,
|
||||
limit: number,
|
||||
config?: AuthProfileOrderConfig,
|
||||
scope?: Pick<
|
||||
CodexControlRequestOptions,
|
||||
"agentDir" | "agentId" | "authProfileId" | "sessionId" | "sessionKey"
|
||||
>,
|
||||
) {
|
||||
return await safeValue(
|
||||
async () => await listCodexAppServerModels(requestOptions(pluginConfig, limit, config, scope)),
|
||||
async () => await listCodexAppServerModels(requestOptions(pluginConfig, limit, config)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function readCodexStatusProbes(
|
||||
pluginConfig: unknown,
|
||||
config?: AuthProfileOrderConfig,
|
||||
scope: Pick<
|
||||
CodexControlRequestOptions,
|
||||
"agentDir" | "agentId" | "authProfileId" | "sessionId" | "sessionKey"
|
||||
> = {},
|
||||
) {
|
||||
const [models, account, limits, mcps, skills] = await Promise.all([
|
||||
safeCodexModelList(pluginConfig, 20, config, scope),
|
||||
safeCodexModelList(pluginConfig, 20, config),
|
||||
safeCodexControlRequest(
|
||||
pluginConfig,
|
||||
CODEX_CONTROL_METHODS.account,
|
||||
{ refreshToken: false },
|
||||
{ config, ...scope },
|
||||
{ config },
|
||||
),
|
||||
safeCodexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.rateLimits, undefined, {
|
||||
config,
|
||||
...scope,
|
||||
}),
|
||||
safeCodexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.rateLimits, undefined, { config }),
|
||||
safeCodexControlRequest(
|
||||
pluginConfig,
|
||||
CODEX_CONTROL_METHODS.listMcpServers,
|
||||
{ limit: 100 },
|
||||
{ config, ...scope },
|
||||
),
|
||||
safeCodexControlRequest(
|
||||
pluginConfig,
|
||||
CODEX_CONTROL_METHODS.listSkills,
|
||||
{},
|
||||
{
|
||||
config,
|
||||
...scope,
|
||||
},
|
||||
{ config },
|
||||
),
|
||||
safeCodexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.listSkills, {}, { config }),
|
||||
]);
|
||||
|
||||
return { models, account, limits, mcps, skills };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,12 +9,11 @@ import type {
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { describeControlFailure } from "./app-server/capabilities.js";
|
||||
import { formatCodexDisplayText } from "./command-formatters.js";
|
||||
import type { CodexCommandDepsOverride } from "./command-handlers.js";
|
||||
import type { CodexCommandDeps } from "./command-handlers.js";
|
||||
|
||||
type CodexCommandOptions = {
|
||||
pluginConfig?: unknown;
|
||||
resolvePluginConfig?: () => unknown;
|
||||
deps: CodexCommandDepsOverride;
|
||||
deps?: Partial<CodexCommandDeps>;
|
||||
};
|
||||
|
||||
type CodexSubcommandHandler = (
|
||||
@@ -51,17 +50,14 @@ export function createCodexCommand(options: CodexCommandOptions): OpenClawPlugin
|
||||
/** Dispatches a `/codex` command to the subcommand handler and formats failures for chat. */
|
||||
export async function handleCodexCommand(
|
||||
ctx: PluginCommandContext,
|
||||
options: CodexCommandInternalOptions,
|
||||
options: CodexCommandInternalOptions = {},
|
||||
): Promise<PluginCommandResult> {
|
||||
const { loadSubcommandHandler, resolvePluginConfig, ...subcommandOptions } = options;
|
||||
const { loadSubcommandHandler, ...subcommandOptions } = options;
|
||||
try {
|
||||
const handleCodexSubcommand = loadSubcommandHandler
|
||||
? await loadSubcommandHandler()
|
||||
: await loadDefaultCodexSubcommandHandler();
|
||||
return await handleCodexSubcommand(ctx, {
|
||||
...subcommandOptions,
|
||||
pluginConfig: resolvePluginConfig?.() ?? subcommandOptions.pluginConfig,
|
||||
});
|
||||
return await handleCodexSubcommand(ctx, subcommandOptions);
|
||||
} catch (error) {
|
||||
return {
|
||||
text: `Codex command failed: ${formatCodexDisplayText(describeControlFailure(error))}`,
|
||||
|
||||
@@ -1,37 +1,17 @@
|
||||
// Codex plugin module implements conversation binding data behavior.
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import process from "node:process";
|
||||
import type { PluginConversationBinding } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { asOptionalRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
const APP_SERVER_BINDING_DATA_VERSION = 2;
|
||||
const CLI_BINDING_DATA_VERSION = 1;
|
||||
const BINDING_DATA_VERSION = 1;
|
||||
|
||||
export type CodexAppServerConversationBindingData = {
|
||||
kind: "codex-app-server-session";
|
||||
version: 2;
|
||||
bindingId: string;
|
||||
version: 1;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
source?: CodexAppServerConversationSource;
|
||||
start?: CodexAppServerConversationStart;
|
||||
legacyBinding?: true;
|
||||
};
|
||||
|
||||
export type CodexAppServerConversationSource = {
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
threadId: string;
|
||||
sessionKey?: string;
|
||||
};
|
||||
|
||||
export type CodexAppServerConversationStart = {
|
||||
id: string;
|
||||
threadId?: string;
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
authProfileId?: string;
|
||||
agentId?: string;
|
||||
};
|
||||
|
||||
export type CodexCliNodeConversationBindingData = {
|
||||
@@ -39,7 +19,6 @@ export type CodexCliNodeConversationBindingData = {
|
||||
version: 1;
|
||||
nodeId: string;
|
||||
sessionId: string;
|
||||
agentId?: string;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
@@ -48,43 +27,34 @@ export type CodexConversationBindingData =
|
||||
| CodexCliNodeConversationBindingData;
|
||||
|
||||
export function createCodexConversationBindingData(params: {
|
||||
bindingId?: string;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
source?: CodexAppServerConversationSource;
|
||||
start?: CodexAppServerConversationStart;
|
||||
agentId?: string;
|
||||
}): CodexAppServerConversationBindingData {
|
||||
const agentId = params.agentId?.trim();
|
||||
const agentDir = params.agentDir?.trim();
|
||||
const source = readConversationSource(params.source);
|
||||
const start = readConversationStart(params.start);
|
||||
const agentId = params.agentId?.trim();
|
||||
return {
|
||||
kind: "codex-app-server-session",
|
||||
version: APP_SERVER_BINDING_DATA_VERSION,
|
||||
bindingId: params.bindingId?.trim() || randomUUID(),
|
||||
version: BINDING_DATA_VERSION,
|
||||
sessionFile: params.sessionFile,
|
||||
workspaceDir: params.workspaceDir,
|
||||
...(agentId ? { agentId } : {}),
|
||||
...(agentDir ? { agentDir } : {}),
|
||||
...(source ? { source } : {}),
|
||||
...(start ? { start } : {}),
|
||||
...(agentId ? { agentId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCodexCliNodeConversationBindingData(params: {
|
||||
nodeId: string;
|
||||
sessionId: string;
|
||||
agentId?: string;
|
||||
cwd?: string;
|
||||
}): CodexCliNodeConversationBindingData {
|
||||
const agentId = params.agentId?.trim();
|
||||
const cwd = params.cwd?.trim();
|
||||
return {
|
||||
kind: "codex-cli-node-session",
|
||||
version: CLI_BINDING_DATA_VERSION,
|
||||
version: BINDING_DATA_VERSION,
|
||||
nodeId: params.nodeId,
|
||||
sessionId: params.sessionId,
|
||||
...(agentId ? { agentId } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
};
|
||||
}
|
||||
@@ -104,7 +74,7 @@ export function readCodexConversationBindingDataRecord(
|
||||
): CodexConversationBindingData | undefined {
|
||||
if (data.kind === "codex-cli-node-session") {
|
||||
if (
|
||||
data.version !== CLI_BINDING_DATA_VERSION ||
|
||||
data.version !== BINDING_DATA_VERSION ||
|
||||
typeof data.nodeId !== "string" ||
|
||||
!data.nodeId.trim() ||
|
||||
typeof data.sessionId !== "string" ||
|
||||
@@ -114,72 +84,35 @@ export function readCodexConversationBindingDataRecord(
|
||||
}
|
||||
return {
|
||||
kind: "codex-cli-node-session",
|
||||
version: CLI_BINDING_DATA_VERSION,
|
||||
version: BINDING_DATA_VERSION,
|
||||
nodeId: data.nodeId.trim(),
|
||||
sessionId: data.sessionId.trim(),
|
||||
agentId:
|
||||
typeof data.agentId === "string" && data.agentId.trim() ? data.agentId.trim() : undefined,
|
||||
cwd: typeof data.cwd === "string" && data.cwd.trim() ? data.cwd.trim() : undefined,
|
||||
};
|
||||
}
|
||||
if (data.kind !== "codex-app-server-session") {
|
||||
return undefined;
|
||||
}
|
||||
const bindingId =
|
||||
data.version === APP_SERVER_BINDING_DATA_VERSION &&
|
||||
typeof data.bindingId === "string" &&
|
||||
data.bindingId.trim()
|
||||
? data.bindingId.trim()
|
||||
: data.version === 1 && typeof data.sessionFile === "string" && data.sessionFile.trim()
|
||||
? legacyCodexConversationBindingId(data.sessionFile)
|
||||
: undefined;
|
||||
if (!bindingId) {
|
||||
if (
|
||||
data.version !== BINDING_DATA_VERSION ||
|
||||
typeof data.sessionFile !== "string" ||
|
||||
!data.sessionFile.trim()
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const start = readConversationStart(readRecord(data.start));
|
||||
const source = readConversationSource(readRecord(data.source));
|
||||
const legacyBinding = data.version === 1;
|
||||
return {
|
||||
kind: "codex-app-server-session",
|
||||
version: APP_SERVER_BINDING_DATA_VERSION,
|
||||
bindingId,
|
||||
version: BINDING_DATA_VERSION,
|
||||
sessionFile: data.sessionFile,
|
||||
workspaceDir:
|
||||
typeof data.workspaceDir === "string" && data.workspaceDir.trim()
|
||||
? data.workspaceDir
|
||||
: process.cwd(),
|
||||
agentId:
|
||||
typeof data.agentId === "string" && data.agentId.trim() ? data.agentId.trim() : undefined,
|
||||
agentDir:
|
||||
typeof data.agentDir === "string" && data.agentDir.trim() ? data.agentDir.trim() : undefined,
|
||||
...(source ? { source } : {}),
|
||||
...(start ? { start } : {}),
|
||||
...(legacyBinding ? { legacyBinding: true } : {}),
|
||||
agentDir: typeof data.agentDir === "string" && data.agentDir.trim() ? data.agentDir : undefined,
|
||||
agentId: typeof data.agentId === "string" && data.agentId.trim() ? data.agentId : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function readConversationSource(
|
||||
value: CodexAppServerConversationSource | Record<string, unknown> | undefined,
|
||||
): CodexAppServerConversationSource | undefined {
|
||||
const agentId = readString(value, "agentId");
|
||||
const sessionId = readString(value, "sessionId");
|
||||
const threadId = readString(value, "threadId");
|
||||
const sessionKey = readString(value, "sessionKey");
|
||||
if (!agentId || !sessionId || !threadId) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
agentId,
|
||||
sessionId,
|
||||
threadId,
|
||||
...(sessionKey ? { sessionKey } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Doctor/runtime v1 decoder key for shipped conversation bindings that stored a file locator. */
|
||||
export function legacyCodexConversationBindingId(sessionFile: string): string {
|
||||
return `legacy-${createHash("sha256").update(sessionFile).digest("base64url")}`;
|
||||
}
|
||||
|
||||
export function resolveCodexDefaultWorkspaceDir(pluginConfig: unknown): string {
|
||||
const appServer = readRecord(readRecord(pluginConfig)?.appServer);
|
||||
const configured = readString(appServer, "defaultWorkspaceDir");
|
||||
@@ -190,20 +123,3 @@ function readString(record: Record<string, unknown> | undefined, key: string) {
|
||||
const value = record?.[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readConversationStart(
|
||||
value: CodexAppServerConversationStart | Record<string, unknown> | undefined,
|
||||
): CodexAppServerConversationStart | undefined {
|
||||
const read = (key: keyof CodexAppServerConversationStart) => {
|
||||
const candidate = value?.[key];
|
||||
return typeof candidate === "string" && candidate.trim() ? candidate.trim() : undefined;
|
||||
};
|
||||
const start = {
|
||||
id: read("id"),
|
||||
threadId: read("threadId"),
|
||||
model: read("model"),
|
||||
modelProvider: read("modelProvider"),
|
||||
authProfileId: read("authProfileId"),
|
||||
};
|
||||
return start.id ? { ...start, id: start.id } : undefined;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user