mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 22:41:16 +08:00
Compare commits
20 Commits
refactor/s
...
josh/trans
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43ea501f38 | ||
|
|
4b1e5b7943 | ||
|
|
92b6af76d9 | ||
|
|
53a9f13cf4 | ||
|
|
b2f71db7bb | ||
|
|
6fb1f386c6 | ||
|
|
ae4ab2a41f | ||
|
|
4f3d8a57dd | ||
|
|
f454d6202f | ||
|
|
1556e3c68c | ||
|
|
a4d3add6da | ||
|
|
b4cdc33fc9 | ||
|
|
c2c20a0b0d | ||
|
|
a753e6bc86 | ||
|
|
425a4ab2f2 | ||
|
|
724160b7eb | ||
|
|
6699e7331a | ||
|
|
b0625bdd1c | ||
|
|
4ca22b95bc | ||
|
|
3950605561 |
@@ -8,7 +8,26 @@
|
||||
},
|
||||
"rules": {
|
||||
"curly": "error",
|
||||
"eslint/no-underscore-dangle": "error",
|
||||
"eslint/no-underscore-dangle": [
|
||||
"error",
|
||||
{
|
||||
"allow": [
|
||||
"__openclaw",
|
||||
"__test",
|
||||
"__testing",
|
||||
"__resetUsageFormatCachesForTest",
|
||||
"_createdAt",
|
||||
"_default",
|
||||
"_getActiveHandles",
|
||||
"_getActiveRequests",
|
||||
"_registerProvider",
|
||||
"_resetActiveManagedProxyStateForTests",
|
||||
"_resetIMessageShortIdMemoryForTest",
|
||||
"_resetIMessageShortIdState",
|
||||
"_setGitHubCopilotDeviceFlowFetchGuardForTesting"
|
||||
]
|
||||
}
|
||||
],
|
||||
"eslint-plugin-unicorn/prefer-array-find": "error",
|
||||
"eslint/no-array-constructor": "error",
|
||||
"eslint/no-await-in-loop": "off",
|
||||
@@ -218,13 +237,6 @@
|
||||
"**/node_modules/**"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["src/security/**"],
|
||||
"rules": {
|
||||
"eslint/no-warning-comments": "off",
|
||||
"oxc/no-map-spread": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"**/*.test.ts",
|
||||
|
||||
@@ -201,6 +201,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Never commit real phone numbers, videos, credentials, live config.
|
||||
- Secrets: channel/provider creds in `~/.openclaw/credentials/`; model auth profiles in `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`.
|
||||
- Dependency patches/overrides/vendor changes need explicit approval. `pnpm-workspace.yaml` patched dependencies use exact versions only.
|
||||
- Release/package guards: no hard-coded retired-package denylists; use generic artifact/dependency checks or fix build source.
|
||||
- Lockfiles/shrinkwrap are security surface: review `pnpm-lock.yaml`, `npm-shrinkwrap.json`, `package-lock.json`; root/plugin npm packages ship shrinkwrap, not package-lock.
|
||||
- Carbon pins owner-only: do not change `@buape/carbon` unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks.
|
||||
- Releases/publish/version bumps need explicit approval. Use `$release-openclaw-maintainer`.
|
||||
@@ -220,7 +221,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Crabbox/WebVNC human demos: keep remote desktop visible/windowed; no fullscreen remote browser unless video/capture-style output.
|
||||
- ClawSweeper ops: `$clawsweeper`. Deployed hook sessions may post one concise `#clawsweeper` note only when surprising/actionable/risky; if using message tool, reply exactly `NO_REPLY`.
|
||||
- Generated-media completions wake the requester agent first. Requester visible-reply config decides final text vs message tool; direct media send is fallback/recovery only.
|
||||
- `message_tool_only`: visible source reply = current-source `message(action=send)` only. No `NO_REPLY` prompt/contract; no message call = no source reply. Never auto-publish private final.
|
||||
- `message_tool_only`: normal agent final visible reply = current-source `message(action=send)` only. No `NO_REPLY` prompt/contract; no message call = no source reply. Plugin-owned bound-thread reply = plugin return value; no message tool needed. Never auto-publish private final.
|
||||
- Memory wiki prompt digest stays tiny; prefer `wiki_search` / `wiki_get`; verify contact data before use; source-class provenance for generated people facts.
|
||||
- Rebrand/migration/config warnings: run `openclaw doctor`.
|
||||
- Never edit `node_modules`.
|
||||
|
||||
66
CHANGELOG.md
66
CHANGELOG.md
@@ -410,21 +410,76 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Backup/doctor: treat missing configured plugin load paths as warnings so stale local plugin installs do not block backup planning or state import.
|
||||
- Doctor/migration: merge legacy transcript JSONL imports instead of replacing SQLite rows, quarantine headerless transcript artifacts, and make warning-status migrations exit nonzero while pre-migration backups avoid workspace archives.
|
||||
- Gateway/update: avoid fetching unrelated tags during dev-channel git updates so moved release tags do not block branch-based updates. (#84737) Thanks @rubencu.
|
||||
- CLI/update: suppress the expected future-config warning while an old update parent hands off to the freshly installed post-core process.
|
||||
- MiniMax: store OAuth token expiry as an absolute millisecond timestamp so OAuth profiles no longer appear expired on every request. (#83480) Thanks @NianJiuZst.
|
||||
- Agents/Anthropic: strip missing or blank thinking signatures for signed-thinking providers even when recovery supplies a narrow replay policy without signature preservation. Fixes #84430. (#84448) Thanks @NianJiuZst.
|
||||
- Agents/channels: send a visible notice when an aborted main session cannot be resumed after restart, including Telegram group targets. (#85805) Thanks @pfrederiksen.
|
||||
- Discord/voice: serialize overlapping voice joins, retry aborted startup readiness within the configured timeout, upgrade meeting-notes-only sessions to realtime when the normal follow join arrives, detach promoted meeting-notes ownership without leaving voice, and include `OpenClaw` in default realtime wake names.
|
||||
- Gateway/restart: honor the configured restart drain budget for embedded runs and avoid spending the deferral timeout twice after forced restart timeouts. (#85708) Thanks @Kaspre.
|
||||
- Gateway/boot: run `BOOT.md` startup checks in an isolated boot session so gateway restarts do not overwrite the agent's main session mapping. (#85479)
|
||||
- Meeting Notes: include a speaker-labeled transcript section in generated summaries so Discord group voice captures show who said each captured utterance.
|
||||
- Discord/voice: recover stale realtime playback state when Discord stream-close/player-idle events do not arrive, and keep generated runtime plugin aliases available after postbuild rewrites.
|
||||
- Discord/voice: keep realtime playback running when meeting notes attaches to an existing voice session or a realtime consult starts, and route realtime user transcripts into meeting notes.
|
||||
- Config/secrets: preflight active runtime SecretRefs before root and include config writes persist, and roll back unchanged file/env state when post-write refresh fails. Fixes #46531. (#84454) Thanks @samzong.
|
||||
- CLI/models: preserve SecretRef-backed custom provider `apiKey` markers when `models status` regenerates `models.json`, avoiding resolved plaintext secrets on disk. Fixes #84632. (#84658) Thanks @NianJiuZst.
|
||||
- WhatsApp/auto-reply: deliver deferred media replies through the foreground reply fence so overlapping no-reply turns no longer hide already visible responses. (#85517) Thanks @cavit99.
|
||||
- Sessions/security: replace agent-to-agent wildcard allowlist regexes with a precompiled linear matcher so cross-agent access checks avoid backtracking-prone patterns. (#85849) Thanks @SebTardif.
|
||||
- WebChat: keep the run-complete indicator in progress until deferred history replay renders the assistant reply, so Done no longer appears before response text. (#85374) Thanks @neeravmakwana.
|
||||
- Agents/tools: give timed-out or cancelled process trees a bounded SIGTERM cleanup window before SIGKILL while preserving tree-aware cancellation. Fixes #66399. (#85865) Thanks @IWhatsskill.
|
||||
- Agents/subagents: treat aborted subagent stop reasons as killed terminal failures so parent sessions get error announcements instead of silent success. Fixes #72293. (#85860) Thanks @IWhatsskill.
|
||||
- Agents/providers: clamp proxy-like OpenAI Chat Completions output caps against the final request payload so strict local/API-compatible servers no longer reject prompts that already consume part of the context window. Fixes #83086. (#85889) Thanks @rendrag-git.
|
||||
- Agents/compaction: skip agent-harness preflight for provider-owned CLI runtime sessions so over-threshold Claude CLI sessions continue through normal compaction instead of failing on a missing harness. Fixes #84857. (#84878) Thanks @zhangguiping-xydt.
|
||||
- Codex/app-server: keep successful native hook relays available through a short post-turn grace window so late Codex hook subprocesses can finish policy enforcement without clearing a replacement relay. (#83987) Thanks @Kaspre.
|
||||
- Control UI/config: save form-mode edits from the source config snapshot so runtime-only provider defaults like empty `models.providers.<id>.baseUrl` are not written back and rejected. Fixes #85831. Thanks @garyd9.
|
||||
- Browser/existing-session: launch Chrome DevTools MCP with usage statistics disabled by default so its telemetry watchdog stays off unless an operator explicitly opts in. (#85886) Thanks @rohitjavvadi.
|
||||
- Telegram: normalize legacy durable group retry targets before retry sends, polls, and pins so group retries keep using the real chat id. (#85656) Thanks @luoyanglang.
|
||||
- Agents/PDF: route MiniMax PDF fallback policy through plugin metadata so MiniMax uses text extraction instead of VLM image fallback. (#85590, fixes #85575) Thanks @neeravmakwana.
|
||||
- CLI/plugins: tighten timeout, numeric option, media payload, permission, profile/TLS, plugin metadata, JSON, and remote URL handling; prevent stuck progress/app-server/IRC/Synology/Twitch waits; and keep imported chat history ordering stable.
|
||||
- Telegram/config: suppress the missing `accounts.default` warning when `channels.telegram.defaultAccount` names a configured account that also sorts first. Fixes #83948. Thanks @crypto86m.
|
||||
- Telegram: serialize visible topic replies through core reply-lane admission so heartbeat and queued follow-up turns cannot continue ownerless or misroute responses. (#85709) Thanks @jalehman.
|
||||
- CLI/node: print node status recovery hints on stdout consistently while keeping status errors on stderr. Fixes #83925. Thanks @davinci282828.
|
||||
- WebChat: summarize internal message-tool source replies so tool cards no longer duplicate the visible reply body. (#84773) Thanks @jason-allen-oneal.
|
||||
- Gateway/WebChat: hide duplicate `gateway-injected` assistant rows when Cursor ACP already persisted the same `acp-runtime` reply. Fixes #85741. Thanks @lxf-lxf.
|
||||
- WebChat: scope the visible attachment button to its own composer file input so clicking Upload reliably opens the file picker. (#83952, fixes #47983) Thanks @jason-allen-oneal.
|
||||
- Gateway: preserve deferred lifecycle-error cleanup across later non-terminal events so provider timeouts can persist failed session state instead of leaving sessions stuck running. (#85256, fixes #63819) Thanks @samzong.
|
||||
- Gateway/update: stop treating inherited macOS `XPC_SERVICE_NAME` values as launchd supervision during update respawn, so GUI-spawned gateways use detached respawn instead of exiting for a missing LaunchAgent. Fixes #85224. Thanks @richardmqq.
|
||||
- Gateway: stop sending duplicate message-phase `sessions.changed` websocket events after displayable `session.message` transcript updates. (#84834)
|
||||
- Agents/subagents: report tool-only child progress during timeout summaries instead of showing no visible output.
|
||||
- Telegram/ACP: preserve explicit `:topic:` conversation suffixes when inbound ACP targets do not carry a separate thread id.
|
||||
- Browser/proxy: bypass the managed proxy for the exact local managed Chrome CDP readiness and DevTools WebSocket endpoints, so `openclaw browser start` works when the operator proxy blocks loopback egress. (#83255) Thanks @lightcap.
|
||||
- Ollama: bypass the managed proxy for configured local embedding origins while keeping SSRF guardrails on unconfigured targets. Thanks @Kaspre.
|
||||
- OpenAI/images: route Codex API-key image generation through the native OpenAI Images API instead of the Codex OAuth streaming backend, avoiding 401s from valid API keys.
|
||||
- Agents/OpenAI completions: omit empty tool payload fields for proxy-like OpenAI-compatible endpoints so strict vLLM-style servers accept tool-free turns. (#85835) Thanks @rendrag-git.
|
||||
- Sandbox: keep workspace skill mounts read-only for remote container-cwd file operations and reject symlinked skill roots before creating protected overlays. (#85591) Thanks @jason-allen-oneal.
|
||||
- Scripts/Windows: route remaining QA, release, profile, and live-media `pnpm` launches through the managed runner so native Windows avoids brittle `.cmd` execution and shell-argv warnings.
|
||||
- Release: align generated config/API baselines and the meeting-notes plugin version so release preflight stays green on native Windows.
|
||||
- Install/Windows: run Git hook setup through a Node prepare helper so native Windows installs no longer print POSIX shell errors.
|
||||
- Checks/Windows: chunk and serialize extension oxlint shards on native Windows so changed gates avoid Go-backed linter memory spikes.
|
||||
- Release/Windows: run installed `openclaw.cmd` verification through explicit `cmd.exe` wrapping so npm prepublish/postpublish checks avoid Node shell-argv warnings.
|
||||
- Release/Windows: run release-check npm pack/install/root probes through the shared npm runner so native Windows avoids bare `npm` lookup and `.cmd` shell-argv handling.
|
||||
- Release/Windows: run cross-OS release check `.cmd` shims through explicit `cmd.exe` wrapping so native Windows install and gateway probes avoid Node shell-argv handling.
|
||||
- Control UI/Windows: run i18n Pi, npm, and pnpm helper commands through explicit Windows runners so native Windows translation sync avoids brittle `.cmd` launches.
|
||||
- Scripts/Windows: run the Z.AI fallback repro through the shared pnpm runner so native Windows avoids raw `.cmd` launches.
|
||||
- Codex/Windows: run app-server protocol formatting through the shared pnpm runner so native Windows avoids raw `.cmd` launches.
|
||||
- Plugins/Windows: run plugin npm package staging through the shared npm runner so native Windows release checks avoid bare `npm` lookup and `.cmd` shell-argv handling.
|
||||
- Checks/Windows: route full `pnpm check` stage commands through the managed child runner so Windows avoids Node shell-argv deprecation warnings there too.
|
||||
- Agents/fs: allow workspace-only host write/edit tools to write through in-workspace symlink directory parents while preserving outside-workspace symlink rejection. Fixes #84696. Thanks @garbagenetwork.
|
||||
- Checks/Windows: run managed child commands through explicit `cmd.exe` wrapping instead of Node shell mode with argv, avoiding Node 24 subprocess deprecation warnings during changed checks.
|
||||
- Gateway: omit internal stream-error placeholder entries from agent prompt history so failed assistant turns are not replayed as model-authored text. (#85652) Thanks @anyech.
|
||||
- Sessions: enforce the session write-lock max-hold policy during lock acquisition so long-held locks can be reclaimed before the stale-lock window. (#85764) Thanks @njuboy11.
|
||||
- Sessions/status: preserve user-facing model, fallback, usage, and cost attribution when internal subagent handoff runs use fallback models. (#85726, fixes #85082) Thanks @brokemac79.
|
||||
- Install/update: honor `OPENCLAW_HOME` when deriving default dev checkout and installer onboarding paths, while keeping explicit `OPENCLAW_GIT_DIR` and `OPENCLAW_CONFIG_PATH` overrides authoritative. Fixes #54014. Thanks @robertPiro.
|
||||
- Models: prune retired Groq, GitHub Copilot, OpenAI, xAI, and old Claude catalog entries, with doctor migration to upgrade existing configs to current provider refs.
|
||||
- Plugins/Gateway: treat non-empty return values from plugin gateway method handlers as successful responses so `openclaw gateway call` no longer times out after completed plugin work. Fixes #59470. Thanks @HTMG23.
|
||||
- Doctor/update: recognize junction-backed source checkouts as git installs by comparing canonical paths before showing package-manager update guidance. Fixes #82215. Thanks @igormf.
|
||||
- Channels: honor `/verbose on` for tool/progress summaries across direct chats, groups, channels, and forum topics while preserving quiet default behavior. (#85488) Thanks @kurplunkin.
|
||||
- Update: keep the detached gateway restart handoff best-effort when the restart script process cannot be spawned. (#83892) Thanks @davinci282828.
|
||||
- Windows/config: skip POSIX login-shell env fallback on native Windows so startup no longer warns about missing `/bin/sh`. Fixes #84795. Thanks @JIRBOY.
|
||||
- Telegram: persist the prompt-context message cache through plugin state and record bot-authored replies after sends and draft streaming so later turns can include prior assistant replies without relying on the JSON sidecar. (#85231) Thanks @keshavbotagent.
|
||||
- Agents/subagents: keep Codex persona and user workspace files turn-scoped so native Codex subagents inherit only shared tool guidance by default. (#85811) Thanks @lastguru-net.
|
||||
- CLI/skills: show an all-ready note with next-step commands when skill setup has no missing dependencies to install. (#85032) Thanks @aniruddhaadak80.
|
||||
- Microsoft Foundry: route DeepSeek V4 Pro and Flash models through the Foundry Responses API while keeping older DeepSeek models on their existing path. (#85549) Thanks @roslinmahmud.
|
||||
- Status/usage: show configured cost estimates for AWS SDK models in full usage output while keeping token-only usage replies cost-free. (#85619) Thanks @ItsOtherMauridian.
|
||||
@@ -433,6 +488,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram: send local `path`/`filePath` and structured attachment media from `sendMessage` actions instead of dropping them or sending text-only messages. (#85219) Thanks @keshavbotagent.
|
||||
- Sessions/status: show the estimated context budget when fresh provider usage is unavailable and clear stale estimates across session resets and compaction boundaries. (#84830) Thanks @giodl73-repo.
|
||||
- Gateway/config: pin relative `OPENCLAW_STATE_DIR` overrides to an absolute path at startup so later working-directory changes cannot retarget gateway state. (#52264) Thanks @PerfectPan.
|
||||
- Checks/Parallels: make changed-lane scripts, shrinkwrap generation, and Parallels package smoke host commands run through native Windows-safe paths and `npm`/`pnpm` shims.
|
||||
- Release/package: run npm release, prepublish, and postpublish verification through Windows-safe npm command shims so native Windows checks can execute `npm.cmd` instead of treating it as a binary.
|
||||
- Agents/harness: pass CLI runtime aliases through harness selection so provider-owned CLI aliases no longer get rejected before reaching the right runtime. (#85631) Thanks @potterdigital.
|
||||
- Secrets: show the irreversible apply warning after interactive `secrets configure` confirmation so confirmed migrations still get the final safety prompt. (#85638) Thanks @alkor2000.
|
||||
@@ -444,11 +500,16 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers/Anthropic: migrate 1M context handling to GA-capable Claude 4.x models by sizing eligible models at 1M without the retired `context-1m-2025-08-07` beta, ignoring that retired beta in older configs, and preserving OAuth-required Anthropic beta headers. (#45613) Thanks @haoyu-haoyu.
|
||||
- Cron/Telegram: parse forum-topic delivery targets through the Telegram plugin instead of cron core, including `:topic:` and `:topicId` forms for announce delivery. Thanks @etticat.
|
||||
- Twitch: keep stale message-handler cleanup callbacks from removing newer handler registrations for the same account, preserving inbound message delivery after reconnects. Fixes #83888. (#85425) Thanks @alkor2000.
|
||||
- Control UI/chat: keep light-mode model, thinking, config, and agents select arrows visible without tiling background icons. Fixes #85713. Thanks @Linux2010.
|
||||
- Memory/LanceDB: expose public memory artifacts through the active memory provider bridge so memory-wiki imports durable memory files, daily notes, dream reports, and event logs without depending on memory-core internals. Fixes #83604. (#85060) Thanks @brokemac79.
|
||||
- Crabbox: keep AWS hydration compatible with local Actions replay by inlining the hydrate workflow's Node/pnpm setup instead of invoking repo-local composite actions.
|
||||
- Agents/subagents: simplify native sub-agent completion handoff so children report their latest visible assistant result to the requester without using `message`, while keeping parent-owned message-tool delivery policy intact. Fixes #85070. (#85089) Thanks @brokemac79.
|
||||
- Docker setup: stop printing the Gateway bearer token in setup logs and printed follow-up commands.
|
||||
- Gateway: defer channel account startup work until HTTP readiness and remove startup model prewarm, avoiding startup event-loop stalls and timer-delay warnings.
|
||||
- Models/perf: reuse plugin metadata during models.json planning, keep bundled catalog augmentation manifest/static, and use static provider catalogs for metadata-only startup discovery so provider model normalization, auth discovery, and Gateway startup metadata do not reload broad plugin runtimes.
|
||||
- Agents: let embedded compaction fallback retries proceed when PI-compatible candidates do not need agent harness plugin preparation.
|
||||
- Backup/doctor: treat missing configured plugin load paths as warnings so stale local plugin installs do not block backup planning or state import.
|
||||
- Doctor/migration: merge legacy transcript JSONL imports instead of replacing SQLite rows, quarantine headerless transcript artifacts, and make warning-status migrations exit nonzero while pre-migration backups avoid workspace archives.
|
||||
- Agents/tools: honor configured custom provider API keys when deciding whether media, image-generation, video-generation, music-generation, and PDF tools are available. (#85570)
|
||||
- StepFun: stop advertising stale generic API key auth choices so onboarding only offers runtime-backed Standard and Step Plan choices.
|
||||
- Diagnostics: keep OpenTelemetry log bodies behind explicit content capture and scrub scoped agent-session keys from OpenTelemetry and Prometheus labels while preserving bounded queue-lane prefixes.
|
||||
@@ -1366,6 +1427,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Voice Call/Telnyx: add realtime media-streaming call support for conversational voice calls. (#81024) Thanks @dynamite-bud.
|
||||
- Dependencies: add release dependency evidence reports, npm advisory gating, and PR dependency-change awareness so maintainers can review dependency risk before and during releases. Thanks @joshavant.
|
||||
- Gateway: expose optional `isHeartbeat` metadata on agent event payloads so clients can distinguish scheduled heartbeat runs from ordinary chat runs. (#80610) Thanks @medns.
|
||||
- Cron/state: store runtime schedule state and run history in the shared SQLite state database; `openclaw doctor --fix` imports legacy `jobs-state.json` and `cron/runs/*.jsonl` files.
|
||||
- Gateway/state: store device identity/auth, bootstrap tokens, device and node pairing ledgers, channel pairing requests/allowlists, inferred commitments, subagent run records, TUI restore pointers, auth routing state, OpenRouter model cache, web push subscriptions/VAPID keys, APNs registrations, and update-check state in the shared SQLite state database; `openclaw doctor --fix` imports and removes the legacy JSON files.
|
||||
- Agents: add `agents.defaults.runRetries` and `agents.list[].runRetries` config for embedded Pi runner retry loop limits. (#80661) Thanks @medns.
|
||||
- Codex: add node-backed Codex CLI session listing and binding so an OpenClaw conversation can continue an existing Codex CLI session running on a paired node.
|
||||
|
||||
@@ -1545,6 +1608,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Subagents/maintenance: preserve pending subagent registry sessions during session-store cleanup, pruning, and disk-budget enforcement so in-flight subagent runs are not deleted by background maintenance before they complete. (#81498) Thanks @ai-hpc.
|
||||
- Control UI/chat: reconcile terminal and reconnect run cleanup with cached session activity, stale compaction/fallback indicators, and a compact composer run-status chip so completed or interrupted turns do not leave Stop active. Fixes #76874 and #64220; refs #71630. Thanks @BunsDev.
|
||||
- Maintainer tooling: clarify which pnpm test/check commands are safe locally versus inside Codex worktrees, routing linked-worktree gates through node wrappers and Crabbox/Testbox.
|
||||
- Gateway/sessions: remove the automatic cron session reaper and retired `cron.sessionRetention`; session rows are retained for explicit reset/delete flows while cron run-log pruning remains under `cron.runLog`.
|
||||
- Auto-reply: preserve same-key ordering when debounced inbound work falls back to immediate flushes, so follow-up turns cannot overtake an active buffered flush.
|
||||
- Telegram/WhatsApp: keep Telegram same-chat replies ordered behind active no-delay turns without blocking WhatsApp follow-up message dispatch.
|
||||
- Codex migration: avoid duplicate cached plugin bundle warnings when app-server plugin inventory is available.
|
||||
@@ -1743,7 +1807,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Slack: route handled top-level channel turns in implicit-conversation channels to thread-scoped sessions when Slack reply threading is enabled, keeping the root turn and later thread replies on one OpenClaw session. (#78522) Thanks @zeroth-blip.
|
||||
- Telegram: re-probe the primary fetch transport after repeated sticky fallback success so transient IPv4 or pinned-IP fallback promotion can recover without a gateway restart. Fixes #77088. (#77157) Thanks @MkDev11.
|
||||
- Agents/harness: skip tool-result middleware validation when no handler is registered, and sanitize incoming tool result `details` (functions, symbols, bigints, cycles, oversized payloads) before middleware sees them. Tool emitters legitimately produce raw dependency payloads on `details`, and the harness owes any registered middleware a JSON-safe view of that payload; otherwise a no-op middleware (e.g. bundled `tokenjuice` on the `pi` runtime) causes the validator to reject every tool result and silently substitute a failure sentinel, dropping outbound Discord messages, exec output, cron results, and any other tool whose payload carries non-serializable values. Thanks @solomonneas.
|
||||
- Runtime/install: raise the supported Node 22 floor to `22.16+` so native SQLite query handling can rely on the `node:sqlite` statement metadata API while continuing to recommend Node 24. (#78921)
|
||||
- Runtime/install: raise the supported Node 22 floor to `22.19+` so native SQLite query handling can rely on the `node:sqlite` statement metadata API while continuing to recommend Node 24. (#78921)
|
||||
- Discord/voice: make duplicate same-guild auto-join entries resolve to the last configured channel so moving an agent between voice channels does not keep joining the stale channel.
|
||||
- Discord/voice: add realtime `/vc` modes so Discord voice channels can run as STT/TTS, a realtime talk buffer with the OpenClaw agent brain, or a bidi realtime session with `openclaw_agent_consult`.
|
||||
- Discord/voice: add bounded realtime gateway logs for voice channel joins, realtime model/voice selection, transcripts, consult routing/answers, and playback start, allow OpenAI realtime Discord sessions to disable input-triggered response interruption for echo-heavy rooms while keeping explicit Discord barge-in available for new and already-active speakers, and allow voice turns to target an existing Discord channel agent session.
|
||||
|
||||
@@ -759,6 +759,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let sessioneffects: AnyCodable?
|
||||
public let sourcereplydeliverymode: AnyCodable?
|
||||
public let disablemessagetool: Bool?
|
||||
public let initialvfsentries: [[String: AnyCodable]]?
|
||||
public let voicewaketrigger: String?
|
||||
public let idempotencykey: String
|
||||
public let label: String?
|
||||
@@ -800,6 +801,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
sessioneffects: AnyCodable?,
|
||||
sourcereplydeliverymode: AnyCodable?,
|
||||
disablemessagetool: Bool?,
|
||||
initialvfsentries: [[String: AnyCodable]]?,
|
||||
voicewaketrigger: String?,
|
||||
idempotencykey: String,
|
||||
label: String?)
|
||||
@@ -840,6 +842,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.sessioneffects = sessioneffects
|
||||
self.sourcereplydeliverymode = sourcereplydeliverymode
|
||||
self.disablemessagetool = disablemessagetool
|
||||
self.initialvfsentries = initialvfsentries
|
||||
self.voicewaketrigger = voicewaketrigger
|
||||
self.idempotencykey = idempotencykey
|
||||
self.label = label
|
||||
@@ -882,6 +885,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
case sessioneffects = "sessionEffects"
|
||||
case sourcereplydeliverymode = "sourceReplyDeliveryMode"
|
||||
case disablemessagetool = "disableMessageTool"
|
||||
case initialvfsentries = "initialVfsEntries"
|
||||
case voicewaketrigger = "voiceWakeTrigger"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
case label
|
||||
@@ -1598,12 +1602,12 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
public let activeminutes: Int?
|
||||
public let includeglobal: Bool?
|
||||
public let includeunknown: Bool?
|
||||
public let configuredagentsonly: Bool?
|
||||
public let includederivedtitles: Bool?
|
||||
public let includelastmessage: Bool?
|
||||
public let label: String?
|
||||
public let spawnedby: String?
|
||||
public let agentid: String?
|
||||
public let configuredagentsonly: Bool?
|
||||
public let search: String?
|
||||
|
||||
public init(
|
||||
@@ -1612,12 +1616,12 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
activeminutes: Int?,
|
||||
includeglobal: Bool?,
|
||||
includeunknown: Bool?,
|
||||
configuredagentsonly: Bool?,
|
||||
includederivedtitles: Bool?,
|
||||
includelastmessage: Bool?,
|
||||
label: String?,
|
||||
spawnedby: String?,
|
||||
agentid: String? = nil,
|
||||
configuredagentsonly: Bool?,
|
||||
search: String?)
|
||||
{
|
||||
self.limit = limit
|
||||
@@ -1625,12 +1629,12 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
self.activeminutes = activeminutes
|
||||
self.includeglobal = includeglobal
|
||||
self.includeunknown = includeunknown
|
||||
self.configuredagentsonly = configuredagentsonly
|
||||
self.includederivedtitles = includederivedtitles
|
||||
self.includelastmessage = includelastmessage
|
||||
self.label = label
|
||||
self.spawnedby = spawnedby
|
||||
self.agentid = agentid
|
||||
self.configuredagentsonly = configuredagentsonly
|
||||
self.search = search
|
||||
}
|
||||
|
||||
@@ -1640,50 +1644,16 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
case activeminutes = "activeMinutes"
|
||||
case includeglobal = "includeGlobal"
|
||||
case includeunknown = "includeUnknown"
|
||||
case configuredagentsonly = "configuredAgentsOnly"
|
||||
case includederivedtitles = "includeDerivedTitles"
|
||||
case includelastmessage = "includeLastMessage"
|
||||
case label
|
||||
case spawnedby = "spawnedBy"
|
||||
case agentid = "agentId"
|
||||
case configuredagentsonly = "configuredAgentsOnly"
|
||||
case search
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCleanupParams: Codable, Sendable {
|
||||
public let agent: String?
|
||||
public let allagents: Bool?
|
||||
public let enforce: Bool?
|
||||
public let activekey: String?
|
||||
public let fixmissing: Bool?
|
||||
public let fixdmscope: Bool?
|
||||
|
||||
public init(
|
||||
agent: String?,
|
||||
allagents: Bool?,
|
||||
enforce: Bool?,
|
||||
activekey: String?,
|
||||
fixmissing: Bool?,
|
||||
fixdmscope: Bool?)
|
||||
{
|
||||
self.agent = agent
|
||||
self.allagents = allagents
|
||||
self.enforce = enforce
|
||||
self.activekey = activekey
|
||||
self.fixmissing = fixmissing
|
||||
self.fixdmscope = fixdmscope
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agent
|
||||
case allagents = "allAgents"
|
||||
case enforce
|
||||
case activekey = "activeKey"
|
||||
case fixmissing = "fixMissing"
|
||||
case fixdmscope = "fixDmScope"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsPreviewParams: Codable, Sendable {
|
||||
public let keys: [String]
|
||||
public let limit: Int?
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
8162a661edc183008a336db265a092acc90762c6c547b1383ef14fd0d381dea5 config-baseline.json
|
||||
5ee177382cf32c2816dca0a4e67cd6c01df1045d600b21a6e9c11639ddb10ce8 config-baseline.core.json
|
||||
7a7aba829deb8b54047b5c9e7dd3f3c9eade721e2f728db339c4ea99b77a162e config-baseline.channel.json
|
||||
e6a1d6f51f0d9c04bd92d51deebfaca8c7917dd28d7998d225c0074e0a095348 config-baseline.plugin.json
|
||||
e903d5e935a075ad4fa4446964871b0347636e159a3db2fbcfe036bd303c074c config-baseline.json
|
||||
6a46df8f096703bc8bb0e4bc7f6d8e9cfce1d760c61ff1bd0c20ab7fe274004a config-baseline.core.json
|
||||
507acac5476b823f93bec2f6ab0061d023fbaca80e0d981fb916f9c6436f1f2a config-baseline.channel.json
|
||||
9279edd18923a2da92d38f2894f4881932189b974c2cb6a7d057a0f43f96b413 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
34d396bf8f1b2963884256e87c8879a378e2ce7c8064ae0c30d734085a305dd6 plugin-sdk-api-baseline.json
|
||||
bf3e94dcccaf169811990dfd058a16ace8ce2ab44ea9eac6042b525b6d8baf5f plugin-sdk-api-baseline.jsonl
|
||||
9a3ee218fb45e9dd0d4e98c59f9ea640f66983e8d6c35fa17ccb35866c039bce plugin-sdk-api-baseline.json
|
||||
b257d1adbe8fdbe31c418bbbf4246a6aa26d305a776905db2a3a7e2284ede3d1 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -40,9 +40,10 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t
|
||||
## How cron works
|
||||
|
||||
- Cron runs **inside the Gateway** process (not inside the model).
|
||||
- Job definitions, runtime state, and run history persist in OpenClaw's shared SQLite state database so restarts do not lose schedules.
|
||||
- On upgrade, legacy `~/.openclaw/cron/jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files are imported once and renamed with a `.migrated` suffix. Malformed job rows are skipped from runtime and copied to `jobs-quarantine.json` for later repair or review.
|
||||
- `cron.store` still names the logical cron store key and legacy import path. After import, editing that JSON file no longer changes active cron jobs; use `openclaw cron add|edit|remove` or the Gateway cron RPC methods instead.
|
||||
- Job definitions, runtime execution state, and run history persist in the shared SQLite state database at `~/.openclaw/state/openclaw.sqlite`, so restarts do not lose schedules.
|
||||
- Legacy `~/.openclaw/cron/jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files are imported once by `openclaw doctor --fix` and renamed with a `.migrated` suffix.
|
||||
- The optional `cron.store` path is now a legacy import namespace and display hint, not a runtime JSON writer. After import, editing that JSON file no longer changes active cron jobs; use `openclaw cron add|edit|remove` or the Gateway cron RPC methods instead.
|
||||
- If legacy import finds malformed `jobs.json` rows, valid jobs continue importing and the malformed raw rows are preserved in SQLite quarantine state for later repair or review.
|
||||
- All cron executions create [background task](/automation/tasks) records.
|
||||
- On Gateway startup, overdue isolated agent-turn jobs are rescheduled out of the channel-connect window instead of replaying immediately, so Discord/Telegram startup and native-command setup stay responsive after restarts.
|
||||
- One-shot jobs (`--at`) auto-delete after success by default.
|
||||
@@ -459,7 +460,9 @@ Model override note:
|
||||
|
||||
`maxConcurrentRuns` limits both scheduled cron dispatch and isolated agent-turn execution, and defaults to 8. Isolated cron agent turns use the queue's dedicated `cron-nested` execution lane internally, so raising this value lets independent cron LLM runs progress in parallel instead of only starting their outer cron wrappers. The shared non-cron `nested` lane is not widened by this setting.
|
||||
|
||||
`cron.store` is a logical store key and legacy import path. Existing stores are imported into SQLite on first load and archived; future cron changes should go through the CLI or Gateway API.
|
||||
Cron data is keyed by the resolved `cron.store` value inside the shared SQLite state database. That value is a logical store key and legacy import path, not a runtime JSON write path. SQLite stores job definitions, pending slots, active markers, last-run metadata, run history, and the schedule identity used to invalidate stale pending slots after a job update.
|
||||
|
||||
Run `openclaw doctor --fix` once after upgrading from an older version so doctor can import and archive legacy `jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files. After import, existing stores no longer drive active cron jobs; future cron changes should go through the CLI or Gateway API.
|
||||
|
||||
Disable cron: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
|
||||
|
||||
@@ -471,7 +474,7 @@ Disable cron: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Maintenance">
|
||||
`cron.sessionRetention` (default `24h`) prunes isolated run-session entries. `cron.runLog.keepLines` limits retained SQLite run-history rows per job; `maxBytes` is retained for config compatibility with older file-backed run logs.
|
||||
`cron.runLog.keepLines` limits retained SQLite run-history rows per job; `maxBytes` is retained for config compatibility with older file-backed run logs. Session rows are SQLite-backed and are not age/count-pruned.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
summary: "Group chat behavior across surfaces (Discord/iMessage/Matrix/Microsoft Teams/Signal/Slack/Telegram/WhatsApp/Zalo)"
|
||||
read_when:
|
||||
- Changing group chat behavior or mention gating
|
||||
- Scoping mentionPatterns to specific group conversations
|
||||
title: "Groups"
|
||||
sidebarTitle: "Groups"
|
||||
---
|
||||
@@ -57,6 +58,8 @@ For direct chats and any other source event, use `messages.visibleReplies: "mess
|
||||
|
||||
This replaces the old pattern of forcing the model to answer `NO_REPLY` for most lurk-mode turns. In tool-only mode, the prompt does not define a `NO_REPLY` contract. Doing nothing visible simply means not calling the message tool.
|
||||
|
||||
Plugin-owned conversation bindings are the exception. Once a plugin binds a thread and claims the inbound turn, the plugin's returned reply is the visible binding response; it does not need `message(action=send)`. That reply is plugin runtime output, not private model final text.
|
||||
|
||||
Typing indicators are still sent for direct group requests. Ambient always-on room events, when enabled, stay strict and quiet unless the agent calls the message tool.
|
||||
|
||||
Sessions suppress verbose tool/progress summaries by default. Use `/verbose on`
|
||||
@@ -360,10 +363,89 @@ Replying to a bot message counts as an implicit mention when the channel support
|
||||
}
|
||||
```
|
||||
|
||||
## Scope configured mention patterns
|
||||
|
||||
Configured `mentionPatterns` are regex fallback triggers. Use them when the
|
||||
platform does not expose a native bot mention, or when you want plain text such
|
||||
as `openclaw:` to count as a mention. Native platform mentions are separate:
|
||||
when Discord, Slack, Telegram, Matrix, or another channel can prove the message
|
||||
explicitly mentioned the bot, that native mention still triggers even if
|
||||
configured regex patterns are denied.
|
||||
|
||||
By default, configured mention patterns apply everywhere that channel passes
|
||||
provider and conversation facts into mention detection. To keep broad patterns
|
||||
from waking the agent in every group, scope them per channel with
|
||||
`channels.<channel>.mentionPatterns`.
|
||||
|
||||
Use `mode: "deny"` when regex mention patterns should be off by default for a
|
||||
channel, then opt in specific rooms with `allowIn`:
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
groupChat: {
|
||||
mentionPatterns: ["\\bopenclaw\\b", "\\bops bot\\b"],
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
slack: {
|
||||
mentionPatterns: {
|
||||
mode: "deny",
|
||||
allowIn: ["C0123OPS"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Use the default `mode: "allow"` (or omit `mode`) when regex mention patterns
|
||||
should apply broadly, then turn them off in noisy rooms with `denyIn`:
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
groupChat: {
|
||||
mentionPatterns: ["\\bopenclaw\\b"],
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
mentionPatterns: {
|
||||
denyIn: ["-1001234567890", "-1001234567890:topic:42"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Policy resolution:
|
||||
|
||||
| Field | Effect |
|
||||
| --------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `mode: "allow"` | Regex mention patterns are enabled unless the conversation ID is in `denyIn`. This is the default. |
|
||||
| `mode: "deny"` | Regex mention patterns are disabled unless the conversation ID is in `allowIn`. |
|
||||
| `allowIn` | Conversation IDs where regex mention patterns are enabled in deny mode. |
|
||||
| `denyIn` | Conversation IDs where regex mention patterns are disabled. `denyIn` wins over `allowIn` if both include the same ID. |
|
||||
|
||||
Supported scoped regex policy today:
|
||||
|
||||
| Channel | IDs used in `allowIn` / `denyIn` |
|
||||
| -------- | ------------------------------------------------------------ |
|
||||
| Discord | Discord channel IDs. |
|
||||
| Matrix | Matrix room IDs. |
|
||||
| Slack | Slack channel IDs. |
|
||||
| Telegram | Group chat IDs, or `chatId:topic:threadId` for forum topics. |
|
||||
| WhatsApp | WhatsApp conversation IDs such as `123@g.us`. |
|
||||
|
||||
Account-level channel configs can set the same policy under
|
||||
`channels.<channel>.accounts.<accountId>.mentionPatterns` when that channel
|
||||
supports multiple accounts. Account policy takes precedence over the top-level
|
||||
channel policy for that account.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Mention gating notes">
|
||||
- `mentionPatterns` are case-insensitive safe regex patterns; invalid patterns and unsafe nested-repetition forms are ignored.
|
||||
- Surfaces that provide explicit mentions still pass; patterns are a fallback.
|
||||
- Surfaces that provide explicit mentions still pass; configured regex patterns are a fallback.
|
||||
- `channels.<channel>.mentionPatterns.mode: "deny"` disables configured mention patterns by default for that channel; opt selected conversations back in with `allowIn`.
|
||||
- `channels.<channel>.mentionPatterns.denyIn` disables configured mention patterns for specific conversation IDs while native platform @mentions still pass.
|
||||
- Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group).
|
||||
|
||||
@@ -118,7 +118,7 @@ Skipped runs are tracked separately from execution errors. They do not affect re
|
||||
|
||||
For isolated jobs that target a local configured model provider, cron runs a lightweight provider preflight before starting the agent turn. Loopback, private-network, and `.local` `api: "ollama"` providers are probed at `/api/tags`; local OpenAI-compatible providers such as vLLM, SGLang, and LM Studio are probed at `/models`. If the endpoint is unreachable, the run is recorded as `skipped` and retried on a later schedule; matching dead endpoints are cached for 5 minutes to avoid many jobs hammering the same local server.
|
||||
|
||||
Note: cron jobs, pending runtime state, and run history live in the shared SQLite state database. Legacy `jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files are imported once and renamed with a `.migrated` suffix. After import, edit schedules with `openclaw cron add|edit|remove` instead of editing JSON files.
|
||||
Note: cron job definitions, pending runtime state, and run history live in the shared SQLite state database. Legacy `jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files are imported and removed by `openclaw doctor --fix`; malformed legacy rows are preserved in SQLite quarantine state during import. After import, edit schedules with `openclaw cron add|edit|remove` instead of editing JSON files.
|
||||
|
||||
### Manual runs
|
||||
|
||||
@@ -196,10 +196,10 @@ Cron does not classify final-output prose or approval-looking refusal phrases as
|
||||
|
||||
## Retention
|
||||
|
||||
Retention and pruning are controlled in config:
|
||||
Cron run-log retention is controlled in config:
|
||||
|
||||
- `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions.
|
||||
- `cron.runLog.keepLines` prunes retained SQLite run-history rows per job. `cron.runLog.maxBytes` remains accepted for compatibility with older file-backed run logs.
|
||||
- Session rows are SQLite-backed and are not pruned by age/count maintenance.
|
||||
|
||||
## Migrating older jobs
|
||||
|
||||
|
||||
@@ -222,6 +222,10 @@ Target-side auth-required installs are reported on the affected plugin item with
|
||||
Their explicit config entries are written disabled until you reauthorize and
|
||||
enable them. Other install failures are item-scoped `error` results.
|
||||
|
||||
The native Codex plugin config also accepts first-party `openai-bundled` and
|
||||
`openai-primary-runtime` marketplace identities, but migration does not
|
||||
auto-discover or install them from source state.
|
||||
|
||||
If Codex app-server plugin inventory is unavailable during planning, migration
|
||||
falls back to cached bundle advisory items instead of failing the whole
|
||||
migration.
|
||||
|
||||
@@ -39,7 +39,7 @@ Probes are real requests (may consume tokens and trigger rate limits).
|
||||
Use `--agent <id>` to inspect a configured agent's model/auth state. When omitted,
|
||||
the command uses `OPENCLAW_AGENT_DIR` if set, otherwise the
|
||||
configured default agent.
|
||||
Probe rows can come from auth profiles, env credentials, or `models.json`.
|
||||
Probe rows can come from auth profiles, env credentials, or the stored model catalog.
|
||||
For OpenAI ChatGPT/Codex OAuth troubleshooting, `openclaw models status`,
|
||||
`openclaw models auth list --provider openai`, and
|
||||
`openclaw config get agents.defaults.model --json` are the quickest way to
|
||||
|
||||
@@ -103,69 +103,11 @@ JSON examples:
|
||||
|
||||
## Repair
|
||||
|
||||
Run maintenance now (instead of waiting for the next write cycle):
|
||||
|
||||
```bash
|
||||
openclaw sessions cleanup --dry-run
|
||||
openclaw sessions cleanup --agent work --dry-run
|
||||
openclaw sessions cleanup --all-agents --dry-run
|
||||
openclaw sessions cleanup --enforce
|
||||
openclaw sessions cleanup --enforce --active-key "agent:main:telegram:direct:123"
|
||||
openclaw sessions cleanup --dry-run --fix-dm-scope
|
||||
openclaw sessions cleanup --json
|
||||
```
|
||||
|
||||
`openclaw sessions cleanup` uses `session.maintenance` settings from config:
|
||||
|
||||
- Scope note: `openclaw sessions cleanup` maintains session stores, transcripts, and trajectory sidecars. It does not prune cron run history, which is managed by `cron.runLog.keepLines` in [Cron configuration](/automation/cron-jobs#configuration) and explained in [Cron maintenance](/automation/cron-jobs#maintenance).
|
||||
- Cleanup also prunes unreferenced primary transcripts, compaction checkpoints, and trajectory sidecars older than `session.maintenance.pruneAfter`; files still referenced by `sessions.json` are preserved.
|
||||
|
||||
- `--dry-run`: preview how many entries would be pruned/capped without writing.
|
||||
- In text mode, dry-run prints a per-session action table (`Action`, `Key`, `Age`, `Model`, `Flags`) so you can see what would be kept vs removed.
|
||||
- `--enforce`: apply maintenance even when `session.maintenance.mode` is `warn`.
|
||||
- `--fix-missing`: remove entries whose transcript files are missing or header-only/empty, even if they would not normally age/count out yet.
|
||||
- `--fix-dm-scope`: when `session.dmScope` is `main`, retire stale peer-keyed direct-DM rows left behind by earlier `per-peer`, `per-channel-peer`, or `per-account-channel-peer` routing. Use `--dry-run` first; applying the cleanup removes those rows from `sessions.json` and preserves their transcripts as deleted archives.
|
||||
- `--active-key <key>`: protect a specific active key from disk-budget eviction. Durable external conversation pointers, such as group sessions and thread-scoped chat sessions, are also kept by age/count/disk-budget maintenance.
|
||||
- `--agent <id>`: run cleanup for one configured agent store.
|
||||
- `--all-agents`: run cleanup for all configured agent stores.
|
||||
- `--store <path>`: run against a specific `sessions.json` file.
|
||||
- `--json`: print a JSON summary. With `--all-agents`, output includes one summary per store.
|
||||
|
||||
When a Gateway is reachable, non-dry-run cleanup for configured agent stores is
|
||||
sent through the Gateway so it shares the same session-store writer as runtime
|
||||
traffic. Use `--store <path>` for explicit offline repair of a store file.
|
||||
|
||||
`openclaw sessions cleanup --all-agents --dry-run --json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"allAgents": true,
|
||||
"mode": "warn",
|
||||
"dryRun": true,
|
||||
"stores": [
|
||||
{
|
||||
"agentId": "main",
|
||||
"storePath": "/home/user/.openclaw/agents/main/sessions/sessions.json",
|
||||
"beforeCount": 120,
|
||||
"afterCount": 80,
|
||||
"missing": 0,
|
||||
"dmScopeRetired": 0,
|
||||
"pruned": 40,
|
||||
"capped": 0
|
||||
},
|
||||
{
|
||||
"agentId": "work",
|
||||
"storePath": "/home/user/.openclaw/agents/work/sessions/sessions.json",
|
||||
"beforeCount": 18,
|
||||
"afterCount": 18,
|
||||
"missing": 0,
|
||||
"dmScopeRetired": 0,
|
||||
"pruned": 0,
|
||||
"capped": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
Legacy JSON import belongs to `openclaw doctor --fix`. Runtime commands do not
|
||||
prune, cap, import, or rewrite session databases. If doctor reports session rows
|
||||
whose transcript events are missing, rerun doctor to import any remaining legacy
|
||||
sources; if the source transcript is gone, reset or delete the affected session
|
||||
explicitly.
|
||||
|
||||
Related:
|
||||
|
||||
|
||||
@@ -127,8 +127,9 @@ See [Sandboxing](/gateway/sandboxing) and [Multi-Agent Sandbox & Tools](/tools/m
|
||||
|
||||
Configure logging before the delegate handles any real data:
|
||||
|
||||
- Cron run history: OpenClaw shared SQLite state database
|
||||
- Session transcripts: `~/.openclaw/agents/delegate/sessions`
|
||||
- Cron run history: `~/.openclaw/state/openclaw.sqlite`
|
||||
- Session rows and transcripts:
|
||||
`~/.openclaw/agents/delegate/agent/openclaw-agent.sqlite`
|
||||
- Identity provider audit logs (Exchange, Google Workspace)
|
||||
|
||||
All delegate actions flow through OpenClaw's session store. For compliance, ensure these logs are retained and reviewed.
|
||||
|
||||
@@ -119,6 +119,14 @@ stays separate from `MEMORY.md` and that the agent does not claim the candidate
|
||||
was promoted. It does not add production shadow-trial behavior or change the
|
||||
deep-phase promotion engine.
|
||||
|
||||
The `memory-core` shadow-trial runner keeps that same report-only contract for
|
||||
code paths that need a stable artifact. It accepts the candidate, trial prompt,
|
||||
baseline outcome, candidate outcome, verdict, reason, risk flags, and evidence
|
||||
references, then writes a report with `promotion action: report-only`. Helpful
|
||||
verdicts map to a `promote` recommendation, neutral verdicts map to `defer`, and
|
||||
harmful verdicts map to `reject`; none of those recommendations writes to
|
||||
`MEMORY.md` or applies deep-phase promotion.
|
||||
|
||||
## Scheduling
|
||||
|
||||
When enabled, `memory-core` auto-manages one cron job for a full dreaming sweep. Each sweep runs phases in order: light → REM → deep.
|
||||
|
||||
@@ -790,10 +790,12 @@ Group messages default to **require mention** (metadata mention or safe regex pa
|
||||
|
||||
Visible replies are controlled separately. Normal group, channel, and internal WebChat direct requests default to automatic final delivery: final assistant text posts through the legacy visible reply path. Opt into `messages.visibleReplies: "message_tool"` or `messages.groupChat.visibleReplies: "message_tool"` when visible output should only post after the agent calls `message(action=send)`. If the model returns final text without calling the message tool in an opted-in tool-only mode, that final text stays private and the gateway verbose log records suppressed payload metadata.
|
||||
|
||||
Tool-only visible replies require a model/runtime that reliably calls tools, and are recommended for shared ambient rooms on latest-generation models such as GPT 5.5. Some weaker models can answer final text but fail to understand that source-visible output must be sent with `message(action=send)`. For those models, use `"automatic"` so the final assistant turn is the visible reply path. If the session log shows assistant text with `didSendViaMessagingTool: false`, the model produced private final text instead of calling the message tool. Switch to a stronger tool-calling model for that channel, inspect the gateway verbose log for the suppressed payload summary, or set `messages.groupChat.visibleReplies: "automatic"` to use visible final replies for every group/channel request.
|
||||
Tool-only visible replies require a model/runtime that reliably calls tools, and are recommended for shared ambient rooms on latest-generation models such as GPT 5.5. Some weaker models can answer final text but fail to understand that source-visible output must be sent with `message(action=send)`. For those models, use `"automatic"` so the final assistant turn is the visible reply path. If the gateway verbose log or SQLite transcript shows assistant text with `didSendViaMessagingTool: false`, the model produced private final text instead of calling the message tool. Switch to a stronger tool-calling model for that channel, inspect the gateway verbose log for the suppressed payload summary, or set `messages.groupChat.visibleReplies: "automatic"` to use visible final replies for every group/channel request.
|
||||
|
||||
If the message tool is unavailable under the active tool policy, OpenClaw falls back to automatic visible replies instead of silently suppressing the response. `openclaw doctor` warns about this mismatch.
|
||||
|
||||
This rule applies to normal agent final text. Plugin-owned conversation bindings use the owning plugin's returned reply as the visible response for claimed bound-thread turns; the plugin does not need to call `message(action=send)` for those binding replies.
|
||||
|
||||
**Troubleshooting: group @mention triggers typing then silence (no error)**
|
||||
|
||||
Symptom: a group/channel @mention shows the typing indicator and the gateway log reports `dispatch complete (queuedFinal=false, replies=0)`, but no message lands in the room. DMs to the same agent reply normally.
|
||||
|
||||
@@ -316,7 +316,8 @@ conversation bindings, or any non-Codex harness.
|
||||
migrated plugin entry when global `codexPlugins.enabled` is also true.
|
||||
Default: `true` for explicit entries.
|
||||
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.marketplaceName`:
|
||||
stable marketplace identity. V1 only supports `"openai-curated"`.
|
||||
stable marketplace identity. V1 supports `"openai-curated"`,
|
||||
`"openai-bundled"`, and `"openai-primary-runtime"`.
|
||||
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.pluginName`: stable
|
||||
Codex plugin identity from migration, for example `"google-calendar"`.
|
||||
- `plugins.entries.codex.config.codexPlugins.plugins.<key>.allow_destructive_actions`:
|
||||
@@ -1284,9 +1285,8 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway
|
||||
}
|
||||
```
|
||||
|
||||
- `sessionRetention`: how long to keep completed isolated cron run sessions before pruning from `sessions.json`. Also controls cleanup of archived deleted cron transcripts. Default: `24h`; set `false` to disable.
|
||||
- `runLog.maxBytes`: accepted for compatibility with older file-backed cron run logs. Default: `2_000_000` bytes.
|
||||
- `runLog.keepLines`: newest SQLite run-history rows retained per job. Default: `2000`.
|
||||
- `runLog.maxBytes`: approximate max serialized SQLite run-log bytes per job before pruning. Default: `2_000_000` bytes.
|
||||
- `runLog.keepLines`: newest rows retained when run-log pruning is triggered. Default: `2000`.
|
||||
- `webhookToken`: bearer token used for cron webhook POST delivery (`delivery.mode = "webhook"`), if omitted no auth header is sent.
|
||||
- `webhook`: deprecated legacy migration fallback URL (http/https). Runtime does not read it; doctor can use it to translate legacy `notify: true` cron jobs into per-job `delivery.mode = "webhook"` plus `delivery.to`.
|
||||
|
||||
|
||||
@@ -429,8 +429,7 @@ candidate contains redacted secret placeholders such as `***`.
|
||||
}
|
||||
```
|
||||
|
||||
- `sessionRetention`: prune completed isolated run sessions from `sessions.json` (default `24h`; set `false` to disable).
|
||||
- `runLog`: prune retained cron run-history rows per job. `maxBytes` remains accepted for older file-backed run logs.
|
||||
- `runLog`: prune SQLite cron run history by approximate serialized size (`maxBytes`) and retained rows.
|
||||
- See [Cron jobs](/automation/cron-jobs) for feature overview and CLI examples.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -385,8 +385,22 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
On Linux, doctor also warns when the user's crontab still invokes legacy `~/.openclaw/bin/ensure-whatsapp.sh`. That host-local script is not maintained by current OpenClaw and can write false `Gateway inactive` messages to `~/.openclaw/logs/whatsapp-health.log` when cron cannot reach the systemd user bus. Remove the stale crontab entry with `crontab -e`; use `openclaw channels status --probe`, `openclaw doctor`, and `openclaw gateway status` for current health checks.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="3c. Session lock cleanup">
|
||||
Doctor scans every agent session directory for stale write-lock files — files left behind when a session exited abnormally. For each lock file found it reports: the path, PID, whether the PID is still alive, lock age, and whether it is considered stale (dead PID, malformed owner metadata, older than 30 minutes, or a live PID that can be proven to belong to a non-OpenClaw process). In `--fix` / `--repair` mode it removes locks with dead, orphaned, recycled, malformed-old, or non-OpenClaw owners automatically. Old locks that are still owned by a live OpenClaw process are reported but left in place so doctor does not cut off an active transcript writer.
|
||||
<Accordion title="Legacy runtime JSON imports">
|
||||
Doctor checks for older runtime JSON ledgers that are now stored in
|
||||
`~/.openclaw/state/openclaw.sqlite`. In `--fix` mode it imports each legacy
|
||||
file into SQLite and removes the file after a successful import.
|
||||
|
||||
Current imports include:
|
||||
|
||||
- `identity/device.json`
|
||||
- `identity/device-auth.json`
|
||||
- `devices/bootstrap.json`
|
||||
- `devices/pending.json` and `devices/paired.json`
|
||||
- `nodes/pending.json` and `nodes/paired.json`
|
||||
- `push/web-push-subscriptions.json`
|
||||
- `push/vapid-keys.json`
|
||||
- `push/apns-registrations.json`
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="3c. Legacy session file cleanup">
|
||||
Doctor treats old session JSON/JSONL trees as migration inputs. In `--fix` / `--repair` mode it imports supported legacy rows into the per-agent SQLite database, verifies the resulting database state, and can remove obsolete file-era sidecars after a successful import. Runtime session writes no longer depend on lock files or whole-file rewrite queues.
|
||||
|
||||
@@ -296,9 +296,8 @@ replacement. Gateway startup does not generate bundled-plugin dependency trees.
|
||||
For full persistence details on VM deployments, see
|
||||
[Docker VM Runtime - What persists where](/install/docker-vm-runtime#what-persists-where).
|
||||
|
||||
**Disk growth hotspots:** watch `media/`, session JSONL files, the shared
|
||||
SQLite state database, installed plugin package roots, and rolling file logs
|
||||
under `/tmp/openclaw/`.
|
||||
**Disk growth hotspots:** watch `media/`, the shared SQLite state database,
|
||||
installed plugin package roots, and rolling file logs under `/tmp/openclaw/`.
|
||||
|
||||
### Shell helpers (optional)
|
||||
|
||||
|
||||
@@ -38,14 +38,14 @@ All Codex harness settings live under `plugins.entries.codex.config`.
|
||||
|
||||
Supported top-level fields:
|
||||
|
||||
| Field | Default | Meaning |
|
||||
| -------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `discovery` | enabled | Model discovery settings for Codex app-server `model/list`. |
|
||||
| `appServer` | managed stdio app-server | Transport, command, auth, approval, sandbox, and timeout settings. |
|
||||
| `codexDynamicToolsLoading` | `"searchable"` | Use `"direct"` to put OpenClaw dynamic tools directly in the initial Codex tool context. |
|
||||
| `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. |
|
||||
| `codexPlugins` | disabled | Native Codex plugin/app support for migrated source-installed curated plugins. See [Native Codex plugins](/plugins/codex-native-plugins). |
|
||||
| `computerUse` | disabled | Codex Computer Use setup. See [Codex Computer Use](/plugins/codex-computer-use). |
|
||||
| Field | Default | Meaning |
|
||||
| -------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `discovery` | enabled | Model discovery settings for Codex app-server `model/list`. |
|
||||
| `appServer` | managed stdio app-server | Transport, command, auth, approval, sandbox, and timeout settings. |
|
||||
| `codexDynamicToolsLoading` | `"searchable"` | Use `"direct"` to put OpenClaw dynamic tools directly in the initial Codex tool context. |
|
||||
| `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. |
|
||||
| `codexPlugins` | disabled | Native Codex plugin/app support for configured first-party Codex plugins. See [Native Codex plugins](/plugins/codex-native-plugins). |
|
||||
| `computerUse` | disabled | Codex Computer Use setup. See [Codex Computer Use](/plugins/codex-computer-use). |
|
||||
|
||||
## App-server transport
|
||||
|
||||
|
||||
@@ -526,7 +526,7 @@ Supported top-level Codex plugin fields:
|
||||
| -------------------------- | -------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `codexDynamicToolsLoading` | `"searchable"` | Use `"direct"` to put OpenClaw dynamic tools directly in the initial Codex tool context. |
|
||||
| `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. |
|
||||
| `codexPlugins` | disabled | Native Codex plugin/app support for migrated source-installed curated plugins. |
|
||||
| `codexPlugins` | disabled | Native Codex plugin/app support for configured first-party Codex plugins. |
|
||||
|
||||
Supported `appServer` fields:
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ summary: "Configure migrated native Codex plugins for Codex-mode OpenClaw agents
|
||||
title: "Native Codex plugins"
|
||||
read_when:
|
||||
- You want Codex-mode OpenClaw agents to use native Codex plugins
|
||||
- You are migrating source-installed openai-curated Codex plugins
|
||||
- You are configuring first-party Codex plugin marketplaces
|
||||
- You are troubleshooting codexPlugins, app inventory, destructive actions, or plugin app diagnostics
|
||||
---
|
||||
|
||||
@@ -22,7 +22,9 @@ Use this page after the base [Codex harness](/plugins/codex-harness) is working.
|
||||
- The selected OpenClaw agent runtime must be the native Codex harness.
|
||||
- `plugins.entries.codex.enabled` must be true.
|
||||
- `plugins.entries.codex.config.codexPlugins.enabled` must be true.
|
||||
- V1 supports only `openai-curated` plugins that migration observed as
|
||||
- V1 supports first-party Codex plugin marketplaces: `openai-curated`,
|
||||
`openai-bundled`, and `openai-primary-runtime`.
|
||||
- Migration only auto-discovers `openai-curated` plugins that it observed as
|
||||
source-installed in the source Codex home.
|
||||
- The target Codex app-server must be able to see the expected marketplace,
|
||||
plugin, and app inventory.
|
||||
@@ -52,9 +54,11 @@ Apply the migration when the plan looks right:
|
||||
openclaw migrate apply codex --yes
|
||||
```
|
||||
|
||||
Migration writes explicit `codexPlugins` entries for eligible plugins and calls
|
||||
Codex app-server `plugin/install` for selected plugins. A typical migrated
|
||||
config looks like this:
|
||||
Migration writes explicit `codexPlugins` entries for eligible curated plugins
|
||||
and calls Codex app-server `plugin/install` for selected plugins. Explicit
|
||||
config may also reference Codex's bundled and primary-runtime first-party
|
||||
marketplaces when the target app-server inventory exposes those plugin apps. A
|
||||
typical migrated config looks like this:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -146,8 +150,10 @@ up the updated app set.
|
||||
|
||||
V1 is intentionally narrow:
|
||||
|
||||
- Runtime config accepts `openai-curated`, `openai-bundled`, and
|
||||
`openai-primary-runtime` plugin identities.
|
||||
- Only `openai-curated` plugins that were already installed in the source Codex
|
||||
app-server inventory are migration-eligible.
|
||||
app-server inventory are migration-eligible for automatic migration.
|
||||
- App-backed source plugins must pass the migration-time subscription gate.
|
||||
`--verify-plugin-apps` adds the source app-inventory gate. Subscription-gated
|
||||
accounts plus, in verification mode, inaccessible, disabled, missing source
|
||||
@@ -160,7 +166,9 @@ V1 is intentionally narrow:
|
||||
- There is no `plugins["*"]` wildcard and no config key that grants arbitrary
|
||||
install authority.
|
||||
- Unsupported marketplaces, cached plugin bundles, hooks, and Codex config files
|
||||
are preserved in the migration report for manual review.
|
||||
are preserved in the migration report for manual review. Bundled and
|
||||
primary-runtime first-party plugins can still be added manually through
|
||||
explicit `codexPlugins` config.
|
||||
|
||||
## App inventory and ownership
|
||||
|
||||
@@ -248,8 +256,10 @@ app-server auth or rerun with `--verify-plugin-apps` if you want source app
|
||||
inventory to decide eligibility when account lookup fails.
|
||||
|
||||
**`marketplace_missing` or `plugin_missing`:** the target Codex app-server
|
||||
cannot see the expected `openai-curated` marketplace or plugin. Rerun migration
|
||||
against the target runtime or inspect Codex app-server plugin status.
|
||||
cannot see the expected first-party marketplace or plugin. Rerun migration
|
||||
against the target runtime, inspect Codex app-server plugin status, or confirm
|
||||
the explicit `marketplaceName` is one of `openai-curated`, `openai-bundled`, or
|
||||
`openai-primary-runtime`.
|
||||
|
||||
**`app_inventory_missing` or `app_inventory_stale`:** app readiness came from an
|
||||
empty or stale cache. OpenClaw schedules an async refresh and excludes plugin
|
||||
|
||||
23
docs/plugins/reference/skill-workshop.md
Normal file
23
docs/plugins/reference/skill-workshop.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
summary: "Captures repeatable workflows as workspace skills, with pending review, safe writes, and skill prompt refresh."
|
||||
read_when:
|
||||
- You are installing, configuring, or auditing the skill-workshop plugin
|
||||
title: "Skill Workshop plugin"
|
||||
---
|
||||
|
||||
# Skill Workshop plugin
|
||||
|
||||
Captures repeatable workflows as workspace skills, with pending review, safe writes, and skill prompt refresh.
|
||||
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/skill-workshop`
|
||||
- Install route: included in OpenClaw
|
||||
|
||||
## Surface
|
||||
|
||||
contracts: tools
|
||||
|
||||
## Related docs
|
||||
|
||||
- [skill-workshop](/plugins/skill-workshop)
|
||||
@@ -586,6 +586,7 @@ releases.
|
||||
| `plugin-sdk/reply-reference` | Reply reference planning | `createReplyReferencePlanner` |
|
||||
| `plugin-sdk/reply-chunking` | Reply chunk helpers | Text/markdown chunking helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session row helpers | SQLite-backed session row, session-key, updated-at, and transcript row helpers |
|
||||
| `plugin-sdk/sqlite-runtime` | SQLite helpers | Focused database open/path helpers for first-party runtime and migration tests |
|
||||
| `plugin-sdk/state-paths` | State path helpers | Config, credentials, migration, and explicit operator-file path helpers; runtime state and caches belong in SQLite stores |
|
||||
| `plugin-sdk/routing` | Routing/session-key helpers | `resolveAgentRoute`, `buildAgentSessionKey`, `resolveDefaultAgentBoundAccountId`, session-key normalization helpers |
|
||||
| `plugin-sdk/status-helpers` | Channel status helpers | Channel/account status summary builders, runtime-state defaults, issue metadata helpers |
|
||||
@@ -653,7 +654,8 @@ releases.
|
||||
| `plugin-sdk/memory-core-engine-runtime` | Memory engine runtime facade | Memory index/search runtime facade |
|
||||
| `plugin-sdk/memory-core-host-engine-foundation` | Memory host foundation engine | Memory host foundation engine exports |
|
||||
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding engine | Memory embedding contracts, registry access, local provider, and generic batch/remote helpers; concrete remote providers live in their owning plugins |
|
||||
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine | Memory host QMD engine exports |
|
||||
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine | Memory host QMD engine exports; new code should use `memory-core-host-engine-session-transcripts` for SQLite transcript indexing helpers |
|
||||
| `plugin-sdk/memory-core-host-engine-session-transcripts` | Memory host SQLite session transcript engine | Memory host SQLite session transcript indexing exports |
|
||||
| `plugin-sdk/memory-core-host-engine-storage` | Memory host storage engine | Memory host storage engine exports |
|
||||
| `plugin-sdk/memory-core-host-multimodal` | Memory host multimodal helpers | Memory host multimodal helpers |
|
||||
| `plugin-sdk/memory-core-host-query` | Memory host query helpers | Memory host query helpers |
|
||||
|
||||
@@ -27,6 +27,8 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
|
||||
| `plugin-sdk/core` | `defineChannelPluginEntry`, `createChatChannelPlugin`, `createChannelPluginBase`, `defineSetupPluginEntry`, `buildChannelConfigSchema`, `buildJsonChannelConfigSchema` |
|
||||
| `plugin-sdk/config-schema` | `OpenClawSchema` |
|
||||
| `plugin-sdk/provider-entry` | `defineSingleProviderPluginEntry` |
|
||||
| `plugin-sdk/provider-ai` | OpenClaw-owned provider stream/model/message types plus simple streaming helpers used by bundled provider plugins |
|
||||
| `plugin-sdk/provider-ai-oauth` | OpenClaw-owned OAuth helper facade for provider runtime code |
|
||||
| `plugin-sdk/migration` | Migration provider item helpers such as `createMigrationItem`, reason constants, item status markers, redaction helpers, and `summarizeMigrationItems` |
|
||||
| `plugin-sdk/migration-runtime` | Runtime migration helpers such as `copyMigrationFileItem`, `withCachedMigrationConfigRuntime`, and `writeMigrationReport` |
|
||||
| `plugin-sdk/health` | Doctor health-check registration, detection, repair, selection, severity, and finding types for bundled health consumers |
|
||||
@@ -238,6 +240,7 @@ and pairing-path families.
|
||||
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
|
||||
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
|
||||
| `plugin-sdk/session-store-runtime` | SQLite-backed session row, session-key, updated-at, and transcript row helpers |
|
||||
| `plugin-sdk/sqlite-runtime` | Focused SQLite database open/path helpers for first-party runtime and migration tests |
|
||||
| `plugin-sdk/cron-store-runtime` | SQLite cron store load/save helpers |
|
||||
| `plugin-sdk/state-paths` | Config, credentials, migration, and explicit operator-file path helpers; runtime state and caches belong in SQLite stores |
|
||||
| `plugin-sdk/plugin-state-runtime` | Plugin sidecar SQLite keyed-state types |
|
||||
@@ -294,6 +297,7 @@ and pairing-path families.
|
||||
| `plugin-sdk/response-limit-runtime` | Bounded response-body reader without the broad media runtime surface |
|
||||
| `plugin-sdk/session-binding-runtime` | Current conversation binding state without configured binding routing or pairing stores |
|
||||
| `plugin-sdk/session-store-runtime` | SQLite session row helpers without broad config writes, maintenance imports, or raw database openers |
|
||||
| `plugin-sdk/sqlite-runtime` | Focused SQLite database helpers without session-row helper imports |
|
||||
| `plugin-sdk/context-visibility-runtime` | Context visibility resolution and supplemental context filtering without broad config/security imports |
|
||||
| `plugin-sdk/string-coerce-runtime` | Narrow primitive record/string coercion and normalization helpers without markdown/logging imports |
|
||||
| `plugin-sdk/host-runtime` | Hostname and SCP host normalization helpers |
|
||||
@@ -348,7 +352,8 @@ and pairing-path families.
|
||||
| `plugin-sdk/memory-core-engine-runtime` | Memory index/search runtime facade |
|
||||
| `plugin-sdk/memory-core-host-engine-foundation` | Memory host foundation engine exports |
|
||||
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding contracts, registry access, local provider, and generic batch/remote helpers |
|
||||
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine exports |
|
||||
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine exports; use `memory-core-host-engine-session-transcripts` for SQLite transcript indexing helpers |
|
||||
| `plugin-sdk/memory-core-host-engine-session-transcripts` | Memory host SQLite session transcript indexing exports |
|
||||
| `plugin-sdk/memory-core-host-engine-storage` | Memory host storage engine exports |
|
||||
| `plugin-sdk/memory-core-host-multimodal` | Memory host multimodal helpers |
|
||||
| `plugin-sdk/memory-core-host-query` | Memory host query helpers |
|
||||
|
||||
713
docs/plugins/skill-workshop.md
Normal file
713
docs/plugins/skill-workshop.md
Normal file
@@ -0,0 +1,713 @@
|
||||
---
|
||||
summary: "Experimental capture of reusable procedures as workspace skills with review, approval, quarantine, and hot skill refresh"
|
||||
title: "Skill workshop plugin"
|
||||
read_when:
|
||||
- You want agents to turn corrections or reusable procedures into workspace skills
|
||||
- You are configuring procedural skill memory
|
||||
- You are debugging skill_workshop tool behavior
|
||||
- You are deciding whether to enable automatic skill creation
|
||||
---
|
||||
|
||||
Skill Workshop is **experimental**. It is disabled by default, its capture
|
||||
heuristics and reviewer prompts may change between releases, and automatic
|
||||
writes should be used only in trusted workspaces after reviewing pending-mode
|
||||
output first.
|
||||
|
||||
Skill Workshop is procedural memory for workspace skills. It lets an agent turn
|
||||
reusable workflows, user corrections, hard-won fixes, and recurring pitfalls
|
||||
into `SKILL.md` files under:
|
||||
|
||||
```text
|
||||
<workspace>/skills/<skill-name>/SKILL.md
|
||||
```
|
||||
|
||||
This is different from long-term memory:
|
||||
|
||||
- **Memory** stores facts, preferences, entities, and past context.
|
||||
- **Skills** store reusable procedures the agent should follow on future tasks.
|
||||
- **Skill Workshop** is the bridge from a useful turn to a durable workspace
|
||||
skill, with safety checks and optional approval.
|
||||
|
||||
Skill Workshop is useful when the agent learns a procedure such as:
|
||||
|
||||
- how to validate externally sourced animated GIF assets
|
||||
- how to replace screenshot assets and verify dimensions
|
||||
- how to run a repo-specific QA scenario
|
||||
- how to debug a recurring provider failure
|
||||
- how to repair a stale local workflow note
|
||||
|
||||
It is not intended for:
|
||||
|
||||
- facts like "the user likes blue"
|
||||
- broad autobiographical memory
|
||||
- raw transcript archiving
|
||||
- secrets, credentials, or hidden prompt text
|
||||
- one-off instructions that will not repeat
|
||||
|
||||
## Default state
|
||||
|
||||
The bundled plugin is **experimental** and **disabled by default** unless it is
|
||||
explicitly enabled in `plugins.entries.skill-workshop`.
|
||||
|
||||
The plugin manifest does not set `enabledByDefault: true`. The `enabled: true`
|
||||
default inside the plugin config schema applies only after the plugin entry has
|
||||
already been selected and loaded.
|
||||
|
||||
Experimental means:
|
||||
|
||||
- the plugin is supported enough for opt-in testing and dogfooding
|
||||
- proposal storage, reviewer thresholds, and capture heuristics can evolve
|
||||
- pending approval is the recommended starting mode
|
||||
- auto apply is for trusted personal/workspace setups, not shared or hostile
|
||||
input-heavy environments
|
||||
|
||||
## Enable
|
||||
|
||||
Minimal safe config:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"skill-workshop": {
|
||||
enabled: true,
|
||||
config: {
|
||||
autoCapture: true,
|
||||
approvalPolicy: "pending",
|
||||
reviewMode: "hybrid",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
With this config:
|
||||
|
||||
- the `skill_workshop` tool is available
|
||||
- explicit reusable corrections are queued as pending proposals
|
||||
- threshold-based reviewer passes can propose skill updates
|
||||
- no skill file is written until a pending proposal is applied
|
||||
|
||||
Use automatic writes only in trusted workspaces:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"skill-workshop": {
|
||||
enabled: true,
|
||||
config: {
|
||||
autoCapture: true,
|
||||
approvalPolicy: "auto",
|
||||
reviewMode: "hybrid",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`approvalPolicy: "auto"` still uses the same scanner and quarantine path. It
|
||||
does not apply proposals with critical findings.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Default | Range / values | Meaning |
|
||||
| -------------------- | ----------- | ------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| `enabled` | `true` | boolean | Enables the plugin after the plugin entry is loaded. |
|
||||
| `autoCapture` | `true` | boolean | Enables post-turn capture/review on successful agent turns. |
|
||||
| `approvalPolicy` | `"pending"` | `"pending"`, `"auto"` | Queue proposals or write safe proposals automatically. |
|
||||
| `reviewMode` | `"hybrid"` | `"off"`, `"heuristic"`, `"llm"`, `"hybrid"` | Chooses explicit correction capture, LLM reviewer, both, or neither. |
|
||||
| `reviewInterval` | `15` | `1..200` | Run reviewer after this many successful turns. |
|
||||
| `reviewMinToolCalls` | `8` | `1..500` | Run reviewer after this many observed tool calls. |
|
||||
| `reviewTimeoutMs` | `45000` | `5000..180000` | Timeout for the embedded reviewer run. |
|
||||
| `maxPending` | `50` | `1..200` | Max pending/quarantined proposals kept per workspace. |
|
||||
| `maxSkillBytes` | `40000` | `1024..200000` | Max generated skill/support file size. |
|
||||
|
||||
Recommended profiles:
|
||||
|
||||
```json5
|
||||
// Conservative: explicit tool use only, no automatic capture.
|
||||
{
|
||||
autoCapture: false,
|
||||
approvalPolicy: "pending",
|
||||
reviewMode: "off",
|
||||
}
|
||||
```
|
||||
|
||||
```json5
|
||||
// Review-first: capture automatically, but require approval.
|
||||
{
|
||||
autoCapture: true,
|
||||
approvalPolicy: "pending",
|
||||
reviewMode: "hybrid",
|
||||
}
|
||||
```
|
||||
|
||||
```json5
|
||||
// Trusted automation: write safe proposals immediately.
|
||||
{
|
||||
autoCapture: true,
|
||||
approvalPolicy: "auto",
|
||||
reviewMode: "hybrid",
|
||||
}
|
||||
```
|
||||
|
||||
```json5
|
||||
// Low-cost: no reviewer LLM call, only explicit correction phrases.
|
||||
{
|
||||
autoCapture: true,
|
||||
approvalPolicy: "pending",
|
||||
reviewMode: "heuristic",
|
||||
}
|
||||
```
|
||||
|
||||
## Capture paths
|
||||
|
||||
Skill Workshop has three capture paths.
|
||||
|
||||
### Tool suggestions
|
||||
|
||||
The model can call `skill_workshop` directly when it sees a reusable procedure
|
||||
or when the user asks it to save/update a skill.
|
||||
|
||||
This is the most explicit path and works even with `autoCapture: false`.
|
||||
|
||||
### Heuristic capture
|
||||
|
||||
When `autoCapture` is enabled and `reviewMode` is `heuristic` or `hybrid`, the
|
||||
plugin scans successful turns for explicit user correction phrases:
|
||||
|
||||
- `next time`
|
||||
- `from now on`
|
||||
- `remember to`
|
||||
- `make sure to`
|
||||
- `always ... use/check/verify/record/save/prefer`
|
||||
- `prefer ... when/for/instead/use`
|
||||
- `when asked`
|
||||
|
||||
The heuristic creates a proposal from the latest matching user instruction. It
|
||||
uses topic hints to choose skill names for common workflows:
|
||||
|
||||
- animated GIF tasks -> `animated-gif-workflow`
|
||||
- screenshot or asset tasks -> `screenshot-asset-workflow`
|
||||
- QA or scenario tasks -> `qa-scenario-workflow`
|
||||
- GitHub PR tasks -> `github-pr-workflow`
|
||||
- fallback -> `learned-workflows`
|
||||
|
||||
Heuristic capture is intentionally narrow. It is for clear corrections and
|
||||
repeatable process notes, not for general transcript summarization.
|
||||
|
||||
### LLM reviewer
|
||||
|
||||
When `autoCapture` is enabled and `reviewMode` is `llm` or `hybrid`, the plugin
|
||||
runs a compact embedded reviewer after thresholds are reached.
|
||||
|
||||
The reviewer receives:
|
||||
|
||||
- the recent transcript text, capped to the last 12,000 characters
|
||||
- up to 12 existing workspace skills
|
||||
- up to 2,000 characters from each existing skill
|
||||
- JSON-only instructions
|
||||
|
||||
The reviewer has no tools:
|
||||
|
||||
- `disableTools: true`
|
||||
- `toolsAllow: []`
|
||||
- `disableMessageTool: true`
|
||||
|
||||
The reviewer returns either `{ "action": "none" }` or one proposal. The `action` field is `create`, `append`, or `replace` - prefer `append`/`replace` when a relevant skill already exists; use `create` only when no existing skill fits.
|
||||
|
||||
Example `create`:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"skillName": "media-asset-qa",
|
||||
"title": "Media Asset QA",
|
||||
"reason": "Reusable animated media acceptance workflow",
|
||||
"description": "Validate externally sourced animated media before product use.",
|
||||
"body": "## Workflow\n\n- Verify true animation.\n- Record attribution.\n- Store a local approved copy.\n- Verify in product UI before final reply."
|
||||
}
|
||||
```
|
||||
|
||||
`append` adds `section` + `body`. `replace` swaps `oldText` for `newText` in the named skill.
|
||||
|
||||
## Proposal lifecycle
|
||||
|
||||
Every generated update becomes a proposal with:
|
||||
|
||||
- `id`
|
||||
- `createdAt`
|
||||
- `updatedAt`
|
||||
- `workspaceDir`
|
||||
- optional `agentId`
|
||||
- optional `sessionId`
|
||||
- `skillName`
|
||||
- `title`
|
||||
- `reason`
|
||||
- `source`: `tool`, `agent_end`, or `reviewer`
|
||||
- `status`
|
||||
- `change`
|
||||
- optional `scanFindings`
|
||||
- optional `quarantineReason`
|
||||
|
||||
Proposal statuses:
|
||||
|
||||
- `pending` - waiting for approval
|
||||
- `applied` - written to `<workspace>/skills`
|
||||
- `rejected` - rejected by operator/model
|
||||
- `quarantined` - blocked by critical scanner findings
|
||||
|
||||
State is stored per workspace under the Gateway state directory:
|
||||
|
||||
```text
|
||||
<stateDir>/skill-workshop/<workspace-hash>.json
|
||||
```
|
||||
|
||||
Pending and quarantined proposals are deduplicated by skill name and change
|
||||
payload. The store keeps the newest pending/quarantined proposals up to
|
||||
`maxPending`.
|
||||
|
||||
## Tool reference
|
||||
|
||||
The plugin registers one agent tool:
|
||||
|
||||
```text
|
||||
skill_workshop
|
||||
```
|
||||
|
||||
### `status`
|
||||
|
||||
Count proposals by state for the active workspace.
|
||||
|
||||
```json
|
||||
{ "action": "status" }
|
||||
```
|
||||
|
||||
Result shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"workspaceDir": "/path/to/workspace",
|
||||
"pending": 1,
|
||||
"quarantined": 0,
|
||||
"applied": 3,
|
||||
"rejected": 0
|
||||
}
|
||||
```
|
||||
|
||||
### `list_pending`
|
||||
|
||||
List pending proposals.
|
||||
|
||||
```json
|
||||
{ "action": "list_pending" }
|
||||
```
|
||||
|
||||
To list another status:
|
||||
|
||||
```json
|
||||
{ "action": "list_pending", "status": "applied" }
|
||||
```
|
||||
|
||||
Valid `status` values:
|
||||
|
||||
- `pending`
|
||||
- `applied`
|
||||
- `rejected`
|
||||
- `quarantined`
|
||||
|
||||
### `list_quarantine`
|
||||
|
||||
List quarantined proposals.
|
||||
|
||||
```json
|
||||
{ "action": "list_quarantine" }
|
||||
```
|
||||
|
||||
Use this when automatic capture appears to do nothing and the logs mention
|
||||
`skill-workshop: quarantined <skill>`.
|
||||
|
||||
### `inspect`
|
||||
|
||||
Fetch a proposal by id.
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "inspect",
|
||||
"id": "proposal-id"
|
||||
}
|
||||
```
|
||||
|
||||
### `suggest`
|
||||
|
||||
Create a proposal. With `approvalPolicy: "pending"` (default), this queues instead of writing.
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "suggest",
|
||||
"skillName": "animated-gif-workflow",
|
||||
"title": "Animated GIF Workflow",
|
||||
"reason": "User established reusable GIF validation rules.",
|
||||
"description": "Validate animated GIF assets before using them.",
|
||||
"body": "## Workflow\n\n- Verify the URL resolves to image/gif.\n- Confirm it has multiple frames.\n- Record attribution and license.\n- Avoid hotlinking when a local asset is needed."
|
||||
}
|
||||
```
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Request immediate write in auto mode (apply: true)">
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "suggest",
|
||||
"apply": true,
|
||||
"skillName": "animated-gif-workflow",
|
||||
"description": "Validate animated GIF assets before using them.",
|
||||
"body": "## Workflow\n\n- Verify true animation.\n- Record attribution."
|
||||
}
|
||||
```
|
||||
|
||||
With `approvalPolicy: "pending"`, `apply: true` still queues the proposal. Review it, then use
|
||||
the `apply` action after approval.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Force pending under auto policy (apply: false)">
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "suggest",
|
||||
"apply": false,
|
||||
"skillName": "screenshot-asset-workflow",
|
||||
"description": "Screenshot replacement workflow.",
|
||||
"body": "## Workflow\n\n- Verify dimensions.\n- Optimize the PNG.\n- Run the relevant gate."
|
||||
}
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Append to a named section">
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "suggest",
|
||||
"skillName": "qa-scenario-workflow",
|
||||
"section": "Workflow",
|
||||
"description": "QA scenario workflow.",
|
||||
"body": "- For media QA, verify generated assets render and pass final assertions."
|
||||
}
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Replace exact text">
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "suggest",
|
||||
"skillName": "github-pr-workflow",
|
||||
"oldText": "- Check the PR.",
|
||||
"newText": "- Check unresolved review threads, CI status, linked issues, and changed files before deciding."
|
||||
}
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### `apply`
|
||||
|
||||
Apply a pending proposal.
|
||||
|
||||
With `approvalPolicy: "pending"`, this action asks for operator approval before writing the
|
||||
workspace skill.
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "apply",
|
||||
"id": "proposal-id"
|
||||
}
|
||||
```
|
||||
|
||||
`apply` refuses quarantined proposals:
|
||||
|
||||
```text
|
||||
quarantined proposal cannot be applied
|
||||
```
|
||||
|
||||
### `reject`
|
||||
|
||||
Mark a proposal rejected.
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "reject",
|
||||
"id": "proposal-id"
|
||||
}
|
||||
```
|
||||
|
||||
### `write_support_file`
|
||||
|
||||
Write a supporting file inside an existing or proposed skill directory.
|
||||
|
||||
Allowed top-level support directories:
|
||||
|
||||
- `references/`
|
||||
- `templates/`
|
||||
- `scripts/`
|
||||
- `assets/`
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "write_support_file",
|
||||
"skillName": "release-workflow",
|
||||
"relativePath": "references/checklist.md",
|
||||
"body": "# Release Checklist\n\n- Run release docs.\n- Verify changelog.\n"
|
||||
}
|
||||
```
|
||||
|
||||
Support files are workspace-scoped, path-checked, byte-limited by
|
||||
`maxSkillBytes`, scanned, and written atomically.
|
||||
|
||||
## Skill writes
|
||||
|
||||
Skill Workshop writes only under:
|
||||
|
||||
```text
|
||||
<workspace>/skills/<normalized-skill-name>/
|
||||
```
|
||||
|
||||
Skill names are normalized:
|
||||
|
||||
- lowercased
|
||||
- non `[a-z0-9_-]` runs become `-`
|
||||
- leading/trailing non-alphanumerics are removed
|
||||
- max length is 80 characters
|
||||
- final name must match `[a-z0-9][a-z0-9_-]{1,79}`
|
||||
|
||||
For `create`:
|
||||
|
||||
- if the skill does not exist, Skill Workshop writes a new `SKILL.md`
|
||||
- if it already exists, Skill Workshop appends the body to `## Workflow`
|
||||
|
||||
For `append`:
|
||||
|
||||
- if the skill exists, Skill Workshop appends to the requested section
|
||||
- if it does not exist, Skill Workshop creates a minimal skill then appends
|
||||
|
||||
For `replace`:
|
||||
|
||||
- the skill must already exist
|
||||
- `oldText` must be present exactly
|
||||
- only the first exact match is replaced
|
||||
|
||||
All writes are atomic and refresh the in-memory skills snapshot immediately, so
|
||||
the new or updated skill can become visible without a Gateway restart.
|
||||
|
||||
## Safety model
|
||||
|
||||
Skill Workshop has a safety scanner on generated `SKILL.md` content and support
|
||||
files.
|
||||
|
||||
Critical findings quarantine proposals:
|
||||
|
||||
| Rule id | Blocks content that... |
|
||||
| -------------------------------------- | --------------------------------------------------------------------- |
|
||||
| `prompt-injection-ignore-instructions` | tells the agent to ignore prior/higher instructions |
|
||||
| `prompt-injection-system` | references system prompts, developer messages, or hidden instructions |
|
||||
| `prompt-injection-tool` | encourages bypassing tool permission/approval |
|
||||
| `shell-pipe-to-shell` | includes `curl`/`wget` piped into `sh`, `bash`, or `zsh` |
|
||||
| `secret-exfiltration` | appears to send env/process env data over the network |
|
||||
|
||||
Warn findings are retained but do not block by themselves:
|
||||
|
||||
| Rule id | Warns on... |
|
||||
| -------------------- | -------------------------------- |
|
||||
| `destructive-delete` | broad `rm -rf` style commands |
|
||||
| `unsafe-permissions` | `chmod 777` style permission use |
|
||||
|
||||
Quarantined proposals:
|
||||
|
||||
- keep `scanFindings`
|
||||
- keep `quarantineReason`
|
||||
- appear in `list_quarantine`
|
||||
- cannot be applied through `apply`
|
||||
|
||||
To recover from a quarantined proposal, create a new safe proposal with the
|
||||
unsafe content removed. Do not edit the store JSON by hand.
|
||||
|
||||
## Prompt guidance
|
||||
|
||||
When enabled, Skill Workshop injects a short prompt section that tells the agent
|
||||
to use `skill_workshop` for durable procedural memory.
|
||||
|
||||
The guidance emphasizes:
|
||||
|
||||
- procedures, not facts/preferences
|
||||
- user corrections
|
||||
- non-obvious successful procedures
|
||||
- recurring pitfalls
|
||||
- stale/thin/wrong skill repair through append/replace
|
||||
- saving reusable procedure after long tool loops or hard fixes
|
||||
- short imperative skill text
|
||||
- no transcript dumps
|
||||
|
||||
The write mode text changes with `approvalPolicy`:
|
||||
|
||||
- pending mode: queue suggestions; use `apply` after explicit approval
|
||||
- auto mode: apply safe workspace-skill updates unless `apply: false` queues instead
|
||||
|
||||
## Costs and runtime behavior
|
||||
|
||||
Heuristic capture does not call a model.
|
||||
|
||||
LLM review uses an embedded run on the active/default agent model. It is
|
||||
threshold-based so it does not run on every turn by default.
|
||||
|
||||
The reviewer:
|
||||
|
||||
- uses the same configured provider/model context when available
|
||||
- falls back to runtime agent defaults
|
||||
- has `reviewTimeoutMs`
|
||||
- uses lightweight bootstrap context
|
||||
- has no tools
|
||||
- writes nothing directly
|
||||
- can only emit a proposal that goes through the normal scanner and
|
||||
approval/quarantine path
|
||||
|
||||
If the reviewer fails, times out, or returns invalid JSON, the plugin logs a
|
||||
warning/debug message and skips that review pass.
|
||||
|
||||
## Operating patterns
|
||||
|
||||
Use Skill Workshop when the user says:
|
||||
|
||||
- "next time, do X"
|
||||
- "from now on, prefer Y"
|
||||
- "make sure to verify Z"
|
||||
- "save this as a workflow"
|
||||
- "this took a while; remember the process"
|
||||
- "update the local skill for this"
|
||||
|
||||
Good skill text:
|
||||
|
||||
```markdown
|
||||
## Workflow
|
||||
|
||||
- Verify the GIF URL resolves to `image/gif`.
|
||||
- Confirm the file has multiple frames.
|
||||
- Record source URL, license, and attribution.
|
||||
- Store a local copy when the asset will ship with the product.
|
||||
- Verify the local asset renders in the target UI before final reply.
|
||||
```
|
||||
|
||||
Poor skill text:
|
||||
|
||||
```markdown
|
||||
The user asked about a GIF and I searched two websites. Then one was blocked by
|
||||
Cloudflare. The final answer said to check attribution.
|
||||
```
|
||||
|
||||
Reasons the poor version should not be saved:
|
||||
|
||||
- transcript-shaped
|
||||
- not imperative
|
||||
- includes noisy one-off details
|
||||
- does not tell the next agent what to do
|
||||
|
||||
## Debugging
|
||||
|
||||
Check whether the plugin is loaded:
|
||||
|
||||
```bash
|
||||
openclaw plugins list --enabled
|
||||
```
|
||||
|
||||
Check proposal counts from an agent/tool context:
|
||||
|
||||
```json
|
||||
{ "action": "status" }
|
||||
```
|
||||
|
||||
Inspect pending proposals:
|
||||
|
||||
```json
|
||||
{ "action": "list_pending" }
|
||||
```
|
||||
|
||||
Inspect quarantined proposals:
|
||||
|
||||
```json
|
||||
{ "action": "list_quarantine" }
|
||||
```
|
||||
|
||||
Common symptoms:
|
||||
|
||||
| Symptom | Likely cause | Check |
|
||||
| ------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| Tool is unavailable | Plugin entry is not enabled | `plugins.entries.skill-workshop.enabled` and `openclaw plugins list` |
|
||||
| No automatic proposal appears | `autoCapture: false`, `reviewMode: "off"`, or thresholds not met | Config, proposal status, Gateway logs |
|
||||
| Heuristic did not capture | User wording did not match correction patterns | Use explicit `skill_workshop.suggest` or enable LLM reviewer |
|
||||
| Reviewer did not create a proposal | Reviewer returned `none`, invalid JSON, or timed out | Gateway logs, `reviewTimeoutMs`, thresholds |
|
||||
| Proposal is not applied | `approvalPolicy: "pending"` | `list_pending`, then `apply` |
|
||||
| Proposal disappeared from pending | Duplicate proposal reused, max pending pruning, or was applied/rejected/quarantined | `status`, `list_pending` with status filters, `list_quarantine` |
|
||||
| Skill file exists but model misses it | Skill snapshot not refreshed or skill gating excludes it | `openclaw skills` status and workspace skill eligibility |
|
||||
|
||||
Relevant logs:
|
||||
|
||||
- `skill-workshop: queued <skill>`
|
||||
- `skill-workshop: applied <skill>`
|
||||
- `skill-workshop: quarantined <skill>`
|
||||
- `skill-workshop: heuristic capture skipped: ...`
|
||||
- `skill-workshop: reviewer skipped: ...`
|
||||
- `skill-workshop: reviewer found no update`
|
||||
|
||||
## QA scenarios
|
||||
|
||||
Repo-backed QA scenarios:
|
||||
|
||||
- `qa/scenarios/plugins/skill-workshop-animated-gif-autocreate.md`
|
||||
- `qa/scenarios/plugins/skill-workshop-pending-approval.md`
|
||||
- `qa/scenarios/plugins/skill-workshop-reviewer-autonomous.md`
|
||||
|
||||
Run the deterministic coverage:
|
||||
|
||||
```bash
|
||||
pnpm openclaw qa suite \
|
||||
--scenario skill-workshop-animated-gif-autocreate \
|
||||
--scenario skill-workshop-pending-approval \
|
||||
--concurrency 1
|
||||
```
|
||||
|
||||
Run reviewer coverage:
|
||||
|
||||
```bash
|
||||
pnpm openclaw qa suite \
|
||||
--scenario skill-workshop-reviewer-autonomous \
|
||||
--concurrency 1
|
||||
```
|
||||
|
||||
The reviewer scenario is intentionally separate because it enables
|
||||
`reviewMode: "llm"` and exercises the embedded reviewer pass.
|
||||
|
||||
## When not to enable auto apply
|
||||
|
||||
Avoid `approvalPolicy: "auto"` when:
|
||||
|
||||
- the workspace contains sensitive procedures
|
||||
- the agent is working on untrusted input
|
||||
- skills are shared across a broad team
|
||||
- you are still tuning prompts or scanner rules
|
||||
- the model frequently handles hostile web/email content
|
||||
|
||||
Use pending mode first. Switch to auto mode only after reviewing the kind of
|
||||
skills the agent proposes in that workspace.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [Skills](/tools/skills)
|
||||
- [Plugins](/tools/plugin)
|
||||
- [Testing](/reference/test)
|
||||
@@ -127,8 +127,8 @@ runs, reset or delete any intentionally stale session explicitly.
|
||||
Isolated cron runs also create session entries/transcripts. Session rows use the
|
||||
same SQLite session tables as other rows:
|
||||
|
||||
- `cron.sessionRetention` (default `24h`) prunes old isolated cron run sessions from the session store (`false` disables).
|
||||
- `cron.runLog.keepLines` prunes retained SQLite run-history rows per cron job (default: `2000`). `cron.runLog.maxBytes` remains accepted for older file-backed run logs.
|
||||
- Legacy cron session imports happen through `openclaw doctor --fix`.
|
||||
- `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune SQLite cron run history (defaults: `2_000_000` approximate serialized bytes and `2000` rows per job).
|
||||
|
||||
When cron force-creates a new isolated run session, it sanitizes the previous
|
||||
`cron:<jobId>` session entry before writing the new row. It carries safe
|
||||
|
||||
@@ -25,8 +25,8 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
|
||||
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.
|
||||
- `chat.history` is bounded for stability: Gateway may truncate long text fields, omit heavy metadata, and replace oversized entries with `[chat.history omitted: message too large]`.
|
||||
- When a visible assistant message was truncated in `chat.history`, Control UI can open a side reader and fetch the full display-normalized entry on demand through `chat.message.get` without increasing the default history payload.
|
||||
- `chat.history` follows the active transcript branch for modern append-only session files, so abandoned rewrite branches and superseded prompt copies are not rendered in WebChat.
|
||||
- Compaction entries render as an explicit compacted-history divider. The divider explains that the compacted transcript is preserved as a checkpoint and links to the Sessions checkpoint controls, where operators can branch or restore from that compacted view when their permissions allow it.
|
||||
- `chat.history` follows the active SQLite transcript branch, so abandoned rewrite branches and superseded prompt copies are not rendered in WebChat.
|
||||
- Compaction entries render as an explicit compacted-history divider. The divider explains that earlier turns are preserved in a checkpoint and links to the Sessions checkpoint controls, where operators can branch or restore the pre-compaction view when their permissions allow it.
|
||||
- Control UI remembers the backing Gateway `sessionId` returned by `chat.history` and includes it on follow-up `chat.send` calls, so reconnects and page refreshes continue the same stored conversation unless the user starts or resets a session.
|
||||
- Control UI coalesces duplicate in-flight submits for the same session, message, and attachments before generating a new `chat.send` run id; the Gateway still dedupes repeated requests that reuse the same idempotency key.
|
||||
- Workspace startup files and pending `BOOTSTRAP.md` instructions are supplied through the agent system prompt's Project Context, not copied into the WebChat user message. Bootstrap truncation only adds a concise system-prompt recovery notice; detailed counts and config knobs stay on diagnostic surfaces.
|
||||
@@ -50,11 +50,11 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
|
||||
|
||||
WebChat has two separate data paths:
|
||||
|
||||
- The session JSONL file is the durable model/runtime transcript. For normal agent runs, the embedded OpenClaw runtime persists model-visible `user`, `assistant`, and `toolResult` messages through its session manager. WebChat does not write arbitrary delivery, status, or helper text into that transcript.
|
||||
- Gateway `ReplyPayload` events are the live delivery projection. They can be normalized for WebChat/channel display, block streaming, directive tags, media embedding, TTS/audio flags, and UI fallback behavior. They are not themselves the canonical session log.
|
||||
- The per-agent SQLite transcript is the durable model/runtime transcript. For normal agent runs, OpenClaw persists model-visible `user`, `assistant`, and `toolResult` messages through its transcript store. WebChat does not write arbitrary delivery, status, or helper text into that transcript.
|
||||
- Gateway `ReplyPayload` events are the live delivery projection. They can be normalized for WebChat/channel display, block streaming, directive tags, media embedding, TTS/audio flags, and UI fallback behavior. They are not themselves the canonical session transcript.
|
||||
- Harnesses that require visible replies through `tools.message` still use WebChat as a current-run internal source reply sink. A targetless `message.send` from that active WebChat run is projected into the same chat and mirrored to the session transcript; WebChat does not become a reusable outbound channel and never inherits `lastChannel`.
|
||||
- WebChat injects assistant transcript entries only when the Gateway owns a displayed message outside a normal embedded agent turn: `chat.inject`, non-agent command replies, aborted partial output, and WebChat-managed media transcript supplements.
|
||||
- `chat.history` reads the stored session transcript and applies WebChat display projection. If live assistant text appears during a run but disappears after history reload, first check whether the raw JSONL contains the assistant text, then whether `chat.history` projection stripped it, then whether the Control UI optimistic-tail merge replaced local delivery state with the persisted snapshot.
|
||||
- WebChat injects assistant transcript entries only when the Gateway owns a displayed message outside a normal Pi assistant turn: `chat.inject`, non-agent command replies, aborted partial output, and WebChat-managed media transcript supplements.
|
||||
- `chat.history` reads the stored transcript rows and applies WebChat display projection. If live assistant text appears during a run but disappears after history reload, first check whether the transcript rows contain the assistant text, then whether `chat.history` projection stripped it, then whether the Control UI optimistic-tail merge replaced local delivery state with the persisted snapshot.
|
||||
- `chat.message.get` uses the same transcript branch and display projection rules as `chat.history`, including active-agent scoping, but targets one transcript entry by `messageId` and returns an honest unavailable reason when the full content can no longer be returned.
|
||||
|
||||
Normal agent-run final answers should be durable because the embedded runtime writes the assistant `message_end`. Any fallback that mirrors a delivered final payload into the transcript must first avoid duplicating an assistant turn that the embedded runtime already wrote.
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("acpx plugin", () => {
|
||||
createAcpxRuntimeServiceMock.mockReturnValue(service);
|
||||
|
||||
const api = {
|
||||
pluginConfig: { stateDir: "/tmp/acpx" },
|
||||
pluginConfig: { timeoutSeconds: 30 },
|
||||
registerService: vi.fn(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
@@ -71,7 +71,7 @@ describe("acpx plugin", () => {
|
||||
|
||||
const on = vi.fn();
|
||||
const api = createTestPluginApi({
|
||||
pluginConfig: { stateDir: "/tmp/acpx" },
|
||||
pluginConfig: { timeoutSeconds: 30 },
|
||||
registerService: vi.fn(),
|
||||
on,
|
||||
});
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
},
|
||||
"stateDir": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
"deprecated": true,
|
||||
"description": "Legacy option accepted for compatibility and ignored; ACPX state follows the OpenClaw state directory."
|
||||
},
|
||||
"probeAgent": {
|
||||
"type": "string",
|
||||
@@ -49,10 +50,6 @@
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"probeAgent": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"mcpServers": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -101,10 +98,6 @@
|
||||
"label": "Default Working Directory",
|
||||
"help": "Default working directory for embedded ACP session operations when not set per session."
|
||||
},
|
||||
"stateDir": {
|
||||
"label": "State Directory",
|
||||
"help": "Directory used for embedded ACP session state and persistence."
|
||||
},
|
||||
"permissionMode": {
|
||||
"label": "Permission Mode",
|
||||
"help": "Default permission policy for embedded ACP runtime prompts."
|
||||
|
||||
64
extensions/acpx/src/acpx-runtime-compat.d.ts
vendored
Normal file
64
extensions/acpx/src/acpx-runtime-compat.d.ts
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
declare module "acpx/runtime" {
|
||||
export const ACPX_BACKEND_ID: string;
|
||||
|
||||
export type AcpRuntimeDoctorReport = import("../runtime-api.js").AcpRuntimeDoctorReport;
|
||||
export type AcpRuntimeEnsureInput = import("../runtime-api.js").AcpRuntimeEnsureInput;
|
||||
export type AcpRuntimeEvent = import("../runtime-api.js").AcpRuntimeEvent;
|
||||
export type AcpRuntimeHandle = import("../runtime-api.js").AcpRuntimeHandle;
|
||||
export type AcpRuntimeCapabilities = import("../runtime-api.js").AcpRuntimeCapabilities;
|
||||
export type AcpRuntimeStatus = import("../runtime-api.js").AcpRuntimeStatus;
|
||||
export type AcpRuntimeTurn = import("../runtime-api.js").AcpRuntimeTurn;
|
||||
export type AcpRuntimeTurnInput = import("../runtime-api.js").AcpRuntimeTurnInput;
|
||||
export type AcpRuntimeTurnResult = import("../runtime-api.js").AcpRuntimeTurnResult;
|
||||
|
||||
export type AcpAgentRegistry = {
|
||||
resolve(agent: string): string | undefined;
|
||||
list(): string[];
|
||||
};
|
||||
|
||||
export type AcpSessionRecord = Record<string, unknown>;
|
||||
|
||||
export type AcpSessionStore = {
|
||||
load(sessionId: string): Promise<AcpSessionRecord | undefined>;
|
||||
save(record: AcpSessionRecord): Promise<void>;
|
||||
};
|
||||
|
||||
export type AcpRuntimeOptions = {
|
||||
cwd: string;
|
||||
sessionStore: AcpSessionStore;
|
||||
agentRegistry: AcpAgentRegistry;
|
||||
probeAgent?: string;
|
||||
mcpServers?: unknown;
|
||||
permissionMode?: unknown;
|
||||
nonInteractivePermissions?: unknown;
|
||||
timeoutMs?: number;
|
||||
probeAgent?: string;
|
||||
};
|
||||
|
||||
export class AcpxRuntime {
|
||||
constructor(options: AcpRuntimeOptions, testOptions?: unknown);
|
||||
isHealthy(): boolean;
|
||||
probeAvailability(): Promise<void>;
|
||||
doctor(): Promise<AcpRuntimeDoctorReport>;
|
||||
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
|
||||
startTurn(input: AcpRuntimeTurnInput): AcpRuntimeTurn;
|
||||
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
|
||||
getCapabilities(input?: {
|
||||
handle?: AcpRuntimeHandle;
|
||||
}): AcpRuntimeCapabilities | Promise<AcpRuntimeCapabilities>;
|
||||
getStatus(input: { handle: AcpRuntimeHandle; signal?: AbortSignal }): Promise<AcpRuntimeStatus>;
|
||||
setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
|
||||
setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
|
||||
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
|
||||
close(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
reason?: string;
|
||||
discardPersistentState?: boolean;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export function createAcpRuntime(...args: unknown[]): AcpxRuntime;
|
||||
export function createAgentRegistry(params: { overrides?: unknown }): AcpAgentRegistry;
|
||||
export function decodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
|
||||
export function encodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
|
||||
}
|
||||
@@ -34,24 +34,22 @@ function restoreEnv(name: keyof typeof previousEnv): void {
|
||||
}
|
||||
}
|
||||
|
||||
function generatedCodexPaths(stateDir: string): {
|
||||
function generatedCodexPaths(wrapperRoot: string): {
|
||||
configPath: string;
|
||||
wrapperPath: string;
|
||||
} {
|
||||
const baseDir = path.join(stateDir, "acpx");
|
||||
const codexHome = path.join(baseDir, "codex-home");
|
||||
const codexHome = path.join(wrapperRoot, "codex-home");
|
||||
return {
|
||||
configPath: path.join(codexHome, "config.toml"),
|
||||
wrapperPath: path.join(baseDir, "codex-acp-wrapper.mjs"),
|
||||
wrapperPath: path.join(wrapperRoot, "codex-acp-wrapper.mjs"),
|
||||
};
|
||||
}
|
||||
|
||||
function generatedClaudePaths(stateDir: string): {
|
||||
function generatedClaudePaths(wrapperRoot: string): {
|
||||
wrapperPath: string;
|
||||
} {
|
||||
const baseDir = path.join(stateDir, "acpx");
|
||||
return {
|
||||
wrapperPath: path.join(baseDir, "claude-agent-acp-wrapper.mjs"),
|
||||
wrapperPath: path.join(wrapperRoot, "claude-agent-acp-wrapper.mjs"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,9 +98,9 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
it("installs an isolated Codex ACP wrapper without synthesizing auth from canonical OpenClaw OAuth", async () => {
|
||||
const root = await makeTempDir();
|
||||
const agentDir = path.join(root, "agent");
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
const generatedClaude = generatedClaudePaths(stateDir);
|
||||
const wrapperRoot = path.join(root, "wrapper");
|
||||
const generated = generatedCodexPaths(wrapperRoot);
|
||||
const generatedClaude = generatedClaudePaths(wrapperRoot);
|
||||
const installedBinPath = path.join(
|
||||
root,
|
||||
"node_modules",
|
||||
@@ -119,7 +117,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
});
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
wrapperRoot,
|
||||
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
|
||||
});
|
||||
|
||||
@@ -133,11 +131,11 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
await expectPathMissing(path.join(agentDir, "acp-auth", "codex", "auth.json"));
|
||||
});
|
||||
|
||||
it("keeps generated wrappers usable when chmod is rejected by the state filesystem", async () => {
|
||||
it("keeps generated wrappers usable when chmod is rejected by the wrapper filesystem", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generatedCodex = generatedCodexPaths(stateDir);
|
||||
const generatedClaude = generatedClaudePaths(stateDir);
|
||||
const wrapperRoot = path.join(root, "wrapper");
|
||||
const generatedCodex = generatedCodexPaths(wrapperRoot);
|
||||
const generatedClaude = generatedClaudePaths(wrapperRoot);
|
||||
const chmodError = Object.assign(new Error("operation not permitted"), { code: "EPERM" });
|
||||
const chmodSpy = vi.spyOn(fs, "chmod").mockRejectedValue(chmodError);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
@@ -147,7 +145,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
wrapperRoot,
|
||||
});
|
||||
|
||||
expect(chmodSpy).toHaveBeenCalledWith(generatedCodex.wrapperPath, 0o755);
|
||||
@@ -160,8 +158,8 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
it("falls back to the current Codex ACP package range when the local adapter is unavailable", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
const wrapperRoot = path.join(root, "wrapper");
|
||||
const generated = generatedCodexPaths(wrapperRoot);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
@@ -169,7 +167,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
wrapperRoot,
|
||||
resolveInstalledCodexAcpBinPath: async () => undefined,
|
||||
});
|
||||
|
||||
@@ -181,8 +179,8 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
it("falls back to the patched Claude ACP package when the local adapter is unavailable", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedClaudePaths(stateDir);
|
||||
const wrapperRoot = path.join(root, "wrapper");
|
||||
const generated = generatedClaudePaths(wrapperRoot);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
@@ -190,7 +188,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
wrapperRoot,
|
||||
resolveInstalledClaudeAcpBinPath: async () => undefined,
|
||||
});
|
||||
|
||||
@@ -203,8 +201,8 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
it("uses the bundled Codex ACP dependency by default when it is installed", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
const wrapperRoot = path.join(root, "wrapper");
|
||||
const generated = generatedCodexPaths(wrapperRoot);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
@@ -212,7 +210,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
wrapperRoot,
|
||||
});
|
||||
|
||||
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
|
||||
@@ -223,8 +221,8 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
it("keeps the orphaned wrapper alive long enough to force-kill the child process group", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
const wrapperRoot = path.join(root, "wrapper");
|
||||
const generated = generatedCodexPaths(wrapperRoot);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
@@ -232,7 +230,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
wrapperRoot,
|
||||
});
|
||||
|
||||
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
|
||||
@@ -254,8 +252,8 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
it("uses the bundled Claude ACP dependency by default when it is installed", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedClaudePaths(stateDir);
|
||||
const wrapperRoot = path.join(root, "wrapper");
|
||||
const generated = generatedClaudePaths(wrapperRoot);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {},
|
||||
workspaceDir: root,
|
||||
@@ -263,7 +261,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
wrapperRoot,
|
||||
});
|
||||
|
||||
const wrapper = await fs.readFile(generated.wrapperPath, "utf8");
|
||||
@@ -274,8 +272,8 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
it("launches the locally installed Codex ACP bin with isolated CODEX_HOME", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
const wrapperRoot = path.join(root, "wrapper");
|
||||
const generated = generatedCodexPaths(wrapperRoot);
|
||||
const installedBinPath = path.join(root, "codex-acp-bin.js");
|
||||
await fs.writeFile(
|
||||
installedBinPath,
|
||||
@@ -289,7 +287,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
wrapperRoot,
|
||||
resolveInstalledCodexAcpBinPath: async () => installedBinPath,
|
||||
});
|
||||
|
||||
@@ -308,14 +306,14 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
);
|
||||
const launched = JSON.parse(stdout.trim()) as { argv?: unknown; codexHome?: unknown };
|
||||
expect(launched.argv).toStrictEqual([]);
|
||||
const expectedCodexHome = await fs.realpath(path.join(stateDir, "acpx", "codex-home"));
|
||||
const expectedCodexHome = await fs.realpath(path.join(wrapperRoot, "codex-home"));
|
||||
expect(path.resolve(String(launched.codexHome))).toBe(expectedCodexHome);
|
||||
});
|
||||
|
||||
it("launches the locally installed Claude ACP bin without going through npm", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedClaudePaths(stateDir);
|
||||
const wrapperRoot = path.join(root, "wrapper");
|
||||
const generated = generatedClaudePaths(wrapperRoot);
|
||||
const installedBinPath = path.join(root, "claude-agent-acp-bin.js");
|
||||
await fs.writeFile(
|
||||
installedBinPath,
|
||||
@@ -329,7 +327,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
wrapperRoot,
|
||||
resolveInstalledClaudeAcpBinPath: async () => installedBinPath,
|
||||
});
|
||||
|
||||
@@ -349,8 +347,8 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
const root = await makeTempDir();
|
||||
const sourceCodexHome = path.join(root, "source-codex");
|
||||
const agentDir = path.join(root, "agent");
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
const wrapperRoot = path.join(root, "wrapper");
|
||||
const generated = generatedCodexPaths(wrapperRoot);
|
||||
await fs.mkdir(sourceCodexHome, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sourceCodexHome, "auth.json"),
|
||||
@@ -395,7 +393,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
});
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
wrapperRoot,
|
||||
resolveInstalledCodexAcpBinPath: async () => undefined,
|
||||
});
|
||||
|
||||
@@ -430,12 +428,12 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
it("copies only trusted Codex project declarations into the isolated Codex home", async () => {
|
||||
const root = await makeTempDir();
|
||||
const sourceCodexHome = path.join(root, "source-codex");
|
||||
const stateDir = path.join(root, "state");
|
||||
const wrapperRoot = path.join(root, "wrapper");
|
||||
const explicitProject = path.join(root, "explicit project");
|
||||
const inlineProject = path.join(root, "inline-project");
|
||||
const mapProject = path.join(root, "map-project");
|
||||
const untrustedProject = path.join(root, "untrusted-project");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
const generated = generatedCodexPaths(wrapperRoot);
|
||||
await fs.mkdir(sourceCodexHome, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sourceCodexHome, "config.toml"),
|
||||
@@ -457,7 +455,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
wrapperRoot,
|
||||
resolveInstalledCodexAcpBinPath: async () => undefined,
|
||||
});
|
||||
|
||||
@@ -474,8 +472,8 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
it("normalizes an explicitly configured Codex ACP command to the local wrapper", async () => {
|
||||
const root = await makeTempDir();
|
||||
const sourceCodexHome = path.join(root, "source-codex");
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedCodexPaths(stateDir);
|
||||
const wrapperRoot = path.join(root, "wrapper");
|
||||
const generated = generatedCodexPaths(wrapperRoot);
|
||||
await fs.mkdir(sourceCodexHome, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sourceCodexHome, "config.toml"),
|
||||
@@ -495,7 +493,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
wrapperRoot,
|
||||
resolveInstalledCodexAcpBinPath: async () => path.join(root, "codex-acp.js"),
|
||||
});
|
||||
|
||||
@@ -514,8 +512,8 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
it("normalizes an explicitly configured Claude ACP npx command to the local wrapper", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const generated = generatedClaudePaths(stateDir);
|
||||
const wrapperRoot = path.join(root, "wrapper");
|
||||
const generated = generatedClaudePaths(wrapperRoot);
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
agents: {
|
||||
@@ -529,7 +527,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
wrapperRoot,
|
||||
resolveInstalledClaudeAcpBinPath: async () => path.join(root, "claude-agent-acp.js"),
|
||||
});
|
||||
|
||||
@@ -590,7 +588,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
wrapperRoot: stateDir,
|
||||
resolveInstalledCodexAcpBinPath: async () => path.join(root, "codex-acp.js"),
|
||||
});
|
||||
|
||||
@@ -608,7 +606,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
).rejects.toMatchObject({ code: 1 });
|
||||
|
||||
const log = await fs.readFile(
|
||||
path.join(stateDir, "acpx", "codex-acp-wrapper.stderr.lease-secret.log"),
|
||||
path.join(stateDir, "codex-acp-wrapper.stderr.lease-secret.log"),
|
||||
"utf8",
|
||||
);
|
||||
expect(log).toContain("token=[REDACTED]");
|
||||
@@ -631,12 +629,12 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
expect(log).not.toContain("private-secret-body");
|
||||
expect(log).not.toContain("truncated-private-secret");
|
||||
expect(log).not.toContain("tail-secret-1234567890");
|
||||
await expectPathMissing(path.join(stateDir, "acpx", "codex-acp-wrapper.stderr.log"));
|
||||
await expectPathMissing(path.join(stateDir, "codex-acp-wrapper.stderr.log"));
|
||||
});
|
||||
|
||||
it("leaves a custom Claude agent command alone", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const wrapperRoot = path.join(root, "wrapper");
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
agents: {
|
||||
@@ -650,7 +648,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
wrapperRoot,
|
||||
resolveInstalledClaudeAcpBinPath: async () => path.join(root, "claude-agent-acp.js"),
|
||||
});
|
||||
|
||||
@@ -659,7 +657,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
it("does not normalize custom Claude commands that only mention the package name", async () => {
|
||||
const root = await makeTempDir();
|
||||
const stateDir = path.join(root, "state");
|
||||
const wrapperRoot = path.join(root, "wrapper");
|
||||
const command =
|
||||
"node ./custom-claude-wrapper.mjs @agentclientprotocol/claude-agent-acp@0.31.4 --flag";
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
@@ -675,7 +673,7 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
|
||||
const resolved = await prepareAcpxCodexAuthConfig({
|
||||
pluginConfig,
|
||||
stateDir,
|
||||
wrapperRoot,
|
||||
resolveInstalledClaudeAcpBinPath: async () => path.join(root, "claude-agent-acp.js"),
|
||||
});
|
||||
|
||||
|
||||
@@ -697,13 +697,13 @@ function buildClaudeAcpWrapperCommand(wrapperPath: string, configuredCommand?: s
|
||||
|
||||
export async function prepareAcpxCodexAuthConfig(params: {
|
||||
pluginConfig: ResolvedAcpxPluginConfig;
|
||||
stateDir: string;
|
||||
wrapperRoot: string;
|
||||
logger?: unknown;
|
||||
resolveInstalledCodexAcpBinPath?: () => Promise<string | undefined>;
|
||||
resolveInstalledClaudeAcpBinPath?: () => Promise<string | undefined>;
|
||||
}): Promise<ResolvedAcpxPluginConfig> {
|
||||
void params.logger;
|
||||
const codexBaseDir = path.join(params.stateDir, "acpx");
|
||||
const codexBaseDir = params.wrapperRoot;
|
||||
await prepareIsolatedCodexHome({
|
||||
baseDir: codexBaseDir,
|
||||
workspaceDir: params.pluginConfig.cwd,
|
||||
|
||||
@@ -23,6 +23,7 @@ export type AcpxMcpServer = {
|
||||
|
||||
export type AcpxPluginConfig = {
|
||||
cwd?: string;
|
||||
/** @deprecated Ignored; ACPX state now follows OpenClaw's state directory. */
|
||||
stateDir?: string;
|
||||
probeAgent?: string;
|
||||
permissionMode?: AcpxPermissionMode;
|
||||
@@ -38,7 +39,6 @@ export type AcpxPluginConfig = {
|
||||
|
||||
export type ResolvedAcpxPluginConfig = {
|
||||
cwd: string;
|
||||
stateDir: string;
|
||||
probeAgent?: string;
|
||||
permissionMode: AcpxPermissionMode;
|
||||
nonInteractivePermissions: AcpxNonInteractivePermissionPolicy;
|
||||
@@ -78,7 +78,7 @@ const McpServerConfigSchema = z.object({
|
||||
|
||||
export const AcpxPluginConfigSchema = z.strictObject({
|
||||
cwd: nonEmptyTrimmedString("cwd must be a non-empty string").optional(),
|
||||
stateDir: nonEmptyTrimmedString("stateDir must be a non-empty string").optional(),
|
||||
stateDir: z.string({ error: "stateDir must be a string" }).optional(),
|
||||
probeAgent: nonEmptyTrimmedString("probeAgent must be a non-empty string").optional(),
|
||||
permissionMode: z
|
||||
.enum(ACPX_PERMISSION_MODES, {
|
||||
|
||||
@@ -16,7 +16,7 @@ function expectedMcpServerArgs(params: { sourceEntry: string; distEntry: string
|
||||
}
|
||||
|
||||
describe("embedded acpx plugin config", () => {
|
||||
it("resolves workspace stateDir and cwd by default", () => {
|
||||
it("resolves workspace cwd by default", () => {
|
||||
const workspaceDir = path.resolve("/tmp/openclaw-acpx");
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: undefined,
|
||||
@@ -24,7 +24,6 @@ describe("embedded acpx plugin config", () => {
|
||||
});
|
||||
|
||||
expect(resolved.cwd).toBe(workspaceDir);
|
||||
expect(resolved.stateDir).toBe(path.join(workspaceDir, "state"));
|
||||
expect(resolved.permissionMode).toBe("approve-reads");
|
||||
expect(resolved.nonInteractivePermissions).toBe("fail");
|
||||
expect(resolved.timeoutSeconds).toBe(120);
|
||||
@@ -42,6 +41,17 @@ describe("embedded acpx plugin config", () => {
|
||||
expect(resolved.timeoutSeconds).toBe(300);
|
||||
});
|
||||
|
||||
it("accepts legacy stateDir config without changing the resolved cwd", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
stateDir: "/tmp/legacy-acpx-state",
|
||||
},
|
||||
workspaceDir: "/tmp/openclaw-acpx",
|
||||
});
|
||||
|
||||
expect(resolved.cwd).toBe("/tmp/openclaw-acpx");
|
||||
});
|
||||
|
||||
it("keeps explicit probeAgent config", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
@@ -169,8 +179,8 @@ describe("embedded acpx plugin config", () => {
|
||||
expect(server).toEqual({
|
||||
command: process.execPath,
|
||||
args: expectedMcpServerArgs({
|
||||
sourceEntry: "src/mcp/plugin-tools-serve.ts",
|
||||
distEntry: "dist/mcp/plugin-tools-serve.js",
|
||||
sourceEntry: "src/mcp/plugin-tools-serve.ts",
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -187,8 +197,8 @@ describe("embedded acpx plugin config", () => {
|
||||
expect(server).toEqual({
|
||||
command: process.execPath,
|
||||
args: expectedMcpServerArgs({
|
||||
sourceEntry: "src/mcp/openclaw-tools-serve.ts",
|
||||
distEntry: "dist/mcp/openclaw-tools-serve.js",
|
||||
sourceEntry: "src/mcp/openclaw-tools-serve.ts",
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -216,7 +226,9 @@ describe("embedded acpx plugin config", () => {
|
||||
},
|
||||
stateDir: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
deprecated: true,
|
||||
description:
|
||||
"Legacy option accepted for compatibility and ignored; ACPX state follows the OpenClaw state directory.",
|
||||
},
|
||||
permissionMode: {
|
||||
type: "string",
|
||||
|
||||
@@ -235,7 +235,6 @@ export function resolveAcpxPluginConfig(params: {
|
||||
const workspaceDir = params.workspaceDir?.trim() || process.cwd();
|
||||
const fallbackCwd = workspaceDir;
|
||||
const cwd = path.resolve(normalized.cwd?.trim() || fallbackCwd);
|
||||
const stateDir = path.resolve(normalized.stateDir?.trim() || path.join(workspaceDir, "state"));
|
||||
const pluginToolsMcpBridge = normalized.pluginToolsMcpBridge === true;
|
||||
const openClawToolsMcpBridge = normalized.openClawToolsMcpBridge === true;
|
||||
const mcpServers = resolveConfiguredMcpServers({
|
||||
@@ -262,7 +261,6 @@ export function resolveAcpxPluginConfig(params: {
|
||||
|
||||
return {
|
||||
cwd,
|
||||
stateDir,
|
||||
probeAgent,
|
||||
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
|
||||
nonInteractivePermissions:
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { withOpenClawTestState } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createAcpxProcessLeaseStore,
|
||||
OPENCLAW_ACPX_LEASE_ID_ARG,
|
||||
OPENCLAW_ACPX_LEASE_ID_ENV,
|
||||
OPENCLAW_GATEWAY_INSTANCE_ID_ARG,
|
||||
OPENCLAW_GATEWAY_INSTANCE_ID_ENV,
|
||||
withAcpxLeaseEnvironment,
|
||||
type AcpxProcessLease,
|
||||
withAcpxLeaseEnvironment,
|
||||
} from "./process-lease.js";
|
||||
|
||||
function makeLease(index: number): AcpxProcessLease {
|
||||
@@ -27,19 +26,37 @@ function makeLease(index: number): AcpxProcessLease {
|
||||
}
|
||||
|
||||
describe("createAcpxProcessLeaseStore", () => {
|
||||
afterEach(() => {
|
||||
resetPluginStateStoreForTests();
|
||||
});
|
||||
|
||||
it("serializes concurrent lease saves without dropping records", async () => {
|
||||
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-acpx-leases-"));
|
||||
try {
|
||||
const store = createAcpxProcessLeaseStore({ stateDir });
|
||||
await withOpenClawTestState({ label: "acpx-leases" }, async () => {
|
||||
const store = createAcpxProcessLeaseStore();
|
||||
await Promise.all(Array.from({ length: 25 }, (_, index) => store.save(makeLease(index))));
|
||||
|
||||
const leases = await store.listOpen("gateway-test");
|
||||
expect(leases.map((lease) => lease.leaseId).toSorted()).toEqual(
|
||||
Array.from({ length: 25 }, (_, index) => `lease-${index}`).toSorted(),
|
||||
);
|
||||
} finally {
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes terminal leases so long-running gateways do not hit the plugin state cap", async () => {
|
||||
await withOpenClawTestState({ label: "acpx-leases-terminal-prune" }, async () => {
|
||||
const store = createAcpxProcessLeaseStore();
|
||||
|
||||
for (let index = 0; index < 1050; index += 1) {
|
||||
await store.save(makeLease(index));
|
||||
await store.markState(`lease-${index}`, "closed");
|
||||
}
|
||||
|
||||
await store.save(makeLease(1051));
|
||||
expect(await store.load("lease-0")).toBeUndefined();
|
||||
expect((await store.listOpen("gateway-test")).map((lease) => lease.leaseId)).toEqual([
|
||||
"lease-1051",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { randomUUID, createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
|
||||
import { createPluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
|
||||
export const OPENCLAW_ACPX_LEASE_ID_ENV = "OPENCLAW_ACPX_LEASE_ID";
|
||||
export const OPENCLAW_GATEWAY_INSTANCE_ID_ENV = "OPENCLAW_GATEWAY_INSTANCE_ID";
|
||||
@@ -30,12 +28,23 @@ export type AcpxProcessLeaseStore = {
|
||||
markState(leaseId: string, state: AcpxProcessLeaseState): Promise<void>;
|
||||
};
|
||||
|
||||
type LeaseFile = {
|
||||
type LeaseStoreEntry = {
|
||||
version: 1;
|
||||
leases: AcpxProcessLease[];
|
||||
lease: AcpxProcessLease;
|
||||
};
|
||||
|
||||
const LEASE_FILE = "process-leases.json";
|
||||
const ACPX_PLUGIN_ID = "acpx";
|
||||
const PROCESS_LEASES_NAMESPACE = "process-leases";
|
||||
const PROCESS_LEASES_MAX_ENTRIES = 900;
|
||||
|
||||
const leaseStore = createPluginStateKeyedStore<LeaseStoreEntry>(ACPX_PLUGIN_ID, {
|
||||
namespace: PROCESS_LEASES_NAMESPACE,
|
||||
maxEntries: PROCESS_LEASES_MAX_ENTRIES,
|
||||
});
|
||||
|
||||
function isTerminalLeaseState(state: AcpxProcessLeaseState): boolean {
|
||||
return state === "closed" || state === "lost";
|
||||
}
|
||||
|
||||
function normalizeLease(value: unknown): AcpxProcessLease | undefined {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
@@ -69,53 +78,52 @@ function normalizeLease(value: unknown): AcpxProcessLease | undefined {
|
||||
};
|
||||
}
|
||||
|
||||
async function readLeaseFile(filePath: string): Promise<LeaseFile> {
|
||||
const { value } = await readJsonFileWithFallback<Partial<LeaseFile>>(filePath, {
|
||||
version: 1,
|
||||
leases: [],
|
||||
});
|
||||
const leases = Array.isArray(value.leases)
|
||||
? value.leases.map(normalizeLease).filter((lease): lease is AcpxProcessLease => Boolean(lease))
|
||||
: [];
|
||||
return { version: 1, leases };
|
||||
}
|
||||
|
||||
function writeLeaseFile(filePath: string, value: LeaseFile): Promise<void> {
|
||||
return writeJsonFileAtomically(filePath, value);
|
||||
}
|
||||
|
||||
export function createAcpxProcessLeaseStore(params: { stateDir: string }): AcpxProcessLeaseStore {
|
||||
const filePath = path.join(params.stateDir, LEASE_FILE);
|
||||
export function createAcpxProcessLeaseStore(): AcpxProcessLeaseStore {
|
||||
let updateQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
async function readStoredLeases(): Promise<AcpxProcessLease[]> {
|
||||
const entries = await leaseStore.entries();
|
||||
return entries
|
||||
.map((entry) => normalizeLease(entry.value.lease))
|
||||
.filter((lease): lease is AcpxProcessLease => !!lease);
|
||||
}
|
||||
|
||||
async function update(
|
||||
mutator: (leases: AcpxProcessLease[]) => AcpxProcessLease[],
|
||||
): Promise<void> {
|
||||
const run = updateQueue.then(async () => {
|
||||
await fs.mkdir(params.stateDir, { recursive: true });
|
||||
const current = await readLeaseFile(filePath);
|
||||
await writeLeaseFile(filePath, {
|
||||
version: 1,
|
||||
leases: mutator(current.leases),
|
||||
});
|
||||
const current = await readStoredLeases();
|
||||
const next = mutator(current).filter((lease) => !isTerminalLeaseState(lease.state));
|
||||
const nextIds = new Set(next.map((lease) => lease.leaseId));
|
||||
await Promise.all([
|
||||
...current
|
||||
.filter((lease) => !nextIds.has(lease.leaseId))
|
||||
.map((lease) => leaseStore.delete(lease.leaseId)),
|
||||
...next.map((lease) =>
|
||||
leaseStore.register(lease.leaseId, {
|
||||
version: 1,
|
||||
lease,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
});
|
||||
updateQueue = run.catch(() => {});
|
||||
await run;
|
||||
}
|
||||
|
||||
async function readCurrent(): Promise<LeaseFile> {
|
||||
async function readCurrent(): Promise<AcpxProcessLease[]> {
|
||||
await updateQueue;
|
||||
return await readLeaseFile(filePath);
|
||||
return await readStoredLeases();
|
||||
}
|
||||
|
||||
return {
|
||||
async load(leaseId) {
|
||||
const current = await readCurrent();
|
||||
return current.leases.find((lease) => lease.leaseId === leaseId);
|
||||
return current.find((lease) => lease.leaseId === leaseId);
|
||||
},
|
||||
async listOpen(gatewayInstanceId) {
|
||||
const current = await readCurrent();
|
||||
return current.leases.filter(
|
||||
return current.filter(
|
||||
(lease) =>
|
||||
(lease.state === "open" || lease.state === "closing") &&
|
||||
(!gatewayInstanceId || lease.gatewayInstanceId === gatewayInstanceId),
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resetPluginBlobStoreForTests } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import {
|
||||
resetPluginStateStoreForTests,
|
||||
seedPluginStateEntriesForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AcpRuntimeError,
|
||||
type AcpRuntime,
|
||||
@@ -9,7 +14,7 @@ import {
|
||||
type AcpRuntimeTurn,
|
||||
} from "../runtime-api.js";
|
||||
import { OPENCLAW_ACPX_LEASE_ID_ARG, OPENCLAW_GATEWAY_INSTANCE_ID_ARG } from "./process-lease.js";
|
||||
import { AcpxRuntime, testing, type AcpSessionStore } from "./runtime.js";
|
||||
import { AcpxRuntime, createSqliteSessionStore, testing, type AcpSessionStore } from "./runtime.js";
|
||||
|
||||
type TestSessionStore = {
|
||||
load(sessionId: string): Promise<Record<string, unknown> | undefined>;
|
||||
@@ -24,6 +29,7 @@ const CODEX_ACP_WRAPPER_COMMAND_WITH_LEASE = `${CODEX_ACP_WRAPPER_COMMAND} ${OPE
|
||||
const LOCAL_NODE_MODULES_CODEX_COMMAND = `node "${path.resolve(
|
||||
"node_modules/@zed-industries/codex-acp/bin/codex-acp.js",
|
||||
)}"`;
|
||||
const ORIGINAL_STATE_DIR = process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
function makeRuntime(
|
||||
baseStore: TestSessionStore,
|
||||
@@ -142,6 +148,67 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_STATE_DIR === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = ORIGINAL_STATE_DIR;
|
||||
}
|
||||
resetPluginBlobStoreForTests();
|
||||
});
|
||||
|
||||
it("keys SQLite session records by acpxRecordId before display name", async () => {
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-session-store-"));
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
resetPluginBlobStoreForTests();
|
||||
|
||||
const store = createSqliteSessionStore();
|
||||
const record = {
|
||||
name: "agent:codex:acp:oneshot",
|
||||
sessionKey: "agent:codex:acp:oneshot",
|
||||
acpxRecordId: "agent:codex:acp:oneshot:run-1",
|
||||
};
|
||||
|
||||
try {
|
||||
await store.save(record as never);
|
||||
|
||||
await expect(store.load(record.acpxRecordId)).resolves.toMatchObject({
|
||||
acpxRecordId: record.acpxRecordId,
|
||||
});
|
||||
await expect(store.load(record.name)).resolves.toBeUndefined();
|
||||
} finally {
|
||||
resetPluginBlobStoreForTests();
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("persists a runtime session above the keyed-state value cap", async () => {
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-session-store-"));
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
resetPluginBlobStoreForTests();
|
||||
|
||||
const store = createSqliteSessionStore();
|
||||
const largeTranscript = "x".repeat(70_000);
|
||||
const acpxRecordId = "agent:codex:acp:persistent:run-large";
|
||||
|
||||
try {
|
||||
await store.save({
|
||||
name: "agent:codex:acp:persistent",
|
||||
sessionKey: "agent:codex:acp:persistent",
|
||||
acpxRecordId,
|
||||
events: [{ type: "assistant", text: largeTranscript }],
|
||||
} as never);
|
||||
|
||||
await expect(store.load(acpxRecordId)).resolves.toMatchObject({
|
||||
acpxRecordId,
|
||||
events: [{ type: "assistant", text: largeTranscript }],
|
||||
});
|
||||
} finally {
|
||||
resetPluginBlobStoreForTests();
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unsupported runtime session modes with a clear AcpRuntimeError (issue #73071)", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
AcpxRuntime as BaseAcpxRuntime,
|
||||
createAcpRuntime,
|
||||
createAgentRegistry,
|
||||
createFileSessionStore,
|
||||
decodeAcpxRuntimeHandleState,
|
||||
encodeAcpxRuntimeHandleState,
|
||||
type AcpAgentRegistry,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
type AcpRuntimeTurnResult,
|
||||
} from "acpx/runtime";
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { createPluginBlobStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { redactSensitiveText } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { AcpRuntimeError, type AcpRuntime, type AcpRuntimeErrorCode } from "../runtime-api.js";
|
||||
@@ -60,14 +60,30 @@ type OpenClawLeaseSessionMetadata = {
|
||||
};
|
||||
|
||||
function withOpenClawManagedTurnTimeout<T extends object>(input: T): T & { timeoutMs: 0 } {
|
||||
// OpenClaw owns ACP turn deadlines. acpx treats timeout after partial agent
|
||||
// output as a completed turn, which can mark background work done early.
|
||||
// OpenClaw owns ACP turn deadlines; delegate timeouts can mark partial turns done early.
|
||||
return {
|
||||
...input,
|
||||
timeoutMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const ACPX_SESSION_STORE_PLUGIN_ID = "acpx";
|
||||
const ACPX_SESSION_STORE_NAMESPACE = "runtime-sessions";
|
||||
const ACPX_SESSION_STORE_MAX_ENTRIES = 10_000;
|
||||
|
||||
type StoredAcpSessionRecordMetadata = {
|
||||
schemaVersion: 1;
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
const acpxSessionStore = createPluginBlobStore<StoredAcpSessionRecordMetadata>(
|
||||
ACPX_SESSION_STORE_PLUGIN_ID,
|
||||
{
|
||||
namespace: ACPX_SESSION_STORE_NAMESPACE,
|
||||
maxEntries: ACPX_SESSION_STORE_MAX_ENTRIES,
|
||||
},
|
||||
);
|
||||
|
||||
function withOpenClawLeaseSessionMetadata<T extends object>(
|
||||
record: T,
|
||||
metadata: OpenClawLeaseSessionMetadata,
|
||||
@@ -141,6 +157,65 @@ function readSessionRecordName(record: unknown): string {
|
||||
return typeof name === "string" ? name.trim() : "";
|
||||
}
|
||||
|
||||
function resolveAcpSessionRecordKey(record: unknown): string {
|
||||
if (typeof record !== "object" || record === null) {
|
||||
return "";
|
||||
}
|
||||
const fields = record as {
|
||||
acpxRecordId?: unknown;
|
||||
name?: unknown;
|
||||
sessionKey?: unknown;
|
||||
id?: unknown;
|
||||
sessionId?: unknown;
|
||||
};
|
||||
for (const value of [
|
||||
fields.acpxRecordId,
|
||||
fields.name,
|
||||
fields.sessionKey,
|
||||
fields.id,
|
||||
fields.sessionId,
|
||||
]) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeAcpSessionStoreKey(sessionId: string): string {
|
||||
return sessionId.trim();
|
||||
}
|
||||
|
||||
function parseStoredAcpSessionRecord(blob: Buffer): AcpLoadedSessionRecord {
|
||||
const parsed = JSON.parse(blob.toString("utf8")) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed as AcpLoadedSessionRecord;
|
||||
}
|
||||
|
||||
export function createSqliteSessionStore(): AcpSessionStore {
|
||||
return {
|
||||
async load(sessionId: string): Promise<AcpLoadedSessionRecord> {
|
||||
const key = normalizeAcpSessionStoreKey(sessionId);
|
||||
const entry = key ? await acpxSessionStore.lookup(key) : undefined;
|
||||
return entry ? parseStoredAcpSessionRecord(entry.blob) : undefined;
|
||||
},
|
||||
async save(record: AcpSessionRecord): Promise<void> {
|
||||
const key = resolveAcpSessionRecordKey(record);
|
||||
if (!key) {
|
||||
throw new Error("Cannot save ACPX session without a stable session key.");
|
||||
}
|
||||
const payload = Buffer.from(JSON.stringify(record), "utf8");
|
||||
await acpxSessionStore.register(
|
||||
key,
|
||||
{ schemaVersion: 1, bytes: payload.byteLength },
|
||||
payload,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readRecordAgentCommand(record: unknown): string | undefined {
|
||||
if (typeof record !== "object" || record === null) {
|
||||
return undefined;
|
||||
@@ -1217,7 +1292,6 @@ export {
|
||||
ACPX_BACKEND_ID,
|
||||
createAcpRuntime,
|
||||
createAgentRegistry,
|
||||
createFileSessionStore,
|
||||
decodeAcpxRuntimeHandleState,
|
||||
encodeAcpxRuntimeHandleState,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,10 @@ 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 {
|
||||
createPluginStateKeyedStore,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { runtimeRegistry } = vi.hoisted(() => ({
|
||||
@@ -36,7 +40,7 @@ const { reapStaleOpenClawOwnedAcpxOrphansMock } = vi.hoisted(() => ({
|
||||
}),
|
||||
),
|
||||
}));
|
||||
const { acpxRuntimeConstructorMock, createAgentRegistryMock, createFileSessionStoreMock } =
|
||||
const { acpxRuntimeConstructorMock, createAgentRegistryMock, createSqliteSessionStoreMock } =
|
||||
vi.hoisted(() => ({
|
||||
acpxRuntimeConstructorMock: vi.fn(function AcpxRuntime(options: unknown) {
|
||||
return {
|
||||
@@ -60,7 +64,7 @@ const { acpxRuntimeConstructorMock, createAgentRegistryMock, createFileSessionSt
|
||||
};
|
||||
}),
|
||||
createAgentRegistryMock: vi.fn(() => ({})),
|
||||
createFileSessionStoreMock: vi.fn(() => ({})),
|
||||
createSqliteSessionStoreMock: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-api.js", () => ({
|
||||
@@ -77,7 +81,7 @@ vi.mock("./runtime.js", () => ({
|
||||
ACPX_BACKEND_ID: "acpx",
|
||||
AcpxRuntime: acpxRuntimeConstructorMock,
|
||||
createAgentRegistry: createAgentRegistryMock,
|
||||
createFileSessionStore: createFileSessionStoreMock,
|
||||
createSqliteSessionStore: createSqliteSessionStoreMock,
|
||||
}));
|
||||
|
||||
vi.mock("./codex-auth-bridge.js", () => ({
|
||||
@@ -91,13 +95,36 @@ vi.mock("./process-reaper.js", () => ({
|
||||
|
||||
import { getAcpRuntimeBackend } from "../runtime-api.js";
|
||||
import type { OpenClawPluginServiceContext } from "../runtime-api.js";
|
||||
import { createAcpxRuntimeService, resolveAcpxTimerTimeoutMs } from "./service.js";
|
||||
import { createAcpxProcessLeaseStore } from "./process-lease.js";
|
||||
import {
|
||||
ACPX_GATEWAY_INSTANCE_KEY,
|
||||
ACPX_GATEWAY_INSTANCE_NAMESPACE,
|
||||
ACPX_GATEWAY_INSTANCE_PLUGIN_ID,
|
||||
createAcpxRuntimeService,
|
||||
resolveAcpxTimerTimeoutMs,
|
||||
resolveAcpxWrapperRoot,
|
||||
} from "./service.js";
|
||||
|
||||
type GatewayInstanceRecord = {
|
||||
version: 1;
|
||||
id: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
const gatewayInstanceStore = createPluginStateKeyedStore<GatewayInstanceRecord>(
|
||||
ACPX_GATEWAY_INSTANCE_PLUGIN_ID,
|
||||
{
|
||||
namespace: ACPX_GATEWAY_INSTANCE_NAMESPACE,
|
||||
maxEntries: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const previousEnv = {
|
||||
OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE: process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE,
|
||||
OPENCLAW_SKIP_ACPX_RUNTIME: process.env.OPENCLAW_SKIP_ACPX_RUNTIME,
|
||||
OPENCLAW_SKIP_ACPX_RUNTIME_PROBE: process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE,
|
||||
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
|
||||
};
|
||||
|
||||
function restoreEnv(name: keyof typeof previousEnv): void {
|
||||
@@ -122,19 +149,24 @@ afterEach(async () => {
|
||||
reapStaleOpenClawOwnedAcpxOrphansMock.mockClear();
|
||||
acpxRuntimeConstructorMock.mockClear();
|
||||
createAgentRegistryMock.mockClear();
|
||||
createFileSessionStoreMock.mockClear();
|
||||
createSqliteSessionStoreMock.mockClear();
|
||||
restoreEnv("OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE");
|
||||
restoreEnv("OPENCLAW_SKIP_ACPX_RUNTIME");
|
||||
restoreEnv("OPENCLAW_SKIP_ACPX_RUNTIME_PROBE");
|
||||
restoreEnv("OPENCLAW_STATE_DIR");
|
||||
resetPluginStateStoreForTests();
|
||||
await fs.rm(resolveAcpxWrapperRoot(), { recursive: true, force: true });
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createServiceContext(workspaceDir: string): OpenClawPluginServiceContext {
|
||||
const stateDir = path.join(workspaceDir, ".openclaw-plugin-state");
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
return {
|
||||
workspaceDir,
|
||||
stateDir: path.join(workspaceDir, ".openclaw-plugin-state"),
|
||||
stateDir,
|
||||
config: {},
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
@@ -180,11 +212,7 @@ function createStartupTraceRecorder() {
|
||||
}
|
||||
|
||||
function readFirstRuntimeFactoryInput(runtimeFactory: { mock: { calls: Array<Array<unknown>> } }) {
|
||||
const [call] = runtimeFactory.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("Expected runtimeFactory to be called");
|
||||
}
|
||||
const [input] = call;
|
||||
const input = runtimeFactory.mock.calls[0]?.[0];
|
||||
if (typeof input !== "object" || input === null) {
|
||||
throw new Error("Expected runtimeFactory to be called with an options object");
|
||||
}
|
||||
@@ -196,6 +224,14 @@ function readFirstRuntimeFactoryInput(runtimeFactory: { mock: { calls: Array<Arr
|
||||
};
|
||||
}
|
||||
|
||||
async function writeGatewayInstanceIdFixture(id: string): Promise<void> {
|
||||
await gatewayInstanceStore.register(ACPX_GATEWAY_INSTANCE_KEY, {
|
||||
version: 1,
|
||||
id,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("createAcpxRuntimeService", () => {
|
||||
it("caps configured timeout seconds to timer-safe milliseconds", () => {
|
||||
expect(resolveAcpxTimerTimeoutMs(0.001)).toBe(1);
|
||||
@@ -223,24 +259,19 @@ describe("createAcpxRuntimeService", () => {
|
||||
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "0";
|
||||
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE;
|
||||
const workspaceDir = await makeTempDir();
|
||||
const stateDir = path.join(workspaceDir, "custom-state");
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const probeAvailability = vi.fn(async () => {
|
||||
await fs.access(stateDir);
|
||||
});
|
||||
const probeAvailability = vi.fn(async () => {});
|
||||
const runtime = createMockRuntime({
|
||||
doctor: async () => ({ ok: true, message: "ok" }),
|
||||
isHealthy: () => false,
|
||||
probeAvailability,
|
||||
});
|
||||
const service = createAcpxRuntimeService({
|
||||
pluginConfig: { stateDir },
|
||||
runtimeFactory: () => runtime as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
await fs.access(stateDir);
|
||||
expect(probeAvailability).not.toHaveBeenCalled();
|
||||
expect(getAcpRuntimeBackend("acpx")?.healthy).toBeUndefined();
|
||||
|
||||
@@ -304,9 +335,9 @@ describe("createAcpxRuntimeService", () => {
|
||||
|
||||
expect(trace.measured).toEqual([
|
||||
"config.resolve",
|
||||
"gateway-instance-id",
|
||||
"config.prepare-codex-auth",
|
||||
"filesystem.prepare",
|
||||
"gateway-instance-id",
|
||||
"process-leases.reap",
|
||||
"runtime.create",
|
||||
"backend.register",
|
||||
@@ -334,27 +365,21 @@ describe("createAcpxRuntimeService", () => {
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtime = createMockRuntime();
|
||||
const processCleanupDeps = { sleep: vi.fn(async () => {}) };
|
||||
await fs.mkdir(path.join(ctx.stateDir, "acpx"), { recursive: true });
|
||||
await fs.writeFile(path.join(ctx.stateDir, "gateway-instance-id"), "gw-test\n");
|
||||
await fs.writeFile(
|
||||
path.join(ctx.stateDir, "acpx", "process-leases.json"),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
leases: [
|
||||
{
|
||||
leaseId: "lease-1",
|
||||
gatewayInstanceId: "gw-test",
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
wrapperRoot: path.join(ctx.stateDir, "acpx"),
|
||||
wrapperPath: path.join(ctx.stateDir, "acpx", "codex-acp-wrapper.mjs"),
|
||||
rootPid: 101,
|
||||
commandHash: "hash",
|
||||
startedAt: 1,
|
||||
state: "open",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const wrapperRoot = resolveAcpxWrapperRoot();
|
||||
const processLeaseStore = createAcpxProcessLeaseStore();
|
||||
await fs.mkdir(wrapperRoot, { recursive: true });
|
||||
await writeGatewayInstanceIdFixture("gw-test");
|
||||
await processLeaseStore.save({
|
||||
leaseId: "lease-1",
|
||||
gatewayInstanceId: "gw-test",
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
wrapperRoot,
|
||||
wrapperPath: path.join(wrapperRoot, "codex-acp-wrapper.mjs"),
|
||||
rootPid: 101,
|
||||
commandHash: "hash",
|
||||
startedAt: 1,
|
||||
state: "open",
|
||||
});
|
||||
cleanupOpenClawOwnedAcpxProcessTreeMock.mockResolvedValueOnce({
|
||||
inspectedPids: [101, 102],
|
||||
terminatedPids: [101, 102],
|
||||
@@ -370,7 +395,7 @@ describe("createAcpxRuntimeService", () => {
|
||||
rootPid: 101,
|
||||
expectedLeaseId: "lease-1",
|
||||
expectedGatewayInstanceId: "gw-test",
|
||||
wrapperRoot: path.join(ctx.stateDir, "acpx"),
|
||||
wrapperRoot,
|
||||
deps: processCleanupDeps,
|
||||
});
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith("reaped 2 stale OpenClaw-owned ACPX processes");
|
||||
@@ -378,33 +403,48 @@ describe("createAcpxRuntimeService", () => {
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("scopes generated wrapper roots by state dir and gateway instance", async () => {
|
||||
const stateDirA = path.join(await makeTempDir(), "state");
|
||||
const stateDirB = path.join(await makeTempDir(), "state");
|
||||
|
||||
const rootA1 = resolveAcpxWrapperRoot({
|
||||
gatewayInstanceId: "gw-a",
|
||||
stateDir: stateDirA,
|
||||
});
|
||||
const rootA2 = resolveAcpxWrapperRoot({
|
||||
gatewayInstanceId: "gw-b",
|
||||
stateDir: stateDirA,
|
||||
});
|
||||
const rootB1 = resolveAcpxWrapperRoot({
|
||||
gatewayInstanceId: "gw-a",
|
||||
stateDir: stateDirB,
|
||||
});
|
||||
|
||||
expect(rootA1).not.toBe(rootA2);
|
||||
expect(rootA1).not.toBe(rootB1);
|
||||
expect(path.dirname(path.dirname(rootA1))).toBe(resolveAcpxWrapperRoot());
|
||||
});
|
||||
|
||||
it("runs wrapper-root orphan cleanup before dropping pending ACPX leases", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtime = createMockRuntime();
|
||||
const processCleanupDeps = { sleep: vi.fn(async () => {}) };
|
||||
const wrapperRoot = path.join(ctx.stateDir, "acpx");
|
||||
const wrapperRoot = resolveAcpxWrapperRoot();
|
||||
const processLeaseStore = createAcpxProcessLeaseStore();
|
||||
await fs.mkdir(wrapperRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(ctx.stateDir, "gateway-instance-id"), "gw-test\n");
|
||||
await fs.writeFile(
|
||||
path.join(wrapperRoot, "process-leases.json"),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
leases: [
|
||||
{
|
||||
leaseId: "lease-pending",
|
||||
gatewayInstanceId: "gw-test",
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
wrapperRoot,
|
||||
wrapperPath: path.join(wrapperRoot, "codex-acp-wrapper.mjs"),
|
||||
rootPid: 0,
|
||||
commandHash: "hash",
|
||||
startedAt: 1,
|
||||
state: "open",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await writeGatewayInstanceIdFixture("gw-test");
|
||||
await processLeaseStore.save({
|
||||
leaseId: "lease-pending",
|
||||
gatewayInstanceId: "gw-test",
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
wrapperRoot,
|
||||
wrapperPath: path.join(wrapperRoot, "codex-acp-wrapper.mjs"),
|
||||
rootPid: 0,
|
||||
commandHash: "hash",
|
||||
startedAt: 1,
|
||||
state: "open",
|
||||
});
|
||||
reapStaleOpenClawOwnedAcpxOrphansMock.mockResolvedValueOnce({
|
||||
inspectedPids: [201, 202],
|
||||
terminatedPids: [201, 202],
|
||||
@@ -422,10 +462,7 @@ describe("createAcpxRuntimeService", () => {
|
||||
deps: processCleanupDeps,
|
||||
});
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith("reaped 2 stale OpenClaw-owned ACPX processes");
|
||||
const leaseFile = JSON.parse(
|
||||
await fs.readFile(path.join(wrapperRoot, "process-leases.json"), "utf8"),
|
||||
);
|
||||
expect(leaseFile.leases[0].state).toBe("closed");
|
||||
await expect(processLeaseStore.load("lease-pending")).resolves.toBeUndefined();
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { inspect } from "node:util";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { finiteSecondsToTimerSafeMilliseconds } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { createPluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import type {
|
||||
AcpRuntime,
|
||||
OpenClawPluginService,
|
||||
@@ -38,6 +40,23 @@ type AcpxRuntimeLike = AcpRuntime & {
|
||||
const ENABLE_STARTUP_PROBE_ENV = "OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE";
|
||||
const SKIP_RUNTIME_PROBE_ENV = "OPENCLAW_SKIP_ACPX_RUNTIME_PROBE";
|
||||
const ACPX_BACKEND_ID = "acpx";
|
||||
export const ACPX_GATEWAY_INSTANCE_PLUGIN_ID = "acpx";
|
||||
export const ACPX_GATEWAY_INSTANCE_NAMESPACE = "gateway-instance";
|
||||
export const ACPX_GATEWAY_INSTANCE_KEY = "current";
|
||||
|
||||
type AcpxGatewayInstanceRecord = {
|
||||
version: 1;
|
||||
id: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
const gatewayInstanceStore = createPluginStateKeyedStore<AcpxGatewayInstanceRecord>(
|
||||
ACPX_GATEWAY_INSTANCE_PLUGIN_ID,
|
||||
{
|
||||
namespace: ACPX_GATEWAY_INSTANCE_NAMESPACE,
|
||||
maxEntries: 1,
|
||||
},
|
||||
);
|
||||
|
||||
type AcpxRuntimeModule = typeof import("./runtime.js");
|
||||
let runtimeModulePromise: Promise<AcpxRuntimeModule> | null = null;
|
||||
@@ -56,6 +75,30 @@ type CreateAcpxRuntimeServiceParams = {
|
||||
processCleanupDeps?: AcpxProcessCleanupDeps;
|
||||
};
|
||||
|
||||
function sanitizeWrapperRootSegment(value: string, fallback: string): string {
|
||||
const segment = value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
return segment || fallback;
|
||||
}
|
||||
|
||||
function hashWrapperRootStateDir(stateDir: string): string {
|
||||
return createHash("sha256").update(path.resolve(stateDir)).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
export function resolveAcpxWrapperRoot(params?: {
|
||||
gatewayInstanceId: string;
|
||||
stateDir: string;
|
||||
}): string {
|
||||
const baseRoot = path.join(resolvePreferredOpenClawTmpDir(), "acpx");
|
||||
if (!params) {
|
||||
return baseRoot;
|
||||
}
|
||||
return path.join(
|
||||
baseRoot,
|
||||
hashWrapperRootStateDir(params.stateDir),
|
||||
sanitizeWrapperRootSegment(params.gatewayInstanceId, "gateway"),
|
||||
);
|
||||
}
|
||||
|
||||
function loadRuntimeModule(): Promise<AcpxRuntimeModule> {
|
||||
runtimeModulePromise ??= import("./runtime.js");
|
||||
return runtimeModulePromise;
|
||||
@@ -82,9 +125,7 @@ function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntime
|
||||
openclawGatewayInstanceId: params.gatewayInstanceId,
|
||||
openclawProcessLeaseStore: params.processLeaseStore,
|
||||
openclawWrapperRoot: params.wrapperRoot,
|
||||
sessionStore: module.createFileSessionStore({
|
||||
stateDir: params.pluginConfig.stateDir,
|
||||
}),
|
||||
sessionStore: module.createSqliteSessionStore(),
|
||||
agentRegistry: module.createAgentRegistry({
|
||||
overrides: params.pluginConfig.agents,
|
||||
}),
|
||||
@@ -228,21 +269,17 @@ async function withStartupProbeTimeout<T>(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveGatewayInstanceId(stateDir: string): Promise<string> {
|
||||
const filePath = path.join(stateDir, "gateway-instance-id");
|
||||
try {
|
||||
const existing = (await fs.readFile(filePath, "utf8")).trim();
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
async function resolveGatewayInstanceId(): Promise<string> {
|
||||
const existing = await gatewayInstanceStore.lookup(ACPX_GATEWAY_INSTANCE_KEY);
|
||||
if (existing?.version === 1 && existing.id.trim()) {
|
||||
return existing.id;
|
||||
}
|
||||
const next = randomUUID();
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
await fs.writeFile(filePath, `${next}\n`, { mode: 0o600 });
|
||||
await gatewayInstanceStore.register(ACPX_GATEWAY_INSTANCE_KEY, {
|
||||
version: 1,
|
||||
id: next,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -319,22 +356,24 @@ export function createAcpxRuntimeService(
|
||||
...basePluginConfig,
|
||||
probeAgent: basePluginConfig.probeAgent ?? resolveAllowedAgentsProbeAgent(ctx),
|
||||
};
|
||||
const gatewayInstanceId = await measureAcpxStartup(ctx, "gateway-instance-id", () =>
|
||||
resolveGatewayInstanceId(),
|
||||
);
|
||||
const wrapperRoot = resolveAcpxWrapperRoot({
|
||||
gatewayInstanceId,
|
||||
stateDir: ctx.stateDir,
|
||||
});
|
||||
const pluginConfig = await measureAcpxStartup(ctx, "config.prepare-codex-auth", () =>
|
||||
prepareAcpxCodexAuthConfig({
|
||||
pluginConfig: effectiveBasePluginConfig,
|
||||
stateDir: ctx.stateDir,
|
||||
wrapperRoot,
|
||||
logger: ctx.logger,
|
||||
}),
|
||||
);
|
||||
const wrapperRoot = path.join(ctx.stateDir, "acpx");
|
||||
await measureAcpxStartup(ctx, "filesystem.prepare", async () => {
|
||||
await fs.mkdir(pluginConfig.stateDir, { recursive: true });
|
||||
await fs.mkdir(wrapperRoot, { recursive: true });
|
||||
});
|
||||
const gatewayInstanceId = await measureAcpxStartup(ctx, "gateway-instance-id", () =>
|
||||
resolveGatewayInstanceId(ctx.stateDir),
|
||||
);
|
||||
const processLeaseStore = createAcpxProcessLeaseStore({ stateDir: wrapperRoot });
|
||||
const processLeaseStore = createAcpxProcessLeaseStore();
|
||||
const startupReap = await measureAcpxStartup(ctx, "process-leases.reap", () =>
|
||||
reapOpenAcpxProcessLeases({
|
||||
gatewayInstanceId,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
import crypto from "node:crypto";
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import * as readline from "node:readline";
|
||||
import {
|
||||
deleteSqliteSessionTranscript,
|
||||
loadSqliteSessionTranscriptBoundedEvents,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
DEFAULT_PROVIDER,
|
||||
parseModelRef,
|
||||
@@ -23,15 +23,9 @@ import {
|
||||
resolvePluginConfigObject,
|
||||
} from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createPluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { parseAgentSessionKey, parseThreadSessionSuffix } from "openclaw/plugin-sdk/routing";
|
||||
import { isPathInside, replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
|
||||
import {
|
||||
asOptionalRecord as asRecord,
|
||||
normalizeOptionalString,
|
||||
normalizeStringEntries,
|
||||
uniqueStrings,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { tempWorkspace, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_AGENT_ID = "main";
|
||||
@@ -47,7 +41,6 @@ const DEFAULT_MIN_TIMEOUT_MS = 250;
|
||||
const DEFAULT_SETUP_GRACE_TIMEOUT_MS = 0;
|
||||
const DEFAULT_QUERY_MODE = "recent" as const;
|
||||
const DEFAULT_QMD_SEARCH_MODE = "search" as const;
|
||||
const DEFAULT_TRANSCRIPT_DIR = "active-memory";
|
||||
const ACTIVE_MEMORY_RECALL_LANE = "active-memory";
|
||||
const DEFAULT_CIRCUIT_BREAKER_MAX_TIMEOUTS = 3;
|
||||
const DEFAULT_CIRCUIT_BREAKER_COOLDOWN_MS = 60_000;
|
||||
@@ -88,7 +81,6 @@ const ACTIVE_MEMORY_RESERVED_TOOLS_ALLOW = new Set([
|
||||
"web_search",
|
||||
"write",
|
||||
]);
|
||||
const TOGGLE_STATE_FILE = "session-toggles.json";
|
||||
const DEFAULT_PARTIAL_TRANSCRIPT_MAX_CHARS = 32_000;
|
||||
const DEFAULT_TRANSCRIPT_READ_MAX_LINES = 2_000;
|
||||
const DEFAULT_TRANSCRIPT_READ_MAX_BYTES = 50 * 1024 * 1024;
|
||||
@@ -162,7 +154,6 @@ type ActiveRecallPluginConfig = {
|
||||
circuitBreakerMaxTimeouts?: number;
|
||||
circuitBreakerCooldownMs?: number;
|
||||
persistTranscripts?: boolean;
|
||||
transcriptDir?: string;
|
||||
qmd?: {
|
||||
searchMode?: ActiveMemoryQmdSearchMode;
|
||||
};
|
||||
@@ -203,7 +194,6 @@ type ResolvedActiveRecallPluginConfig = {
|
||||
circuitBreakerMaxTimeouts: number;
|
||||
circuitBreakerCooldownMs: number;
|
||||
persistTranscripts: boolean;
|
||||
transcriptDir: string;
|
||||
qmd: {
|
||||
searchMode: ActiveMemoryQmdSearchMode;
|
||||
};
|
||||
@@ -263,10 +253,20 @@ type TranscriptReadLimits = {
|
||||
maxBytes?: number;
|
||||
};
|
||||
|
||||
type TranscriptScope = {
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
type TranscriptScopeTracker = {
|
||||
current?: TranscriptScope;
|
||||
scopes: TranscriptScope[];
|
||||
};
|
||||
|
||||
type RecallSubagentResult = {
|
||||
rawReply: string;
|
||||
resultStatus?: "failed" | "unavailable";
|
||||
transcriptPath?: string;
|
||||
transcriptScope?: TranscriptScope;
|
||||
searchDebug?: ActiveMemorySearchDebug;
|
||||
};
|
||||
|
||||
@@ -286,45 +286,41 @@ type CachedActiveRecallResult = {
|
||||
};
|
||||
|
||||
type ActiveMemoryChatType = "direct" | "group" | "channel" | "explicit";
|
||||
|
||||
type ActiveMemoryToggleStore = {
|
||||
sessions?: Record<string, { disabled?: boolean; updatedAt?: number }>;
|
||||
type ActiveMemorySessionEntry = {
|
||||
chatType?: unknown;
|
||||
groupId?: unknown;
|
||||
nativeChannelId?: unknown;
|
||||
nativeDirectUserId?: unknown;
|
||||
deliveryContext?: {
|
||||
channel?: unknown;
|
||||
to?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type AsyncLock = <T>(task: () => Promise<T>) => Promise<T>;
|
||||
type ActiveMemorySessionToggleEntry = {
|
||||
version: 1;
|
||||
disabled: true;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
const sessionToggleStore = createPluginStateKeyedStore<ActiveMemorySessionToggleEntry>(
|
||||
"active-memory",
|
||||
{
|
||||
namespace: "session-toggles",
|
||||
maxEntries: 50_000,
|
||||
},
|
||||
);
|
||||
|
||||
const toggleStoreLocks = new Map<string, AsyncLock>();
|
||||
let lastActiveRecallCacheSweepAt = 0;
|
||||
let minimumTimeoutMs = DEFAULT_MIN_TIMEOUT_MS;
|
||||
let setupGraceTimeoutMs = DEFAULT_SETUP_GRACE_TIMEOUT_MS;
|
||||
let timeoutPartialDataGraceMs = TIMEOUT_PARTIAL_DATA_GRACE_MS;
|
||||
|
||||
function createAsyncLock(): AsyncLock {
|
||||
let lock: Promise<void> = Promise.resolve();
|
||||
return async function withLock<T>(task: () => Promise<T>): Promise<T> {
|
||||
const previous = lock;
|
||||
let release: (() => void) | undefined;
|
||||
lock = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await previous;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
release?.();
|
||||
}
|
||||
};
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function withToggleStoreLock<T>(statePath: string, task: () => Promise<T>): Promise<T> {
|
||||
let withLock = toggleStoreLocks.get(statePath);
|
||||
if (!withLock) {
|
||||
withLock = createAsyncLock();
|
||||
toggleStoreLocks.set(statePath, withLock);
|
||||
}
|
||||
return withLock(task);
|
||||
}
|
||||
|
||||
type ActiveMemoryThinkingLevel =
|
||||
| "off"
|
||||
| "minimal"
|
||||
@@ -408,17 +404,6 @@ function clampInt(value: number | undefined, fallback: number, min: number, max:
|
||||
return Math.max(min, Math.min(max, Math.floor(value as number)));
|
||||
}
|
||||
|
||||
function normalizeTranscriptDir(value: unknown): string {
|
||||
const raw = typeof value === "string" ? value.trim() : "";
|
||||
if (!raw) {
|
||||
return DEFAULT_TRANSCRIPT_DIR;
|
||||
}
|
||||
const normalized = raw.replace(/\\/g, "/");
|
||||
const parts = normalized.split("/").map((part) => part.trim());
|
||||
const safeParts = parts.filter((part) => part.length > 0 && part !== "." && part !== "..");
|
||||
return safeParts.length > 0 ? path.join(...safeParts) : DEFAULT_TRANSCRIPT_DIR;
|
||||
}
|
||||
|
||||
function normalizeChatIdList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
@@ -429,15 +414,15 @@ function normalizeChatIdList(value: unknown): string[] {
|
||||
if (typeof entry !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = entry.trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
const normalized = normalizeConversationIdValue(entry);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(trimmed)) {
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
out.push(trimmed);
|
||||
seen.add(normalized);
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -499,42 +484,6 @@ function hasDeprecatedModelFallbackPolicy(pluginConfig: unknown): boolean {
|
||||
return raw ? Object.hasOwn(raw, "modelFallbackPolicy") : false;
|
||||
}
|
||||
|
||||
function resolveSafeTranscriptDir(baseSessionsDir: string, transcriptDir: string): string {
|
||||
const normalized = transcriptDir.trim();
|
||||
if (!normalized || normalized.includes(":") || path.isAbsolute(normalized)) {
|
||||
return path.resolve(baseSessionsDir, DEFAULT_TRANSCRIPT_DIR);
|
||||
}
|
||||
const resolvedBase = path.resolve(baseSessionsDir);
|
||||
const candidate = path.resolve(resolvedBase, normalized);
|
||||
if (!isPathInside(resolvedBase, candidate)) {
|
||||
return path.resolve(resolvedBase, DEFAULT_TRANSCRIPT_DIR);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function toSafeTranscriptAgentDirName(agentId: string): string {
|
||||
const encoded = encodeURIComponent(agentId.trim());
|
||||
return encoded ? encoded : "unknown-agent";
|
||||
}
|
||||
|
||||
function resolvePersistentTranscriptBaseDir(api: OpenClawPluginApi, agentId: string): string {
|
||||
return path.join(
|
||||
api.runtime.state.resolveStateDir(),
|
||||
"plugins",
|
||||
"active-memory",
|
||||
"transcripts",
|
||||
"agents",
|
||||
toSafeTranscriptAgentDirName(agentId),
|
||||
);
|
||||
}
|
||||
|
||||
function requireTransientWorkspaceDir(tempDir: string | undefined): string {
|
||||
if (!tempDir) {
|
||||
throw new Error("Active memory transient workspace was not initialized.");
|
||||
}
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
function resolveCanonicalSessionKeyFromSessionId(params: {
|
||||
api: OpenClawPluginApi;
|
||||
agentId: string;
|
||||
@@ -578,6 +527,31 @@ function resolveCanonicalSessionKeyFromSessionId(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function normalizeActiveMemoryChatType(value: unknown): ActiveMemoryChatType | undefined {
|
||||
if (value === "direct" || value === "group" || value === "channel" || value === "explicit") {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeConversationIdValue(value: unknown): string | undefined {
|
||||
const trimmed = normalizeOptionalString(value)?.toLowerCase();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
for (const prefix of ["room:", "group:", "channel:", "direct:", "dm:", "user:"]) {
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
const withoutPrefix = trimmed.slice(prefix.length).trim();
|
||||
return withoutPrefix || undefined;
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function formatRuntimeToolsAllowSource(toolsAllow: readonly string[]): string {
|
||||
return `runtime toolsAllow: ${toolsAllow.join(", ")}`;
|
||||
}
|
||||
@@ -672,81 +646,25 @@ function resolveRecallRunChannelContext(params: {
|
||||
sessionKey: resolvedSessionKey,
|
||||
});
|
||||
const rawStrongEntryChannel =
|
||||
normalizeOptionalString(sessionEntry?.lastChannel) ??
|
||||
normalizeOptionalString(sessionEntry?.deliveryContext?.channel) ??
|
||||
normalizeOptionalString(sessionEntry?.channel);
|
||||
// Channel IDs containing ":" or "/" are scoped conversation IDs, not
|
||||
// runnable channel names. The same guard that
|
||||
// applies to explicit channelId (#76704) must also apply to channels
|
||||
// read from the session store (#77396).
|
||||
// read from SQLite session rows (#77396).
|
||||
const strongEntryChannel =
|
||||
rawStrongEntryChannel && isRunnableChannelName(rawStrongEntryChannel)
|
||||
? rawStrongEntryChannel
|
||||
: undefined;
|
||||
const weakEntryChannel = normalizeOptionalString(sessionEntry?.origin?.provider);
|
||||
return resolveReturnValue({
|
||||
resolvedChannel: strongEntryChannel ?? weakEntryChannel,
|
||||
resolvedChannelStrength: strongEntryChannel
|
||||
? "strong"
|
||||
: weakEntryChannel
|
||||
? "weak"
|
||||
: undefined,
|
||||
resolvedChannel: strongEntryChannel,
|
||||
resolvedChannelStrength: strongEntryChannel ? "strong" : undefined,
|
||||
});
|
||||
} catch {
|
||||
return resolveReturnValue({});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveToggleStatePath(api: OpenClawPluginApi): string {
|
||||
return path.join(
|
||||
api.runtime.state.resolveStateDir(),
|
||||
"plugins",
|
||||
"active-memory",
|
||||
TOGGLE_STATE_FILE,
|
||||
);
|
||||
}
|
||||
|
||||
async function readToggleStore(statePath: string): Promise<ActiveMemoryToggleStore> {
|
||||
try {
|
||||
const raw = await fs.readFile(statePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return {};
|
||||
}
|
||||
const sessions = (parsed as { sessions?: unknown }).sessions;
|
||||
if (!sessions || typeof sessions !== "object" || Array.isArray(sessions)) {
|
||||
return {};
|
||||
}
|
||||
const nextSessions: NonNullable<ActiveMemoryToggleStore["sessions"]> = {};
|
||||
for (const [sessionKey, value] of Object.entries(sessions)) {
|
||||
if (!sessionKey.trim() || !value || typeof value !== "object" || Array.isArray(value)) {
|
||||
continue;
|
||||
}
|
||||
const disabled = (value as { disabled?: unknown }).disabled === true;
|
||||
const updatedAt =
|
||||
typeof (value as { updatedAt?: unknown }).updatedAt === "number"
|
||||
? (value as { updatedAt: number }).updatedAt
|
||||
: undefined;
|
||||
if (disabled) {
|
||||
nextSessions[sessionKey] = { disabled, updatedAt };
|
||||
}
|
||||
}
|
||||
return Object.keys(nextSessions).length > 0 ? { sessions: nextSessions } : {};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function writeToggleStore(statePath: string, store: ActiveMemoryToggleStore): Promise<void> {
|
||||
await replaceFileAtomic({
|
||||
filePath: statePath,
|
||||
content: `${JSON.stringify(store, null, 2)}\n`,
|
||||
tempPrefix: ".active-memory",
|
||||
});
|
||||
}
|
||||
|
||||
async function isSessionActiveMemoryDisabled(params: {
|
||||
api: OpenClawPluginApi;
|
||||
sessionKey?: string;
|
||||
@@ -756,8 +674,8 @@ async function isSessionActiveMemoryDisabled(params: {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const store = await readToggleStore(resolveToggleStatePath(params.api));
|
||||
return store.sessions?.[sessionKey]?.disabled === true;
|
||||
const entry = await sessionToggleStore.lookup(sessionKey);
|
||||
return entry?.disabled === true;
|
||||
} catch (error) {
|
||||
params.api.logger.debug?.(
|
||||
`active-memory: failed to read session toggle (${error instanceof Error ? error.message : String(error)})`,
|
||||
@@ -771,17 +689,15 @@ async function setSessionActiveMemoryDisabled(params: {
|
||||
sessionKey: string;
|
||||
disabled: boolean;
|
||||
}): Promise<void> {
|
||||
const statePath = resolveToggleStatePath(params.api);
|
||||
await withToggleStoreLock(statePath, async () => {
|
||||
const store = await readToggleStore(statePath);
|
||||
const sessions = { ...store.sessions };
|
||||
if (params.disabled) {
|
||||
sessions[params.sessionKey] = { disabled: true, updatedAt: Date.now() };
|
||||
} else {
|
||||
delete sessions[params.sessionKey];
|
||||
}
|
||||
await writeToggleStore(statePath, Object.keys(sessions).length > 0 ? { sessions } : {});
|
||||
});
|
||||
if (params.disabled) {
|
||||
await sessionToggleStore.register(params.sessionKey, {
|
||||
version: 1,
|
||||
disabled: true,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await sessionToggleStore.delete(params.sessionKey);
|
||||
}
|
||||
|
||||
function resolveCommandSessionKey(params: {
|
||||
@@ -932,7 +848,6 @@ function normalizePluginConfig(
|
||||
600_000,
|
||||
),
|
||||
persistTranscripts: raw.persistTranscripts === true,
|
||||
transcriptDir: normalizeTranscriptDir(raw.transcriptDir),
|
||||
qmd: {
|
||||
searchMode: resolveQmdSearchMode(qmd?.searchMode),
|
||||
},
|
||||
@@ -1194,15 +1109,20 @@ function isEligibleInteractiveSession(ctx: {
|
||||
function resolveChatType(ctx: {
|
||||
sessionKey?: string;
|
||||
messageProvider?: string;
|
||||
channelId?: string;
|
||||
mainKey?: string;
|
||||
sessionEntry?: ActiveMemorySessionEntry;
|
||||
}): ActiveMemoryChatType | undefined {
|
||||
const rawSessionKey = ctx.sessionKey?.trim();
|
||||
const { baseSessionKey } = parseThreadSessionSuffix(rawSessionKey);
|
||||
const sessionKey = (baseSessionKey ?? rawSessionKey)?.trim().toLowerCase();
|
||||
const storedChatType = normalizeActiveMemoryChatType(ctx.sessionEntry?.chatType);
|
||||
if (storedChatType) {
|
||||
return storedChatType;
|
||||
}
|
||||
const sessionKey = ctx.sessionKey?.trim().toLowerCase();
|
||||
if (sessionKey) {
|
||||
if (sessionKey.startsWith("agent:") && sessionKey.split(":")[2] === "explicit") {
|
||||
return "explicit";
|
||||
const provider = (ctx.messageProvider ?? "").trim().toLowerCase();
|
||||
if (sessionKey.includes(":direct:")) {
|
||||
return "direct";
|
||||
}
|
||||
if (sessionKey.includes(":dm:")) {
|
||||
return "direct";
|
||||
}
|
||||
if (sessionKey.includes(":group:")) {
|
||||
return "group";
|
||||
@@ -1210,21 +1130,17 @@ function resolveChatType(ctx: {
|
||||
if (sessionKey.includes(":channel:")) {
|
||||
return "channel";
|
||||
}
|
||||
if (sessionKey.includes(":direct:") || sessionKey.includes(":dm:")) {
|
||||
if (sessionKey.includes(":explicit:")) {
|
||||
return "explicit";
|
||||
}
|
||||
if (/^agent:[^:]+:explicit$/.test(sessionKey)) {
|
||||
return "explicit";
|
||||
}
|
||||
if (/^agent:[^:]+:main:thread:/.test(sessionKey)) {
|
||||
return "direct";
|
||||
}
|
||||
const mainKey = ctx.mainKey?.trim().toLowerCase() || "main";
|
||||
const agentSessionParts = sessionKey.split(":");
|
||||
if (
|
||||
agentSessionParts.length === 3 &&
|
||||
agentSessionParts[0] === "agent" &&
|
||||
(agentSessionParts[2] === mainKey || agentSessionParts[2] === "main")
|
||||
) {
|
||||
const provider = (ctx.messageProvider ?? "").trim().toLowerCase();
|
||||
const channelId = (ctx.channelId ?? "").trim();
|
||||
if (provider && provider !== "webchat" && channelId) {
|
||||
return "direct";
|
||||
}
|
||||
if (/^agent:[^:]+:main$/.test(sessionKey) && provider && provider !== "webchat") {
|
||||
return "direct";
|
||||
}
|
||||
}
|
||||
const provider = (ctx.messageProvider ?? "").trim().toLowerCase();
|
||||
@@ -1239,8 +1155,7 @@ function isAllowedChatType(
|
||||
ctx: {
|
||||
sessionKey?: string;
|
||||
messageProvider?: string;
|
||||
channelId?: string;
|
||||
mainKey?: string;
|
||||
sessionEntry?: ActiveMemorySessionEntry;
|
||||
},
|
||||
): boolean {
|
||||
const chatType = resolveChatType(ctx);
|
||||
@@ -1250,63 +1165,47 @@ function isAllowedChatType(
|
||||
return config.allowedChatTypes.includes(chatType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort extraction of the conversation id (peer id) embedded in an
|
||||
* agent-scoped session key, using shared session-key utilities so we
|
||||
* stay aligned with the canonical key shapes produced by
|
||||
* `buildAgentPeerSessionKey` / `resolveThreadSessionKeys`.
|
||||
*
|
||||
* Supported shapes (after stripping the optional `:thread:<id>` suffix):
|
||||
* - agent:<agentId>:direct:<peerId> (dmScope=per-peer)
|
||||
* - agent:<agentId>:<channel>:direct:<peerId> (dmScope=per-channel-peer)
|
||||
* - agent:<agentId>:<channel>:<accountId>:direct:<peerId> (dmScope=per-account-channel-peer)
|
||||
* - agent:<agentId>:<channel>:group:<peerId> (group)
|
||||
* - agent:<agentId>:<channel>:channel:<peerId> (channel)
|
||||
*
|
||||
* The legacy `dm` token is also accepted for backwards compatibility.
|
||||
*
|
||||
* Returns undefined for sessions that do not embed a peer id (for
|
||||
* example dmScope=main `agent:<agentId>:<mainKey>` sessions, or any
|
||||
* non-canonical session key shape).
|
||||
*/
|
||||
function resolveConversationId(ctx: {
|
||||
sessionKey?: string;
|
||||
messageProvider?: string;
|
||||
sessionEntry?: ActiveMemorySessionEntry;
|
||||
}): string | undefined {
|
||||
const rawSessionKey = ctx.sessionKey?.trim();
|
||||
const storedChatType = normalizeActiveMemoryChatType(ctx.sessionEntry?.chatType);
|
||||
if (storedChatType === "direct") {
|
||||
const id =
|
||||
normalizeConversationIdValue(ctx.sessionEntry?.nativeDirectUserId) ??
|
||||
normalizeConversationIdValue(ctx.sessionEntry?.deliveryContext?.to);
|
||||
if (id) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
if (storedChatType === "group" || storedChatType === "channel") {
|
||||
const id =
|
||||
normalizeConversationIdValue(ctx.sessionEntry?.groupId) ??
|
||||
normalizeConversationIdValue(ctx.sessionEntry?.nativeChannelId) ??
|
||||
normalizeConversationIdValue(ctx.sessionEntry?.deliveryContext?.to);
|
||||
if (id) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return resolveConversationIdFromSessionKey(ctx.sessionKey);
|
||||
}
|
||||
|
||||
function resolveConversationIdFromSessionKey(sessionKey: string | undefined): string | undefined {
|
||||
const rawSessionKey = sessionKey?.trim();
|
||||
if (!rawSessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
// Strip generic `:thread:<id>` suffix first so threaded sessions match
|
||||
// the same conversation id as their non-threaded parent. Provider-
|
||||
// specific topic ids (e.g. Telegram/Feishu) that are baked into the
|
||||
// peer id by the channel adapter are preserved.
|
||||
const { baseSessionKey } = parseThreadSessionSuffix(rawSessionKey);
|
||||
const baseKey = (baseSessionKey ?? rawSessionKey).trim();
|
||||
if (!baseKey) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = parseAgentSessionKey(baseKey);
|
||||
const baseSessionKey = parseThreadSessionSuffix(rawSessionKey).baseSessionKey ?? rawSessionKey;
|
||||
const parsed = parseAgentSessionKey(baseSessionKey);
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
const restParts = parsed.rest.split(":").filter(Boolean);
|
||||
if (restParts.length < 2) {
|
||||
// `agent:<agentId>:<mainKey>` (dmScope=main) lands here — there is
|
||||
// no embedded peer id to filter against.
|
||||
return undefined;
|
||||
}
|
||||
// Walk left-to-right until we hit the first chat-type marker. Every
|
||||
// canonical peer key terminates with `<chatType>:<peerId...>`, so the
|
||||
// tail after the first marker is the conversation id we want.
|
||||
for (let index = 0; index < restParts.length - 1; index += 1) {
|
||||
const token = restParts[index];
|
||||
if (token === "direct" || token === "dm" || token === "group" || token === "channel") {
|
||||
const tail = restParts
|
||||
.slice(index + 1)
|
||||
.join(":")
|
||||
.trim();
|
||||
return tail || undefined;
|
||||
const parts = parsed.rest.split(":").filter(Boolean);
|
||||
for (let index = 0; index < parts.length - 1; index += 1) {
|
||||
const kind = parts[index];
|
||||
if (kind === "direct" || kind === "dm" || kind === "group" || kind === "channel") {
|
||||
return normalizeConversationIdValue(parts.slice(index + 1).join(":"));
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
@@ -1327,6 +1226,7 @@ function isAllowedChatId(
|
||||
ctx: {
|
||||
sessionKey?: string;
|
||||
messageProvider?: string;
|
||||
sessionEntry?: ActiveMemorySessionEntry;
|
||||
},
|
||||
): boolean {
|
||||
const hasAllowlist = config.allowedChatIds.length > 0;
|
||||
@@ -1611,7 +1511,6 @@ async function persistPluginStatusLines(params: {
|
||||
await params.api.runtime.agent.session.patchSessionEntry({
|
||||
agentId,
|
||||
sessionKey,
|
||||
preserveActivity: true,
|
||||
update: (existing) => {
|
||||
const previousEntries = Array.isArray(existing.pluginDebugEntries)
|
||||
? existing.pluginDebugEntries
|
||||
@@ -1673,52 +1572,64 @@ function resolveTranscriptReadLimits(
|
||||
};
|
||||
}
|
||||
|
||||
async function streamBoundedTranscriptJsonl(params: {
|
||||
sessionFile: string;
|
||||
async function streamBoundedTranscriptEvents(params: {
|
||||
transcriptScope: TranscriptScope;
|
||||
limits?: TranscriptReadLimits;
|
||||
onRecord: (record: unknown) => boolean | void;
|
||||
}): Promise<void> {
|
||||
const limits = resolveTranscriptReadLimits(params.limits);
|
||||
try {
|
||||
const stats = await fs.stat(params.sessionFile);
|
||||
if (!stats.isFile() || stats.size > limits.maxBytes) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const stream = fsSync.createReadStream(params.sessionFile, {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const rl = readline.createInterface({
|
||||
input: stream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
let seenLines = 0;
|
||||
try {
|
||||
for await (const line of rl) {
|
||||
seenLines += 1;
|
||||
if (seenLines > limits.maxLines) {
|
||||
const events = loadSqliteSessionTranscriptBoundedEvents({
|
||||
...params.transcriptScope,
|
||||
maxBytes: limits.maxBytes,
|
||||
maxEvents: limits.maxLines,
|
||||
});
|
||||
for (const { event } of events) {
|
||||
if (params.onRecord(event)) {
|
||||
break;
|
||||
}
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (params.onRecord(JSON.parse(trimmed) as unknown)) {
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {
|
||||
// Treat transcript recovery as best-effort on timeout/abort paths.
|
||||
} finally {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
function deleteTransientRecallTranscript(transcriptScope: TranscriptScope | undefined): void {
|
||||
if (!transcriptScope) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
deleteSqliteSessionTranscript(transcriptScope);
|
||||
} catch {
|
||||
// Best-effort cleanup; recall results should not fail because transcript cleanup did.
|
||||
}
|
||||
}
|
||||
|
||||
function rememberTranscriptScope(
|
||||
tracker: TranscriptScopeTracker,
|
||||
scope: TranscriptScope | undefined,
|
||||
): void {
|
||||
if (!scope) {
|
||||
return;
|
||||
}
|
||||
tracker.current = scope;
|
||||
if (
|
||||
!tracker.scopes.some(
|
||||
(known) => known.agentId === scope.agentId && known.sessionId === scope.sessionId,
|
||||
)
|
||||
) {
|
||||
tracker.scopes.push(scope);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRunTranscriptScope(
|
||||
fallback: TranscriptScope,
|
||||
result: { meta?: { agentMeta?: { sessionId?: string } } },
|
||||
): TranscriptScope {
|
||||
const sessionId = result.meta?.agentMeta?.sessionId?.trim();
|
||||
return sessionId ? { ...fallback, sessionId } : fallback;
|
||||
}
|
||||
|
||||
function extractActiveMemorySearchDebugFromSessionRecord(
|
||||
value: unknown,
|
||||
): ActiveMemorySearchDebug | undefined {
|
||||
@@ -1794,12 +1705,12 @@ function extractTerminalMemorySearchResultFromSessionRecord(
|
||||
}
|
||||
|
||||
async function readActiveMemorySearchDebug(
|
||||
sessionFile: string,
|
||||
transcriptScope: TranscriptScope,
|
||||
limits?: TranscriptReadLimits,
|
||||
): Promise<ActiveMemorySearchDebug | undefined> {
|
||||
let found: ActiveMemorySearchDebug | undefined;
|
||||
await streamBoundedTranscriptJsonl({
|
||||
sessionFile,
|
||||
await streamBoundedTranscriptEvents({
|
||||
transcriptScope,
|
||||
limits,
|
||||
onRecord: (record) => {
|
||||
const debug = extractActiveMemorySearchDebugFromSessionRecord(record);
|
||||
@@ -1812,12 +1723,12 @@ async function readActiveMemorySearchDebug(
|
||||
}
|
||||
|
||||
async function readTerminalMemorySearchResult(
|
||||
sessionFile: string,
|
||||
transcriptScope: TranscriptScope,
|
||||
limits?: TranscriptReadLimits,
|
||||
): Promise<TerminalMemorySearchResult | undefined> {
|
||||
let found: TerminalMemorySearchResult | undefined;
|
||||
await streamBoundedTranscriptJsonl({
|
||||
sessionFile,
|
||||
await streamBoundedTranscriptEvents({
|
||||
transcriptScope,
|
||||
limits,
|
||||
onRecord: (record) => {
|
||||
const result = extractTerminalMemorySearchResultFromSessionRecord(record);
|
||||
@@ -1832,7 +1743,7 @@ async function readTerminalMemorySearchResult(
|
||||
}
|
||||
|
||||
function watchTerminalMemorySearchResult(params: {
|
||||
getSessionFile: () => string | undefined;
|
||||
getTranscriptScope: () => TranscriptScope | undefined;
|
||||
abortSignal: AbortSignal;
|
||||
}): TerminalMemorySearchWatch {
|
||||
let stopped = false;
|
||||
@@ -1871,8 +1782,10 @@ function watchTerminalMemorySearchResult(params: {
|
||||
}
|
||||
inFlight = true;
|
||||
try {
|
||||
const sessionFile = params.getSessionFile();
|
||||
const result = sessionFile ? await readTerminalMemorySearchResult(sessionFile) : undefined;
|
||||
const transcriptScope = params.getTranscriptScope();
|
||||
const result = transcriptScope
|
||||
? await readTerminalMemorySearchResult(transcriptScope)
|
||||
: undefined;
|
||||
if (result) {
|
||||
finish(result);
|
||||
return;
|
||||
@@ -1958,17 +1871,17 @@ function extractAssistantTextFromSessionRecord(value: unknown): string {
|
||||
}
|
||||
|
||||
async function readPartialAssistantText(
|
||||
sessionFile: string | undefined,
|
||||
transcriptScope: TranscriptScope | undefined,
|
||||
limits?: TranscriptReadLimits,
|
||||
): Promise<string | null> {
|
||||
if (!sessionFile) {
|
||||
if (!transcriptScope) {
|
||||
return null;
|
||||
}
|
||||
const texts: string[] = [];
|
||||
const resolvedLimits = resolveTranscriptReadLimits(limits);
|
||||
let collectedChars = 0;
|
||||
await streamBoundedTranscriptJsonl({
|
||||
sessionFile,
|
||||
await streamBoundedTranscriptEvents({
|
||||
transcriptScope,
|
||||
limits: resolvedLimits,
|
||||
onRecord: (record) => {
|
||||
const text = extractAssistantTextFromSessionRecord(record);
|
||||
@@ -2060,7 +1973,7 @@ async function waitForSubagentPartialTimeoutData(
|
||||
async function buildTimeoutRecallResult(params: {
|
||||
elapsedMs: number;
|
||||
maxSummaryChars: number;
|
||||
sessionFile?: string;
|
||||
transcriptScope?: TranscriptScope;
|
||||
rawReply?: string;
|
||||
searchDebug?: ActiveMemorySearchDebug;
|
||||
subagentPromise?: Promise<RecallSubagentResult>;
|
||||
@@ -2072,7 +1985,7 @@ async function buildTimeoutRecallResult(params: {
|
||||
const rawReply =
|
||||
params.rawReply ??
|
||||
subagentPartialData.rawReply ??
|
||||
(await readPartialAssistantText(params.sessionFile));
|
||||
(await readPartialAssistantText(params.transcriptScope));
|
||||
const summary = truncateSummary(
|
||||
normalizeActiveSummary(rawReply ?? "") ?? "",
|
||||
params.maxSummaryChars,
|
||||
@@ -2080,7 +1993,9 @@ async function buildTimeoutRecallResult(params: {
|
||||
const searchDebug =
|
||||
params.searchDebug ??
|
||||
subagentPartialData.searchDebug ??
|
||||
(params.sessionFile ? await readActiveMemorySearchDebug(params.sessionFile) : undefined);
|
||||
(params.transcriptScope
|
||||
? await readActiveMemorySearchDebug(params.transcriptScope)
|
||||
: undefined);
|
||||
if (summary.length === 0) {
|
||||
return {
|
||||
status: "timeout",
|
||||
@@ -2480,7 +2395,7 @@ async function runRecallSubagent(params: {
|
||||
currentModelId?: string;
|
||||
modelRef?: { provider: string; model: string };
|
||||
abortSignal?: AbortSignal;
|
||||
onSessionFile?: (sessionFile: string) => void;
|
||||
onTranscriptScope?: (transcriptScope: TranscriptScope) => void;
|
||||
}): Promise<RecallSubagentResult> {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(params.api.config, params.agentId);
|
||||
const agentDir = resolveAgentDir(params.api.config, params.agentId);
|
||||
@@ -2510,28 +2425,11 @@ async function runRecallSubagent(params: {
|
||||
const subagentSessionKey = parentSessionKey
|
||||
? `${parentSessionKey}:${subagentSuffix}`
|
||||
: `agent:${params.agentId}:${subagentSuffix}`;
|
||||
const transientWorkspace = params.config.persistTranscripts
|
||||
? undefined
|
||||
: await tempWorkspace({
|
||||
rootDir: resolvePreferredOpenClawTmpDir(),
|
||||
prefix: "openclaw-active-memory-",
|
||||
});
|
||||
const tempDir = transientWorkspace?.dir;
|
||||
const persistedDir = params.config.persistTranscripts
|
||||
? resolveSafeTranscriptDir(
|
||||
resolvePersistentTranscriptBaseDir(params.api, params.agentId),
|
||||
params.config.transcriptDir,
|
||||
)
|
||||
: undefined;
|
||||
const sessionFile =
|
||||
persistedDir !== undefined
|
||||
? path.join(persistedDir, `${subagentSessionId}.jsonl`)
|
||||
: path.join(requireTransientWorkspaceDir(tempDir), "session.jsonl");
|
||||
params.onSessionFile?.(sessionFile);
|
||||
if (persistedDir) {
|
||||
await fs.mkdir(persistedDir, { recursive: true, mode: 0o700 });
|
||||
await fs.chmod(persistedDir, 0o700).catch(() => undefined);
|
||||
}
|
||||
const transcriptScope = {
|
||||
agentId: params.agentId,
|
||||
sessionId: subagentSessionId,
|
||||
};
|
||||
params.onTranscriptScope?.(transcriptScope);
|
||||
const prompt = buildRecallPrompt({
|
||||
config: params.config,
|
||||
query: params.query,
|
||||
@@ -2555,7 +2453,6 @@ async function runRecallSubagent(params: {
|
||||
agentId: params.agentId,
|
||||
messageChannel,
|
||||
messageProvider,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: embeddedConfig,
|
||||
@@ -2595,18 +2492,22 @@ async function runRecallSubagent(params: {
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
const resultTranscriptScope = resolveRunTranscriptScope(transcriptScope, result);
|
||||
params.onTranscriptScope?.(resultTranscriptScope);
|
||||
const searchDebug =
|
||||
(await readActiveMemorySearchDebug(sessionFile)) ??
|
||||
(await readActiveMemorySearchDebug(resultTranscriptScope)) ??
|
||||
readActiveMemorySearchDebugFromRunResult(result);
|
||||
return {
|
||||
rawReply: rawReply || "NONE",
|
||||
transcriptPath: params.config.persistTranscripts ? sessionFile : undefined,
|
||||
transcriptScope: params.config.persistTranscripts ? resultTranscriptScope : undefined,
|
||||
searchDebug,
|
||||
};
|
||||
} catch (error) {
|
||||
if (params.abortSignal?.aborted) {
|
||||
const partialReply = await readPartialAssistantText(sessionFile);
|
||||
const searchDebug = await readActiveMemorySearchDebug(sessionFile);
|
||||
const partialReply = await readPartialAssistantText(transcriptScope);
|
||||
const searchDebug = partialReply
|
||||
? await readActiveMemorySearchDebug(transcriptScope)
|
||||
: undefined;
|
||||
attachPartialTimeoutData(error, partialReply, searchDebug);
|
||||
}
|
||||
if (
|
||||
@@ -2626,8 +2527,6 @@ async function runRecallSubagent(params: {
|
||||
return { rawReply: "NONE", resultStatus: "failed" };
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await transientWorkspace?.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2726,7 +2625,7 @@ async function maybeResolveActiveRecall(params: {
|
||||
|
||||
const controller = new AbortController();
|
||||
const TIMEOUT_SENTINEL = Symbol("timeout");
|
||||
let sessionFile: string | undefined;
|
||||
const transcriptScopes: TranscriptScopeTracker = { scopes: [] };
|
||||
const watchdogTimeoutMs = params.config.timeoutMs + params.config.setupGraceTimeoutMs;
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort(new Error(`active-memory timeout after ${watchdogTimeoutMs}ms`));
|
||||
@@ -2744,17 +2643,18 @@ async function maybeResolveActiveRecall(params: {
|
||||
});
|
||||
|
||||
let terminalMemorySearchWatch: TerminalMemorySearchWatch | undefined;
|
||||
let subagentPromise: Promise<RecallSubagentResult> | undefined;
|
||||
try {
|
||||
const subagentPromise = runRecallSubagent({
|
||||
subagentPromise = runRecallSubagent({
|
||||
...params,
|
||||
modelRef: resolvedModelRef,
|
||||
abortSignal: controller.signal,
|
||||
onSessionFile: (value) => {
|
||||
sessionFile = value;
|
||||
onTranscriptScope: (value) => {
|
||||
rememberTranscriptScope(transcriptScopes, value);
|
||||
},
|
||||
});
|
||||
terminalMemorySearchWatch = watchTerminalMemorySearchResult({
|
||||
getSessionFile: () => sessionFile,
|
||||
getTranscriptScope: () => transcriptScopes.current,
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
// Silently catch late rejections after timeout so they don't become
|
||||
@@ -2772,7 +2672,7 @@ async function maybeResolveActiveRecall(params: {
|
||||
const result = await buildTimeoutRecallResult({
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
maxSummaryChars: params.config.maxSummaryChars,
|
||||
sessionFile,
|
||||
transcriptScope: transcriptScopes.current,
|
||||
subagentPromise,
|
||||
});
|
||||
if (params.config.logging) {
|
||||
@@ -2820,13 +2720,20 @@ async function maybeResolveActiveRecall(params: {
|
||||
return result;
|
||||
}
|
||||
|
||||
const { rawReply, resultStatus, transcriptPath, searchDebug } = raceResult;
|
||||
const {
|
||||
rawReply,
|
||||
resultStatus,
|
||||
transcriptScope: persistedTranscriptScope,
|
||||
searchDebug,
|
||||
} = raceResult;
|
||||
const summary = truncateSummary(
|
||||
normalizeActiveSummary(rawReply) ?? "",
|
||||
params.config.maxSummaryChars,
|
||||
);
|
||||
if (params.config.logging && transcriptPath) {
|
||||
params.api.logger.info?.(`${logPrefix} transcript=${transcriptPath}`);
|
||||
if (params.config.logging && persistedTranscriptScope) {
|
||||
params.api.logger.info?.(
|
||||
`${logPrefix} transcriptScope=${persistedTranscriptScope.agentId}/${persistedTranscriptScope.sessionId}`,
|
||||
);
|
||||
}
|
||||
const result: ActiveRecallResult =
|
||||
summary.length > 0
|
||||
@@ -2881,7 +2788,7 @@ async function maybeResolveActiveRecall(params: {
|
||||
const result = await buildTimeoutRecallResult({
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
maxSummaryChars: params.config.maxSummaryChars,
|
||||
sessionFile,
|
||||
transcriptScope: transcriptScopes.current,
|
||||
rawReply: partialTimeoutData.rawReply,
|
||||
searchDebug: partialTimeoutData.searchDebug,
|
||||
});
|
||||
@@ -2922,6 +2829,18 @@ async function maybeResolveActiveRecall(params: {
|
||||
} finally {
|
||||
terminalMemorySearchWatch?.stop();
|
||||
clearTimeout(timeoutId);
|
||||
if (!params.config.persistTranscripts) {
|
||||
for (const scope of transcriptScopes.scopes) {
|
||||
deleteTransientRecallTranscript(scope);
|
||||
}
|
||||
subagentPromise
|
||||
?.finally(() => {
|
||||
for (const scope of transcriptScopes.scopes) {
|
||||
deleteTransientRecallTranscript(scope);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3101,11 +3020,18 @@ export default definePluginEntry({
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
const sessionEntry =
|
||||
resolvedSessionKey && effectiveAgentId
|
||||
? (api.runtime.agent.session.getSessionEntry({
|
||||
agentId: effectiveAgentId,
|
||||
sessionKey: resolvedSessionKey,
|
||||
}) as ActiveMemorySessionEntry | undefined)
|
||||
: undefined;
|
||||
if (
|
||||
!isAllowedChatType(config, {
|
||||
...ctx,
|
||||
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
|
||||
mainKey: api.config.session?.mainKey,
|
||||
sessionKey: resolvedSessionKey,
|
||||
messageProvider: ctx.messageProvider,
|
||||
sessionEntry,
|
||||
})
|
||||
) {
|
||||
await persistPluginStatusLines({
|
||||
@@ -3117,8 +3043,9 @@ export default definePluginEntry({
|
||||
}
|
||||
if (
|
||||
!isAllowedChatId(config, {
|
||||
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
|
||||
sessionKey: resolvedSessionKey,
|
||||
messageProvider: ctx.messageProvider,
|
||||
sessionEntry,
|
||||
})
|
||||
) {
|
||||
await persistPluginStatusLines({
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
"recentAssistantChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
|
||||
"logging": { "type": "boolean" },
|
||||
"persistTranscripts": { "type": "boolean" },
|
||||
"transcriptDir": { "type": "string" },
|
||||
"cacheTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 },
|
||||
"circuitBreakerMaxTimeouts": { "type": "integer", "minimum": 1, "maximum": 20 },
|
||||
"circuitBreakerCooldownMs": { "type": "integer", "minimum": 5000, "maximum": 600000 },
|
||||
@@ -171,11 +170,7 @@
|
||||
},
|
||||
"persistTranscripts": {
|
||||
"label": "Persist Transcripts",
|
||||
"help": "Keep blocking memory sub-agent session transcripts on disk in a separate plugin-owned directory."
|
||||
},
|
||||
"transcriptDir": {
|
||||
"label": "Transcript Directory",
|
||||
"help": "Relative directory under the agent sessions folder used when transcript persistence is enabled."
|
||||
"help": "Log the blocking memory sub-agent SQLite transcript scope for debugging."
|
||||
},
|
||||
"qmd.searchMode": {
|
||||
"label": "QMD Search Mode",
|
||||
|
||||
6
extensions/amazon-bedrock-mantle/bedrock-token-generator.d.ts
vendored
Normal file
6
extensions/amazon-bedrock-mantle/bedrock-token-generator.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module "@aws/bedrock-token-generator" {
|
||||
export function getTokenProvider(opts?: {
|
||||
region?: string;
|
||||
expiresInSeconds?: number;
|
||||
}): () => Promise<string>;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import { stream, type Model, type SimpleStreamOptions } from "openclaw/plugin-sdk/llm";
|
||||
import type { Model, SimpleStreamOptions } from "openclaw/plugin-sdk/provider-ai";
|
||||
import { streamAnthropic } from "openclaw/plugin-sdk/provider-ai";
|
||||
|
||||
const MANTLE_ANTHROPIC_BETA = "fine-grained-tool-streaming-2025-05-14";
|
||||
type AnthropicOptions = ConstructorParameters<typeof Anthropic>[0];
|
||||
type MantleAnthropicStream = typeof stream;
|
||||
type MantleAnthropicStream = typeof streamAnthropic;
|
||||
type AnthropicStreamClient = Anthropic;
|
||||
|
||||
export function resolveMantleAnthropicBaseUrl(baseUrl: string): string {
|
||||
@@ -83,7 +84,7 @@ export function createMantleAnthropicStreamFn(deps?: {
|
||||
return (model, context, options) => {
|
||||
const apiKey = options?.apiKey ?? "";
|
||||
const createClient = deps?.createClient ?? ((clientOptions) => new Anthropic(clientOptions));
|
||||
const streamFn = deps?.stream ?? stream;
|
||||
const streamFn = deps?.stream ?? streamAnthropic;
|
||||
const client = createClient({
|
||||
apiKey: null,
|
||||
authToken: apiKey,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { registerApiProvider, streamSimple } from "openclaw/plugin-sdk/llm";
|
||||
import { registerApiProvider } from "openclaw/plugin-sdk/llm";
|
||||
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { streamSimple } from "openclaw/plugin-sdk/provider-ai";
|
||||
import {
|
||||
ANTHROPIC_BY_MODEL_REPLAY_HOOKS,
|
||||
normalizeProviderId,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { AnthropicVertex as AnthropicVertexSdk } from "@anthropic-ai/vertex-sdk";
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import {
|
||||
stream as streamDefault,
|
||||
type Model,
|
||||
type ProviderStreamOptions,
|
||||
} from "openclaw/plugin-sdk/llm";
|
||||
streamAnthropic as streamDefault,
|
||||
} from "openclaw/plugin-sdk/provider-ai";
|
||||
import {
|
||||
applyAnthropicPayloadPolicyToParams,
|
||||
resolveAnthropicPayloadPolicy,
|
||||
@@ -195,7 +195,7 @@ export function createAnthropicVertexStreamFn(
|
||||
opts.thinkingEnabled = false;
|
||||
}
|
||||
|
||||
return deps.streamAnthropic(transportModel, context, opts);
|
||||
return deps.streamAnthropic(transportModel, context, opts as ProviderStreamOptions);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
6
extensions/anthropic/cli-backend-api.ts
Normal file
6
extensions/anthropic/cli-backend-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { buildAnthropicCliBackend } from "./cli-backend.js";
|
||||
export {
|
||||
CLAUDE_CLI_BACKEND_ID,
|
||||
isClaudeCliProvider,
|
||||
normalizeClaudeBackendConfig,
|
||||
} from "./cli-shared.js";
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import { streamSimple } from "openclaw/plugin-sdk/llm";
|
||||
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { streamSimple } from "openclaw/plugin-sdk/provider-ai";
|
||||
import {
|
||||
applyAnthropicPayloadPolicyToParams,
|
||||
composeProviderStreamWrappers,
|
||||
|
||||
@@ -73,8 +73,9 @@ function normalizeAzureSpeechProviderConfig(
|
||||
rawConfig: Record<string, unknown>,
|
||||
): AzureSpeechProviderConfig {
|
||||
const raw = resolveAzureSpeechConfigRecord(rawConfig);
|
||||
const region = trimToUndefined(raw?.region) ?? readAzureSpeechEnvRegion();
|
||||
const endpoint = trimToUndefined(raw?.endpoint) ?? readAzureSpeechEnvEndpoint();
|
||||
const region =
|
||||
trimToUndefined(raw?.region) ?? (endpoint ? undefined : readAzureSpeechEnvRegion());
|
||||
const baseUrl = normalizeAzureSpeechBaseUrl({
|
||||
baseUrl: trimToUndefined(raw?.baseUrl),
|
||||
endpoint,
|
||||
@@ -99,8 +100,8 @@ function normalizeAzureSpeechProviderConfig(
|
||||
|
||||
function readAzureSpeechProviderConfig(config: SpeechProviderConfig): AzureSpeechProviderConfig {
|
||||
const defaults = normalizeAzureSpeechProviderConfig({});
|
||||
const region = trimToUndefined(config.region) ?? defaults.region;
|
||||
const endpoint = trimToUndefined(config.endpoint) ?? defaults.endpoint;
|
||||
const region = trimToUndefined(config.region) ?? (endpoint ? undefined : defaults.region);
|
||||
const baseUrl = normalizeAzureSpeechBaseUrl({
|
||||
baseUrl: trimToUndefined(config.baseUrl) ?? defaults.baseUrl,
|
||||
endpoint,
|
||||
|
||||
1
extensions/browser/browser-runtime-api.ts
Normal file
1
extensions/browser/browser-runtime-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src/browser-runtime.js";
|
||||
@@ -215,16 +215,14 @@ function wrapBrowserExternalJson(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function formatTabsToolResult(tabs: unknown[]): AgentToolResult<unknown> {
|
||||
function formatTabsToolResult(tabs: unknown[]): AgentToolResult {
|
||||
const formattedTabs = tabs.map((tab) => formatAgentTab(tab));
|
||||
const wrapped = wrapBrowserExternalJson({
|
||||
kind: "tabs",
|
||||
payload: { tabs: formattedTabs },
|
||||
includeWarning: false,
|
||||
});
|
||||
const content: AgentToolResult<unknown>["content"] = [
|
||||
{ type: "text", text: wrapped.wrappedText },
|
||||
];
|
||||
const content: AgentToolResult["content"] = [{ type: "text", text: wrapped.wrappedText }];
|
||||
return {
|
||||
content,
|
||||
details: {
|
||||
@@ -239,7 +237,7 @@ function formatConsoleToolResult(result: {
|
||||
targetId?: string;
|
||||
url?: string;
|
||||
messages?: unknown[];
|
||||
}): AgentToolResult<unknown> {
|
||||
}): AgentToolResult {
|
||||
const wrapped = wrapBrowserExternalJson({
|
||||
kind: "console",
|
||||
payload: result,
|
||||
@@ -316,7 +314,7 @@ export async function executeTabsAction(params: {
|
||||
profile?: string;
|
||||
timeoutMs?: number;
|
||||
proxyRequest: BrowserProxyRequest | null;
|
||||
}): Promise<AgentToolResult<unknown>> {
|
||||
}): Promise<AgentToolResult> {
|
||||
const { baseUrl, profile, timeoutMs, proxyRequest } = params;
|
||||
if (proxyRequest) {
|
||||
const result = await proxyRequest({
|
||||
@@ -338,7 +336,7 @@ export async function executeSnapshotAction(params: {
|
||||
profile?: string;
|
||||
proxyRequest: BrowserProxyRequest | null;
|
||||
onTabActivity?: (targetId: string | undefined) => void;
|
||||
}): Promise<AgentToolResult<unknown>> {
|
||||
}): Promise<AgentToolResult> {
|
||||
const { input, baseUrl, profile, proxyRequest } = params;
|
||||
const snapshotDefaults = browserToolActionDeps.getRuntimeConfig().browser?.snapshotDefaults;
|
||||
const format: "ai" | "aria" | undefined =
|
||||
@@ -525,7 +523,7 @@ export async function executeConsoleAction(params: {
|
||||
baseUrl?: string;
|
||||
profile?: string;
|
||||
proxyRequest: BrowserProxyRequest | null;
|
||||
}): Promise<AgentToolResult<unknown>> {
|
||||
}): Promise<AgentToolResult> {
|
||||
const { input, baseUrl, profile, proxyRequest } = params;
|
||||
const level = normalizeOptionalString(input.level);
|
||||
const targetId = normalizeOptionalString(input.targetId);
|
||||
@@ -555,7 +553,7 @@ export async function executeActAction(params: {
|
||||
profile?: string;
|
||||
proxyRequest: BrowserProxyRequest | null;
|
||||
onTabActivity?: (targetId: string | undefined) => void;
|
||||
}): Promise<AgentToolResult<unknown>> {
|
||||
}): Promise<AgentToolResult> {
|
||||
const { request, baseUrl, profile, proxyRequest } = params;
|
||||
const effectiveRequest = withConfiguredActTimeout(request, profile);
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ACT_MAX_VIEWPORT_DIMENSION } from "./browser/act-policy.js";
|
||||
import { BrowserToolSchema } from "./browser-tool.schema.js";
|
||||
import { ACT_MAX_VIEWPORT_DIMENSION } from "./browser/act-policy.js";
|
||||
|
||||
type SchemaRecord = Record<string, { maximum?: number; properties?: SchemaRecord }>;
|
||||
|
||||
|
||||
@@ -1400,11 +1400,10 @@ describe("chrome.ts internal", () => {
|
||||
.mockImplementation(() => {
|
||||
throw new Error("decoration blew up");
|
||||
});
|
||||
// The real decoration throws via our writes — fake by spying on
|
||||
// fs.writeFileSync to throw for the marker file.
|
||||
// The real decoration throws via preference writes; fake that path.
|
||||
const writeSpy = vi.spyOn(fs, "writeFileSync").mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
if (s.endsWith(".openclaw-profile-decorated") || s.endsWith("Preferences")) {
|
||||
if (s.endsWith("Preferences")) {
|
||||
throw new Error("write blew up");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { loadJsonFile, saveJsonFile } from "openclaw/plugin-sdk/json-store";
|
||||
import {
|
||||
@@ -6,10 +5,6 @@ import {
|
||||
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
|
||||
} from "./constants.js";
|
||||
|
||||
function decoratedMarkerPath(userDataDir: string) {
|
||||
return path.join(userDataDir, ".openclaw-profile-decorated");
|
||||
}
|
||||
|
||||
function safeReadJson(filePath: string): Record<string, unknown> | null {
|
||||
const parsed = loadJsonFile(filePath);
|
||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
||||
@@ -166,12 +161,6 @@ export function decorateOpenClawProfile(
|
||||
setDeep(prefs, ["savefile", "default_directory"], opts.downloadDir);
|
||||
}
|
||||
safeWriteJson(preferencesPath, prefs);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(decoratedMarkerPath(userDataDir), `${Date.now()}\n`, "utf-8");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureProfileCleanExit(userDataDir: string) {
|
||||
|
||||
@@ -186,11 +186,11 @@ describe("browser chrome profile decoration", () => {
|
||||
expect(prefs.download).toBeUndefined();
|
||||
expect(prefs.savefile).toBeUndefined();
|
||||
|
||||
const marker = await fsp.readFile(
|
||||
path.join(userDataDir, ".openclaw-profile-decorated"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(marker.trim()).toMatch(/^\d+$/);
|
||||
await expect(
|
||||
fsp.access(path.join(userDataDir, ".openclaw-profile-decorated")),
|
||||
).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes managed download prefs when a download dir is provided", async () => {
|
||||
|
||||
@@ -21,10 +21,8 @@ describe("profile name validation", () => {
|
||||
|
||||
it("rejects empty or missing names", () => {
|
||||
expect(isValidProfileName("")).toBe(false);
|
||||
// @ts-expect-error testing invalid input
|
||||
expect(isValidProfileName(null)).toBe(false);
|
||||
// @ts-expect-error testing invalid input
|
||||
expect(isValidProfileName(undefined)).toBe(false);
|
||||
expect(isValidProfileName(null as unknown as string)).toBe(false);
|
||||
expect(isValidProfileName(undefined as unknown as string)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects names that are too long", () => {
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("persistBrowserProxyFiles", () => {
|
||||
const savedPath = mapping.get(sourcePath);
|
||||
expect(typeof savedPath).toBe("string");
|
||||
expect(path.normalize(savedPath ?? "")).toContain(
|
||||
`${path.sep}.openclaw${path.sep}media${path.sep}browser${path.sep}`,
|
||||
`${path.sep}openclaw${path.sep}media${path.sep}browser${path.sep}`,
|
||||
);
|
||||
await expect(fs.readFile(savedPath ?? "", "utf8")).resolves.toBe("hello from browser proxy");
|
||||
});
|
||||
|
||||
@@ -44,19 +44,7 @@ function createExistingSessionProfileState(params?: {
|
||||
};
|
||||
}
|
||||
|
||||
function readFirstReachabilityCall(
|
||||
isReachable: ReturnType<typeof vi.fn>,
|
||||
): [number | undefined, { ephemeral?: boolean; signal?: AbortSignal } | undefined] {
|
||||
const [call] = isReachable.mock.calls as Array<
|
||||
[number | undefined, { ephemeral?: boolean; signal?: AbortSignal } | undefined]
|
||||
>;
|
||||
if (!call) {
|
||||
throw new Error("expected reachability probe call");
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function createManagedProfileState(profileOverrides?: Record<string, unknown>) {
|
||||
function createManagedProfileState(profileOverrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
resolved: {
|
||||
enabled: true,
|
||||
@@ -355,7 +343,12 @@ describe("basic browser routes", () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(isTransportAvailable).toHaveBeenCalledTimes(1);
|
||||
expect(isTransportAvailable).toHaveBeenCalledWith(5_000);
|
||||
const [timeoutMs, reachabilityOptions] = readFirstReachabilityCall(isReachable);
|
||||
const [timeoutMs, reachabilityOptions] =
|
||||
(
|
||||
isReachable.mock.calls as unknown as Array<
|
||||
[number, { ephemeral?: boolean; signal?: AbortSignal }]
|
||||
>
|
||||
)[0] ?? [];
|
||||
expect(timeoutMs).toBeGreaterThan(0);
|
||||
expect(timeoutMs).toBeLessThanOrEqual(7_000);
|
||||
expect(reachabilityOptions?.ephemeral).toBe(true);
|
||||
@@ -384,7 +377,12 @@ describe("basic browser routes", () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const [timeoutMs, reachabilityOptions] = readFirstReachabilityCall(isReachable);
|
||||
const [timeoutMs, reachabilityOptions] =
|
||||
(
|
||||
isReachable.mock.calls as unknown as Array<
|
||||
[number, { ephemeral?: boolean; signal?: AbortSignal }]
|
||||
>
|
||||
)[0] ?? [];
|
||||
expect(timeoutMs).toBe(4_000);
|
||||
expect(reachabilityOptions?.ephemeral).toBe(true);
|
||||
expect(reachabilityOptions?.signal).toBeInstanceOf(AbortSignal);
|
||||
@@ -409,9 +407,8 @@ describe("basic browser routes", () => {
|
||||
});
|
||||
|
||||
expect(isReachable).toHaveBeenCalledTimes(1);
|
||||
const [, reachabilityOptions] = readFirstReachabilityCall(isReachable);
|
||||
expect(reachabilityOptions?.ephemeral).toBe(true);
|
||||
expect(reachabilityOptions?.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(isReachable.mock.calls[0]?.[1]?.ephemeral).toBe(true);
|
||||
expect(isReachable.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("skips the page-reachability probe when transport is unavailable", async () => {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { withBrowserFetchPreconnect } from "../../test-fetch.js";
|
||||
import "../test-support/browser-security.mock.js";
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
import "./server-context.chrome-test-harness.js";
|
||||
import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js";
|
||||
import * as cdpHelpersModule from "./cdp.helpers.js";
|
||||
@@ -43,14 +48,6 @@ function fetchCallUrls(fetchMock: ReturnType<typeof vi.fn>): string[] {
|
||||
return fetchMock.mock.calls.map(([url]) => String(url));
|
||||
}
|
||||
|
||||
function fetchJsonCall(fetchJson: ReturnType<typeof vi.fn>, index: number): unknown[] {
|
||||
const call = fetchJson.mock.calls[index];
|
||||
if (!call) {
|
||||
throw new Error(`expected fetchJson call ${index + 1}`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function createOldTabCleanupFetchMock(
|
||||
existingTabs: ReturnType<typeof makeManagedTabsWithNew>,
|
||||
params?: { rejectNewTabClose?: boolean },
|
||||
@@ -383,13 +380,13 @@ describe("browser server-context tab selection state", () => {
|
||||
const opened = await openclaw.openTab("https://example.com");
|
||||
expect(opened.targetId).toBe("NEW");
|
||||
const jsonNewEndpoint = "http://127.0.0.1:18800/json/new?https%3A%2F%2Fexample.com";
|
||||
expect(fetchJsonCall(fetchJson, 0)).toEqual([
|
||||
expect(fetchJson.mock.calls[0]).toEqual([
|
||||
jsonNewEndpoint,
|
||||
CDP_JSON_NEW_TIMEOUT_MS,
|
||||
{ method: "PUT" },
|
||||
undefined,
|
||||
]);
|
||||
expect(fetchJsonCall(fetchJson, 1)).toEqual([
|
||||
expect(fetchJson.mock.calls[1]).toEqual([
|
||||
jsonNewEndpoint,
|
||||
CDP_JSON_NEW_TIMEOUT_MS,
|
||||
undefined,
|
||||
|
||||
@@ -18,9 +18,7 @@ export async function runBrowserResizeWithOutput(params: {
|
||||
return;
|
||||
}
|
||||
if (width > ACT_MAX_VIEWPORT_DIMENSION || height > ACT_MAX_VIEWPORT_DIMENSION) {
|
||||
defaultRuntime.error(
|
||||
danger(`width and height must not exceed ${ACT_MAX_VIEWPORT_DIMENSION}`),
|
||||
);
|
||||
defaultRuntime.error(danger(`width and height must not exceed ${ACT_MAX_VIEWPORT_DIMENSION}`));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
formatCliCommand,
|
||||
formatHelpExamples,
|
||||
addGatewayClientOptions,
|
||||
formatDocsLink,
|
||||
registerCommandGroups,
|
||||
resolveCliArgvInvocation,
|
||||
shouldEagerRegisterSubcommands,
|
||||
theme,
|
||||
type CommandGroupEntry,
|
||||
type CommandGroupPlaceholder,
|
||||
} from "openclaw/plugin-sdk/cli-runtime";
|
||||
import { browserActionExamples, browserCoreExamples } from "./browser-cli-examples.js";
|
||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||
import {
|
||||
addGatewayClientOptions,
|
||||
danger,
|
||||
defaultRuntime,
|
||||
formatCliCommand,
|
||||
formatDocsLink,
|
||||
formatHelpExamples,
|
||||
theme,
|
||||
} from "./core-api.js";
|
||||
|
||||
const browserCliRuntime = {
|
||||
error: (...args: unknown[]) => console.error(...args),
|
||||
exit: (code: number) => {
|
||||
process.exit(code);
|
||||
},
|
||||
};
|
||||
|
||||
type BrowserCommandRegistrar = (args: {
|
||||
browser: Command;
|
||||
@@ -260,10 +263,10 @@ export function registerBrowserCli(program: Command, argv: string[] = process.ar
|
||||
)
|
||||
.action(() => {
|
||||
browser.outputHelp();
|
||||
defaultRuntime.error(
|
||||
danger(`Missing subcommand. Try: "${formatCliCommand("openclaw browser status")}"`),
|
||||
browserCliRuntime.error(
|
||||
theme.error(`Missing subcommand. Try: "${formatCliCommand("openclaw browser status")}"`),
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
browserCliRuntime.exit(1);
|
||||
});
|
||||
|
||||
addGatewayClientOptions(browser);
|
||||
|
||||
@@ -32,6 +32,7 @@ vi.mock("./src/http-route.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./src/documents.js", () => ({
|
||||
resolveCanvasHttpPathToMaterializedLocalPath: mocks.resolveCanvasHttpPathToLocalPath,
|
||||
resolveCanvasHttpPathToLocalPath: mocks.resolveCanvasHttpPathToLocalPath,
|
||||
}));
|
||||
|
||||
|
||||
@@ -109,14 +109,19 @@ export default definePluginEntry({
|
||||
await httpRouteHandler?.close();
|
||||
},
|
||||
});
|
||||
let resolveCanvasHttpPathToLocalPathPromise:
|
||||
| Promise<(typeof import("./src/documents.js"))["resolveCanvasHttpPathToLocalPath"]>
|
||||
let resolveCanvasHttpPathToMaterializedLocalPathPromise:
|
||||
| Promise<
|
||||
(typeof import("./src/documents.js"))["resolveCanvasHttpPathToMaterializedLocalPath"]
|
||||
>
|
||||
| undefined;
|
||||
api.registerHostedMediaResolver(async (mediaUrl) => {
|
||||
resolveCanvasHttpPathToLocalPathPromise ??= import("./src/documents.js").then(
|
||||
({ resolveCanvasHttpPathToLocalPath }) => resolveCanvasHttpPathToLocalPath,
|
||||
resolveCanvasHttpPathToMaterializedLocalPathPromise ??= import("./src/documents.js").then(
|
||||
({ resolveCanvasHttpPathToMaterializedLocalPath }) =>
|
||||
resolveCanvasHttpPathToMaterializedLocalPath,
|
||||
);
|
||||
return (await resolveCanvasHttpPathToLocalPathPromise)(mediaUrl);
|
||||
return await (
|
||||
await resolveCanvasHttpPathToMaterializedLocalPathPromise
|
||||
)(mediaUrl);
|
||||
});
|
||||
}
|
||||
api.registerNodeInvokePolicy({
|
||||
|
||||
@@ -102,7 +102,7 @@ export const canvasConfigSchema: CanvasPluginConfigSchema = {
|
||||
},
|
||||
"host.root": {
|
||||
label: "Canvas Host Root Directory",
|
||||
help: "Directory to serve. Defaults to the OpenClaw state canvas directory.",
|
||||
help: "Optional directory to serve. Managed Canvas documents are stored in SQLite.",
|
||||
advanced: true,
|
||||
},
|
||||
"host.port": {
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { mkdtemp, mkdir, writeFile, readFile } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { resetPluginBlobStoreForTests } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCanvasDocumentEntryUrl,
|
||||
createCanvasDocument,
|
||||
readCanvasDocumentHttpBlob,
|
||||
resolveCanvasDocumentAssets,
|
||||
resolveCanvasDocumentDir,
|
||||
resolveCanvasHttpPathToLocalPath,
|
||||
resolveCanvasHttpPathToMaterializedLocalPath,
|
||||
} from "./documents.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
resetPluginBlobStoreForTests();
|
||||
await Promise.all(
|
||||
tempDirs.splice(0).map(async (dir) => {
|
||||
await import("node:fs/promises").then((fs) => fs.rm(dir, { recursive: true, force: true }));
|
||||
@@ -21,7 +25,7 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("canvas documents", () => {
|
||||
it("builds entry urls for materialized path documents under managed storage", async () => {
|
||||
it("builds entry urls for SQLite-backed managed documents", async () => {
|
||||
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
|
||||
tempDirs.push(stateDir);
|
||||
const workspaceDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-workspace-"));
|
||||
@@ -42,7 +46,17 @@ describe("canvas documents", () => {
|
||||
|
||||
expect(document.entryUrl).toContain("/__openclaw__/canvas/documents/");
|
||||
expect(document.localEntrypoint).toBe("index.html");
|
||||
expect(resolveCanvasDocumentDir(document.id, { stateDir })).toContain(stateDir);
|
||||
expect(resolveCanvasDocumentDir(document.id, { stateDir })).toBe(
|
||||
`sqlite:canvas/documents/${document.id}`,
|
||||
);
|
||||
await expect(
|
||||
readCanvasDocumentHttpBlob(document.entryUrl, { stateDir }),
|
||||
).resolves.toMatchObject({
|
||||
documentId: document.id,
|
||||
logicalPath: "index.html",
|
||||
contentType: "text/html; charset=utf-8",
|
||||
});
|
||||
expect(resolveCanvasHttpPathToLocalPath(document.entryUrl, { stateDir })).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes nested local entrypoint urls", () => {
|
||||
@@ -74,12 +88,9 @@ describe("canvas documents", () => {
|
||||
{ stateDir },
|
||||
);
|
||||
|
||||
const indexHtml = await import("node:fs/promises").then((fs) =>
|
||||
fs.readFile(
|
||||
path.join(resolveCanvasDocumentDir(document.id, { stateDir }), "index.html"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const indexHtml = (
|
||||
await readCanvasDocumentHttpBlob(document.entryUrl, { stateDir })
|
||||
)?.blob.toString("utf8");
|
||||
|
||||
expect(indexHtml).toContain("<div class='demo'>Front</div>");
|
||||
expect(indexHtml).toContain("<style>.demo{color:red}</style>");
|
||||
@@ -111,12 +122,9 @@ describe("canvas documents", () => {
|
||||
expect(first.id).toBe("status-card");
|
||||
expect(second.id).toBe("status-card");
|
||||
|
||||
const indexHtml = await import("node:fs/promises").then((fs) =>
|
||||
fs.readFile(
|
||||
path.join(resolveCanvasDocumentDir(second.id, { stateDir }), "index.html"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const indexHtml = (
|
||||
await readCanvasDocumentHttpBlob(second.entryUrl, { stateDir })
|
||||
)?.blob.toString("utf8");
|
||||
expect(indexHtml).toContain("second");
|
||||
expect(indexHtml).not.toContain("first");
|
||||
});
|
||||
@@ -152,10 +160,7 @@ describe("canvas documents", () => {
|
||||
{
|
||||
logicalPath: "collection.media/audio.mp3",
|
||||
contentType: "audio/mpeg",
|
||||
localPath: path.join(
|
||||
resolveCanvasDocumentDir(document.id, { stateDir }),
|
||||
"collection.media/audio.mp3",
|
||||
),
|
||||
localPath: `sqlite:canvas/documents/${document.id}/collection.media/audio.mp3`,
|
||||
url: `/__openclaw__/canvas/documents/${document.id}/collection.media/audio.mp3`,
|
||||
},
|
||||
]);
|
||||
@@ -168,13 +173,15 @@ describe("canvas documents", () => {
|
||||
{
|
||||
logicalPath: "collection.media/audio.mp3",
|
||||
contentType: "audio/mpeg",
|
||||
localPath: path.join(
|
||||
resolveCanvasDocumentDir(document.id, { stateDir }),
|
||||
"collection.media/audio.mp3",
|
||||
),
|
||||
localPath: `sqlite:canvas/documents/${document.id}/collection.media/audio.mp3`,
|
||||
url: `http://127.0.0.1:19003/__openclaw__/canvas/documents/${document.id}/collection.media/audio.mp3`,
|
||||
},
|
||||
]);
|
||||
const audioBlob = await readCanvasDocumentHttpBlob(
|
||||
`/__openclaw__/canvas/documents/${document.id}/collection.media/audio.mp3`,
|
||||
{ stateDir },
|
||||
);
|
||||
expect(audioBlob?.blob.toString("utf8")).toBe("audio");
|
||||
});
|
||||
|
||||
it("wraps local pdf documents in an index viewer page", async () => {
|
||||
@@ -196,10 +203,9 @@ describe("canvas documents", () => {
|
||||
);
|
||||
|
||||
expect(document.entryUrl).toBe(`/__openclaw__/canvas/documents/${document.id}/index.html`);
|
||||
const indexHtml = await readFile(
|
||||
path.join(resolveCanvasDocumentDir(document.id, { stateDir }), "index.html"),
|
||||
"utf8",
|
||||
);
|
||||
const indexHtml = (
|
||||
await readCanvasDocumentHttpBlob(document.entryUrl, { stateDir })
|
||||
)?.blob.toString("utf8");
|
||||
expect(indexHtml).toContain('type="application/pdf"');
|
||||
expect(indexHtml).toContain('data="demo.pdf"');
|
||||
});
|
||||
@@ -220,10 +226,9 @@ describe("canvas documents", () => {
|
||||
);
|
||||
|
||||
expect(document.entryUrl).toBe(`/__openclaw__/canvas/documents/${document.id}/index.html`);
|
||||
const indexHtml = await readFile(
|
||||
path.join(resolveCanvasDocumentDir(document.id, { stateDir }), "index.html"),
|
||||
"utf8",
|
||||
);
|
||||
const indexHtml = (
|
||||
await readCanvasDocumentHttpBlob(document.entryUrl, { stateDir })
|
||||
)?.blob.toString("utf8");
|
||||
expect(indexHtml).toContain('type="application/pdf"');
|
||||
expect(indexHtml).toContain('data="https://example.com/demo.pdf"');
|
||||
});
|
||||
@@ -240,25 +245,83 @@ describe("canvas documents", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed encoded hosted canvas document paths", async () => {
|
||||
it("materializes SQLite-backed canvas documents only when a local media path is needed", async () => {
|
||||
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
|
||||
tempDirs.push(stateDir);
|
||||
const documentId = "cv_malformed";
|
||||
const documentDir = resolveCanvasDocumentDir(documentId, { stateDir });
|
||||
await mkdir(documentDir, { recursive: true });
|
||||
await writeFile(path.join(documentDir, "%E0%A4%A.html"), "literal-percent-name", "utf8");
|
||||
|
||||
expect(
|
||||
resolveCanvasHttpPathToLocalPath(
|
||||
`/__openclaw__/canvas/documents/${documentId}/%E0%A4%A.html`,
|
||||
{ stateDir },
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveCanvasHttpPathToLocalPath(
|
||||
`/__openclaw__/canvas/documents/${documentId}/%25E0%25A4%25A.html`,
|
||||
{ stateDir },
|
||||
),
|
||||
).toBe(path.join(documentDir, "%E0%A4%A.html"));
|
||||
const document = await createCanvasDocument(
|
||||
{
|
||||
kind: "html_bundle",
|
||||
entrypoint: { type: "html", value: "<div>media</div>" },
|
||||
},
|
||||
{ stateDir },
|
||||
);
|
||||
|
||||
const localPath = await resolveCanvasHttpPathToMaterializedLocalPath(document.entryUrl, {
|
||||
stateDir,
|
||||
});
|
||||
|
||||
expect(localPath).toMatch(/canvas-documents/);
|
||||
expect(await readFile(localPath ?? "", "utf8")).toContain("<div>media</div>");
|
||||
});
|
||||
|
||||
it("materializes nested SQLite-backed assets without basename collisions", async () => {
|
||||
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
|
||||
tempDirs.push(stateDir);
|
||||
const workspaceDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-workspace-"));
|
||||
tempDirs.push(workspaceDir);
|
||||
await mkdir(path.join(workspaceDir, "images"), { recursive: true });
|
||||
await mkdir(path.join(workspaceDir, "thumbnails"), { recursive: true });
|
||||
await writeFile(path.join(workspaceDir, "images/logo.png"), "full-size", "utf8");
|
||||
await writeFile(path.join(workspaceDir, "thumbnails/logo.png"), "thumbnail", "utf8");
|
||||
|
||||
const document = await createCanvasDocument(
|
||||
{
|
||||
kind: "html_bundle",
|
||||
entrypoint: { type: "html", value: "<div>assets</div>" },
|
||||
assets: [
|
||||
{ logicalPath: "images/logo.png", sourcePath: "images/logo.png" },
|
||||
{ logicalPath: "thumbnails/logo.png", sourcePath: "thumbnails/logo.png" },
|
||||
],
|
||||
},
|
||||
{ stateDir, workspaceDir },
|
||||
);
|
||||
|
||||
const imagePath = await resolveCanvasHttpPathToMaterializedLocalPath(
|
||||
`/__openclaw__/canvas/documents/${document.id}/images/logo.png`,
|
||||
{ stateDir },
|
||||
);
|
||||
const thumbnailPath = await resolveCanvasHttpPathToMaterializedLocalPath(
|
||||
`/__openclaw__/canvas/documents/${document.id}/thumbnails/logo.png`,
|
||||
{ stateDir },
|
||||
);
|
||||
|
||||
expect(imagePath).not.toBe(thumbnailPath);
|
||||
expect(await readFile(imagePath ?? "", "utf8")).toBe("full-size");
|
||||
expect(await readFile(thumbnailPath ?? "", "utf8")).toBe("thumbnail");
|
||||
});
|
||||
|
||||
it("keeps explicit canvas roots file-backed", async () => {
|
||||
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
|
||||
tempDirs.push(stateDir);
|
||||
const canvasRootDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-root-"));
|
||||
tempDirs.push(canvasRootDir);
|
||||
|
||||
const document = await createCanvasDocument(
|
||||
{
|
||||
kind: "html_bundle",
|
||||
entrypoint: { type: "html", value: "<div>file</div>" },
|
||||
},
|
||||
{ stateDir, canvasRootDir },
|
||||
);
|
||||
|
||||
const documentDir = resolveCanvasDocumentDir(document.id, { stateDir, rootDir: canvasRootDir });
|
||||
expect(documentDir).toContain(canvasRootDir);
|
||||
expect(await readFile(path.join(documentDir, "index.html"), "utf8")).toContain(
|
||||
"<div>file</div>",
|
||||
);
|
||||
expect(resolveCanvasHttpPathToLocalPath(document.entryUrl, { rootDir: canvasRootDir })).toBe(
|
||||
path.join(documentDir, "index.html"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createPluginBlobStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { root as fsRoot, sanitizeUntrustedFileName } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { CANVAS_HOST_PATH } from "./host/a2ui.js";
|
||||
|
||||
@@ -53,6 +54,41 @@ type CanvasDocumentResolvedAsset = {
|
||||
};
|
||||
|
||||
const CANVAS_DOCUMENTS_DIR_NAME = "documents";
|
||||
const CANVAS_DOCUMENTS_PLUGIN_ID = "canvas";
|
||||
const CANVAS_DOCUMENTS_NAMESPACE = "documents";
|
||||
const CANVAS_DOCUMENTS_MAX_ENTRIES = 20_000;
|
||||
|
||||
type CanvasDocumentBlobMetadata = {
|
||||
documentId: string;
|
||||
logicalPath: string;
|
||||
role: "manifest" | "file";
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
type CanvasDocumentStorageRoot = {
|
||||
write(logicalPath: string, value: string): Promise<void>;
|
||||
copyIn(
|
||||
logicalPath: string,
|
||||
sourcePath: string,
|
||||
options?: { contentType?: string },
|
||||
): Promise<void>;
|
||||
flush?(): Promise<void>;
|
||||
};
|
||||
|
||||
type CanvasDocumentBlob = {
|
||||
documentId: string;
|
||||
logicalPath: string;
|
||||
contentType?: string;
|
||||
blob: Buffer;
|
||||
};
|
||||
|
||||
function canvasDocumentBlobStore(stateDir?: string) {
|
||||
return createPluginBlobStore<CanvasDocumentBlobMetadata>(CANVAS_DOCUMENTS_PLUGIN_ID, {
|
||||
namespace: CANVAS_DOCUMENTS_NAMESPACE,
|
||||
maxEntries: CANVAS_DOCUMENTS_MAX_ENTRIES,
|
||||
...(stateDir ? { env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function isPdfPathLike(value: string): boolean {
|
||||
return /\.pdf(?:[?#].*)?$/i.test(value.trim());
|
||||
@@ -113,20 +149,25 @@ function normalizeCanvasDocumentId(value: string): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveCanvasRootDir(rootDir?: string, stateDir = resolveStateDir()): string {
|
||||
const resolved = rootDir?.trim() ? resolveUserPath(rootDir) : path.join(stateDir, "canvas");
|
||||
return path.resolve(resolved);
|
||||
function resolveCanvasRootDir(rootDir?: string): string {
|
||||
if (!rootDir?.trim()) {
|
||||
throw new Error("canvas rootDir required for file-backed document storage");
|
||||
}
|
||||
return path.resolve(resolveUserPath(rootDir));
|
||||
}
|
||||
|
||||
function resolveCanvasDocumentsDir(rootDir?: string, stateDir = resolveStateDir()): string {
|
||||
return path.join(resolveCanvasRootDir(rootDir, stateDir), CANVAS_DOCUMENTS_DIR_NAME);
|
||||
function resolveCanvasDocumentsDir(rootDir?: string): string {
|
||||
return path.join(resolveCanvasRootDir(rootDir), CANVAS_DOCUMENTS_DIR_NAME);
|
||||
}
|
||||
|
||||
export function resolveCanvasDocumentDir(
|
||||
documentId: string,
|
||||
options?: { rootDir?: string; stateDir?: string },
|
||||
): string {
|
||||
return path.join(resolveCanvasDocumentsDir(options?.rootDir, options?.stateDir), documentId);
|
||||
if (!options?.rootDir?.trim()) {
|
||||
return `sqlite:canvas/documents/${normalizeCanvasDocumentId(documentId)}`;
|
||||
}
|
||||
return path.join(resolveCanvasDocumentsDir(options?.rootDir), documentId);
|
||||
}
|
||||
|
||||
export function buildCanvasDocumentEntryUrl(documentId: string, entrypoint: string): string {
|
||||
@@ -146,6 +187,9 @@ export function resolveCanvasHttpPathToLocalPath(
|
||||
requestPath: string,
|
||||
options?: { rootDir?: string; stateDir?: string },
|
||||
): string | null {
|
||||
if (!options?.rootDir?.trim()) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = requestPath.trim();
|
||||
const prefix = `${CANVAS_HOST_PATH}/${CANVAS_DOCUMENTS_DIR_NAME}/`;
|
||||
if (!trimmed.startsWith(prefix)) {
|
||||
@@ -171,9 +215,7 @@ export function resolveCanvasHttpPathToLocalPath(
|
||||
try {
|
||||
const documentId = normalizeCanvasDocumentId(rawDocumentId);
|
||||
const normalizedEntrypoint = normalizeLogicalPath(entrySegments.join("/"));
|
||||
const documentsDir = path.resolve(
|
||||
resolveCanvasDocumentsDir(options?.rootDir, options?.stateDir),
|
||||
);
|
||||
const documentsDir = path.resolve(resolveCanvasDocumentsDir(options?.rootDir));
|
||||
const candidatePath = path.resolve(
|
||||
resolveCanvasDocumentDir(documentId, options),
|
||||
normalizedEntrypoint,
|
||||
@@ -189,17 +231,107 @@ export function resolveCanvasHttpPathToLocalPath(
|
||||
}
|
||||
}
|
||||
|
||||
type CanvasDocumentRoot = Awaited<ReturnType<typeof fsRoot>>;
|
||||
async function createFilesystemCanvasRoot(rootDir: string): Promise<CanvasDocumentStorageRoot> {
|
||||
await fs.rm(rootDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
await fs.mkdir(rootDir, { recursive: true });
|
||||
const root = await fsRoot(rootDir);
|
||||
return {
|
||||
async write(logicalPath, value) {
|
||||
await root.write(logicalPath, value);
|
||||
},
|
||||
async copyIn(logicalPath, sourcePath) {
|
||||
await root.copyIn(logicalPath, sourcePath);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function clearSqliteCanvasDocument(documentId: string, stateDir?: string): Promise<void> {
|
||||
const store = canvasDocumentBlobStore(stateDir);
|
||||
const prefix = `${documentId}/`;
|
||||
const entries = await store.entries();
|
||||
await Promise.all(
|
||||
entries.filter((entry) => entry.key.startsWith(prefix)).map((entry) => store.delete(entry.key)),
|
||||
);
|
||||
}
|
||||
|
||||
function createSqliteCanvasRoot(documentId: string, stateDir?: string): CanvasDocumentStorageRoot {
|
||||
const files = new Map<string, { blob: Buffer; contentType?: string }>();
|
||||
return {
|
||||
async write(logicalPath, value) {
|
||||
files.set(normalizeLogicalPath(logicalPath), {
|
||||
blob: Buffer.from(value, "utf8"),
|
||||
contentType: contentTypeForLogicalPath(logicalPath),
|
||||
});
|
||||
},
|
||||
async copyIn(logicalPath, sourcePath, options) {
|
||||
const normalized = normalizeLogicalPath(logicalPath);
|
||||
files.set(normalized, {
|
||||
blob: await fs.readFile(sourcePath),
|
||||
contentType: options?.contentType ?? contentTypeForLogicalPath(normalized),
|
||||
});
|
||||
},
|
||||
async flush() {
|
||||
await clearSqliteCanvasDocument(documentId, stateDir);
|
||||
const store = canvasDocumentBlobStore(stateDir);
|
||||
await Promise.all(
|
||||
[...files.entries()].map(([logicalPath, file]) =>
|
||||
store.register(
|
||||
`${documentId}/${logicalPath}`,
|
||||
{
|
||||
documentId,
|
||||
logicalPath,
|
||||
role: logicalPath === "manifest.json" ? "manifest" : "file",
|
||||
...(file.contentType ? { contentType: file.contentType } : {}),
|
||||
},
|
||||
file.blob,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function contentTypeForLogicalPath(logicalPath: string): string | undefined {
|
||||
const lower = logicalPath.toLowerCase();
|
||||
if (lower.endsWith(".html") || lower.endsWith(".htm")) {
|
||||
return "text/html; charset=utf-8";
|
||||
}
|
||||
if (lower.endsWith(".json")) {
|
||||
return "application/json; charset=utf-8";
|
||||
}
|
||||
if (lower.endsWith(".pdf")) {
|
||||
return "application/pdf";
|
||||
}
|
||||
if (lower.endsWith(".png")) {
|
||||
return "image/png";
|
||||
}
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
if (lower.endsWith(".gif")) {
|
||||
return "image/gif";
|
||||
}
|
||||
if (lower.endsWith(".webp")) {
|
||||
return "image/webp";
|
||||
}
|
||||
if (lower.endsWith(".mp3")) {
|
||||
return "audio/mpeg";
|
||||
}
|
||||
if (lower.endsWith(".mp4")) {
|
||||
return "video/mp4";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function writeManifest(
|
||||
root: CanvasDocumentRoot,
|
||||
root: CanvasDocumentStorageRoot,
|
||||
manifest: CanvasDocumentManifest,
|
||||
): Promise<void> {
|
||||
await root.writeJson("manifest.json", manifest, { space: 2 });
|
||||
await root.write("manifest.json", `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function copyAssets(
|
||||
root: CanvasDocumentRoot,
|
||||
root: CanvasDocumentStorageRoot,
|
||||
assets: CanvasDocumentAsset[] | undefined,
|
||||
workspaceDir: string,
|
||||
): Promise<CanvasDocumentManifest["assets"]> {
|
||||
@@ -211,7 +343,7 @@ async function copyAssets(
|
||||
: path.isAbsolute(asset.sourcePath)
|
||||
? path.resolve(asset.sourcePath)
|
||||
: path.resolve(workspaceDir, asset.sourcePath);
|
||||
await root.copyIn(logicalPath, sourcePath);
|
||||
await root.copyIn(logicalPath, sourcePath, { contentType: asset.contentType });
|
||||
copied.push({
|
||||
logicalPath,
|
||||
...(asset.contentType ? { contentType: asset.contentType } : {}),
|
||||
@@ -221,8 +353,8 @@ async function copyAssets(
|
||||
}
|
||||
|
||||
async function materializeEntrypoint(
|
||||
rootDir: string,
|
||||
root: CanvasDocumentRoot,
|
||||
documentId: string,
|
||||
root: CanvasDocumentStorageRoot,
|
||||
input: CanvasDocumentCreateInput,
|
||||
workspaceDir: string,
|
||||
): Promise<Pick<CanvasDocumentManifest, "entryUrl" | "localEntrypoint" | "externalUrl">> {
|
||||
@@ -235,7 +367,7 @@ async function materializeEntrypoint(
|
||||
await root.write(fileName, entrypoint.value);
|
||||
return {
|
||||
localEntrypoint: fileName,
|
||||
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), fileName),
|
||||
entryUrl: buildCanvasDocumentEntryUrl(documentId, fileName),
|
||||
};
|
||||
}
|
||||
if (entrypoint.type === "url") {
|
||||
@@ -245,7 +377,7 @@ async function materializeEntrypoint(
|
||||
return {
|
||||
localEntrypoint: fileName,
|
||||
externalUrl: entrypoint.value,
|
||||
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), fileName),
|
||||
entryUrl: buildCanvasDocumentEntryUrl(documentId, fileName),
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -270,7 +402,7 @@ async function materializeEntrypoint(
|
||||
await root.write("index.html", wrapper);
|
||||
return {
|
||||
localEntrypoint: "index.html",
|
||||
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), "index.html"),
|
||||
entryUrl: buildCanvasDocumentEntryUrl(documentId, "index.html"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -280,12 +412,12 @@ async function materializeEntrypoint(
|
||||
await root.write("index.html", buildPdfWrapper(fileName));
|
||||
return {
|
||||
localEntrypoint: "index.html",
|
||||
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), "index.html"),
|
||||
entryUrl: buildCanvasDocumentEntryUrl(documentId, "index.html"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
localEntrypoint: fileName,
|
||||
entryUrl: buildCanvasDocumentEntryUrl(path.basename(rootDir), fileName),
|
||||
entryUrl: buildCanvasDocumentEntryUrl(documentId, fileName),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -295,15 +427,18 @@ export async function createCanvasDocument(
|
||||
): Promise<CanvasDocumentManifest> {
|
||||
const workspaceDir = options?.workspaceDir ?? process.cwd();
|
||||
const id = input.id?.trim() ? normalizeCanvasDocumentId(input.id) : canvasDocumentId();
|
||||
const rootDir = resolveCanvasDocumentDir(id, {
|
||||
stateDir: options?.stateDir,
|
||||
rootDir: options?.canvasRootDir,
|
||||
});
|
||||
await fs.rm(rootDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
await fs.mkdir(rootDir, { recursive: true });
|
||||
const root = await fsRoot(rootDir);
|
||||
const fileBacked = Boolean(options?.canvasRootDir?.trim());
|
||||
const rootDir = fileBacked
|
||||
? resolveCanvasDocumentDir(id, {
|
||||
stateDir: options?.stateDir,
|
||||
rootDir: options?.canvasRootDir,
|
||||
})
|
||||
: "";
|
||||
const root = fileBacked
|
||||
? await createFilesystemCanvasRoot(rootDir)
|
||||
: createSqliteCanvasRoot(id, options?.stateDir);
|
||||
const assets = await copyAssets(root, input.assets, workspaceDir);
|
||||
const entry = await materializeEntrypoint(rootDir, root, input, workspaceDir);
|
||||
const entry = await materializeEntrypoint(id, root, input, workspaceDir);
|
||||
const manifest: CanvasDocumentManifest = {
|
||||
id,
|
||||
kind: input.kind,
|
||||
@@ -319,6 +454,7 @@ export async function createCanvasDocument(
|
||||
assets,
|
||||
};
|
||||
await writeManifest(root, manifest);
|
||||
await root.flush?.();
|
||||
return manifest;
|
||||
}
|
||||
|
||||
@@ -327,16 +463,106 @@ export function resolveCanvasDocumentAssets(
|
||||
options?: { baseUrl?: string; stateDir?: string; canvasRootDir?: string },
|
||||
): CanvasDocumentResolvedAsset[] {
|
||||
const baseUrl = options?.baseUrl?.trim().replace(/\/+$/, "");
|
||||
const documentDir = resolveCanvasDocumentDir(manifest.id, {
|
||||
stateDir: options?.stateDir,
|
||||
rootDir: options?.canvasRootDir,
|
||||
});
|
||||
const fileBacked = Boolean(options?.canvasRootDir?.trim());
|
||||
const documentDir = fileBacked
|
||||
? resolveCanvasDocumentDir(manifest.id, {
|
||||
stateDir: options?.stateDir,
|
||||
rootDir: options?.canvasRootDir,
|
||||
})
|
||||
: `sqlite:canvas/documents/${manifest.id}`;
|
||||
return manifest.assets.map((asset) => ({
|
||||
logicalPath: asset.logicalPath,
|
||||
...(asset.contentType ? { contentType: asset.contentType } : {}),
|
||||
localPath: path.join(documentDir, asset.logicalPath),
|
||||
localPath: fileBacked
|
||||
? path.join(documentDir, asset.logicalPath)
|
||||
: `${documentDir}/${asset.logicalPath}`,
|
||||
url: baseUrl
|
||||
? `${baseUrl}${buildCanvasDocumentAssetUrl(manifest.id, asset.logicalPath)}`
|
||||
: buildCanvasDocumentAssetUrl(manifest.id, asset.logicalPath),
|
||||
}));
|
||||
}
|
||||
|
||||
function parseCanvasDocumentRequestPath(requestPath: string): {
|
||||
documentId: string;
|
||||
logicalPath: string;
|
||||
} | null {
|
||||
const trimmed = requestPath.trim();
|
||||
const pathWithoutQuery = trimmed.replace(/[?#].*$/, "");
|
||||
const prefix = `${CANVAS_HOST_PATH}/${CANVAS_DOCUMENTS_DIR_NAME}/`;
|
||||
const relative = pathWithoutQuery.startsWith(prefix)
|
||||
? pathWithoutQuery.slice(prefix.length)
|
||||
: pathWithoutQuery.startsWith(`/${CANVAS_DOCUMENTS_DIR_NAME}/`)
|
||||
? pathWithoutQuery.slice(`/${CANVAS_DOCUMENTS_DIR_NAME}/`.length)
|
||||
: null;
|
||||
if (relative == null) {
|
||||
return null;
|
||||
}
|
||||
const segments = relative
|
||||
.split("/")
|
||||
.map((segment) => {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
return segment;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (segments.length < 2) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return {
|
||||
documentId: normalizeCanvasDocumentId(segments[0] ?? ""),
|
||||
logicalPath: normalizeLogicalPath(segments.slice(1).join("/")),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readCanvasDocumentHttpBlob(
|
||||
requestPath: string,
|
||||
options?: { stateDir?: string },
|
||||
): Promise<CanvasDocumentBlob | null> {
|
||||
const parsed = parseCanvasDocumentRequestPath(requestPath);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
const entry = await canvasDocumentBlobStore(options?.stateDir).lookup(
|
||||
`${parsed.documentId}/${parsed.logicalPath}`,
|
||||
);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
documentId: parsed.documentId,
|
||||
logicalPath: parsed.logicalPath,
|
||||
...(entry.metadata.contentType ? { contentType: entry.metadata.contentType } : {}),
|
||||
blob: entry.blob,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveCanvasHttpPathToMaterializedLocalPath(
|
||||
requestPath: string,
|
||||
options?: { stateDir?: string; rootDir?: string },
|
||||
): Promise<string | null> {
|
||||
const filePath = resolveCanvasHttpPathToLocalPath(requestPath, options);
|
||||
if (filePath) {
|
||||
return filePath;
|
||||
}
|
||||
const entry = await readCanvasDocumentHttpBlob(requestPath, options);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const materializationDir = path.join(
|
||||
resolvePreferredOpenClawTmpDir(),
|
||||
"canvas-documents",
|
||||
entry.documentId,
|
||||
);
|
||||
await fs.mkdir(materializationDir, { recursive: true, mode: 0o700 });
|
||||
const normalizedLogicalPath = normalizeLogicalPath(entry.logicalPath);
|
||||
const filePathOut = path.join(materializationDir, normalizedLogicalPath);
|
||||
await fs.mkdir(path.dirname(filePathOut), { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(filePathOut, entry.blob);
|
||||
return filePathOut;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { withStateDirEnv } from "openclaw/plugin-sdk/test-env";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
@@ -11,7 +12,7 @@ describe("canvas host state dir defaults", () => {
|
||||
({ createCanvasHostHandler } = await import("./server.js"));
|
||||
});
|
||||
|
||||
it("uses OPENCLAW_STATE_DIR for the default canvas root", async () => {
|
||||
it("uses a temp materialization root by default", async () => {
|
||||
await withStateDirEnv("openclaw-canvas-state-", async ({ stateDir }) => {
|
||||
const handler = await createCanvasHostHandler({
|
||||
runtime: defaultRuntime,
|
||||
@@ -19,10 +20,13 @@ describe("canvas host state dir defaults", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
const expectedRoot = await fs.realpath(path.join(stateDir, "canvas"));
|
||||
const tempRoot = await fs.realpath(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "canvas-host"),
|
||||
);
|
||||
const actualRoot = await fs.realpath(handler.rootDir);
|
||||
expect(actualRoot).toBe(expectedRoot);
|
||||
const indexPath = path.join(expectedRoot, "index.html");
|
||||
expect(actualRoot).toBe(tempRoot);
|
||||
expect(actualRoot.startsWith(await fs.realpath(stateDir))).toBe(false);
|
||||
const indexPath = path.join(tempRoot, "index.html");
|
||||
const indexContents = await fs.readFile(indexPath, "utf8");
|
||||
expect(indexContents).toContain("OpenClaw Canvas");
|
||||
} finally {
|
||||
|
||||
@@ -12,13 +12,14 @@ import {
|
||||
import chokidar from "chokidar";
|
||||
import { detectMime } from "openclaw/plugin-sdk/media-mime";
|
||||
import { isTruthyEnvValue, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import {
|
||||
lowercasePreservingWhitespace,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { ensureDir, resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { type WebSocket, WebSocketServer } from "ws";
|
||||
import { readCanvasDocumentHttpBlob } from "../documents.js";
|
||||
import {
|
||||
CANVAS_HOST_PATH,
|
||||
CANVAS_WS_PATH,
|
||||
@@ -214,7 +215,7 @@ async function prepareCanvasRoot(rootDir: string) {
|
||||
}
|
||||
|
||||
function resolveDefaultCanvasRoot(): string {
|
||||
const candidates = [path.join(resolveStateDir(), "canvas")];
|
||||
const candidates = [path.join(resolvePreferredOpenClawTmpDir(), "canvas-host")];
|
||||
const existing = candidates.find((dir) => {
|
||||
try {
|
||||
return fsSync.statSync(dir).isDirectory();
|
||||
@@ -374,6 +375,14 @@ export async function createCanvasHostHandler(
|
||||
return true;
|
||||
}
|
||||
|
||||
const documentBlob = await readCanvasDocumentHttpBlob(`${CANVAS_HOST_PATH}${urlPath}`);
|
||||
if (documentBlob) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("Content-Type", documentBlob.contentType ?? "application/octet-stream");
|
||||
res.end(req.method === "HEAD" ? undefined : documentBlob.blob);
|
||||
return true;
|
||||
}
|
||||
|
||||
const opened = await resolveFileWithinRoot(rootReal, urlPath);
|
||||
if (!opened) {
|
||||
if (urlPath === "/" || urlPath.endsWith("/")) {
|
||||
|
||||
@@ -20,6 +20,7 @@ type OAuthCredentials = {
|
||||
refresh: string;
|
||||
access: string;
|
||||
expires: number;
|
||||
email?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
"blurb": "self-hosted chat via first-class ClickClack bot tokens.",
|
||||
"systemImage": "bubble.left.and.bubble.right",
|
||||
"markdownCapable": true,
|
||||
"preferSessionLookupForAnnounceTarget": true,
|
||||
"order": 85,
|
||||
"commands": {
|
||||
"nativeCommandsAutoEnabled": false,
|
||||
|
||||
@@ -119,9 +119,7 @@ export async function handleClickClackInbound(params: {
|
||||
}
|
||||
const senderName = message.author?.display_name || message.author_id;
|
||||
const previousTimestamp = runtime.channel.session.readSessionUpdatedAt({
|
||||
storePath: runtime.channel.session.resolveStorePath(params.config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
}),
|
||||
agentId: route.agentId,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = runtime.channel.reply.formatAgentEnvelope({
|
||||
@@ -132,9 +130,6 @@ export async function handleClickClackInbound(params: {
|
||||
envelope: runtime.channel.reply.resolveEnvelopeFormatOptions(params.config as OpenClawConfig),
|
||||
body: message.body,
|
||||
});
|
||||
const storePath = runtime.channel.session.resolveStorePath(params.config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
BodyForAgent: message.body,
|
||||
@@ -169,7 +164,6 @@ export async function handleClickClackInbound(params: {
|
||||
accountId: params.account.accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
ctxPayload,
|
||||
recordInboundSession: runtime.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
|
||||
@@ -71,9 +71,12 @@ export function createCodexAppServerAgentHarness(options?: {
|
||||
});
|
||||
},
|
||||
reset: async (params) => {
|
||||
if (params.sessionFile) {
|
||||
if (params.sessionId || params.sessionKey) {
|
||||
const { clearCodexAppServerBinding } = await import("./src/app-server/session-binding.js");
|
||||
await clearCodexAppServerBinding(params.sessionFile);
|
||||
await clearCodexAppServerBinding({
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
}
|
||||
},
|
||||
dispose: async () => {
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
},
|
||||
"marketplaceName": {
|
||||
"type": "string",
|
||||
"enum": ["openai-curated"]
|
||||
"enum": ["openai-curated", "openai-bundled", "openai-primary-runtime"]
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
|
||||
@@ -14,7 +14,10 @@ import { resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type { CodexDynamicToolSpec, JsonValue } from "./protocol.js";
|
||||
import { isJsonObject } from "./protocol.js";
|
||||
import type { CodexAppServerThreadBinding } from "./session-binding.js";
|
||||
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
|
||||
import {
|
||||
readCodexMirroredSessionHistoryMessages,
|
||||
type CodexMirroredSessionHistoryScope,
|
||||
} from "./session-history.js";
|
||||
import {
|
||||
areCodexDynamicToolFingerprintsCompatible,
|
||||
buildContextEngineBinding,
|
||||
@@ -70,12 +73,12 @@ type CodexWorkspaceBootstrapContext = CodexBootstrapContext & {
|
||||
};
|
||||
|
||||
export async function readMirroredSessionHistoryMessages(
|
||||
sessionFile: string,
|
||||
transcriptScope: CodexMirroredSessionHistoryScope,
|
||||
): Promise<AgentMessage[] | undefined> {
|
||||
const messages = await readCodexMirroredSessionHistoryMessages(sessionFile);
|
||||
const messages = await readCodexMirroredSessionHistoryMessages(transcriptScope);
|
||||
if (!messages) {
|
||||
embeddedAgentLog.warn("failed to read mirrored session history for codex harness hooks", {
|
||||
sessionFile,
|
||||
transcriptScope,
|
||||
});
|
||||
}
|
||||
return messages;
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import {
|
||||
clearRuntimeAuthProfileStoreSnapshots,
|
||||
loadAuthProfileStoreForSecretsRuntime,
|
||||
replaceRuntimeAuthProfileStoreSnapshots,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { upsertAuthProfile } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -914,14 +915,20 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
|
||||
try {
|
||||
upsertAuthProfile({
|
||||
agentDir,
|
||||
profileId: "openai:aws",
|
||||
credential: {
|
||||
type: "aws-sdk",
|
||||
provider: "openai",
|
||||
} as never,
|
||||
});
|
||||
replaceRuntimeAuthProfileStoreSnapshots([
|
||||
{
|
||||
agentDir,
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:aws": {
|
||||
type: "aws-sdk",
|
||||
provider: "openai",
|
||||
} as never,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
applyCodexAppServerAuthProfile({
|
||||
@@ -1407,11 +1414,10 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes inherited main Codex OAuth without cloning it into the child store", async () => {
|
||||
it("refreshes inherited main Codex OAuth through the owner store", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const stateDir = path.join(root, "state");
|
||||
const childAgentDir = path.join(stateDir, "agents", "worker", "agent");
|
||||
const childAuthPath = path.join(childAgentDir, "auth-profiles.json");
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
vi.stubEnv("OPENCLAW_AGENT_DIR", "");
|
||||
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
|
||||
@@ -1446,7 +1452,6 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
});
|
||||
|
||||
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("main-refresh-token");
|
||||
await expectPathMissing(childAuthPath);
|
||||
const mainProfile = expectOAuthProfile(
|
||||
loadAuthProfileStoreForSecretsRuntime().profiles["openai:work"],
|
||||
);
|
||||
@@ -1462,7 +1467,6 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const stateDir = path.join(root, "state");
|
||||
const childAgentDir = path.join(stateDir, "agents", "worker", "agent");
|
||||
const childAuthPath = path.join(childAgentDir, "auth-profiles.json");
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
vi.stubEnv("OPENCLAW_AGENT_DIR", "");
|
||||
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
|
||||
@@ -1484,24 +1488,19 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
email: "main-codex@example.test",
|
||||
},
|
||||
});
|
||||
await fs.mkdir(childAgentDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
childAuthPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:work": {
|
||||
type: "oauth",
|
||||
provider: "openai",
|
||||
access: "child-stale-access-token",
|
||||
refresh: "child-stale-refresh-token",
|
||||
expires: Date.now() - 60_000,
|
||||
accountId: "account-main",
|
||||
email: "main-codex@example.test",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
upsertAuthProfile({
|
||||
agentDir: childAgentDir,
|
||||
profileId: "openai:work",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "openai",
|
||||
access: "child-stale-access-token",
|
||||
refresh: "child-stale-refresh-token",
|
||||
expires: Date.now() - 60_000,
|
||||
accountId: "account-main",
|
||||
email: "main-codex@example.test",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
refreshCodexAppServerAuthTokens({
|
||||
@@ -1524,8 +1523,8 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
const childProfile = expectOAuthProfile(
|
||||
loadAuthProfileStoreForSecretsRuntime(childAgentDir).profiles["openai:work"],
|
||||
);
|
||||
expect(childProfile?.access).toBe("child-stale-access-token");
|
||||
expect(childProfile?.refresh).toBe("child-stale-refresh-token");
|
||||
expect(childProfile?.access).toBe("main-refreshed-access-token");
|
||||
expect(childProfile?.refresh).toBe("main-refreshed-refresh-token");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type AuthProfileStore,
|
||||
type OAuthCredential,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { updateAuthProfileStoreWithLock } from "openclaw/plugin-sdk/provider-auth";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import type { CodexAppServerStartOptions } from "./config.js";
|
||||
import type {
|
||||
@@ -595,7 +596,49 @@ async function resolveOAuthCredentialForCodexAppServer(
|
||||
isCodexAppServerAuthProvider(storedCredential.provider, params.config)
|
||||
? storedCredential
|
||||
: credential;
|
||||
return resolved?.apiKey ? { ...candidate, access: resolved.apiKey } : candidate;
|
||||
const resolvedCredential = resolved?.apiKey
|
||||
? { ...candidate, access: resolved.apiKey }
|
||||
: candidate;
|
||||
if (params.forceRefresh) {
|
||||
await mirrorOwnerRefreshToLocalOAuthClone({
|
||||
agentDir: params.agentDir,
|
||||
ownerAgentDir,
|
||||
profileId,
|
||||
credential: resolvedCredential,
|
||||
config: params.config,
|
||||
});
|
||||
}
|
||||
return resolvedCredential;
|
||||
}
|
||||
|
||||
async function mirrorOwnerRefreshToLocalOAuthClone(params: {
|
||||
agentDir: string;
|
||||
ownerAgentDir?: string;
|
||||
profileId: string;
|
||||
credential: OAuthCredential;
|
||||
config?: AuthProfileOrderConfig;
|
||||
}): Promise<void> {
|
||||
if (params.ownerAgentDir === params.agentDir) {
|
||||
return;
|
||||
}
|
||||
// Only rewrite an existing local clone. Missing child rows should keep
|
||||
// inheriting the owner profile instead of materializing a new copy.
|
||||
await updateAuthProfileStoreWithLock({
|
||||
agentDir: params.agentDir,
|
||||
saveOptions: {
|
||||
filterExternalAuthProfiles: false,
|
||||
forceLocalProfileIds: [params.profileId],
|
||||
syncExternalCli: false,
|
||||
},
|
||||
updater: (store) => {
|
||||
const local = store.profiles[params.profileId];
|
||||
if (local?.type !== "oauth" || !isCodexAppServerAuthProvider(local.provider, params.config)) {
|
||||
return false;
|
||||
}
|
||||
store.profiles[params.profileId] = { ...params.credential };
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isCodexAppServerAuthProvider(provider: string, config?: AuthProfileOrderConfig): boolean {
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness";
|
||||
import { AUTH_PROFILE_RUNTIME_CONTRACT } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
} from "openclaw/plugin-sdk/sqlite-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl } from "./run-attempt.js";
|
||||
@@ -37,12 +41,15 @@ function runCodexAppServerAttempt(
|
||||
);
|
||||
}
|
||||
|
||||
function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
|
||||
function testSessionId(suffix: string = AUTH_PROFILE_RUNTIME_CONTRACT.sessionId): string {
|
||||
return suffix;
|
||||
}
|
||||
|
||||
function createParams(sessionId: string, workspaceDir: string): EmbeddedRunAttemptParams {
|
||||
return {
|
||||
prompt: AUTH_PROFILE_RUNTIME_CONTRACT.workspacePrompt,
|
||||
sessionId: AUTH_PROFILE_RUNTIME_CONTRACT.sessionId,
|
||||
sessionKey: AUTH_PROFILE_RUNTIME_CONTRACT.sessionKey,
|
||||
sessionFile,
|
||||
sessionKey: `agent:main:${sessionId}`,
|
||||
sessionId,
|
||||
workspaceDir,
|
||||
runId: AUTH_PROFILE_RUNTIME_CONTRACT.runId,
|
||||
provider: AUTH_PROFILE_RUNTIME_CONTRACT.codexHarnessProvider,
|
||||
@@ -158,18 +165,22 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-auth-contract-"));
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", tmpDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
abortAgentHarnessRun(AUTH_PROFILE_RUNTIME_CONTRACT.sessionId);
|
||||
resetCodexAppServerClientFactoryForTest();
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
vi.unstubAllEnvs();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("passes the exact OpenAI Codex auth profile into app-server startup", async () => {
|
||||
const harness = createCodexAuthProfileHarness({ startMethod: "thread/start" });
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
const params = createParams(sessionFile, tmpDir);
|
||||
const sessionId = testSessionId();
|
||||
const params = createParams(sessionId, tmpDir);
|
||||
params.authProfileId = AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId;
|
||||
params.agentDir = tmpDir;
|
||||
|
||||
@@ -189,15 +200,18 @@ 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");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-auth-contract",
|
||||
cwd: tmpDir,
|
||||
authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
|
||||
dynamicToolsFingerprint: "[]",
|
||||
});
|
||||
const sessionId = testSessionId("auth-profile-resume");
|
||||
await writeCodexAppServerBinding(
|
||||
{ sessionKey: `agent:main:${sessionId}`, sessionId },
|
||||
{
|
||||
threadId: "thread-auth-contract",
|
||||
cwd: tmpDir,
|
||||
authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
|
||||
dynamicToolsFingerprint: "[]",
|
||||
},
|
||||
);
|
||||
// authProfileId is intentionally omitted to exercise the resume-bound profile path.
|
||||
const params = createParams(sessionFile, tmpDir);
|
||||
const params = createParams(sessionId, tmpDir);
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await vi.waitFor(
|
||||
@@ -214,14 +228,17 @@ 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");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-auth-contract",
|
||||
cwd: tmpDir,
|
||||
authProfileId: "openai:stale",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
});
|
||||
const params = createParams(sessionFile, tmpDir);
|
||||
const sessionId = testSessionId("auth-profile-abort");
|
||||
await writeCodexAppServerBinding(
|
||||
{ sessionKey: `agent:main:${sessionId}`, sessionId },
|
||||
{
|
||||
threadId: "thread-auth-contract",
|
||||
cwd: tmpDir,
|
||||
authProfileId: "openai:stale",
|
||||
dynamicToolsFingerprint: "[]",
|
||||
},
|
||||
);
|
||||
const params = createParams(sessionId, tmpDir);
|
||||
params.authProfileId = AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
@@ -236,7 +253,10 @@ describe("Auth profile runtime contract - Codex app-server adapter", () => {
|
||||
await harness.completeTurn();
|
||||
await run;
|
||||
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.authProfileId).toBe(AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId);
|
||||
await expect(
|
||||
readCodexAppServerBinding({ sessionKey: params.sessionKey, sessionId }),
|
||||
).resolves.toMatchObject({
|
||||
authProfileId: AUTH_PROFILE_RUNTIME_CONTRACT.openAiCodexProfileId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,10 +10,16 @@ import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import { maybeCompactCodexAppServerSession as maybeCompactCodexAppServerSessionImpl } from "./compact.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import {
|
||||
readCodexAppServerBinding,
|
||||
writeCodexAppServerBinding,
|
||||
type CodexAppServerThreadBinding,
|
||||
} from "./session-binding.js";
|
||||
|
||||
let tempDir: string;
|
||||
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
|
||||
const testSessionKey = "agent:main:session-1";
|
||||
const testSessionId = "session-1";
|
||||
|
||||
type MaybeCompactOptions = NonNullable<Parameters<typeof maybeCompactCodexAppServerSessionImpl>[1]>;
|
||||
|
||||
@@ -37,44 +43,47 @@ function maybeCompactCodexAppServerSession(
|
||||
}
|
||||
|
||||
async function writeTestBinding(
|
||||
options: Partial<Parameters<typeof writeCodexAppServerBinding>[1]> = {},
|
||||
options: Partial<CodexAppServerThreadBinding> = {},
|
||||
): Promise<string> {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-1",
|
||||
cwd: tempDir,
|
||||
...options,
|
||||
});
|
||||
return sessionFile;
|
||||
await writeCodexAppServerBinding(
|
||||
{
|
||||
sessionKey: testSessionKey,
|
||||
sessionId: testSessionId,
|
||||
},
|
||||
{
|
||||
threadId: "thread-1",
|
||||
cwd: tempDir,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
return testSessionId;
|
||||
}
|
||||
|
||||
function startCompaction(sessionFile: string, options: { currentTokenCount?: number } = {}) {
|
||||
function startCompaction(sessionId: string, options: { currentTokenCount?: number } = {}) {
|
||||
return maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
sessionId,
|
||||
sessionKey: testSessionKey,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function startSandboxedCompaction(sessionFile: string) {
|
||||
function startSandboxedCompaction(sessionId: string) {
|
||||
return maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
sessionId,
|
||||
sessionKey: testSessionKey,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
config: { agents: { defaults: { sandbox: { mode: "all" } } } },
|
||||
});
|
||||
}
|
||||
|
||||
function startNodeExecCompaction(sessionFile: string) {
|
||||
function startNodeExecCompaction(path: string) {
|
||||
return maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
path,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
config: { tools: { exec: { host: "node", node: "worker-1" } } },
|
||||
@@ -135,7 +144,7 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
await maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
path: sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "budget",
|
||||
currentTokenCount: 456,
|
||||
@@ -164,7 +173,7 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
await maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
path: sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
currentTokenCount: 789,
|
||||
}),
|
||||
@@ -260,10 +269,8 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
});
|
||||
|
||||
it("reports missing thread bindings as failed native compaction", async () => {
|
||||
const sessionFile = path.join(tempDir, "missing-binding.jsonl");
|
||||
|
||||
const result = requireCompactResult(
|
||||
await startCompaction(sessionFile, { currentTokenCount: 123 }),
|
||||
await startCompaction("missing-binding", { currentTokenCount: 123 }),
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
@@ -336,7 +343,7 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
await maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
path: sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
config: {
|
||||
@@ -372,7 +379,7 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
await maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:sara:session-1",
|
||||
sessionFile,
|
||||
path: sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
config: {
|
||||
@@ -414,7 +421,7 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
await maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:nik:session-1",
|
||||
sessionFile,
|
||||
path: sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
config: {
|
||||
@@ -463,7 +470,7 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
await maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:lossless:session-1",
|
||||
sessionFile,
|
||||
path: sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
contextEngine,
|
||||
@@ -487,7 +494,15 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
|
||||
expect(fake.request).not.toHaveBeenCalled();
|
||||
expect(contextEngine.compact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:lossless:session-1",
|
||||
compactionTarget: "threshold",
|
||||
force: true,
|
||||
}),
|
||||
);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"ignoring OpenClaw compaction overrides for Codex app-server compaction; Codex uses native server-side compaction",
|
||||
{
|
||||
@@ -517,7 +532,7 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
await maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:lossless-child:session-1",
|
||||
sessionFile,
|
||||
path: sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
contextEngine,
|
||||
@@ -545,7 +560,15 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
|
||||
expect(fake.request).not.toHaveBeenCalled();
|
||||
expect(contextEngine.compact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:lossless-child:session-1",
|
||||
compactionTarget: "threshold",
|
||||
force: true,
|
||||
}),
|
||||
);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"ignoring OpenClaw compaction overrides for Codex app-server compaction; Codex uses native server-side compaction",
|
||||
{
|
||||
@@ -564,17 +587,18 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
const fake = createFakeCodexClient();
|
||||
const factory = vi.fn(async () => fake.client);
|
||||
setCodexAppServerClientFactoryForTest(factory);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-1",
|
||||
cwd: tempDir,
|
||||
authProfileId: "openai:binding",
|
||||
});
|
||||
await writeCodexAppServerBinding(
|
||||
{ sessionKey: testSessionKey, sessionId: testSessionId },
|
||||
{
|
||||
threadId: "thread-1",
|
||||
cwd: tempDir,
|
||||
authProfileId: "openai:binding",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
sessionId: testSessionId,
|
||||
sessionKey: testSessionKey,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
authProfileId: "openai:runtime",
|
||||
@@ -588,7 +612,7 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
expect(factory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards compaction to native Codex even when a context engine owns compaction", async () => {
|
||||
it("forwards compaction to native Codex when the context engine does not own compaction", async () => {
|
||||
const fake = createFakeCodexClient();
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
const sessionFile = await writeTestBinding();
|
||||
@@ -608,6 +632,61 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
rewrittenEntries: 0,
|
||||
}),
|
||||
);
|
||||
const contextEngine: ContextEngine = {
|
||||
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: false },
|
||||
assemble: vi.fn() as never,
|
||||
ingest: vi.fn() as never,
|
||||
compact,
|
||||
maintain,
|
||||
};
|
||||
|
||||
const result = requireCompactResult(
|
||||
await maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
path: sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
contextEngine,
|
||||
contextEngineRuntimeContext: { workspaceDir: tempDir, provider: "codex" },
|
||||
currentTokenCount: 123,
|
||||
trigger: "manual",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.compacted).toBe(false);
|
||||
expect(compactDetails(result)).toMatchObject({
|
||||
backend: "codex-app-server",
|
||||
threadId: "thread-1",
|
||||
signal: "thread/compact/start",
|
||||
pending: true,
|
||||
});
|
||||
expect(compact).not.toHaveBeenCalled();
|
||||
expect(maintain).not.toHaveBeenCalled();
|
||||
expect(await readCodexAppServerBinding(sessionFile)).toMatchObject({
|
||||
threadId: "thread-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("delegates to an owning context engine without requiring a Codex binding", async () => {
|
||||
const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined);
|
||||
const compact = vi.fn(async () => ({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
summary: "engine summary",
|
||||
firstKeptEntryId: "entry-1",
|
||||
tokensBefore: 123,
|
||||
},
|
||||
}));
|
||||
const maintain = vi.fn(
|
||||
async (_params: Parameters<NonNullable<ContextEngine["maintain"]>>[0]) => ({
|
||||
changed: false,
|
||||
bytesFreed: 0,
|
||||
rewrittenEntries: 0,
|
||||
}),
|
||||
);
|
||||
const contextEngine: ContextEngine = {
|
||||
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
|
||||
assemble: vi.fn() as never,
|
||||
@@ -620,39 +699,94 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
await maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
path: path.join(tempDir, "missing-binding.jsonl"),
|
||||
workspaceDir: tempDir,
|
||||
contextEngine,
|
||||
contextEngineRuntimeContext: { workspaceDir: tempDir, provider: "codex" },
|
||||
contextTokenBudget: 777,
|
||||
currentTokenCount: 123,
|
||||
trigger: "manual",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.compacted).toBe(false);
|
||||
expect(compactDetails(result)).toMatchObject({
|
||||
backend: "codex-app-server",
|
||||
threadId: "thread-1",
|
||||
signal: "thread/compact/start",
|
||||
pending: true,
|
||||
});
|
||||
expect(compact).not.toHaveBeenCalled();
|
||||
expect(maintain).not.toHaveBeenCalled();
|
||||
expect(await readCodexAppServerBinding(sessionFile)).toMatchObject({
|
||||
threadId: "thread-1",
|
||||
});
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.result?.summary).toBe("engine summary");
|
||||
expect(result.result?.firstKeptEntryId).toBe("entry-1");
|
||||
expect(result.result?.tokensBefore).toBe(123);
|
||||
const details = compactDetails(result);
|
||||
expect(details.engine).toBe("lossless-claw");
|
||||
expect(details.codexThreadBindingInvalidated).toBe(true);
|
||||
expect(
|
||||
await readCodexAppServerBinding({
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionId: "session-1",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
expect(compact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
transcriptScope: expect.objectContaining({ agentId: "main", sessionId: "session-1" }),
|
||||
tokenBudget: 777,
|
||||
currentTokenCount: 123,
|
||||
compactionTarget: "threshold",
|
||||
customInstructions: undefined,
|
||||
force: true,
|
||||
runtimeContext: { workspaceDir: tempDir, provider: "codex" },
|
||||
abortSignal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
expect(maintain).toHaveBeenCalledTimes(1);
|
||||
const [maintainCall] = maintain.mock.calls[0] ?? [];
|
||||
const maintainParams = maintainCall as
|
||||
| {
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
runtimeContext?: { workspaceDir?: string; provider?: string };
|
||||
}
|
||||
| undefined;
|
||||
expect(maintainParams?.sessionId).toBe("session-1");
|
||||
expect(maintainParams?.sessionKey).toBe("agent:main:session-1");
|
||||
expect(maintainParams?.runtimeContext?.workspaceDir).toBe(tempDir);
|
||||
expect(maintainParams?.runtimeContext?.provider).toBe("codex");
|
||||
expect(info).toHaveBeenCalledWith(
|
||||
"starting context-engine-owned Codex app-server compaction",
|
||||
expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
engineId: "lossless-claw",
|
||||
tokenBudget: 777,
|
||||
currentTokenCount: 123,
|
||||
trigger: "manual",
|
||||
compactionTarget: "threshold",
|
||||
force: true,
|
||||
}),
|
||||
);
|
||||
expect(info).toHaveBeenCalledWith(
|
||||
"completed context-engine-owned Codex app-server compaction",
|
||||
expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
engineId: "lossless-claw",
|
||||
ok: true,
|
||||
compacted: true,
|
||||
codexThreadBindingInvalidated: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a Codex binding instead of delegating to an owning context engine", async () => {
|
||||
it("honors explicit force for budget-triggered owning context-engine compaction", async () => {
|
||||
const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined);
|
||||
const sessionFile = await writeTestBinding();
|
||||
const compact = vi.fn(async () => ({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
summary: "engine summary",
|
||||
firstKeptEntryId: "entry-1",
|
||||
tokensBefore: 123,
|
||||
tokensBefore: 900,
|
||||
tokensAfter: 100,
|
||||
},
|
||||
}));
|
||||
const contextEngine: ContextEngine = {
|
||||
@@ -662,21 +796,291 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
compact,
|
||||
};
|
||||
|
||||
const result = requireCompactResult(
|
||||
await maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
path: sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
contextEngine,
|
||||
contextTokenBudget: 777,
|
||||
currentTokenCount: 900,
|
||||
trigger: "budget",
|
||||
force: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(compact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
compactionTarget: "budget",
|
||||
force: true,
|
||||
}),
|
||||
);
|
||||
expect(info).toHaveBeenCalledWith(
|
||||
"starting context-engine-owned Codex app-server compaction",
|
||||
expect.objectContaining({
|
||||
trigger: "budget",
|
||||
compactionTarget: "budget",
|
||||
force: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("adopts successor session id after owning context-engine compaction", async () => {
|
||||
const sessionFile = await writeTestBinding();
|
||||
const compact = vi.fn(async () => ({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
summary: "engine summary",
|
||||
firstKeptEntryId: "entry-1",
|
||||
tokensBefore: 55,
|
||||
sessionId: "session-1-compacted",
|
||||
},
|
||||
}));
|
||||
const maintain = vi.fn(
|
||||
async (_params: Parameters<NonNullable<ContextEngine["maintain"]>>[0]) => ({
|
||||
changed: false,
|
||||
bytesFreed: 0,
|
||||
rewrittenEntries: 0,
|
||||
}),
|
||||
);
|
||||
const contextEngine: ContextEngine = {
|
||||
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
|
||||
assemble: vi.fn() as never,
|
||||
ingest: vi.fn() as never,
|
||||
compact,
|
||||
maintain,
|
||||
};
|
||||
|
||||
const result = requireCompactResult(
|
||||
await maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
path: sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
contextEngine,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.result?.sessionId).toBe("session-1-compacted");
|
||||
expect(await readCodexAppServerBinding(sessionFile)).toBeUndefined();
|
||||
expect(maintain).toHaveBeenCalledTimes(1);
|
||||
const [maintainCall] = maintain.mock.calls[0] ?? [];
|
||||
const maintainParams = maintainCall as
|
||||
| {
|
||||
sessionId?: string;
|
||||
}
|
||||
| undefined;
|
||||
expect(maintainParams?.sessionId).toBe("session-1-compacted");
|
||||
});
|
||||
|
||||
it("returns context-engine compaction success when maintenance fails", async () => {
|
||||
const sessionFile = await writeTestBinding();
|
||||
const compact = vi.fn(async () => ({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
summary: "engine summary",
|
||||
firstKeptEntryId: "entry-1",
|
||||
tokensBefore: 55,
|
||||
},
|
||||
}));
|
||||
const contextEngine: ContextEngine = {
|
||||
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
|
||||
assemble: vi.fn() as never,
|
||||
ingest: vi.fn() as never,
|
||||
compact,
|
||||
maintain: vi.fn(async () => {
|
||||
throw new Error("maintenance boom");
|
||||
}),
|
||||
};
|
||||
|
||||
const pendingResult = maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
path: sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
contextEngine,
|
||||
});
|
||||
|
||||
const result = requireCompactResult(await pendingResult);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(result.result?.summary).toBe("engine summary");
|
||||
const details = compactDetails(result);
|
||||
expect(details.codexThreadBindingInvalidated).toBe(true);
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not require a Codex binding when the owning context engine compacts", async () => {
|
||||
const compact = vi.fn(async () => ({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
summary: "engine summary",
|
||||
firstKeptEntryId: "entry-1",
|
||||
tokensBefore: 8,
|
||||
},
|
||||
}));
|
||||
const maintain = vi.fn(async () => ({
|
||||
changed: false,
|
||||
bytesFreed: 0,
|
||||
rewrittenEntries: 0,
|
||||
}));
|
||||
const contextEngine: ContextEngine = {
|
||||
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
|
||||
assemble: vi.fn() as never,
|
||||
ingest: vi.fn() as never,
|
||||
compact,
|
||||
maintain,
|
||||
};
|
||||
|
||||
const result = await maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile: path.join(tempDir, "missing-binding.jsonl"),
|
||||
path: path.join(tempDir, "missing-binding.jsonl"),
|
||||
workspaceDir: tempDir,
|
||||
contextEngine,
|
||||
trigger: "manual",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
failure: { reason: "missing_thread_binding" },
|
||||
const compactResult = requireCompactResult(result);
|
||||
expect(compactResult.ok).toBe(true);
|
||||
expect(compactResult.compacted).toBe(true);
|
||||
expect(compactResult.result?.summary).toBe("engine summary");
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
expect(maintain).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not run context-engine maintenance when owning compaction does not compact", async () => {
|
||||
const maintain = vi.fn(async () => ({
|
||||
changed: false,
|
||||
bytesFreed: 0,
|
||||
rewrittenEntries: 0,
|
||||
}));
|
||||
const contextEngine: ContextEngine = {
|
||||
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
|
||||
assemble: vi.fn() as never,
|
||||
ingest: vi.fn() as never,
|
||||
compact: vi.fn(async () => ({
|
||||
ok: true,
|
||||
compacted: false,
|
||||
reason: "below threshold",
|
||||
})),
|
||||
maintain,
|
||||
};
|
||||
|
||||
const result = await maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
path: path.join(tempDir, "missing-binding.jsonl"),
|
||||
workspaceDir: tempDir,
|
||||
contextEngine,
|
||||
});
|
||||
|
||||
const compactResult = requireCompactResult(result);
|
||||
expect(compactResult.ok).toBe(true);
|
||||
expect(compactResult.compacted).toBe(false);
|
||||
expect(compactResult.reason).toBe("below threshold");
|
||||
expect(maintain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("owning context-engine compaction safety timeout", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("bounds a hung owning context-engine compact() and reports a clean ok:false", async () => {
|
||||
const sessionFile = await writeTestBinding();
|
||||
const compact = vi.fn<ContextEngine["compact"]>(() => new Promise(() => {}));
|
||||
const contextEngine: ContextEngine = {
|
||||
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
|
||||
assemble: vi.fn() as never,
|
||||
ingest: vi.fn() as never,
|
||||
compact,
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
const pendingResult = maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
path: sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
contextEngine,
|
||||
// 1 s host-resolved compaction timeout.
|
||||
config: { agents: { defaults: { compaction: { timeoutSeconds: 1 } } } },
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
const result = requireCompactResult(await pendingResult);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.compacted).toBe(false);
|
||||
expect(result.reason).toContain("timed out");
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("threads a composed caller abort signal into the owning context-engine compact()", async () => {
|
||||
const sessionFile = await writeTestBinding();
|
||||
const controller = new AbortController();
|
||||
const compact = vi.fn<ContextEngine["compact"]>(async () => ({
|
||||
ok: true,
|
||||
compacted: false,
|
||||
reason: "below threshold",
|
||||
}));
|
||||
const contextEngine: ContextEngine = {
|
||||
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
|
||||
assemble: vi.fn() as never,
|
||||
ingest: vi.fn() as never,
|
||||
compact,
|
||||
};
|
||||
|
||||
await maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
path: sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
contextEngine,
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
expect(compact.mock.calls[0]?.[0]?.abortSignal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("aborts a hung owning context-engine compact() when the caller signal fires", async () => {
|
||||
const sessionFile = await writeTestBinding();
|
||||
const controller = new AbortController();
|
||||
const compact = vi.fn<ContextEngine["compact"]>(() => new Promise(() => {}));
|
||||
const contextEngine: ContextEngine = {
|
||||
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
|
||||
assemble: vi.fn() as never,
|
||||
ingest: vi.fn() as never,
|
||||
compact,
|
||||
};
|
||||
|
||||
const pendingResult = maybeCompactCodexAppServerSession({
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
path: sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
contextEngine,
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
controller.abort(new Error("run aborted"));
|
||||
const result = requireCompactResult(await pendingResult);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.compacted).toBe(false);
|
||||
expect(result.reason).toContain("run aborted");
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(compact).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import {
|
||||
compactContextEngineWithSafetyTimeout,
|
||||
embeddedAgentLog,
|
||||
formatErrorMessage,
|
||||
resolveCompactionTimeoutMs,
|
||||
runHarnessContextEngineMaintenance,
|
||||
type CompactEmbeddedAgentSessionParams,
|
||||
type EmbeddedAgentCompactResult,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
@@ -10,7 +14,11 @@ import {
|
||||
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
import type { JsonObject } from "./protocol.js";
|
||||
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
|
||||
import { readCodexAppServerBinding } from "./session-binding.js";
|
||||
import {
|
||||
clearCodexAppServerBinding,
|
||||
readCodexAppServerBinding,
|
||||
type CodexAppServerBindingIdentity,
|
||||
} from "./session-binding.js";
|
||||
|
||||
const warnedIgnoredCompactionOverrides = new Set<string>();
|
||||
|
||||
@@ -19,12 +27,145 @@ export async function maybeCompactCodexAppServerSession(
|
||||
options: { pluginConfig?: unknown; clientFactory?: CodexAppServerClientFactory } = {},
|
||||
): 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.
|
||||
if (params.contextEngine?.info.ownsCompaction === true) {
|
||||
return compactOwningContextEngine(params, params.contextEngine);
|
||||
}
|
||||
return compactCodexNativeThread(params, options);
|
||||
}
|
||||
|
||||
async function compactOwningContextEngine(
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
contextEngine: NonNullable<CompactEmbeddedAgentSessionParams["contextEngine"]>,
|
||||
): Promise<EmbeddedAgentCompactResult> {
|
||||
const compactionTarget = params.trigger === "manual" ? "threshold" : "budget";
|
||||
const force = params.force === true || params.trigger === "manual";
|
||||
embeddedAgentLog.info("starting context-engine-owned Codex app-server compaction", {
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
engineId: contextEngine.info.id,
|
||||
tokenBudget: params.contextTokenBudget,
|
||||
currentTokenCount: params.currentTokenCount,
|
||||
trigger: params.trigger,
|
||||
compactionTarget,
|
||||
force,
|
||||
});
|
||||
let result: Awaited<ReturnType<typeof contextEngine.compact>>;
|
||||
try {
|
||||
result = await compactContextEngineWithSafetyTimeout(
|
||||
contextEngine,
|
||||
{
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
transcriptScope: buildContextEngineTranscriptScope(params),
|
||||
tokenBudget: params.contextTokenBudget,
|
||||
currentTokenCount: params.currentTokenCount,
|
||||
compactionTarget,
|
||||
customInstructions: params.customInstructions,
|
||||
force,
|
||||
runtimeContext: params.contextEngineRuntimeContext,
|
||||
},
|
||||
resolveCompactionTimeoutMs(params.config),
|
||||
params.abortSignal,
|
||||
);
|
||||
} catch (error) {
|
||||
embeddedAgentLog.warn("context-engine-owned Codex app-server compaction failed", {
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
engineId: contextEngine.info.id,
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: `context engine compaction failed: ${formatErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.ok && result.compacted) {
|
||||
const compactedSessionId = result.result?.sessionId ?? params.sessionId;
|
||||
try {
|
||||
await runHarnessContextEngineMaintenance({
|
||||
contextEngine,
|
||||
sessionId: compactedSessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
transcriptScope: buildContextEngineTranscriptScope({
|
||||
...params,
|
||||
sessionId: compactedSessionId,
|
||||
}),
|
||||
reason: "compaction",
|
||||
runtimeContext: params.contextEngineRuntimeContext,
|
||||
config: params.config,
|
||||
});
|
||||
} catch (error) {
|
||||
embeddedAgentLog.warn("context engine compaction maintenance failed", {
|
||||
sessionId: compactedSessionId,
|
||||
engineId: contextEngine.info.id,
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
}
|
||||
await clearCodexAppServerBinding({
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
embeddedAgentLog.info("completed context-engine-owned Codex app-server compaction", {
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
engineId: contextEngine.info.id,
|
||||
ok: result.ok,
|
||||
compacted: result.compacted,
|
||||
reason: result.reason,
|
||||
codexThreadBindingInvalidated: result.ok && result.compacted,
|
||||
});
|
||||
return {
|
||||
ok: result.ok,
|
||||
compacted: result.compacted,
|
||||
reason: result.reason,
|
||||
result: result.result
|
||||
? {
|
||||
...result.result,
|
||||
summary: result.result.summary ?? "",
|
||||
firstKeptEntryId: result.result.firstKeptEntryId ?? "",
|
||||
details: mergeContextEngineCompactionDetails(result.result.details, {
|
||||
engine: contextEngine.info.id,
|
||||
codexThreadBindingInvalidated: result.ok && result.compacted,
|
||||
}),
|
||||
}
|
||||
: result.ok && result.compacted
|
||||
? {
|
||||
summary: "",
|
||||
firstKeptEntryId: "",
|
||||
tokensBefore: params.currentTokenCount ?? 0,
|
||||
details: { engine: contextEngine.info.id, codexThreadBindingInvalidated: true },
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeContextEngineCompactionDetails(
|
||||
details: unknown,
|
||||
extra: Record<string, unknown>,
|
||||
): unknown {
|
||||
if (details && typeof details === "object" && !Array.isArray(details)) {
|
||||
return {
|
||||
...(details as Record<string, unknown>),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
return extra;
|
||||
}
|
||||
|
||||
function buildContextEngineTranscriptScope(
|
||||
params: Pick<CompactEmbeddedAgentSessionParams, "agentId" | "path" | "sessionId">,
|
||||
): { agentId: string; path?: string; sessionId: string } {
|
||||
return {
|
||||
agentId: params.agentId ?? "main",
|
||||
...(params.path ? { path: params.path } : {}),
|
||||
sessionId: params.sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
function warnIfIgnoringOpenClawCompactionOverrides(
|
||||
params: CompactEmbeddedAgentSessionParams,
|
||||
): void {
|
||||
@@ -161,7 +302,11 @@ async function compactCodexNativeThread(
|
||||
return { ok: false, compacted: false, reason: nativeExecutionBlock };
|
||||
}
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
|
||||
const binding = await readCodexAppServerBinding(params.sessionFile, { config: params.config });
|
||||
const bindingIdentity: CodexAppServerBindingIdentity = {
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
};
|
||||
const binding = await readCodexAppServerBinding(bindingIdentity, { config: params.config });
|
||||
if (!binding?.threadId) {
|
||||
return failedCodexThreadBindingCompactionResult(params, {
|
||||
reason: "no codex app-server thread binding",
|
||||
|
||||
@@ -653,6 +653,59 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
expect(resolveCodexPluginsPolicy(config).pluginPolicies).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("accepts native plugin identities from every first-party OpenAI marketplace", () => {
|
||||
// OpenAI ships first-party Codex plugins across three marketplaces: the local
|
||||
// openai-bundled marketplace shipped with Codex.app (chrome, browser, computer-use,
|
||||
// latex-tectonic), the remote openai-curated marketplace, and the
|
||||
// openai-primary-runtime marketplace owned by the Codex primary runtime
|
||||
// (documents, spreadsheets, presentations). All three should resolve.
|
||||
const config = readCodexPluginConfig({
|
||||
codexPlugins: {
|
||||
enabled: true,
|
||||
plugins: {
|
||||
chrome: {
|
||||
marketplaceName: "openai-bundled",
|
||||
pluginName: "chrome",
|
||||
},
|
||||
"google-calendar": {
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "google-calendar",
|
||||
},
|
||||
documents: {
|
||||
marketplaceName: "openai-primary-runtime",
|
||||
pluginName: "documents",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.codexPlugins?.enabled).toBe(true);
|
||||
const policy = resolveCodexPluginsPolicy(config);
|
||||
expect(policy.pluginPolicies).toEqual([
|
||||
{
|
||||
configKey: "chrome",
|
||||
marketplaceName: "openai-bundled",
|
||||
pluginName: "chrome",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
},
|
||||
{
|
||||
configKey: "documents",
|
||||
marketplaceName: "openai-primary-runtime",
|
||||
pluginName: "documents",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
},
|
||||
{
|
||||
configKey: "google-calendar",
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "google-calendar",
|
||||
enabled: true,
|
||||
allowDestructiveActions: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats configured and environment commands as explicit overrides", () => {
|
||||
expectFields(
|
||||
resolveRuntimeForTest({
|
||||
|
||||
@@ -60,7 +60,30 @@ type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "
|
||||
export type CodexDynamicToolsLoading = "searchable" | "direct";
|
||||
export type CodexPluginDestructivePolicy = boolean;
|
||||
|
||||
export const CODEX_PLUGINS_MARKETPLACE_NAME = "openai-curated";
|
||||
// OpenAI ships first-party Codex plugins across three marketplaces:
|
||||
// - openai-curated: remote curated marketplace, fetched via `codex plugin marketplace add`
|
||||
// - openai-bundled: local marketplace that ships with Codex.app and the Codex CLI
|
||||
// (browser, chrome, computer-use, latex-tectonic)
|
||||
// - openai-primary-runtime: marketplace owned by the Codex primary runtime
|
||||
// (documents, spreadsheets, presentations)
|
||||
// All three are owned by OpenAI. Allow activating plugins from any of them.
|
||||
export const CODEX_PLUGINS_MARKETPLACE_NAMES = [
|
||||
"openai-curated",
|
||||
"openai-bundled",
|
||||
"openai-primary-runtime",
|
||||
] as const;
|
||||
export type CodexPluginsMarketplaceName = (typeof CODEX_PLUGINS_MARKETPLACE_NAMES)[number];
|
||||
|
||||
// Back-compat constant for callers that still reference the curated marketplace by name.
|
||||
export const CODEX_PLUGINS_MARKETPLACE_NAME: CodexPluginsMarketplaceName = "openai-curated";
|
||||
|
||||
export function isCodexPluginsMarketplaceName(
|
||||
name: string | undefined,
|
||||
): name is CodexPluginsMarketplaceName {
|
||||
return (
|
||||
name !== undefined && (CODEX_PLUGINS_MARKETPLACE_NAMES as readonly string[]).includes(name)
|
||||
);
|
||||
}
|
||||
|
||||
export type CodexComputerUseConfig = {
|
||||
enabled?: boolean;
|
||||
@@ -103,7 +126,7 @@ export type CodexAppServerExperimentalConfig = {
|
||||
|
||||
export type ResolvedCodexPluginPolicy = {
|
||||
configKey: string;
|
||||
marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
|
||||
marketplaceName: CodexPluginsMarketplaceName;
|
||||
pluginName: string;
|
||||
enabled: boolean;
|
||||
allowDestructiveActions: CodexPluginDestructivePolicy;
|
||||
@@ -130,7 +153,7 @@ export type CodexAppServerStartOptions = {
|
||||
|
||||
export type CodexAppServerRuntimeOptions = {
|
||||
start: CodexAppServerStartOptions;
|
||||
codeModeOnly: boolean;
|
||||
codeModeOnly?: boolean;
|
||||
requestTimeoutMs: number;
|
||||
turnCompletionIdleTimeoutMs: number;
|
||||
postToolRawAssistantCompletionIdleTimeoutMs?: number;
|
||||
@@ -255,7 +278,7 @@ const codexAppServerExperimentalSchema = z
|
||||
const codexPluginEntryConfigSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
marketplaceName: z.literal(CODEX_PLUGINS_MARKETPLACE_NAME).optional(),
|
||||
marketplaceName: z.enum(CODEX_PLUGINS_MARKETPLACE_NAMES).optional(),
|
||||
pluginName: z.string().trim().min(1).optional(),
|
||||
allow_destructive_actions: z.boolean().optional(),
|
||||
})
|
||||
@@ -365,13 +388,13 @@ export function resolveCodexPluginsPolicy(pluginConfig?: unknown): ResolvedCodex
|
||||
const allowDestructiveActions = config?.allow_destructive_actions ?? true;
|
||||
const pluginPolicies = Object.entries(config?.plugins ?? {})
|
||||
.flatMap(([configKey, entry]): ResolvedCodexPluginPolicy[] => {
|
||||
if (entry.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME || !entry.pluginName) {
|
||||
if (!isCodexPluginsMarketplaceName(entry.marketplaceName) || !entry.pluginName) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
configKey,
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
marketplaceName: entry.marketplaceName,
|
||||
pluginName: entry.pluginName,
|
||||
enabled: enabled && entry.enabled !== false,
|
||||
allowDestructiveActions: entry.allow_destructive_actions ?? allowDestructiveActions,
|
||||
|
||||
@@ -18,13 +18,10 @@ type ProjectorNotification = Parameters<CodexAppServerEventProjector["handleNoti
|
||||
async function createParams(): Promise<EmbeddedRunAttemptParams> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-delivery-contract-"));
|
||||
tempDirs.add(tempDir);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
SessionManager.open(sessionFile);
|
||||
return {
|
||||
prompt: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.prompt,
|
||||
sessionId: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.sessionId,
|
||||
sessionKey: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.sessionKey,
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
runId: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.runId,
|
||||
provider: "codex",
|
||||
|
||||
@@ -36,7 +36,7 @@ function createTool(overrides: Partial<AnyAgentTool>): AnyAgentTool {
|
||||
} as unknown as AnyAgentTool;
|
||||
}
|
||||
|
||||
function mediaResult(mediaUrl: string, audioAsVoice?: boolean): AgentToolResult<unknown> {
|
||||
function mediaResult(mediaUrl: string, audioAsVoice?: boolean): AgentToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: "Generated media reply." }],
|
||||
details: {
|
||||
@@ -48,7 +48,7 @@ function mediaResult(mediaUrl: string, audioAsVoice?: boolean): AgentToolResult<
|
||||
};
|
||||
}
|
||||
|
||||
function textToolResult(text: string, details: unknown = {}): AgentToolResult<unknown> {
|
||||
function textToolResult(text: string, details: unknown = {}): AgentToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details,
|
||||
@@ -57,7 +57,7 @@ function textToolResult(text: string, details: unknown = {}): AgentToolResult<un
|
||||
|
||||
function createBridgeWithToolResult(
|
||||
toolName: string,
|
||||
toolResult: AgentToolResult<unknown>,
|
||||
toolResult: AgentToolResult,
|
||||
hookContext?: Parameters<typeof createCodexDynamicToolBridge>[0]["hookContext"],
|
||||
) {
|
||||
return createCodexDynamicToolBridge({
|
||||
@@ -131,7 +131,7 @@ function expectContextFields(context: unknown, fields: Record<string, unknown>)
|
||||
}
|
||||
}
|
||||
|
||||
function expectToolResult(value: unknown, expected: AgentToolResult<unknown>) {
|
||||
function expectToolResult(value: unknown, expected: AgentToolResult) {
|
||||
const result = requireRecord(value, "tool result");
|
||||
expect(result.content).toEqual(expected.content);
|
||||
expect(result.details).toEqual(expected.details);
|
||||
@@ -610,7 +610,7 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
audioAsVoice: true,
|
||||
},
|
||||
},
|
||||
} satisfies AgentToolResult<unknown>;
|
||||
} satisfies AgentToolResult;
|
||||
const tool = createTool({
|
||||
execute: vi.fn(async () => toolResult),
|
||||
});
|
||||
@@ -640,7 +640,7 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
const toolResult = {
|
||||
content: [{ type: "text", text: "Sent." }],
|
||||
details: { messageId: "message-1" },
|
||||
} satisfies AgentToolResult<unknown>;
|
||||
} satisfies AgentToolResult;
|
||||
const tool = createTool({
|
||||
name: "message",
|
||||
execute: vi.fn(async () => toolResult),
|
||||
@@ -679,7 +679,7 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
const toolResult = {
|
||||
content: [{ type: "text", text: "Sent." }],
|
||||
details: { messageId: "message-1" },
|
||||
} satisfies AgentToolResult<unknown>;
|
||||
} satisfies AgentToolResult;
|
||||
const tool = createTool({
|
||||
name: "message",
|
||||
execute: vi.fn(async () => toolResult),
|
||||
@@ -808,14 +808,12 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
|
||||
it("applies agent tool result middleware from the active plugin registry", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const handler = vi.fn(
|
||||
async (event: { result: AgentToolResult<unknown>; toolName: string }) => ({
|
||||
result: {
|
||||
...event.result,
|
||||
content: [{ type: "text" as const, text: `${event.toolName} compacted` }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const handler = vi.fn(async (event: { result: AgentToolResult; toolName: string }) => ({
|
||||
result: {
|
||||
...event.result,
|
||||
content: [{ type: "text" as const, text: `${event.toolName} compacted` }],
|
||||
},
|
||||
}));
|
||||
registry.agentToolResultMiddlewares.push({
|
||||
pluginId: "tokenjuice",
|
||||
pluginName: "Tokenjuice",
|
||||
@@ -1000,7 +998,7 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
|
||||
it("uses raw tool provenance for media trust after middleware rewrites details", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const handler = vi.fn(async (event: { result: AgentToolResult<unknown> }) => ({
|
||||
const handler = vi.fn(async (event: { result: AgentToolResult }) => ({
|
||||
result: {
|
||||
...event.result,
|
||||
content: [{ type: "text" as const, text: "Generated media reply." }],
|
||||
@@ -1047,7 +1045,7 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
const factory = async (codex: {
|
||||
on: (
|
||||
event: "tool_result",
|
||||
handler: (event: any) => Promise<{ result: AgentToolResult<unknown> }>,
|
||||
handler: (event: any) => Promise<{ result: AgentToolResult }>,
|
||||
) => void;
|
||||
}) => {
|
||||
codex.on("tool_result", async (event) => ({
|
||||
@@ -1084,7 +1082,7 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
});
|
||||
|
||||
it("keeps config out of Codex tool-result contexts", async () => {
|
||||
const config = { session: { store: "/tmp/openclaw-session-store.json" } };
|
||||
const config = { session: {} };
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const middlewareContexts: Record<string, unknown>[] = [];
|
||||
const legacyContexts: Record<string, unknown>[] = [];
|
||||
@@ -1098,7 +1096,7 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
handler: (
|
||||
event: unknown,
|
||||
ctx: Record<string, unknown>,
|
||||
) => Promise<{ result: AgentToolResult<unknown> } | void>,
|
||||
) => Promise<{ result: AgentToolResult } | void>,
|
||||
) => void;
|
||||
}) => {
|
||||
codex.on("tool_result", async (eventValue, ctx) => {
|
||||
@@ -1369,7 +1367,7 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
);
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const handler = vi.fn(
|
||||
async (event: { args: Record<string, unknown>; result: AgentToolResult<unknown> }) => {
|
||||
async (event: { args: Record<string, unknown>; result: AgentToolResult }) => {
|
||||
events.push("middleware");
|
||||
expect(event.args).toEqual({ command: "status" });
|
||||
return {
|
||||
@@ -1466,10 +1464,10 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
|
||||
it("passes per-call abort signals into dynamic tool execution", async () => {
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
let resolveTool: ((result: AgentToolResult<unknown>) => void) | undefined;
|
||||
let resolveTool: ((result: AgentToolResult) => void) | undefined;
|
||||
const execute = vi.fn(
|
||||
async (_callId: string, _args: Record<string, unknown>, signal: AbortSignal) =>
|
||||
await new Promise<AgentToolResult<unknown>>((resolve) => {
|
||||
await new Promise<AgentToolResult>((resolve) => {
|
||||
capturedSignal = signal;
|
||||
resolveTool = resolve;
|
||||
}),
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
wrapToolWithBeforeToolCallHook,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
import type { ImageContent, TextContent } from "openclaw/plugin-sdk/llm";
|
||||
import type { ImageContent, TextContent } from "openclaw/plugin-sdk/provider-ai";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
asOptionalRecord as readRecord,
|
||||
@@ -435,8 +435,8 @@ function composeAbortSignals(...signals: Array<AbortSignal | undefined>): AbortS
|
||||
function collectToolTelemetry(params: {
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
result: AgentToolResult<unknown> | undefined;
|
||||
mediaTrustResult?: AgentToolResult<unknown>;
|
||||
result: AgentToolResult | undefined;
|
||||
mediaTrustResult?: AgentToolResult;
|
||||
telemetry: CodexDynamicToolBridge["telemetry"];
|
||||
isError: boolean;
|
||||
}): void {
|
||||
@@ -543,7 +543,7 @@ function readPositiveInteger(value: unknown): number | undefined {
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function isToolResultError(result: AgentToolResult<unknown>): boolean {
|
||||
function isToolResultError(result: AgentToolResult): boolean {
|
||||
const details = result.details;
|
||||
if (!isRecord(details)) {
|
||||
return false;
|
||||
@@ -572,7 +572,7 @@ function isToolResultError(result: AgentToolResult<unknown>): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isToolResultYield(result: AgentToolResult<unknown>): boolean {
|
||||
function isToolResultYield(result: AgentToolResult): boolean {
|
||||
const details = result.details;
|
||||
if (!isRecord(details) || typeof details.status !== "string") {
|
||||
return false;
|
||||
@@ -580,13 +580,13 @@ function isToolResultYield(result: AgentToolResult<unknown>): boolean {
|
||||
return details.status.trim().toLowerCase() === "yielded";
|
||||
}
|
||||
|
||||
function isAsyncStartedToolResult(result: AgentToolResult<unknown>): boolean {
|
||||
function isAsyncStartedToolResult(result: AgentToolResult): boolean {
|
||||
const details = result.details;
|
||||
return isRecord(details) && details.async === true && details.status === "started";
|
||||
}
|
||||
|
||||
function inferToolResultDiagnosticTerminalType(
|
||||
result: AgentToolResult<unknown>,
|
||||
result: AgentToolResult,
|
||||
isError: boolean,
|
||||
): CodexDynamicToolDiagnosticTerminalType {
|
||||
const details = result.details;
|
||||
|
||||
@@ -4,9 +4,9 @@ import path from "node:path";
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness";
|
||||
import {
|
||||
embeddedAgentLog,
|
||||
replaceSqliteSessionTranscriptEvents,
|
||||
resetAgentEventsForTest,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import {
|
||||
onInternalDiagnosticEvent,
|
||||
resetDiagnosticEventsForTest,
|
||||
@@ -61,12 +61,23 @@ function assistantMessage(text: string, timestamp: number) {
|
||||
async function createParams(): Promise<EmbeddedRunAttemptParams> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-projector-"));
|
||||
tempDirs.add(tempDir);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
SessionManager.open(sessionFile).appendMessage(assistantMessage("history", Date.now()));
|
||||
const sessionId = "session-1";
|
||||
replaceSqliteSessionTranscriptEvents({
|
||||
agentId: "main",
|
||||
sessionId,
|
||||
events: [
|
||||
{ type: "session", version: 1, id: sessionId },
|
||||
{
|
||||
type: "message",
|
||||
id: "history",
|
||||
parentId: null,
|
||||
message: assistantMessage("history", Date.now()),
|
||||
},
|
||||
],
|
||||
});
|
||||
return {
|
||||
prompt: "hello",
|
||||
sessionId: "session-1",
|
||||
sessionFile,
|
||||
sessionId,
|
||||
workspaceDir: tempDir,
|
||||
runId: "run-1",
|
||||
provider: "openai",
|
||||
@@ -143,6 +154,19 @@ function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function mockCallArg(
|
||||
mock: { mock: { calls: unknown[][] } },
|
||||
callIndex: number,
|
||||
argIndex: number,
|
||||
label: string,
|
||||
) {
|
||||
const call = mock.mock.calls.at(callIndex);
|
||||
if (!call) {
|
||||
throw new Error(`Expected ${label} call`);
|
||||
}
|
||||
return call[argIndex];
|
||||
}
|
||||
|
||||
function requireArray(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`Expected ${label}`);
|
||||
@@ -161,18 +185,6 @@ function expectUsageFields(
|
||||
expect(record.total ?? record.totalTokens).toBe(expected.total);
|
||||
}
|
||||
|
||||
function mockCallArg(mock: unknown, callIndex: number, argIndex: number, label: string) {
|
||||
const calls = (mock as { mock?: { calls?: unknown[][] } }).mock?.calls;
|
||||
if (!Array.isArray(calls)) {
|
||||
throw new Error(`Expected ${label} mock calls`);
|
||||
}
|
||||
const call = calls[callIndex];
|
||||
if (!call) {
|
||||
throw new Error(`Expected ${label} call ${callIndex + 1}`);
|
||||
}
|
||||
return call[argIndex];
|
||||
}
|
||||
|
||||
function findAgentEvent(
|
||||
mock: unknown,
|
||||
params: { stream: string; phase?: string; itemId?: string; name?: string },
|
||||
@@ -705,8 +717,7 @@ describe("CodexAppServerEventProjector", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
const toolProgressText = (mockCallArg(onToolResult, 0, 0, "onToolResult") as { text?: string })
|
||||
.text;
|
||||
const toolProgressText = onToolResult.mock.calls[0]?.[0]?.text;
|
||||
expect(toolProgressText).toBe("🛠️ `run tests (workspace)`");
|
||||
|
||||
await projector.handleNotification(
|
||||
@@ -1155,7 +1166,6 @@ describe("CodexAppServerEventProjector", () => {
|
||||
{
|
||||
prompt: "hello",
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
runId: "run-1",
|
||||
provider: "openai",
|
||||
@@ -1546,7 +1556,9 @@ describe("CodexAppServerEventProjector", () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const onToolResult = vi.fn();
|
||||
const trajectoryRecorder = {
|
||||
enabled: true as const,
|
||||
filePath: "trajectory.jsonl",
|
||||
runtimeScope: "sqlite:test:trajectory:session-1",
|
||||
recordEvent: vi.fn(),
|
||||
flush: vi.fn(async () => undefined),
|
||||
};
|
||||
@@ -1630,7 +1642,9 @@ describe("CodexAppServerEventProjector", () => {
|
||||
it("uses streamed command output when final command snapshots omit aggregated output", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const trajectoryRecorder = {
|
||||
enabled: true as const,
|
||||
filePath: "trajectory.jsonl",
|
||||
runtimeScope: "sqlite:test:trajectory:session-1",
|
||||
recordEvent: vi.fn(),
|
||||
flush: vi.fn(async () => undefined),
|
||||
};
|
||||
@@ -1738,7 +1752,9 @@ describe("CodexAppServerEventProjector", () => {
|
||||
it("does not duplicate native tool starts when the snapshot completes a started item", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const trajectoryRecorder = {
|
||||
enabled: true as const,
|
||||
filePath: "trajectory.jsonl",
|
||||
runtimeScope: "sqlite:test:trajectory:session-1",
|
||||
recordEvent: vi.fn(),
|
||||
flush: vi.fn(async () => undefined),
|
||||
};
|
||||
@@ -3138,7 +3154,6 @@ describe("CodexAppServerEventProjector", () => {
|
||||
|
||||
it("fires before_compaction and after_compaction hooks for codex compaction items", async () => {
|
||||
const { projector, beforeCompaction, afterCompaction } = await createProjectorWithHooks();
|
||||
const openSpy = vi.spyOn(SessionManager, "open");
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/started", {
|
||||
@@ -3150,35 +3165,26 @@ describe("CodexAppServerEventProjector", () => {
|
||||
item: { type: "contextCompaction", id: "compact-1" },
|
||||
}),
|
||||
);
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
|
||||
const beforePayload = requireRecord(
|
||||
mockCallArg(beforeCompaction, 0, 0, "beforeCompaction"),
|
||||
"before payload",
|
||||
expect(beforeCompaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messageCount: 1,
|
||||
messages: [expect.objectContaining({ role: "assistant" })],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
runId: "run-1",
|
||||
sessionId: "session-1",
|
||||
}),
|
||||
);
|
||||
expect(beforePayload.messageCount).toBe(1);
|
||||
expect(String(beforePayload.sessionFile)).toContain("session.jsonl");
|
||||
const beforeMessages = requireArray(beforePayload.messages, "before messages");
|
||||
expect(requireRecord(beforeMessages[0], "before message").role).toBe("assistant");
|
||||
const beforeContext = requireRecord(
|
||||
mockCallArg(beforeCompaction, 0, 1, "beforeCompaction"),
|
||||
"before context",
|
||||
expect(afterCompaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messageCount: 1,
|
||||
compactedCount: -1,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
runId: "run-1",
|
||||
sessionId: "session-1",
|
||||
}),
|
||||
);
|
||||
expect(beforeContext.runId).toBe("run-1");
|
||||
expect(beforeContext.sessionId).toBe("session-1");
|
||||
const afterPayload = requireRecord(
|
||||
mockCallArg(afterCompaction, 0, 0, "afterCompaction"),
|
||||
"after payload",
|
||||
);
|
||||
expect(afterPayload.messageCount).toBe(1);
|
||||
expect(afterPayload.compactedCount).toBe(-1);
|
||||
expect(String(afterPayload.sessionFile)).toContain("session.jsonl");
|
||||
const afterContext = requireRecord(
|
||||
mockCallArg(afterCompaction, 0, 1, "afterCompaction"),
|
||||
"after context",
|
||||
);
|
||||
expect(afterContext.runId).toBe("run-1");
|
||||
expect(afterContext.sessionId).toBe("session-1");
|
||||
});
|
||||
|
||||
it("projects codex hook started and completed notifications into agent events", async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
formatToolProgressOutput,
|
||||
inferToolMetaFromArgs,
|
||||
normalizeUsage,
|
||||
resolveSessionAgentIds,
|
||||
runAgentHarnessAfterCompactionHook,
|
||||
runAgentHarnessAfterToolCallHook,
|
||||
runAgentHarnessBeforeCompactionHook,
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
import { generatedImageAssetFromBase64 } from "openclaw/plugin-sdk/image-generation";
|
||||
import type { AssistantMessage, Usage } from "openclaw/plugin-sdk/llm";
|
||||
import type { AssistantMessage, Usage } from "openclaw/plugin-sdk/provider-ai";
|
||||
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
|
||||
import { asDateTimestampMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { resolveCodexLocalRuntimeAttribution } from "./local-runtime-attribution.js";
|
||||
@@ -538,7 +539,6 @@ export class CodexAppServerEventProjector {
|
||||
if (item?.type === "contextCompaction" && itemId) {
|
||||
this.activeCompactionItemIds.add(itemId);
|
||||
await runAgentHarnessBeforeCompactionHook({
|
||||
sessionFile: this.params.sessionFile,
|
||||
messages: await this.readMirroredSessionMessages(),
|
||||
ctx: {
|
||||
runId: this.params.runId,
|
||||
@@ -597,7 +597,6 @@ export class CodexAppServerEventProjector {
|
||||
this.activeCompactionItemIds.delete(itemId);
|
||||
this.completedCompactionCount += 1;
|
||||
await runAgentHarnessAfterCompactionHook({
|
||||
sessionFile: this.params.sessionFile,
|
||||
messages: await this.readMirroredSessionMessages(),
|
||||
compactedCount: -1,
|
||||
ctx: {
|
||||
@@ -831,20 +830,16 @@ export class CodexAppServerEventProjector {
|
||||
if (readString(item, "role") !== "assistant") {
|
||||
return;
|
||||
}
|
||||
if (readString(item, "phase") === "commentary") {
|
||||
return;
|
||||
}
|
||||
const text = extractRawAssistantText(item);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const itemId = readString(item, "id") ?? `raw-assistant-${this.assistantItemOrder.length + 1}`;
|
||||
const phase = readString(item, "phase");
|
||||
if (phase) {
|
||||
this.assistantPhaseByItem.set(itemId, phase);
|
||||
}
|
||||
this.rememberAssistantItem(itemId);
|
||||
this.assistantTextByItem.set(itemId, text);
|
||||
if (phase === "commentary") {
|
||||
this.emitCommentaryProgress({ itemId, text });
|
||||
}
|
||||
}
|
||||
|
||||
private recordNativeGeneratedMedia(item: CodexThreadItem | undefined): void {
|
||||
@@ -1539,7 +1534,18 @@ export class CodexAppServerEventProjector {
|
||||
}
|
||||
|
||||
private async readMirroredSessionMessages(): Promise<AgentMessage[]> {
|
||||
return (await readCodexMirroredSessionHistoryMessages(this.params.sessionFile)) ?? [];
|
||||
const { sessionAgentId } = resolveSessionAgentIds({
|
||||
agentId: this.params.agentId,
|
||||
config: this.params.config,
|
||||
sessionKey: this.params.sessionKey,
|
||||
});
|
||||
return (
|
||||
(await readCodexMirroredSessionHistoryMessages({
|
||||
agentId: sessionAgentId,
|
||||
path: this.params.path,
|
||||
sessionId: this.params.sessionId,
|
||||
})) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
private createAssistantMessage(text: string): AssistantMessage {
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
lastEventAt: 20_000,
|
||||
progressSummary: "Codex native subagent started.",
|
||||
});
|
||||
expect(vi.mocked(runtime.createRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
expect(vi.mocked(runtime.createRunningTaskRun).mock.calls.at(0)?.[0]).not.toHaveProperty(
|
||||
"childSessionKey",
|
||||
);
|
||||
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith({
|
||||
@@ -240,7 +240,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
lastEventAt: 40_000,
|
||||
progressSummary: "Codex native subagent spawned.",
|
||||
});
|
||||
expect(vi.mocked(runtime.createRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
expect(vi.mocked(runtime.createRunningTaskRun).mock.calls.at(0)?.[0]).not.toHaveProperty(
|
||||
"childSessionKey",
|
||||
);
|
||||
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith({
|
||||
|
||||
@@ -26,13 +26,10 @@ type MirrorTaggedMessage = { __openclaw?: { mirrorIdentity?: string } };
|
||||
async function createParams(): Promise<EmbeddedRunAttemptParams> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-outcome-contract-"));
|
||||
tempDirs.add(tempDir);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
SessionManager.open(sessionFile);
|
||||
return {
|
||||
prompt: OUTCOME_FALLBACK_RUNTIME_CONTRACT.prompt,
|
||||
sessionId: OUTCOME_FALLBACK_RUNTIME_CONTRACT.sessionId,
|
||||
sessionKey: OUTCOME_FALLBACK_RUNTIME_CONTRACT.sessionKey,
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
runId: OUTCOME_FALLBACK_RUNTIME_CONTRACT.runId,
|
||||
provider: "codex",
|
||||
|
||||
@@ -22,6 +22,59 @@ describe("Codex plugin activation", () => {
|
||||
expect((params as Record<string, unknown> | undefined)?.[key]).toBe(expected);
|
||||
}
|
||||
|
||||
it("activates plugins from every first-party OpenAI marketplace", async () => {
|
||||
// chrome ships in openai-bundled (with Codex.app), documents ships in
|
||||
// openai-primary-runtime (Codex primary runtime). Both should activate the
|
||||
// same way openai-curated plugins do.
|
||||
for (const { plugin, marketplace } of [
|
||||
{ plugin: "chrome", marketplace: "openai-bundled" as const },
|
||||
{ plugin: "documents", marketplace: "openai-primary-runtime" as const },
|
||||
]) {
|
||||
const calls: string[] = [];
|
||||
const result = await ensureCodexPluginActivation({
|
||||
identity: identity(plugin, marketplace),
|
||||
request: async (method) => {
|
||||
calls.push(method);
|
||||
if (method === "plugin/list") {
|
||||
return pluginListFor(marketplace, [
|
||||
pluginSummary(plugin, { installed: true, enabled: true }),
|
||||
]);
|
||||
}
|
||||
throw new Error(`unexpected request ${method}`);
|
||||
},
|
||||
});
|
||||
|
||||
expectActivationResult(result, {
|
||||
ok: true,
|
||||
reason: "already_active",
|
||||
installAttempted: false,
|
||||
});
|
||||
expect(result.marketplace?.name).toBe(marketplace);
|
||||
expect(calls).toEqual(["plugin/list"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects activation requests for marketplaces outside the openai allowlist", async () => {
|
||||
const result = await ensureCodexPluginActivation({
|
||||
identity: {
|
||||
configKey: "rogue",
|
||||
marketplaceName: "third-party" as never,
|
||||
pluginName: "rogue",
|
||||
enabled: true,
|
||||
allowDestructiveActions: false,
|
||||
},
|
||||
request: async () => {
|
||||
throw new Error("plugin/list should not be reached when marketplace is rejected");
|
||||
},
|
||||
});
|
||||
|
||||
expectActivationResult(result, {
|
||||
ok: false,
|
||||
reason: "marketplace_missing",
|
||||
installAttempted: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips plugin/install when the migrated plugin is already active", async () => {
|
||||
const calls: string[] = [];
|
||||
const result = await ensureCodexPluginActivation({
|
||||
@@ -295,10 +348,13 @@ describe("Codex plugin activation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function identity(pluginName: string): ResolvedCodexPluginPolicy {
|
||||
function identity(
|
||||
pluginName: string,
|
||||
marketplaceName: ResolvedCodexPluginPolicy["marketplaceName"] = CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
): ResolvedCodexPluginPolicy {
|
||||
return {
|
||||
configKey: pluginName,
|
||||
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
marketplaceName,
|
||||
pluginName,
|
||||
enabled: true,
|
||||
allowDestructiveActions: false,
|
||||
@@ -320,6 +376,24 @@ function pluginList(plugins: v2.PluginSummary[]): v2.PluginListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
function pluginListFor(
|
||||
marketplaceName: ResolvedCodexPluginPolicy["marketplaceName"],
|
||||
plugins: v2.PluginSummary[],
|
||||
): v2.PluginListResponse {
|
||||
return {
|
||||
marketplaces: [
|
||||
{
|
||||
name: marketplaceName,
|
||||
path: `/marketplaces/${marketplaceName}`,
|
||||
interface: null,
|
||||
plugins,
|
||||
},
|
||||
],
|
||||
marketplaceLoadErrors: [],
|
||||
featuredPluginIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function pluginSummary(id: string, overrides: Partial<v2.PluginSummary> = {}): v2.PluginSummary {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { CodexAppInventoryCache, CodexAppInventoryRequest } from "./app-inventory-cache.js";
|
||||
import { CODEX_PLUGINS_MARKETPLACE_NAME, type ResolvedCodexPluginPolicy } from "./config.js";
|
||||
import {
|
||||
CODEX_PLUGINS_MARKETPLACE_NAMES,
|
||||
isCodexPluginsMarketplaceName,
|
||||
type ResolvedCodexPluginPolicy,
|
||||
} from "./config.js";
|
||||
import {
|
||||
findOpenAiCuratedPluginSummary,
|
||||
pluginReadParams,
|
||||
@@ -48,27 +52,32 @@ export type CodexPluginRuntimeRefreshResult = {
|
||||
export async function ensureCodexPluginActivation(
|
||||
params: EnsureCodexPluginActivationParams,
|
||||
): Promise<CodexPluginActivationResult> {
|
||||
if (params.identity.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME) {
|
||||
if (!isCodexPluginsMarketplaceName(params.identity.marketplaceName)) {
|
||||
return activationFailure(params.identity, "marketplace_missing", {
|
||||
message: "Only " + CODEX_PLUGINS_MARKETPLACE_NAME + " plugins can be activated.",
|
||||
message:
|
||||
"Only " + CODEX_PLUGINS_MARKETPLACE_NAMES.join(" or ") + " plugins can be activated.",
|
||||
});
|
||||
}
|
||||
|
||||
const listed = (await params.request("plugin/list", {
|
||||
cwds: [],
|
||||
} satisfies v2.PluginListParams)) as v2.PluginListResponse;
|
||||
const resolved = findOpenAiCuratedPluginSummary(listed, params.identity.pluginName);
|
||||
const resolved = findOpenAiCuratedPluginSummary(
|
||||
listed,
|
||||
params.identity.pluginName,
|
||||
params.identity.marketplaceName,
|
||||
);
|
||||
if (!resolved) {
|
||||
const hasCuratedMarketplace = listed.marketplaces.some(
|
||||
(marketplace) => marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
const hasMarketplace = listed.marketplaces.some(
|
||||
(marketplace) => marketplace.name === params.identity.marketplaceName,
|
||||
);
|
||||
if (!hasCuratedMarketplace) {
|
||||
if (!hasMarketplace) {
|
||||
return activationFailure(params.identity, "marketplace_missing", {
|
||||
message: `Codex marketplace ${CODEX_PLUGINS_MARKETPLACE_NAME} was not found.`,
|
||||
message: `Codex marketplace ${params.identity.marketplaceName} was not found.`,
|
||||
});
|
||||
}
|
||||
return activationFailure(params.identity, "plugin_missing", {
|
||||
message: `${params.identity.pluginName} was not found in ${CODEX_PLUGINS_MARKETPLACE_NAME}.`,
|
||||
message: `${params.identity.pluginName} was not found in ${params.identity.marketplaceName}.`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ import type {
|
||||
} from "./app-inventory-cache.js";
|
||||
import {
|
||||
CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
CODEX_PLUGINS_MARKETPLACE_NAMES,
|
||||
isCodexPluginsMarketplaceName,
|
||||
resolveCodexPluginsPolicy,
|
||||
type CodexPluginsMarketplaceName,
|
||||
type ResolvedCodexPluginPolicy,
|
||||
type ResolvedCodexPluginsPolicy,
|
||||
} from "./config.js";
|
||||
@@ -15,7 +18,7 @@ import type { v2 } from "./protocol.js";
|
||||
export type CodexPluginRuntimeRequest = (method: string, params?: unknown) => Promise<unknown>;
|
||||
|
||||
export type CodexPluginMarketplaceRef = {
|
||||
name: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
|
||||
name: CodexPluginsMarketplaceName;
|
||||
path?: string;
|
||||
remoteMarketplaceName?: string;
|
||||
};
|
||||
@@ -57,7 +60,6 @@ export type CodexPluginInventoryRecord = {
|
||||
|
||||
export type CodexPluginInventory = {
|
||||
policy: ResolvedCodexPluginsPolicy;
|
||||
marketplace?: CodexPluginMarketplaceRef;
|
||||
records: CodexPluginInventoryRecord[];
|
||||
diagnostics: CodexPluginInventoryDiagnostic[];
|
||||
appInventory?: CodexAppInventoryCacheRead;
|
||||
@@ -95,25 +97,14 @@ export async function readCodexPluginInventory(
|
||||
const listed = (await params.request("plugin/list", {
|
||||
cwds: [],
|
||||
} satisfies v2.PluginListParams)) as v2.PluginListResponse;
|
||||
const marketplaceEntry = listed.marketplaces.find(
|
||||
(marketplace) => marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
);
|
||||
if (!marketplaceEntry) {
|
||||
return {
|
||||
policy,
|
||||
records: [],
|
||||
diagnostics: policy.pluginPolicies
|
||||
.filter((pluginPolicy) => pluginPolicy.enabled)
|
||||
.map((pluginPolicy) => ({
|
||||
code: "marketplace_missing",
|
||||
plugin: pluginPolicy,
|
||||
message: `Codex marketplace ${CODEX_PLUGINS_MARKETPLACE_NAME} was not found.`,
|
||||
})),
|
||||
...(appInventory ? { appInventory } : {}),
|
||||
};
|
||||
// Index the supported marketplaces (curated + bundled) by name so each plugin
|
||||
// policy is matched to the marketplace its config actually points at.
|
||||
const marketplaceByName = new Map<CodexPluginsMarketplaceName, v2.PluginMarketplaceEntry>();
|
||||
for (const marketplace of listed.marketplaces) {
|
||||
if (isCodexPluginsMarketplaceName(marketplace.name)) {
|
||||
marketplaceByName.set(marketplace.name, marketplace);
|
||||
}
|
||||
}
|
||||
|
||||
const marketplace = marketplaceRef(marketplaceEntry);
|
||||
const diagnostics: CodexPluginInventoryDiagnostic[] = [];
|
||||
const records: CodexPluginInventoryRecord[] = [];
|
||||
if (appInventory?.state === "missing") {
|
||||
@@ -132,12 +123,22 @@ export async function readCodexPluginInventory(
|
||||
if (!pluginPolicy.enabled) {
|
||||
continue;
|
||||
}
|
||||
const marketplaceEntry = marketplaceByName.get(pluginPolicy.marketplaceName);
|
||||
if (!marketplaceEntry) {
|
||||
diagnostics.push({
|
||||
code: "marketplace_missing",
|
||||
plugin: pluginPolicy,
|
||||
message: `Codex marketplace ${pluginPolicy.marketplaceName} was not found.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const marketplace = marketplaceRef(marketplaceEntry);
|
||||
const summary = findPluginSummary(marketplaceEntry, pluginPolicy.pluginName);
|
||||
if (!summary) {
|
||||
diagnostics.push({
|
||||
code: "plugin_missing",
|
||||
plugin: pluginPolicy,
|
||||
message: `${pluginPolicy.pluginName} was not found in ${CODEX_PLUGINS_MARKETPLACE_NAME}.`,
|
||||
message: `${pluginPolicy.pluginName} was not found in ${pluginPolicy.marketplaceName}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -187,7 +188,6 @@ export async function readCodexPluginInventory(
|
||||
|
||||
const inventory = {
|
||||
policy,
|
||||
marketplace,
|
||||
records,
|
||||
diagnostics,
|
||||
...(appInventory ? { appInventory } : {}),
|
||||
@@ -198,15 +198,32 @@ export async function readCodexPluginInventory(
|
||||
export function findOpenAiCuratedPluginSummary(
|
||||
listed: v2.PluginListResponse,
|
||||
pluginName: string,
|
||||
marketplaceName?: CodexPluginsMarketplaceName,
|
||||
): { marketplace: CodexPluginMarketplaceRef; summary: v2.PluginSummary } | undefined {
|
||||
const marketplaceEntry = listed.marketplaces.find(
|
||||
(marketplace) => marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
);
|
||||
if (!marketplaceEntry) {
|
||||
return undefined;
|
||||
if (marketplaceName) {
|
||||
const marketplaceEntry = listed.marketplaces.find(
|
||||
(marketplace) => marketplace.name === marketplaceName,
|
||||
);
|
||||
if (!marketplaceEntry) {
|
||||
return undefined;
|
||||
}
|
||||
const summary = findPluginSummary(marketplaceEntry, pluginName);
|
||||
return summary ? { marketplace: marketplaceRef(marketplaceEntry), summary } : undefined;
|
||||
}
|
||||
const summary = findPluginSummary(marketplaceEntry, pluginName);
|
||||
return summary ? { marketplace: marketplaceRef(marketplaceEntry), summary } : undefined;
|
||||
// No marketplace hint: search every supported marketplace and return the first hit.
|
||||
for (const allowedName of CODEX_PLUGINS_MARKETPLACE_NAMES) {
|
||||
const marketplaceEntry = listed.marketplaces.find(
|
||||
(marketplace) => marketplace.name === allowedName,
|
||||
);
|
||||
if (!marketplaceEntry) {
|
||||
continue;
|
||||
}
|
||||
const summary = findPluginSummary(marketplaceEntry, pluginName);
|
||||
if (summary) {
|
||||
return { marketplace: marketplaceRef(marketplaceEntry), summary };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function pluginReadParams(
|
||||
@@ -349,8 +366,12 @@ function pluginNameFromPluginId(pluginId: string, marketplaceName: string): stri
|
||||
}
|
||||
|
||||
function marketplaceRef(marketplace: v2.PluginMarketplaceEntry): CodexPluginMarketplaceRef {
|
||||
// marketplace.name is validated at every call site via isCodexPluginsMarketplaceName.
|
||||
const name = isCodexPluginsMarketplaceName(marketplace.name)
|
||||
? marketplace.name
|
||||
: CODEX_PLUGINS_MARKETPLACE_NAME;
|
||||
return {
|
||||
name: CODEX_PLUGINS_MARKETPLACE_NAME,
|
||||
name,
|
||||
...(marketplace.path ? { path: marketplace.path } : {}),
|
||||
...(!marketplace.path ? { remoteMarketplaceName: marketplace.name } : {}),
|
||||
};
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
import { resetDiagnosticEventsForTest } from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
import { clearInternalHooks, resetGlobalHookRunner } from "openclaw/plugin-sdk/hook-runtime";
|
||||
import { clearPluginCommands } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
} from "openclaw/plugin-sdk/sqlite-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { afterEach, beforeEach, expect, vi } from "vitest";
|
||||
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
|
||||
@@ -89,12 +93,15 @@ async function drainActiveAppServerAttemptsForTest(): Promise<void> {
|
||||
]);
|
||||
}
|
||||
|
||||
export function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
|
||||
export function createParams(
|
||||
transcriptPath: string,
|
||||
workspaceDir: string,
|
||||
): EmbeddedRunAttemptParams {
|
||||
return {
|
||||
prompt: "hello",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
path: transcriptPath,
|
||||
workspaceDir,
|
||||
runId: "run-1",
|
||||
provider: "codex",
|
||||
@@ -115,6 +122,18 @@ export function createParams(sessionFile: string, workspaceDir: string): Embedde
|
||||
} as EmbeddedRunAttemptParams;
|
||||
}
|
||||
|
||||
export function codexAppServerTestBindingIdentity(
|
||||
params: Pick<EmbeddedRunAttemptParams, "sessionKey" | "sessionId"> = {
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionId: "session-1",
|
||||
},
|
||||
) {
|
||||
return {
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCodexRuntimePlanFixture(): NonNullable<
|
||||
EmbeddedRunAttemptParams["runtimePlan"]
|
||||
> {
|
||||
@@ -470,6 +489,7 @@ export function setupRunAttemptTestHooks(): void {
|
||||
vi.stubEnv("CODEX_API_KEY", "");
|
||||
vi.stubEnv("OPENAI_API_KEY", "");
|
||||
tempDir = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "openclaw-codex-run-"));
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", tempDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -487,6 +507,8 @@ export function setupRunAttemptTestHooks(): void {
|
||||
resetGlobalHookRunner();
|
||||
clearInternalHooks();
|
||||
defaultCodexAppInventoryCache.clear();
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
resetAgentEventsForTest,
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { closeOpenClawStateDatabaseForTest } from "openclaw/plugin-sdk/sqlite-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
@@ -12,6 +13,7 @@ import { runCodexAppServerAttempt } from "./run-attempt.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
|
||||
let tempDir: string;
|
||||
let previousStateDir: string | undefined;
|
||||
|
||||
function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
|
||||
return {
|
||||
@@ -89,12 +91,20 @@ describe("Codex app-server main thread cleanup", () => {
|
||||
vi.stubEnv("CODEX_API_KEY", "");
|
||||
vi.stubEnv("OPENAI_API_KEY", "");
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-run-cleanup-"));
|
||||
previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
resetAgentEventsForTest();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness";
|
||||
import {
|
||||
embeddedAgentLog,
|
||||
openTranscriptSessionManagerForSession,
|
||||
type AgentMessage,
|
||||
type HarnessContextEngine as ContextEngine,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox";
|
||||
import {
|
||||
replaceSqliteSessionTranscriptEvents,
|
||||
upsertSessionEntry,
|
||||
} from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import {
|
||||
closeOpenClawAgentDatabasesForTest,
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
} from "openclaw/plugin-sdk/sqlite-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexAppServerClientFactory } from "./client-factory.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
@@ -42,12 +49,11 @@ function runCodexAppServerAttempt(
|
||||
);
|
||||
}
|
||||
|
||||
function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
|
||||
function createParams(sessionId: string, workspaceDir: string): EmbeddedRunAttemptParams {
|
||||
return {
|
||||
prompt: "hello",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
sessionId,
|
||||
sessionKey: `agent:main:${sessionId}`,
|
||||
workspaceDir,
|
||||
runId: "run-1",
|
||||
provider: "codex",
|
||||
@@ -107,6 +113,41 @@ function toolResultMessage(payload: unknown, timestamp: number): AgentMessage {
|
||||
} as unknown as AgentMessage;
|
||||
}
|
||||
|
||||
function seedSessionTranscript(sessionId: string, messages: AgentMessage[]): void {
|
||||
replaceSqliteSessionTranscriptEvents({
|
||||
agentId: "main",
|
||||
sessionId,
|
||||
events: [
|
||||
{
|
||||
type: "session",
|
||||
id: "session-1",
|
||||
timestamp: new Date(1).toISOString(),
|
||||
cwd: tempDir || "/tmp/openclaw-codex-test",
|
||||
},
|
||||
...messages.map((message, index) => ({
|
||||
type: "message",
|
||||
id: `entry-${index + 1}`,
|
||||
parentId: index === 0 ? null : `entry-${index}`,
|
||||
timestamp: new Date(message.timestamp ?? Date.now()).toISOString(),
|
||||
message,
|
||||
})),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function openTestTranscriptSession(params: {
|
||||
sessionId: string;
|
||||
sessionFile?: string;
|
||||
workspaceDir: string;
|
||||
}) {
|
||||
return openTranscriptSessionManagerForSession({
|
||||
agentId: "main",
|
||||
path: params.sessionFile,
|
||||
sessionId: params.sessionId,
|
||||
cwd: params.workspaceDir,
|
||||
});
|
||||
}
|
||||
|
||||
function threadStartResult(threadId = "thread-1") {
|
||||
return {
|
||||
thread: {
|
||||
@@ -255,7 +296,7 @@ function optionalString(value: unknown): string {
|
||||
}
|
||||
|
||||
function requireFirstCallArg(mock: unknown, label: string): unknown {
|
||||
const call = (mock as MockCallReader).mock.calls[0];
|
||||
const call = (mock as MockCallReader).mock.calls.at(0);
|
||||
if (!call) {
|
||||
throw new Error(`expected ${label} to be called`);
|
||||
}
|
||||
@@ -306,24 +347,25 @@ function getRequestInputTextAt(
|
||||
describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-context-engine-"));
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", tempDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetCodexAppServerClientFactoryForTest();
|
||||
vi.restoreAllMocks();
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
vi.unstubAllEnvs();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("bootstraps and assembles non-legacy context before the Codex turn starts", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const sessionId = "session-1";
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
assistantMessage("existing context", Date.now()) as never,
|
||||
);
|
||||
const openSpy = vi.spyOn(SessionManager, "open");
|
||||
seedSessionTranscript(sessionId, [assistantMessage("existing context", Date.now())]);
|
||||
const contextEngine = createContextEngine();
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const params = createParams(sessionId, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
params.contextTokenBudget = 321;
|
||||
params.config = { memory: { citations: "on" } } as EmbeddedRunAttemptParams["config"];
|
||||
@@ -338,15 +380,15 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
const bootstrapParams = requireFirstCallArg(contextEngine.bootstrap, "bootstrap") as Parameters<
|
||||
NonNullable<ContextEngine["bootstrap"]>
|
||||
>[0];
|
||||
expect(bootstrapParams.sessionId).toBe("session-1");
|
||||
expect(bootstrapParams.sessionId).toBe(sessionId);
|
||||
expect(bootstrapParams.sessionKey).toBe("agent:main:session-1");
|
||||
expect(bootstrapParams.sessionFile).toBe(sessionFile);
|
||||
expect(bootstrapParams.transcriptScope).toEqual({ agentId: "main", sessionId });
|
||||
|
||||
expect(contextEngine.assemble).toHaveBeenCalledTimes(1);
|
||||
const assembleParams = requireFirstCallArg(contextEngine.assemble, "assemble") as Parameters<
|
||||
ContextEngine["assemble"]
|
||||
>[0];
|
||||
expect(assembleParams.sessionId).toBe("session-1");
|
||||
expect(assembleParams.sessionId).toBe(sessionId);
|
||||
expect(assembleParams.sessionKey).toBe("agent:main:session-1");
|
||||
expect(assembleParams.tokenBudget).toBe(321);
|
||||
expect(assembleParams.citationsMode).toBe("on");
|
||||
@@ -361,46 +403,12 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
);
|
||||
expectRequestInputTextContains(harness, "OpenClaw assembled context for this turn:");
|
||||
|
||||
await harness.completeTurn();
|
||||
await run;
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps context-engine history bound to the run session when sandbox key differs", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
assistantMessage("canonical main context", Date.now()) as never,
|
||||
);
|
||||
const contextEngine = createContextEngine();
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.sessionKey = "agent:main:main";
|
||||
params.sandboxSessionKey = "agent:main:telegram:default:direct:12345";
|
||||
params.contextEngine = contextEngine;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
if (!contextEngine.bootstrap) {
|
||||
throw new Error("expected bootstrap hook");
|
||||
}
|
||||
const bootstrapParams = requireFirstCallArg(contextEngine.bootstrap, "bootstrap") as Parameters<
|
||||
NonNullable<ContextEngine["bootstrap"]>
|
||||
>[0];
|
||||
expect(bootstrapParams.sessionKey).toBe("agent:main:main");
|
||||
|
||||
const assembleParams = requireFirstCallArg(contextEngine.assemble, "assemble") as Parameters<
|
||||
ContextEngine["assemble"]
|
||||
>[0];
|
||||
expect(assembleParams.sessionKey).toBe("agent:main:main");
|
||||
|
||||
await harness.completeTurn();
|
||||
await run;
|
||||
});
|
||||
|
||||
it("uses the runtime token budget for large Codex context-engine projections", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const sessionId = "session-1";
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const longContext = `large LCM context start ${"x".repeat(30_000)} LARGE_CONTEXT_END`;
|
||||
const contextEngine = createContextEngine({
|
||||
@@ -411,7 +419,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
})),
|
||||
});
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const params = createParams(sessionId, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
params.contextTokenBudget = 80_000;
|
||||
|
||||
@@ -428,7 +436,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
});
|
||||
|
||||
it("uses configured compaction reserve when sizing Codex context-engine projections", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const sessionId = "session-1";
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const longContext = `configured reserve context start ${"x".repeat(30_000)} CONFIG_END`;
|
||||
const contextEngine = createContextEngine({
|
||||
@@ -439,7 +447,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
})),
|
||||
});
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const params = createParams(sessionId, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
params.contextTokenBudget = 80_000;
|
||||
params.config = {
|
||||
@@ -460,11 +468,9 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
|
||||
it("projects thread-bootstrap context only once for a matching context-engine epoch", async () => {
|
||||
const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const sessionId = "session-1";
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
assistantMessage("bootstrap-only context", Date.now()) as never,
|
||||
);
|
||||
seedSessionTranscript(sessionId, [assistantMessage("bootstrap-only context", Date.now())]);
|
||||
const contextEngine = createContextEngine({
|
||||
assemble: vi.fn(async ({ messages, prompt }) => ({
|
||||
messages: [...messages, userMessage(prompt ?? "", 10)],
|
||||
@@ -474,7 +480,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
})),
|
||||
});
|
||||
const firstHarness = createStartedThreadHarness();
|
||||
const firstParams = createParams(sessionFile, workspaceDir);
|
||||
const firstParams = createParams(sessionId, workspaceDir);
|
||||
firstParams.contextEngine = contextEngine;
|
||||
|
||||
const firstRun = runCodexAppServerAttempt(firstParams);
|
||||
@@ -484,7 +490,10 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
await firstHarness.completeTurn();
|
||||
await firstRun;
|
||||
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
const savedBinding = await readCodexAppServerBinding({
|
||||
sessionKey: `agent:main:${sessionId}`,
|
||||
sessionId,
|
||||
});
|
||||
expect(savedBinding?.contextEngine?.projection).toEqual({
|
||||
schemaVersion: 1,
|
||||
mode: "thread_bootstrap",
|
||||
@@ -712,40 +721,44 @@ 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 sessionId = "session-1";
|
||||
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,
|
||||
);
|
||||
sessionManager.appendMessage(
|
||||
assistantMessage("previous stale-bootstrap answer", Date.now() + 1) as never,
|
||||
);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-stale-bootstrap",
|
||||
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-stale",
|
||||
},
|
||||
seedSessionTranscript(sessionId, [
|
||||
userMessage("previous stale-bootstrap request", Date.now()),
|
||||
assistantMessage("previous stale-bootstrap answer", Date.now() + 1),
|
||||
]);
|
||||
upsertSessionEntry({
|
||||
agentId: "main",
|
||||
sessionKey: `agent:main:${sessionId}`,
|
||||
entry: {
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
totalTokens: 12_000,
|
||||
totalTokensFresh: true,
|
||||
},
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(path.dirname(sessionFile), "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:main:session-1": {
|
||||
sessionFile,
|
||||
totalTokens: 12_000,
|
||||
await writeCodexAppServerBinding(
|
||||
{
|
||||
sessionKey: `agent:main:${sessionId}`,
|
||||
sessionId,
|
||||
},
|
||||
{
|
||||
threadId: "thread-stale-bootstrap",
|
||||
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-stale",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
|
||||
await fs.mkdir(rolloutDir, { recursive: true });
|
||||
@@ -771,13 +784,13 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const params = createParams(sessionId, workspaceDir);
|
||||
params.agentDir = agentDir;
|
||||
params.config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
rotateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: "1mb",
|
||||
},
|
||||
},
|
||||
@@ -804,24 +817,30 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
|
||||
it("starts a fresh Codex thread and reprojects when context-engine epoch changes", async () => {
|
||||
const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const sessionId = "session-1";
|
||||
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: {
|
||||
await writeCodexAppServerBinding(
|
||||
{
|
||||
sessionKey: `agent:main:${sessionId}`,
|
||||
sessionId,
|
||||
},
|
||||
{
|
||||
threadId: "thread-old",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: "[]",
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
mode: "thread_bootstrap",
|
||||
epoch: "epoch-old",
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint:
|
||||
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"projectionMaxChars":24000}',
|
||||
projection: {
|
||||
schemaVersion: 1,
|
||||
mode: "thread_bootstrap",
|
||||
epoch: "epoch-old",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
const contextEngine = createContextEngine({
|
||||
assemble: vi.fn(async ({ prompt }) => ({
|
||||
messages: [assistantMessage("new epoch context", 10), userMessage(prompt ?? "", 11)],
|
||||
@@ -836,7 +855,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const params = createParams(sessionId, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
@@ -863,7 +882,10 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
});
|
||||
await run;
|
||||
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
const savedBinding = await readCodexAppServerBinding({
|
||||
sessionKey: `agent:main:${sessionId}`,
|
||||
sessionId,
|
||||
});
|
||||
expect(savedBinding?.threadId).toBe("thread-new");
|
||||
expect(savedBinding?.contextEngine?.projection?.epoch).toBe("epoch-new");
|
||||
expect(info).toHaveBeenCalledWith(
|
||||
@@ -952,121 +974,6 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
await run;
|
||||
});
|
||||
|
||||
it("reprojects thread-bootstrap context for native-disabled transient Codex threads", async () => {
|
||||
const restoreSandboxBackend = registerSandboxBackend(
|
||||
"codex-context-test-sandbox",
|
||||
async () => ({
|
||||
id: "codex-context-test-sandbox",
|
||||
runtimeId: "codex-context-test-runtime",
|
||||
runtimeLabel: "Codex Context Test Sandbox",
|
||||
workdir: "/workspace",
|
||||
buildExecSpec: async () => ({
|
||||
argv: ["true"],
|
||||
env: {},
|
||||
stdinMode: "pipe-closed" as const,
|
||||
}),
|
||||
runShellCommand: async () => ({
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
code: 0,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
try {
|
||||
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 }) => ({
|
||||
messages: [
|
||||
assistantMessage("native-disabled 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) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-transient");
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
throw new Error("native-disabled turns should not resume the previous Codex thread");
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
params.config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "all",
|
||||
backend: "codex-context-test-sandbox",
|
||||
scope: "session",
|
||||
workspaceAccess: "rw",
|
||||
prune: { idleHours: 0, maxAgeDays: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as EmbeddedRunAttemptParams["config"];
|
||||
|
||||
let runError: unknown;
|
||||
const run = runCodexAppServerAttempt(params).catch((error: unknown) => {
|
||||
runError = error;
|
||||
throw error;
|
||||
});
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (runError) {
|
||||
throw runError;
|
||||
}
|
||||
expect(harness.requests.map((request) => request.method)).toContain("turn/start");
|
||||
},
|
||||
{ interval: 1 },
|
||||
);
|
||||
|
||||
expect(harness.requests.map((request) => request.method)).toEqual([
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
]);
|
||||
expectRequestInputTextContains(harness, "OpenClaw assembled context for this turn:");
|
||||
expectRequestInputTextContains(harness, "native-disabled context");
|
||||
|
||||
await harness.notify({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-transient",
|
||||
turnId: "turn-1",
|
||||
turn: {
|
||||
id: "turn-1",
|
||||
status: "completed",
|
||||
items: [{ type: "agentMessage", id: "msg-1", text: "transient answer" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
await run;
|
||||
} finally {
|
||||
restoreSandboxBackend();
|
||||
}
|
||||
});
|
||||
|
||||
it("starts a fresh Codex thread when thread-bootstrap projection falls back to per-turn projection", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
@@ -1135,28 +1042,38 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
});
|
||||
|
||||
it("retries a resumed context-engine thread on a fresh Codex thread without plugin compaction", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const sessionId = "session-1";
|
||||
const successorSessionId = "session-1-compacted";
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
assistantMessage("pre-compaction context", Date.now()) as never,
|
||||
);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-old",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: "[]",
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint:
|
||||
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"contextTokenBudget":400000,"projectionMaxChars":1000000}',
|
||||
projection: {
|
||||
seedSessionTranscript(sessionId, [assistantMessage("pre-compaction context", Date.now())]);
|
||||
await writeCodexAppServerBinding(
|
||||
{
|
||||
sessionKey: `agent:main:${sessionId}`,
|
||||
sessionId,
|
||||
},
|
||||
{
|
||||
threadId: "thread-old",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: "[]",
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
mode: "thread_bootstrap",
|
||||
epoch: "epoch-before",
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint:
|
||||
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"contextTokenBudget":400000,"projectionMaxChars":1000000}',
|
||||
projection: {
|
||||
schemaVersion: 1,
|
||||
mode: "thread_bootstrap",
|
||||
epoch: "epoch-before",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
let epoch = "epoch-before";
|
||||
const compact = vi.fn(async () => {
|
||||
epoch = "epoch-after";
|
||||
seedSessionTranscript(successorSessionId, [
|
||||
assistantMessage("successor compacted context", Date.now()),
|
||||
]);
|
||||
return {
|
||||
ok: true,
|
||||
compacted: true,
|
||||
@@ -1164,7 +1081,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
summary: "summary",
|
||||
firstKeptEntryId: "entry-1",
|
||||
tokensBefore: 10,
|
||||
sessionId: "session-1-compacted",
|
||||
sessionId: successorSessionId,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1197,7 +1114,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const params = createParams(sessionId, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
params.contextTokenBudget = 400_000;
|
||||
|
||||
@@ -1230,7 +1147,10 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
const retryInputText = getRequestInputTextAt(harness, -1);
|
||||
expect(retryInputText).toBe("hello");
|
||||
expect(retryInputText).not.toContain("successor compacted context");
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
const savedBinding = await readCodexAppServerBinding({
|
||||
sessionKey: `agent:main:${sessionId}`,
|
||||
sessionId,
|
||||
});
|
||||
expect(savedBinding?.threadId).toBe("thread-fresh");
|
||||
expect(savedBinding?.contextEngine?.engineId).toBe("lossless-claw");
|
||||
expect(savedBinding?.contextEngine?.projection?.epoch).toBe("epoch-before");
|
||||
@@ -1387,9 +1307,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
it("does not pre-compact over-budget rendered context-engine prompts before Codex turn/start", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
assistantMessage("pre-compaction context", Date.now()) as never,
|
||||
);
|
||||
seedSessionTranscript(sessionFile, [assistantMessage("pre-compaction context", Date.now())]);
|
||||
const hugePayload = {
|
||||
rows: Array.from({ length: 10 }, (_, index) => ({
|
||||
id: index,
|
||||
@@ -1429,49 +1347,16 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
expect(result.assistantTexts).toContain("final answer");
|
||||
});
|
||||
|
||||
it("fails first-turn Codex context overflow instead of falling back to OpenClaw compaction", async () => {
|
||||
it("bounds a hung owning context-engine compaction during Codex overflow recovery", async () => {
|
||||
const sessionId = "session-1";
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const compact = vi.fn<ContextEngine["compact"]>(async () => ({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: { summary: "summary", firstKeptEntryId: "entry-1", tokensBefore: 100_000 },
|
||||
}));
|
||||
const assemble = vi.fn<ContextEngine["assemble"]>().mockResolvedValue({
|
||||
messages: [assistantMessage("large projected context", 10)],
|
||||
estimatedTokens: 100_000,
|
||||
contextProjection: { mode: "thread_bootstrap", epoch: "epoch-before" },
|
||||
});
|
||||
const contextEngine = createContextEngine({ assemble, compact });
|
||||
const harness = createStartedThreadHarness(async (method) => {
|
||||
if (method === "turn/start") {
|
||||
throw new Error("Codex ran out of room in the model's context window");
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
params.contextTokenBudget = 16_000;
|
||||
|
||||
await expect(runCodexAppServerAttempt(params)).rejects.toThrow(
|
||||
"Codex ran out of room in the model's context window",
|
||||
);
|
||||
|
||||
expect(compact).not.toHaveBeenCalled();
|
||||
expect(assemble).toHaveBeenCalledTimes(1);
|
||||
expect(harness.requests.map((request) => request.method)).toEqual([
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
"thread/unsubscribe",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not call hung owning context-engine compaction during Codex overflow recovery", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
assistantMessage("pre-compaction context", Date.now()) as never,
|
||||
);
|
||||
openTranscriptSessionManagerForSession({
|
||||
agentId: "main",
|
||||
path: sessionFile,
|
||||
sessionId,
|
||||
cwd: workspaceDir,
|
||||
}).appendMessage(assistantMessage("pre-compaction context", Date.now()) as never);
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-old",
|
||||
cwd: workspaceDir,
|
||||
@@ -1548,14 +1433,12 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
});
|
||||
|
||||
it("keeps current inbound context at the front of the Codex context-engine prompt", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const sessionId = "session-1";
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
assistantMessage("older context", Date.now()) as never,
|
||||
);
|
||||
seedSessionTranscript(sessionId, [assistantMessage("older context", Date.now())]);
|
||||
const contextEngine = createContextEngine();
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const params = createParams(sessionId, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
params.currentInboundContext = {
|
||||
text: [
|
||||
@@ -1579,7 +1462,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
});
|
||||
|
||||
it("calls afterTurn with the mirrored transcript and runs turn maintenance", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const sessionId = "session-1";
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const afterTurn = vi.fn(
|
||||
async (_params: Parameters<NonNullable<ContextEngine["afterTurn"]>>[0]) => undefined,
|
||||
@@ -1587,7 +1470,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
const maintain = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 }));
|
||||
const contextEngine = createContextEngine({ afterTurn, maintain, bootstrap: undefined });
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const params = createParams(sessionId, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
params.contextTokenBudget = 111;
|
||||
|
||||
@@ -1600,7 +1483,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
const afterTurnCall = requireFirstCallArg(afterTurn, "afterTurn") as Parameters<
|
||||
NonNullable<ContextEngine["afterTurn"]>
|
||||
>[0];
|
||||
expect(afterTurnCall.sessionId).toBe("session-1");
|
||||
expect(afterTurnCall.sessionId).toBe(sessionId);
|
||||
expect(afterTurnCall.sessionKey).toBe("agent:main:session-1");
|
||||
expect(afterTurnCall.prePromptMessageCount).toBe(0);
|
||||
expect(afterTurnCall.tokenBudget).toBe(111);
|
||||
@@ -1609,53 +1492,8 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
expect(maintain).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reloads mirrored history after bootstrap mutates the session transcript", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
assistantMessage("existing context", Date.now()) as never,
|
||||
);
|
||||
const afterTurn = vi.fn(
|
||||
async (_params: Parameters<NonNullable<ContextEngine["afterTurn"]>>[0]) => undefined,
|
||||
);
|
||||
const bootstrap = vi.fn(
|
||||
async ({ sessionFile: file }: Parameters<NonNullable<ContextEngine["bootstrap"]>>[0]) => {
|
||||
SessionManager.open(file).appendMessage(
|
||||
assistantMessage("bootstrap context", Date.now() + 1) as never,
|
||||
);
|
||||
return { bootstrapped: true };
|
||||
},
|
||||
);
|
||||
const contextEngine = createContextEngine({
|
||||
bootstrap,
|
||||
afterTurn,
|
||||
maintain: undefined,
|
||||
});
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await harness.completeTurn();
|
||||
await run;
|
||||
|
||||
const assembleParams = requireFirstCallArg(contextEngine.assemble, "assemble") as Parameters<
|
||||
ContextEngine["assemble"]
|
||||
>[0];
|
||||
expect(assembleParams.messages.map((message) => message.role)).toEqual([
|
||||
"assistant",
|
||||
"assistant",
|
||||
]);
|
||||
const afterTurnParams = requireFirstCallArg(afterTurn, "afterTurn") as Parameters<
|
||||
NonNullable<ContextEngine["afterTurn"]>
|
||||
>[0];
|
||||
expect(afterTurnParams.prePromptMessageCount).toBe(2);
|
||||
expectRequestInputTextContains(harness, "bootstrap context");
|
||||
});
|
||||
|
||||
it("logs assemble failures as a formatted message instead of the raw error object", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const sessionId = "session-1";
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const rawError = new Error("Authorization: Bearer sk-abcdefghijklmnopqrstuv");
|
||||
const contextEngine = createContextEngine({
|
||||
@@ -1666,7 +1504,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
});
|
||||
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const params = createParams(sessionId, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
@@ -1684,7 +1522,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
});
|
||||
|
||||
it("falls back to ingestBatch and skips turn maintenance on prompt failure", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const sessionId = "session-1";
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const ingestBatch = vi.fn(async () => ({ ingestedCount: 2 }));
|
||||
const maintain = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 }));
|
||||
@@ -1695,7 +1533,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
bootstrap: undefined,
|
||||
});
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const params = createParams(sessionId, workspaceDir);
|
||||
params.contextEngine = contextEngine;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
abortAgentHarnessRun,
|
||||
openTranscriptSessionManagerForSession,
|
||||
onAgentEvent,
|
||||
type AgentEventPayload,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import {
|
||||
onInternalDiagnosticEvent,
|
||||
waitForDiagnosticEventsDrained,
|
||||
@@ -37,6 +37,15 @@ function flushDiagnosticEvents() {
|
||||
return waitForDiagnosticEventsDrained();
|
||||
}
|
||||
|
||||
function openTestTranscriptSession(params: { sessionFile: string; workspaceDir: string }) {
|
||||
return openTranscriptSessionManagerForSession({
|
||||
agentId: "main",
|
||||
path: params.sessionFile,
|
||||
sessionId: "session-1",
|
||||
cwd: params.workspaceDir,
|
||||
});
|
||||
}
|
||||
|
||||
setupRunAttemptTestHooks();
|
||||
|
||||
describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
@@ -56,7 +65,7 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const sessionManager = openTestTranscriptSession({ sessionFile, workspaceDir });
|
||||
sessionManager.appendMessage(assistantMessage("existing context", Date.now()));
|
||||
const harness = createStartedThreadHarness();
|
||||
|
||||
@@ -204,7 +213,8 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
const diagnosticEvents: DiagnosticEventPayload[] = [];
|
||||
const diagnosticContentByType = new Map<string, DiagnosticEventPrivateData>();
|
||||
let diagnosticTypesAtLlmOutput: string[] = [];
|
||||
const llmOutput = vi.fn(() => {
|
||||
const llmOutput = vi.fn(async () => {
|
||||
await waitForDiagnosticEventsDrained();
|
||||
diagnosticTypesAtLlmOutput = diagnosticEvents.map((event) => event.type);
|
||||
});
|
||||
initializeGlobalHookRunner(
|
||||
@@ -296,7 +306,10 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
).toContain("hello back");
|
||||
expect(completedEvent?.requestPayloadBytes).toBeGreaterThan(0);
|
||||
expect(llmOutput).toHaveBeenCalledTimes(1);
|
||||
expect(diagnosticTypesAtLlmOutput).toContain("model.call.completed");
|
||||
await vi.waitFor(
|
||||
() => expect(diagnosticTypesAtLlmOutput).toContain("model.call.completed"),
|
||||
fastWait,
|
||||
);
|
||||
expect(diagnosticTypesAtLlmOutput).not.toContain("model.call.error");
|
||||
} finally {
|
||||
stopDiagnostics();
|
||||
@@ -489,7 +502,7 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
SessionManager.open(sessionFile).appendMessage(
|
||||
openTestTranscriptSession({ sessionFile, workspaceDir }).appendMessage(
|
||||
assistantMessage("existing context", Date.now()),
|
||||
);
|
||||
createStartedThreadHarness(async (method) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as approvalBridge from "./approval-bridge.js";
|
||||
import {
|
||||
codexAppServerTestBindingIdentity,
|
||||
createParams,
|
||||
createResumeHarness,
|
||||
createStartedThreadHarness,
|
||||
@@ -462,9 +463,10 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
|
||||
await firstHarness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await firstRun;
|
||||
expect((await readCodexAppServerBinding(sessionFile))?.nativeHookRelayGeneration).toBe(
|
||||
firstGeneration,
|
||||
);
|
||||
expect(
|
||||
(await readCodexAppServerBinding(codexAppServerTestBindingIdentity()))
|
||||
?.nativeHookRelayGeneration,
|
||||
).toBe(firstGeneration);
|
||||
|
||||
const secondHarness = createResumeHarness();
|
||||
const secondParams = createParams(sessionFile, workspaceDir);
|
||||
@@ -491,7 +493,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
it("accepts a stale first hook generation when resuming a pre-generation binding", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
await writeCodexAppServerBinding(codexAppServerTestBindingIdentity(), {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
@@ -545,16 +547,17 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
|
||||
await harness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
|
||||
await run;
|
||||
expect((await readCodexAppServerBinding(sessionFile))?.nativeHookRelayGeneration).toBe(
|
||||
currentGeneration,
|
||||
);
|
||||
expect(
|
||||
(await readCodexAppServerBinding(codexAppServerTestBindingIdentity()))
|
||||
?.nativeHookRelayGeneration,
|
||||
).toBe(currentGeneration);
|
||||
testing.flushPendingCodexNativeHookRelayUnregistersForTests();
|
||||
});
|
||||
|
||||
it("rotates native hook relay generations when an existing binding starts a fresh thread", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
await writeCodexAppServerBinding(codexAppServerTestBindingIdentity(), {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
@@ -594,16 +597,17 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
expect((await readCodexAppServerBinding(sessionFile))?.nativeHookRelayGeneration).toBe(
|
||||
currentGeneration,
|
||||
);
|
||||
expect(
|
||||
(await readCodexAppServerBinding(codexAppServerTestBindingIdentity()))
|
||||
?.nativeHookRelayGeneration,
|
||||
).toBe(currentGeneration);
|
||||
testing.flushPendingCodexNativeHookRelayUnregistersForTests();
|
||||
});
|
||||
|
||||
it("rotates native hook relay generations when resume fails over to a fresh thread", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
await writeCodexAppServerBinding(codexAppServerTestBindingIdentity(), {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
@@ -647,9 +651,10 @@ describe("runCodexAppServerAttempt native hook relay", () => {
|
||||
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
expect((await readCodexAppServerBinding(sessionFile))?.nativeHookRelayGeneration).toBe(
|
||||
currentGeneration,
|
||||
);
|
||||
expect(
|
||||
(await readCodexAppServerBinding(codexAppServerTestBindingIdentity()))
|
||||
?.nativeHookRelayGeneration,
|
||||
).toBe(currentGeneration);
|
||||
testing.flushPendingCodexNativeHookRelayUnregistersForTests();
|
||||
});
|
||||
|
||||
|
||||
@@ -3,11 +3,13 @@ import path from "node:path";
|
||||
import {
|
||||
abortAgentHarnessRun,
|
||||
embeddedAgentLog,
|
||||
listTrajectoryRuntimeEvents,
|
||||
loadSqliteSessionTranscriptEvents,
|
||||
openTranscriptSessionManagerForSession,
|
||||
onAgentEvent,
|
||||
type AgentEventPayload,
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import {
|
||||
onInternalDiagnosticEvent,
|
||||
waitForDiagnosticEventsDrained,
|
||||
@@ -34,7 +36,6 @@ import {
|
||||
} from "./attempt-context.js";
|
||||
import * as authBridge from "./auth-bridge.js";
|
||||
import { resolveCodexAppServerEnvApiKeyCacheKey } from "./auth-bridge.js";
|
||||
import { CodexAppServerRpcError } from "./client.js";
|
||||
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
import {
|
||||
CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
|
||||
@@ -73,7 +74,6 @@ import {
|
||||
} from "./sandbox-exec-server.js";
|
||||
import { createSandboxContext } from "./sandbox-exec-server.test-helpers.js";
|
||||
import { readCodexAppServerBinding, writeCodexAppServerBinding } from "./session-binding.js";
|
||||
import * as sharedClientModule from "./shared-client.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
import { buildTurnStartParams, startOrResumeThread } from "./thread-lifecycle.js";
|
||||
|
||||
@@ -117,12 +117,38 @@ function expectResumeRequest(
|
||||
}
|
||||
}
|
||||
|
||||
function openTestTranscriptSession(params: { sessionFile: string; workspaceDir: string }) {
|
||||
return openTranscriptSessionManagerForSession({
|
||||
agentId: "main",
|
||||
path: params.sessionFile,
|
||||
sessionId: "session-1",
|
||||
cwd: params.workspaceDir,
|
||||
});
|
||||
}
|
||||
|
||||
function appServerTestBindingIdentity(sessionId = "session-1") {
|
||||
return {
|
||||
sessionKey: `agent:main:${sessionId}`,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
function readTranscriptRawForTest(sessionFile: string, sessionId = "session-1"): string {
|
||||
return loadSqliteSessionTranscriptEvents({
|
||||
agentId: "main",
|
||||
path: sessionFile,
|
||||
sessionId,
|
||||
})
|
||||
.map((entry) => JSON.stringify(entry.event))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
async function writeExistingBinding(
|
||||
sessionFile: string,
|
||||
_sessionFile: string,
|
||||
workspaceDir: string,
|
||||
overrides: Partial<Parameters<typeof writeCodexAppServerBinding>[1]> = {},
|
||||
) {
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
await writeCodexAppServerBinding(appServerTestBindingIdentity(), {
|
||||
threadId: "thread-existing",
|
||||
cwd: workspaceDir,
|
||||
model: "gpt-5.4-codex",
|
||||
@@ -939,14 +965,14 @@ describe("runCodexAppServerAttempt", () => {
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
});
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
const binding = await readCodexAppServerBinding(appServerTestBindingIdentity());
|
||||
expect(binding?.mcpServersFingerprint).toBe("mcp-v1");
|
||||
});
|
||||
|
||||
it("starts a new Codex thread when the MCP server fingerprint changes", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
await writeCodexAppServerBinding(appServerTestBindingIdentity(), {
|
||||
threadId: "old-thread",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: JSON.stringify([]),
|
||||
@@ -974,50 +1000,10 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(binding.mcpServersFingerprint).toBe("mcp-v2");
|
||||
});
|
||||
|
||||
it("uses task cwd for Codex app-server requests while keeping bootstrap workspace separate", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const taskCwd = path.join(tempDir, "task-repo");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.mkdir(taskCwd, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "workspace bootstrap", "utf8");
|
||||
await fs.writeFile(path.join(taskCwd, "task-marker.txt"), "task marker", "utf8");
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
|
||||
await startOrResumeThread({
|
||||
client: {
|
||||
getServerVersion: () => "0.132.0",
|
||||
request: async (method: string, requestParams?: unknown) => {
|
||||
requests.push({ method, params: requestParams });
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult();
|
||||
}
|
||||
return {};
|
||||
},
|
||||
} as never,
|
||||
params,
|
||||
cwd: taskCwd,
|
||||
dynamicTools: [],
|
||||
appServer,
|
||||
developerInstructions: "workspace bootstrap",
|
||||
});
|
||||
const threadStart = requests.find((request) => request.method === "thread/start");
|
||||
expect((threadStart?.params as { cwd?: string } | undefined)?.cwd).toBe(taskCwd);
|
||||
|
||||
const turnStart = buildTurnStartParams(params, {
|
||||
threadId: "thread-1",
|
||||
cwd: taskCwd,
|
||||
appServer,
|
||||
});
|
||||
expect(turnStart.cwd).toBe(taskCwd);
|
||||
});
|
||||
|
||||
it("starts a no-MCP Codex thread when MCP config is evaluated empty", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
await writeCodexAppServerBinding(appServerTestBindingIdentity(), {
|
||||
threadId: "old-thread",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: JSON.stringify([]),
|
||||
@@ -1042,7 +1028,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
expect(binding.threadId).toBe("new-thread");
|
||||
expect(binding.mcpServersFingerprint).toBeUndefined();
|
||||
expect((await readCodexAppServerBinding(sessionFile))?.mcpServersFingerprint).toBeUndefined();
|
||||
expect(
|
||||
(await readCodexAppServerBinding(appServerTestBindingIdentity()))?.mcpServersFingerprint,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("scopes Codex developer reply instructions to message-tool-only delivery", () => {
|
||||
@@ -1085,8 +1073,8 @@ describe("runCodexAppServerAttempt", () => {
|
||||
text: "Unscoped structured command guidance.",
|
||||
},
|
||||
{
|
||||
text: "OpenClaw main command guidance.",
|
||||
surfaces: ["openclaw_main"],
|
||||
text: "PI main command guidance.",
|
||||
surfaces: ["pi_main"],
|
||||
},
|
||||
],
|
||||
handler: async () => ({ text: "ok" }),
|
||||
@@ -1099,7 +1087,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(instructions).toContain("Codex app-server command guidance.");
|
||||
expect(instructions).not.toContain("Legacy global command guidance.");
|
||||
expect(instructions).not.toContain("Unscoped structured command guidance.");
|
||||
expect(instructions).not.toContain("OpenClaw main command guidance.");
|
||||
expect(instructions).not.toContain("PI main command guidance.");
|
||||
});
|
||||
|
||||
it("passes OpenClaw skills as turn collaboration developer instructions", async () => {
|
||||
@@ -1147,15 +1135,10 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(inputText).toBe("hello");
|
||||
const [llmInputPayload] = mockCall(llmInput, "llm_input") as [{ prompt?: string }, unknown];
|
||||
expect(llmInputPayload.prompt).toBe(inputText);
|
||||
const trajectoryEvents = (
|
||||
await fs.readFile(path.join(tempDir, "trajectory", "session-1.jsonl"), "utf8")
|
||||
)
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map(
|
||||
(line) =>
|
||||
JSON.parse(line) as { data?: { prompt?: string; systemPrompt?: string }; type?: string },
|
||||
);
|
||||
const trajectoryEvents = listTrajectoryRuntimeEvents({
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
const compiledContext = trajectoryEvents.find((event) => event.type === "context.compiled");
|
||||
expect(compiledContext?.data?.prompt).toBe(inputText);
|
||||
expect(compiledContext?.data?.systemPrompt).toContain("## OpenClaw Skills");
|
||||
@@ -1210,7 +1193,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await vi.waitFor(async () => {
|
||||
const raw = await fs.readFile(sessionFile, "utf8");
|
||||
const raw = readTranscriptRawForTest(sessionFile);
|
||||
expect(raw).toContain('"role":"user"');
|
||||
expect(raw).toContain('"content":"external channel prompt"');
|
||||
expect(raw).toContain('"idempotencyKey":"codex-app-server:thread-1:turn-1:prompt"');
|
||||
@@ -1225,13 +1208,13 @@ describe("runCodexAppServerAttempt", () => {
|
||||
);
|
||||
});
|
||||
|
||||
const rawBeforeCompletion = await fs.readFile(sessionFile, "utf8");
|
||||
const rawBeforeCompletion = readTranscriptRawForTest(sessionFile);
|
||||
expect(rawBeforeCompletion).not.toContain('"role":"assistant"');
|
||||
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
const rawAfterCompletion = await fs.readFile(sessionFile, "utf8");
|
||||
const rawAfterCompletion = readTranscriptRawForTest(sessionFile);
|
||||
expect(rawAfterCompletion.match(/"role":"user"/gu)).toHaveLength(1);
|
||||
expect(onUserMessagePersisted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -1687,7 +1670,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const sessionManager = openTestTranscriptSession({ sessionFile, workspaceDir });
|
||||
sessionManager.appendMessage(assistantMessage("previous turn", Date.now()));
|
||||
const harness = createStartedThreadHarness();
|
||||
|
||||
@@ -1711,7 +1694,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
const wrappedPluginSystemContext = (text: string) =>
|
||||
`---\n\nOpenClaw plugin-injected system context. This block is not workspace file content.\n\n${text}\n\n---`;
|
||||
expect(threadStartParams?.developerInstructions).toContain(
|
||||
`${wrappedPluginSystemContext("pre system")}\n\ncustom codex system\n\n${wrappedPluginSystemContext("post system")}`,
|
||||
`${wrappedPluginSystemContext("pre system")}\n\ncustom codex system\n\n${wrappedPluginSystemContext(
|
||||
"post system",
|
||||
)}`,
|
||||
);
|
||||
const turnStart = harness.requests.find((request) => request.method === "turn/start");
|
||||
const turnStartParams = turnStart?.params as
|
||||
@@ -1725,7 +1710,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
it("does not inject mirrored history when starting Codex without a native thread binding", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const sessionManager = openTestTranscriptSession({ sessionFile, workspaceDir });
|
||||
sessionManager.appendMessage(userMessage("we are fixing the Opik default project", Date.now()));
|
||||
sessionManager.appendMessage(assistantMessage("Opik default project context", Date.now() + 1));
|
||||
const harness = createStartedThreadHarness();
|
||||
@@ -1754,17 +1739,19 @@ describe("runCodexAppServerAttempt", () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
const binding = await readCodexAppServerBinding(appServerTestBindingIdentity());
|
||||
const bindingUpdatedAt = Date.parse(binding?.updatedAt ?? "");
|
||||
if (!Number.isFinite(bindingUpdatedAt)) {
|
||||
throw new Error("expected valid Codex binding timestamp");
|
||||
}
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
await vi.waitFor(() => expect(Date.now()).toBeGreaterThan(bindingUpdatedAt), fastWait);
|
||||
const messageStartedAt = Date.now();
|
||||
const sessionManager = openTestTranscriptSession({ sessionFile, workspaceDir });
|
||||
sessionManager.appendMessage(
|
||||
userMessage("we were discussing the Sonnet leak screenshots", bindingUpdatedAt + 1_000),
|
||||
userMessage("we were discussing the Sonnet leak screenshots", messageStartedAt),
|
||||
);
|
||||
sessionManager.appendMessage(
|
||||
assistantMessage("David Ondrej was mentioned in that prior thread", bindingUpdatedAt + 2_000),
|
||||
assistantMessage("David Ondrej was mentioned in that prior thread", messageStartedAt + 1),
|
||||
);
|
||||
const harness = createResumeHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
@@ -1793,24 +1780,21 @@ describe("runCodexAppServerAttempt", () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
const oldBindingUpdatedAt = Date.now() - 60_000;
|
||||
const bindingPath = `${sessionFile}.codex-app-server.json`;
|
||||
const bindingPayload = JSON.parse(await fs.readFile(bindingPath, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
bindingPayload.updatedAt = new Date(oldBindingUpdatedAt).toISOString();
|
||||
await fs.writeFile(bindingPath, `${JSON.stringify(bindingPayload, null, 2)}\n`);
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
const binding = await readCodexAppServerBinding(appServerTestBindingIdentity());
|
||||
const bindingUpdatedAt = Date.parse(binding?.updatedAt ?? "");
|
||||
if (!Number.isFinite(bindingUpdatedAt)) {
|
||||
throw new Error("expected valid Codex binding timestamp");
|
||||
}
|
||||
await vi.waitFor(() => expect(Date.now()).toBeGreaterThan(bindingUpdatedAt), fastWait);
|
||||
const messageStartedAt = Date.now();
|
||||
const sessionManager = openTestTranscriptSession({ sessionFile, workspaceDir });
|
||||
sessionManager.appendMessage(
|
||||
userMessage("we were discussing the Sonnet leak screenshots", oldBindingUpdatedAt + 1_000),
|
||||
userMessage("we were discussing the Sonnet leak screenshots", messageStartedAt),
|
||||
);
|
||||
sessionManager.appendMessage(
|
||||
assistantMessage(
|
||||
"David Ondrej was mentioned in that prior thread",
|
||||
oldBindingUpdatedAt + 2_000,
|
||||
),
|
||||
assistantMessage("David Ondrej was mentioned in that prior thread", messageStartedAt),
|
||||
);
|
||||
await vi.waitFor(() => expect(Date.now()).toBeGreaterThan(messageStartedAt), fastWait);
|
||||
|
||||
const firstHarness = createResumeHarness();
|
||||
const firstParams = createParams(sessionFile, workspaceDir);
|
||||
@@ -2639,7 +2623,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
"turn/start",
|
||||
"thread/unsubscribe",
|
||||
]);
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
const binding = await readCodexAppServerBinding(appServerTestBindingIdentity());
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
});
|
||||
|
||||
@@ -2666,7 +2650,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
"turn/start",
|
||||
"thread/unsubscribe",
|
||||
]);
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
const binding = await readCodexAppServerBinding(appServerTestBindingIdentity());
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
});
|
||||
|
||||
@@ -2735,18 +2719,17 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
|
||||
it("does not drop turn completion notifications emitted while turn/start is in flight", async () => {
|
||||
const harness: ReturnType<typeof createAppServerHarness> = createAppServerHarness(
|
||||
async (method) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult();
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
return turnStartResult("turn-1", "completed");
|
||||
}
|
||||
return {};
|
||||
},
|
||||
);
|
||||
let harness: ReturnType<typeof createAppServerHarness>;
|
||||
harness = createAppServerHarness(async (method) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult();
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
return turnStartResult("turn-1", "completed");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const result = await runCodexAppServerAttempt(
|
||||
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
|
||||
@@ -2756,31 +2739,30 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
|
||||
it("does not fail when a buffered terminal notification is followed by client close", async () => {
|
||||
let harness: ReturnType<typeof createAppServerHarness>;
|
||||
let resolveBufferedTerminal!: () => void;
|
||||
const bufferedTerminal = new Promise<void>((resolve) => {
|
||||
resolveBufferedTerminal = resolve;
|
||||
});
|
||||
const harness: ReturnType<typeof createAppServerHarness> = createAppServerHarness(
|
||||
async (method) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult();
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
await harness.notify({
|
||||
method: "item/started",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item: { id: "tool-1", type: "commandExecution" },
|
||||
},
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
resolveBufferedTerminal();
|
||||
return turnStartResult("turn-1", "inProgress");
|
||||
}
|
||||
return {};
|
||||
},
|
||||
);
|
||||
harness = createAppServerHarness(async (method) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult();
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
await harness.notify({
|
||||
method: "item/started",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item: { id: "tool-1", type: "commandExecution" },
|
||||
},
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
resolveBufferedTerminal();
|
||||
return turnStartResult("turn-1", "inProgress");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const run = runCodexAppServerAttempt(
|
||||
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
|
||||
@@ -2797,25 +2779,24 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
|
||||
it("does not time out when turn progress arrives before turn/start returns", async () => {
|
||||
const harness: ReturnType<typeof createAppServerHarness> = createAppServerHarness(
|
||||
async (method) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult();
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
await harness.notify({
|
||||
method: "turn/started",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
turn: { id: "turn-1", status: "inProgress" },
|
||||
},
|
||||
});
|
||||
return turnStartResult("turn-1", "inProgress");
|
||||
}
|
||||
return {};
|
||||
},
|
||||
);
|
||||
let harness: ReturnType<typeof createAppServerHarness>;
|
||||
harness = createAppServerHarness(async (method) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult();
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
await harness.notify({
|
||||
method: "turn/started",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
turn: { id: "turn-1", status: "inProgress" },
|
||||
},
|
||||
});
|
||||
return turnStartResult("turn-1", "inProgress");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
@@ -3801,7 +3782,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
rotateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: "1mb",
|
||||
},
|
||||
},
|
||||
@@ -3817,7 +3798,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
expect(requests.map((entry) => entry.method)).toContain("thread/start");
|
||||
expect(requests.map((entry) => entry.method)).not.toContain("thread/resume");
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
const savedBinding = await readCodexAppServerBinding(appServerTestBindingIdentity());
|
||||
expect(savedBinding?.threadId).toBe("thread-1");
|
||||
});
|
||||
|
||||
@@ -3914,7 +3895,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
truncateAfterCompaction: true,
|
||||
rotateAfterCompaction: true,
|
||||
maxActiveTranscriptBytes: "1mb",
|
||||
},
|
||||
},
|
||||
@@ -3934,7 +3915,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(requests.map((entry) => entry.method)).toContain("thread/start");
|
||||
expect(requests.map((entry) => entry.method)).not.toContain("thread/resume");
|
||||
expect(seenAuthProfileIds).toEqual(["openai:work"]);
|
||||
const savedBinding = await readCodexAppServerBinding(sessionFile);
|
||||
const savedBinding = await readCodexAppServerBinding(appServerTestBindingIdentity());
|
||||
expect(savedBinding?.authProfileId).toBe("openai:work");
|
||||
expect(savedBinding?.threadId).toBe("thread-1");
|
||||
});
|
||||
@@ -4044,134 +4025,6 @@ describe("runCodexAppServerAttempt", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not retire the shared Codex client when a spawned helper run fails with a logical thread/start error", async () => {
|
||||
const clearSpy = vi.spyOn(sharedClientModule, "clearSharedCodexAppServerClientIfCurrent");
|
||||
clearSpy.mockClear();
|
||||
let failedClient: unknown;
|
||||
setCodexAppServerClientFactoryForTest(async () => {
|
||||
const c = {
|
||||
request: vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
throw new CodexAppServerRpcError(
|
||||
{ message: "401 authentication_error: Invalid bearer token" },
|
||||
"thread/start",
|
||||
);
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
addNotificationHandler: vi.fn(() => () => undefined),
|
||||
addRequestHandler: vi.fn(() => () => undefined),
|
||||
};
|
||||
failedClient = c;
|
||||
return c as never;
|
||||
});
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.spawnedBy = "agent:main:session-parent";
|
||||
|
||||
await expect(runCodexAppServerAttempt(params)).rejects.toThrow("Invalid bearer token");
|
||||
const calledWithFailedClient = clearSpy.mock.calls.some(([arg]) => arg === failedClient);
|
||||
expect(calledWithFailedClient).toBe(false);
|
||||
clearSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("retires the shared Codex client when a spawned helper run times out during thread/start", async () => {
|
||||
const clearSpy = vi.spyOn(sharedClientModule, "clearSharedCodexAppServerClientIfCurrent");
|
||||
clearSpy.mockClear();
|
||||
let failedClient: unknown;
|
||||
setCodexAppServerClientFactoryForTest(async () => {
|
||||
const c = {
|
||||
request: vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return await new Promise<never>(() => undefined);
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
addNotificationHandler: vi.fn(() => () => undefined),
|
||||
addRequestHandler: vi.fn(() => () => undefined),
|
||||
};
|
||||
failedClient = c;
|
||||
return c as never;
|
||||
});
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.spawnedBy = "agent:main:session-parent";
|
||||
params.timeoutMs = 1;
|
||||
|
||||
await expect(runCodexAppServerAttempt(params, { startupTimeoutFloorMs: 1 })).rejects.toThrow(
|
||||
"codex app-server startup timed out",
|
||||
);
|
||||
const calledWithFailedClient = clearSpy.mock.calls.some(([arg]) => arg === failedClient);
|
||||
expect(calledWithFailedClient).toBe(true);
|
||||
clearSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("retires the shared Codex client when a spawned helper hits a thread/start write failure", async () => {
|
||||
const clearSpy = vi.spyOn(sharedClientModule, "clearSharedCodexAppServerClientIfCurrent");
|
||||
clearSpy.mockClear();
|
||||
let failedClient: unknown;
|
||||
setCodexAppServerClientFactoryForTest(async () => {
|
||||
const c = {
|
||||
request: vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
throw new Error("write EPIPE");
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
addNotificationHandler: vi.fn(() => () => undefined),
|
||||
addRequestHandler: vi.fn(() => () => undefined),
|
||||
};
|
||||
failedClient = c;
|
||||
return c as never;
|
||||
});
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.spawnedBy = "agent:main:session-parent";
|
||||
|
||||
await expect(runCodexAppServerAttempt(params)).rejects.toThrow("write EPIPE");
|
||||
const calledWithFailedClient = clearSpy.mock.calls.some(([arg]) => arg === failedClient);
|
||||
expect(calledWithFailedClient).toBe(true);
|
||||
clearSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("retires the shared Codex client when a top-level run fails with a logical thread/start error", async () => {
|
||||
const clearSpy = vi.spyOn(sharedClientModule, "clearSharedCodexAppServerClientIfCurrent");
|
||||
clearSpy.mockClear();
|
||||
let failedClient: unknown;
|
||||
setCodexAppServerClientFactoryForTest(async () => {
|
||||
const c = {
|
||||
request: vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
throw new CodexAppServerRpcError(
|
||||
{ message: "401 authentication_error: Invalid bearer token" },
|
||||
"thread/start",
|
||||
);
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
addNotificationHandler: vi.fn(() => () => undefined),
|
||||
addRequestHandler: vi.fn(() => () => undefined),
|
||||
};
|
||||
failedClient = c;
|
||||
return c as never;
|
||||
});
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
|
||||
await expect(runCodexAppServerAttempt(params)).rejects.toThrow("Invalid bearer token");
|
||||
const calledWithFailedClient = clearSpy.mock.calls.some(([arg]) => arg === failedClient);
|
||||
expect(calledWithFailedClient).toBe(true);
|
||||
clearSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("passes configured app-server policy, sandbox, service tier, and model on resume", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user