diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 2d07e7c8afc1..ff57f49e1c7d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"d09d23506269b7ca94b52c5e038b26fe9269a8cfc485ce513ee31aa056eece07","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"a35378b670434316ba33a3ba4e595925be685daee9fb0f3c5989983525b18c50","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 7371525573b0..5cf1ada7b53e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"2ba58871e5ca61397755a3bdb98bd6beca75d6e3b5400331c6381041f3aa3d6f","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"b9c074a11791688e63213f51099ea21745b0e554f63ed13e694ea34a3e4328ac","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json index c395276b2e6d..d9524be6ed7d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json @@ -1 +1 @@ -{"contentHash":"279ca366d222d694f91fdaa9233c86f0ee6c388bdffffeb2b5093aed27a4ab4c","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} +{"contentHash":"0a014810b900e53959679fa292ed45cb0f1a1a3cc6b1129021bfa63b871f8e68","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 8f4bc3d7b0fc..31b85bb7faaa 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"b9fc667192c85df94b1768bc92bc4b621f19afad4e9838fca3886f3bb36b50c9","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"d8b7c867b29b4651375455e938ae634e7213e3e8c17444dc2b0ef091e46fda6c","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 242cba4da697..94ceeb3194f9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"a4de0e245a75d79ad66f8bf13b9eb3adbd6a0248cc49986fb046b990db6b14b7","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"883bbab77caeff122bf8b7505dc9299484fba601e285a42d2c457697b7590b8e","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index b831ccc265d4..205deefc917c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"6c0acdac3db1c698033694ca41efad5bdbe7edf8634f78c5ac9e333ddb94124f","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"2f85610859f047db1d76efbe52d9e63d8126ce8e944d53d6fdafb4e7f503b5dc","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index ba502bb52ed6..f1ffe9571fdc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"20da7d32e9a7937993a7529a5744b6b3457d00c92c7a5ec44f4524459ce7400c","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"6b1f575e77dc71e72c4b2575f366e7e9990d391e85364844e0ecec2146e7f902","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index c035dbb733ac..a6647ae1cf97 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"ce27ae6e3f4b61d18bdaff383135dec6b0a3f020901984a4d7883c766e63129a","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"72d713de6cb87ad8e6d9c49456477367a2d30c8157695ddb66ac57919558272b","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-mutation.json b/docs/.generated/plugin-sdk-api-baseline/config-mutation.json index bdcf1e35f34b..18d68e0be4e8 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-mutation.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-mutation.json @@ -1 +1 @@ -{"contentHash":"eabfc0558c4a4b904d69fcb0b77c67eee1b5f09e8f2d7c8ed297aae7978db3b7","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"} +{"contentHash":"9bcbdee2499e17f5411a42bd45919dfcd7cac92f12e7b0d034b519bbc46c70c5","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json index fd036892e527..5b741b271be5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json @@ -1 +1 @@ -{"contentHash":"0c33e9b6da738eb7627dbcdb324cc90d93c24c844d8777ee7eff9573e5e90a3b","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} +{"contentHash":"770e079b242b9c56e8876f8fd08b402a78f1870367dca19d006ef147f9d493a4","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 080b3b04418f..f0957d7f1ba9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"167ca860b15990c05eaad49e7f2a39e38812d1245011f85e2d536b7d7eb93265","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"2ccfe8eb85378f3be3f78f79f2638b00faa943e351ff69464bfed44ed63e09c1","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index da6e98dd0060..c9fa00676e85 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"c5c5a37bc949bfb4166f511a846a4e8273fb639c46f9fa4e5bb78a4ab71dbfab","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"d23cd5df763235dbe35ede1c23d26735f4c183b03dc216ac212aefe75c0c1f96","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 4d89ef39a170..ccf2daaf63e6 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"ab3965e7bb6ce3e1e9e6b684c01e7929980402aaabb12f9750db4c2b04034622","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"f57a244e027b326090ed91cdc105c0c8c83b24318d99be0ec205fffd33504050","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 4a620326f5f6..a4180100b928 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"1335062907a7948a67c59f4320899aaaf33bbea92001675c12ec392e552d1411","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"5008615c80d86383e18527d71442f1501e4f02bdb3faa1494a48edf40fd66f84","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json index 4c722583f968..bf326a52a6cb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json @@ -1 +1 @@ -{"contentHash":"fa78e49abb6b94784740bffdbe267b310bee1a6fbe12b1204182d749d8bc0c43","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} +{"contentHash":"034bb7d9d0e7b1d8f0176c30c409db9f9f013a1c507eea53beb5c3da1d807340","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index 3d9679b340aa..d7bd00e6e002 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"7233fde521fda9b80333647953bcb0611133e044920b25bd1554b56d2efe4af9","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"6b73741c889f68cc19a5d1f8703df2eca650fe57803795045cff122d746cbb6a","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 23a93eb97304..b8e9d32eb33e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"644411a0c68bc01afaa7d8ad9d7a00ce03d8000268b4ceca19ca94642591b8ae","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"15704fc50b81cebceb677a5a69a7bc8c97afd962081c647de58a027bd816f19d","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-auth.json b/docs/.generated/plugin-sdk-api-baseline/provider-auth.json index 1fc9d0b01933..1657b605ae7a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-auth.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-auth.json @@ -1 +1 @@ -{"contentHash":"e2ef3ba2f2196a04530aea5fdcdbf02e064f6c4cf193fcf56cbbc84869e69109","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"} +{"contentHash":"2c97bf2bccffb72065e3432432788926559cdc97f77a129786c4cc91246882a4","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 543de3138817..3153f34476f5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"285f430aa7b02b6311bd6c1763840245c6b144e2210fac97d664e4ce44ea0d27","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"df8043d0630d95bc4c316da28bf675a713e7f1f0c304959d1b4b9c8b30fcbd0d","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 9482b56f978e..b48deb66cf47 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"d77aac23178bb63cad9dcfc7d8e91f3fd37b5d4f44a4221ea1fa2ff259ccf64c","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"8b0c71c8abe13163eee28ca65e6afb490d0ae3c16288292143ad021975b96ca6","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 376241c2c34e..bb5a15cdc566 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"291f8c385ad37dbafeeab36f8725a9b0e459e670f5db17d5d77a683f87ce830e","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"eb0096fd25551a7f4a58dcf6c6b53120f3f1ddc1248b2b962cca60c75f6daa12","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/plan/runners.md b/docs/plan/runners.md index 8f968083cc1d..ddb4840137b5 100644 --- a/docs/plan/runners.md +++ b/docs/plan/runners.md @@ -24,7 +24,7 @@ advances a milestone. | 4 | Picker: grouping, placement, liveness, enrichment | in progress | #120804, #122531, #122635, #122774, #122923 | | F | Real-wire session boundary harness | landed | #121212 | | 5 | Public worker ingress path | landed | #122578, #122643 | -| 6 | Node worker provider (device runners) | in progress | #122683, #122769, #122829, #122939 | +| 6 | Node worker provider (device runners) | in progress | #122683, #122769, #122829, #122939, #123013 | | 7 | Bundle push consent + runner updates | not started | — | | 8 | Stop-and-continue moves | not started | — | | 9 | Deletions (ssh sandbox, openshell, exec-host clones, …) | not started | — | diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 4bd102700b59..386f82a49425 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -287,7 +287,7 @@ select it to open the owning Approvals page. - Automations (cron jobs): stat cards (automation count, failing count, scheduler state, next wake) above an Automations/Run history tab switch; the Automations tab lists jobs in a filterable table (All/Active/Paused, search, schedule and last-run filters, per-row action menu) with starter suggestions below, and the Run history tab shows recent runs across all automations (`cron.*`). - - Tasks: live active and recent background task ledger with linked sessions and cancellation (`tasks.*`). Chat's Background tasks rail groups running and finished work; selecting a row opens a compact in-rail detail view with a back button, bounded prompt, live activity, and output or error summary. + - Tasks: live active and recent background task ledger with linked sessions and cancellation (`tasks.*`). Chat's Background tasks rail groups running and finished work; selecting a rail row opens that task's live status and transcript or prompt/output inspector in the detail sidebar. - Plugins: browse the installed inventory and curated store, search ClawHub, install and remove plugin code, and enable or disable installed plugins (`plugins.*`); MCP server rows edit `mcp.servers` through the config methods. - Skills: status, enable/disable, install, API key updates (`skills.*`). - Devices: one inventory joins paired device records, the node catalog, and live presence (`device.pair.list`, `node.list`, `system-presence`). The Gateway host is pinned first; paired clients show connection status, roles, tokens, capabilities, and commands. Duplicate pairings collapse into an expandable group, and **Clean up N stale** bulk-removes admin-confirmed offline duplicates that were auto-approved (silent local, trusted-CIDR, or SSH-verified) or predate approval provenance. Entries can be removed (`node.pair.remove`, `device.pair.remove`), device pairing and node re-approvals handled inline (`device.pair.*`, `node.pair.approve`/`reject`), and mobile setup codes created from the same card. @@ -503,7 +503,7 @@ Capability toggles stay disabled until the Gateway, session, and runtime config - The thread workspace rail in each Chat pane lists thread files, project files, and artifacts. It docks to the pane's right edge by default; drag its header (or use the dock button) to move it to the bottom, and the choice is stored in the current browser profile. A collapsed rail takes no space at all: reopen it with ⇧⌘B or the files toggle in the title bar, which carries a changed-file count badge. The separate file, tool, and Canvas detail panel is unaffected. - File paths recognized in chat messages read as their basename with a small glyph for the file type in front — a Markdown page, a `package.json` manifest, a TypeScript source, a `.tsx` component, a config or data file, a shell script, and an image each get their own mark, and anything else falls back to a plain document. When two links in the same message share a basename, each keeps just enough of its trailing path to stay distinct. The full path stays on the link: it is what the tooltip shows, what opens in the file panel, and what the message's **Copy** action returns, since copy hands back the original Markdown. Labels you write yourself in a `[label](path)` link are never rewritten. The glyph is drawn from the bundled icon set, never fetched from the network, and is decorative only: it is not read by screen readers and is not part of copied text. Text that is not a recognizable path — anything carrying spaces, parentheses, a `#` fragment, or a `?` query — stays plain prose. - Clicking a file reference in chat, a file path in an expanded read/edit/write tool card, or a file row in the workspace rail opens the file detail panel. UTF-8 text files use a CodeMirror-based code view with syntax highlighting, line numbers, jump-to-line, in-file search, copy actions, and an open-in-external-editor menu. AVIF, GIF, JPEG, PNG, and WebP images no larger than 256 KiB render inline; other binary files show metadata without lossy text decoding. When the Gateway advertises `sessions.files.set` to an `operator.admin` connection, the text panel adds an Edit mode with dirty tracking and Cmd/Ctrl-S save; unsaved drafts survive file, panel, and session navigation in the current browser tab until explicitly saved or discarded. Saves are compare-and-swap on a content hash returned by `sessions.files.get`: if the file changed on disk since it was loaded (for example because the agent kept working), the panel shows a conflict notice with Reload (take the latest content) and Overwrite (keep the local edit) actions. Writes go through the same fs-safe workspace guards as reads — path containment, symlink/hardlink rejection, and a 256 KiB UTF-8 cap — and only overwrite existing files; the editor never creates or deletes them. - - The background tasks rail in each Chat pane lists the current agent's background tasks and subagents (`tasks.list` scoped by agent, kept live by `task` events): running work shows a live elapsed timer, tool-use count, the tool currently in use, and a stop control, while the collapsible finished section adds run durations. Selecting a rail row replaces the list with a compact detail view in the same rail; its back button returns to the list. Clicking an inline subagent activity row instead opens that subagent's live status and child transcript in the detail sidebar, without replacing the main conversation. Open the rail with the title-bar activity toggle; the task snapshot loads eagerly, so it carries a running-count badge without opening the rail first. The Tasks page remains the full cross-agent ledger. + - The background tasks rail in each Chat pane is the list of the current agent's background tasks and subagents (`tasks.list` scoped by agent, kept live by `task` events): running work shows a live elapsed timer, tool-use count, the tool currently in use, and a stop control, while the collapsible finished section adds run durations. Selecting a task from either a rail row or an inline subagent activity row opens its live status and transcript in the detail sidebar without replacing the main conversation; tasks whose session is the current conversation show their prompt and output inspector there instead. Open the rail with the title-bar activity toggle; the task snapshot loads eagerly, so it carries a running-count badge without opening the rail first. The Tasks page remains the full cross-agent ledger. - The workspace rail, background tasks rail, and detail panel adapt to each pane's own width rather than the window: in a narrow pane or compact window both rails present as bottom strips (side-dock controls hide until the pane widens; the workspace rail keeps first claim on the side slot when only one column fits), and the detail panel stacks below the thread with a horizontal resize handle instead of sharing the row with it. Phone-sized viewports still open the detail panel full-screen. - The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options. - **Split view:** open it from the chat title bar (beside the thread diff, background tasks, and thread files toggles), then split the active pane right or down for as many panes as fit. Each pane has its own thread, transcript, composer, and tool stream. diff --git a/extensions/buzz/src/qa/adapter.runtime.test.ts b/extensions/buzz/src/qa/adapter.runtime.test.ts index 45914366312c..c13cbdb4db7c 100644 --- a/extensions/buzz/src/qa/adapter.runtime.test.ts +++ b/extensions/buzz/src/qa/adapter.runtime.test.ts @@ -1,6 +1,7 @@ -import type { - QaBusInboundMessageInput, - QaBusMessage, +import { + parseQaTarget, + type QaBusInboundMessageInput, + type QaBusMessage, } from "openclaw/plugin-sdk/qa-channel-protocol"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { parseBuzzQaCredentialPayload } from "./credentials.js"; @@ -179,7 +180,13 @@ describe("Buzz QA transport adapter", () => { ...input, id: `bus-outbound-${++outboundIndex}`, direction: "outbound" as const, - conversation: { id: "main", kind: "group" as const }, + conversation: (() => { + const target = parseQaTarget(input.to); + return { + id: target.conversationId, + kind: target.chatType, + }; + })(), })); sendMessage .mockResolvedValueOnce({ eventId: "native-root", timestamp: 1_750_000_000_000 }) @@ -199,7 +206,7 @@ describe("Buzz QA transport adapter", () => { const root = await adapter.sendInbound({ accountId: "sut", - conversation: { id: "main", kind: "group" }, + conversation: { id: "main", kind: "channel" }, senderId: "driver", senderName: "QA Driver", text: "@openclaw root", @@ -216,7 +223,7 @@ describe("Buzz QA transport adapter", () => { }); const followUp = await adapter.sendInbound({ accountId: "sut", - conversation: { id: "main", kind: "group" }, + conversation: { id: "main", kind: "channel" }, senderId: "driver", senderName: "QA Driver", text: "@openclaw follow-up", @@ -240,6 +247,7 @@ describe("Buzz QA transport adapter", () => { }); expect(addOutboundMessage).toHaveBeenLastCalledWith( expect.objectContaining({ + to: "channel:main", threadId: root.id, replyToId: followUp.id, }), diff --git a/extensions/buzz/src/qa/adapter.runtime.ts b/extensions/buzz/src/qa/adapter.runtime.ts index 772d56db1e33..40bd225272c2 100644 --- a/extensions/buzz/src/qa/adapter.runtime.ts +++ b/extensions/buzz/src/qa/adapter.runtime.ts @@ -1,5 +1,6 @@ import { setTimeout as sleep } from "node:timers/promises"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { QaBusConversationKind } from "openclaw/plugin-sdk/qa-channel-protocol"; import type { QaRunnerCliRegistration } from "openclaw/plugin-sdk/qa-runner-runtime"; import type { BuzzInboundMessage } from "../message-event.js"; import { buildBuzzTarget } from "../target.js"; @@ -90,7 +91,10 @@ export async function createBuzzQaTransportAdapter( const accountId = options.sutAccountId?.trim() || "sut"; const nativeMessageIds = new Map(); const busMessageIds = new Map(); + // QA scenarios own the logical target while Buzz uses one physical group room. + // Preserve both logical fields so outbound matching sees the conversation it sent. let logicalConversationId = credentials.roomId; + let logicalConversationKind: QaBusConversationKind = "group"; let relayDriver: Awaited>; const resolveBusMessageId = async (nativeId: string | undefined) => { @@ -118,7 +122,7 @@ export async function createBuzzQaTransportAdapter( ]); const outbound = await context.messages.addOutboundMessage({ accountId, - to: `group:${logicalConversationId}`, + to: `${logicalConversationKind}:${logicalConversationId}`, senderId: credentials.sutPublicKey, text: message.text, timestamp: message.createdAt * 1_000, @@ -154,6 +158,7 @@ export async function createBuzzQaTransportAdapter( heartbeat.throwIfFailed(); relayDriver.assertHealthy(); logicalConversationId = input.conversation.id; + logicalConversationKind = input.conversation.kind; const sent = await relayDriver.sendMessage({ text: input.text, mentionSut: isBuzzMention(input.text), @@ -172,6 +177,7 @@ export async function createBuzzQaTransportAdapter( }, resetTransport() { logicalConversationId = credentials.roomId; + logicalConversationKind = "group"; nativeMessageIds.clear(); busMessageIds.clear(); }, diff --git a/extensions/buzz/src/qa/cli.test.ts b/extensions/buzz/src/qa/cli.test.ts index 4281c79f6fb2..0e3723938172 100644 --- a/extensions/buzz/src/qa/cli.test.ts +++ b/extensions/buzz/src/qa/cli.test.ts @@ -48,4 +48,28 @@ describe("Buzz QA CLI", () => { }), ).toEqual(["channel-canary", "channel-mention-gating"]); }); + + it("forwards an explicitly selected thread follow-up scenario", async () => { + const qa = new Command(); + buzzQaCliRegistration.register(qa); + + await qa.parseAsync([ + "node", + "openclaw", + "buzz", + "--credential-file", + "/secure/buzz-qa.json", + "--scenario", + "thread-follow-up", + ]); + + const params = runLiveTransportQaSuiteCommand.mock.calls[0]?.[0]; + expect( + params?.selectScenarioIds({ + primaryModel: "openai/gpt-5.4", + providerMode: "mock-openai", + scenarioIds: ["thread-follow-up"], + }), + ).toEqual(["thread-follow-up"]); + }); }); diff --git a/extensions/discord/src/actions/runtime.messaging.send.ts b/extensions/discord/src/actions/runtime.messaging.send.ts index ef5ba51e77b1..dea82c14fb45 100644 --- a/extensions/discord/src/actions/runtime.messaging.send.ts +++ b/extensions/discord/src/actions/runtime.messaging.send.ts @@ -351,12 +351,14 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction return jsonResult({ ok: true, thread }); } catch (error) { if (error instanceof DiscordThreadInitialMessageError) { + const initialMessageDelivery = error.initialMessageDelivery; return jsonResult({ ok: true, partial: true, thread: error.thread, - warning: "Discord thread was created, but sending the initial message failed.", + warning: `${error.initialMessageWarning}.`, initialMessageError: error.initialMessageError, + ...(initialMessageDelivery ? { initialMessageDelivery } : {}), }); } throw error; diff --git a/extensions/discord/src/actions/runtime.test.ts b/extensions/discord/src/actions/runtime.test.ts index 06e3e1167fcc..24edd208826e 100644 --- a/extensions/discord/src/actions/runtime.test.ts +++ b/extensions/discord/src/actions/runtime.test.ts @@ -2527,6 +2527,95 @@ describe("handleDiscordMessagingAction", () => { initialMessageError: "missing access", }); }); + + it("returns delivery progress when Discord only delivers part of the initial content", async () => { + const thread = { id: "T1", name: "thread", type: 11 }; + createThreadDiscord.mockRejectedValueOnce( + new DiscordThreadInitialMessageError( + thread as ConstructorParameters[0], + new Error("missing access"), + { + starterMessageDelivered: true, + deliveredChunkCount: 1, + deliveredMessageIds: ["starter1"], + failedChunkDelivery: "unknown", + failedChunkIndex: 1, + totalChunkCount: 2, + }, + ), + ); + + const result = await handleMessagingAction( + "threadCreate", + { + channelId: "C1", + name: "thread", + content: "Initial post", + }, + enableAllActions, + ); + + expect(result.details).toEqual({ + ok: true, + partial: true, + thread, + warning: + "Discord thread was created, but delivery of the remaining initial content could not be confirmed.", + initialMessageError: "missing access", + initialMessageDelivery: { + starterMessageDelivered: true, + deliveredChunkCount: 1, + deliveredMessageIds: ["starter1"], + failedChunkDelivery: "unknown", + failedChunkIndex: 1, + totalChunkCount: 2, + }, + }); + }); + + it("reports unconfirmed delivery when the first initial content chunk is ambiguous", async () => { + const thread = { id: "T1", name: "thread", type: 11 }; + createThreadDiscord.mockRejectedValueOnce( + new DiscordThreadInitialMessageError( + thread as ConstructorParameters[0], + new Error("response lost"), + { + starterMessageDelivered: false, + deliveredChunkCount: 0, + deliveredMessageIds: [], + failedChunkDelivery: "unknown", + failedChunkIndex: 0, + totalChunkCount: 1, + }, + ), + ); + + const result = await handleMessagingAction( + "threadCreate", + { + channelId: "C1", + name: "thread", + content: "Initial post", + }, + enableAllActions, + ); + + expect(result.details).toEqual({ + ok: true, + partial: true, + thread, + warning: "Discord thread was created, but initial message delivery could not be confirmed.", + initialMessageError: "response lost", + initialMessageDelivery: { + starterMessageDelivered: false, + deliveredChunkCount: 0, + deliveredMessageIds: [], + failedChunkDelivery: "unknown", + failedChunkIndex: 0, + totalChunkCount: 1, + }, + }); + }); }); describe("handleDiscordGuildAction", () => { diff --git a/extensions/discord/src/send.creates-thread.test.ts b/extensions/discord/src/send.creates-thread.test.ts index 32c057dd163c..9fe29c6fed12 100644 --- a/extensions/discord/src/send.creates-thread.test.ts +++ b/extensions/discord/src/send.creates-thread.test.ts @@ -4,6 +4,7 @@ import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { RateLimitError } from "./internal/discord.js"; +import { hasDiscordMessageCreateAmbiguity } from "./retry.js"; import { makeDiscordRest } from "./send.test-harness.js"; vi.mock("openclaw/plugin-sdk/web-media", async () => { @@ -302,6 +303,110 @@ describe("sendMessageDiscord", () => { }); }); + it("keeps forum starter messages within Discord's content limit", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildForum }); + postMock.mockResolvedValue({ id: "t1" }); + const content = "a".repeat(2001); + + await createThreadDiscord("chan1", { name: "thread", content }, discordClientOpts(rest)); + + expect(postMock).toHaveBeenCalledTimes(2); + expect(requestBody(postMock as unknown as MockCallSource, 0)).toEqual({ + name: "thread", + message: { content: "a".repeat(2000) }, + }); + expect(requestPath(postMock as unknown as MockCallSource, 1)).toBe( + Routes.channelMessages("t1"), + ); + expect(requestBody(postMock as unknown as MockCallSource, 1)).toMatchObject({ + content: "a", + enforce_nonce: true, + }); + }); + + it("keeps sub-limit multi-line forum content in one starter message", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildForum }); + postMock.mockResolvedValue({ id: "t1" }); + const content = Array.from({ length: 18 }, (_, index) => `line ${index + 1}`).join("\n"); + + await createThreadDiscord("chan1", { name: "thread", content }, discordClientOpts(rest)); + + expect(postMock).toHaveBeenCalledTimes(1); + expect(requestBody(postMock as unknown as MockCallSource)).toEqual({ + name: "thread", + message: { content }, + }); + }); + + it("reports a delivered forum starter when a continuation chunk fails", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildForum }); + postMock + .mockResolvedValueOnce({ id: "t1", message: { id: "starter1", channel_id: "t1" } }) + .mockRejectedValueOnce(Object.assign(new Error("missing access"), { status: 403 })); + + let thrown: unknown; + try { + await createThreadDiscord( + "chan1", + { name: "thread", content: "a".repeat(2001) }, + discordClientOpts(rest), + ); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError); + expect(requireRecord(thrown, "thread initial message error").initialMessageDelivery).toEqual({ + starterMessageDelivered: true, + deliveredChunkCount: 1, + deliveredMessageIds: ["starter1"], + failedChunkDelivery: "not_delivered", + failedChunkIndex: 1, + totalChunkCount: 2, + }); + }); + + it("reports an exhausted ambiguous forum continuation as unknown delivery", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildForum }); + const ambiguous = Object.assign(new Error("response lost"), { status: 502 }); + postMock + .mockResolvedValueOnce({ id: "t1", message: { id: "starter1", channel_id: "t1" } }) + .mockRejectedValue(ambiguous); + + let thrown: unknown; + try { + await createThreadDiscord( + "chan1", + { name: "thread", content: "a".repeat(2001) }, + { + ...discordClientOpts(rest), + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }, + ); + } catch (error) { + thrown = error; + } + + expect(postMock).toHaveBeenCalledTimes(3); + expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError); + expect(hasDiscordMessageCreateAmbiguity(thrown)).toBe(true); + expect(requireRecord(thrown, "thread initial message error").message).toContain( + "delivery of the remaining initial content could not be confirmed", + ); + expect(requireRecord(thrown, "thread initial message error").initialMessageDelivery).toEqual({ + starterMessageDelivered: true, + deliveredChunkCount: 1, + deliveredMessageIds: ["starter1"], + failedChunkDelivery: "unknown", + failedChunkIndex: 1, + totalChunkCount: 2, + }); + }); + it("inherits default_auto_archive_duration for forum threads", async () => { const { rest, getMock, postMock } = makeDiscordRest(); getMock.mockResolvedValue({ @@ -457,11 +562,146 @@ describe("sendMessageDiscord", () => { expect(requestPath(postMock as unknown as MockCallSource, 1)).toBe( Routes.channelMessages("t1"), ); - expect(requestBody(postMock as unknown as MockCallSource, 1)).toEqual({ + expect(requestBody(postMock as unknown as MockCallSource, 1)).toMatchObject({ content: "Hello thread!", + enforce_nonce: true, }); }); + it("chunks long initial messages for non-forum threads", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildText }); + postMock.mockResolvedValue({ id: "t1" }); + const content = "a".repeat(2001); + + await createThreadDiscord("chan1", { name: "thread", content }, discordClientOpts(rest)); + + expect(postMock).toHaveBeenCalledTimes(3); + expect(requestPath(postMock as unknown as MockCallSource, 1)).toBe( + Routes.channelMessages("t1"), + ); + expect(requestBody(postMock as unknown as MockCallSource, 1)).toMatchObject({ + content: "a".repeat(2000), + enforce_nonce: true, + }); + expect(requestPath(postMock as unknown as MockCallSource, 2)).toBe( + Routes.channelMessages("t1"), + ); + expect(requestBody(postMock as unknown as MockCallSource, 2)).toMatchObject({ + content: "a", + enforce_nonce: true, + }); + }); + + it("keeps sub-limit multi-line non-forum content in one initial message", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildText }); + postMock.mockResolvedValue({ id: "t1", channel_id: "t1" }); + const content = Array.from({ length: 18 }, (_, index) => `line ${index + 1}`).join("\n"); + + await createThreadDiscord("chan1", { name: "thread", content }, discordClientOpts(rest)); + + expect(postMock).toHaveBeenCalledTimes(2); + expect(requestBody(postMock as unknown as MockCallSource, 1)).toMatchObject({ content }); + }); + + it("reports delivered non-forum chunks when a later chunk fails", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildText }); + postMock + .mockResolvedValueOnce({ id: "t1", name: "thread", type: ChannelType.PublicThread }) + .mockResolvedValueOnce({ id: "msg1", channel_id: "t1" }) + .mockRejectedValueOnce(Object.assign(new Error("missing access"), { status: 403 })); + + let thrown: unknown; + try { + await createThreadDiscord( + "chan1", + { name: "thread", content: "a".repeat(4001) }, + discordClientOpts(rest), + ); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError); + expect(requireRecord(thrown, "thread initial message error").initialMessageDelivery).toEqual({ + starterMessageDelivered: false, + deliveredChunkCount: 1, + deliveredMessageIds: ["msg1"], + failedChunkDelivery: "not_delivered", + failedChunkIndex: 1, + totalChunkCount: 3, + }); + }); + + it("reports an exhausted ambiguous non-forum chunk as unknown delivery", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildText }); + const ambiguous = Object.assign(new Error("response lost"), { status: 502 }); + postMock + .mockResolvedValueOnce({ id: "t1", name: "thread", type: ChannelType.PublicThread }) + .mockResolvedValueOnce({ id: "msg1", channel_id: "t1" }) + .mockRejectedValue(ambiguous); + + let thrown: unknown; + try { + await createThreadDiscord( + "chan1", + { name: "thread", content: "a".repeat(4001) }, + { + ...discordClientOpts(rest), + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }, + ); + } catch (error) { + thrown = error; + } + + expect(postMock).toHaveBeenCalledTimes(4); + expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError); + expect(hasDiscordMessageCreateAmbiguity(thrown)).toBe(true); + expect(requireRecord(thrown, "thread initial message error").message).toContain( + "delivery of the remaining initial content could not be confirmed", + ); + expect(requireRecord(thrown, "thread initial message error").initialMessageDelivery).toEqual({ + starterMessageDelivered: false, + deliveredChunkCount: 1, + deliveredMessageIds: ["msg1"], + failedChunkDelivery: "unknown", + failedChunkIndex: 1, + totalChunkCount: 3, + }); + }); + + it("retries continuation sends with a stable nonce per chunk", async () => { + const { rest, getMock, postMock } = makeDiscordRest(); + getMock.mockResolvedValue({ type: ChannelType.GuildText }); + postMock + .mockResolvedValueOnce({ id: "t1", name: "thread", type: ChannelType.PublicThread }) + .mockRejectedValueOnce(Object.assign(new Error("bad gateway"), { status: 502 })) + .mockResolvedValueOnce({ id: "msg1", channel_id: "t1" }) + .mockResolvedValueOnce({ id: "msg2", channel_id: "t1" }); + + await createThreadDiscord( + "chan1", + { name: "thread", content: "a".repeat(2001) }, + { + ...discordClientOpts(rest), + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }, + ); + + expect(postMock).toHaveBeenCalledTimes(4); + const firstAttempt = requestBody(postMock as unknown as MockCallSource, 1); + const retryAttempt = requestBody(postMock as unknown as MockCallSource, 2); + const nextChunk = requestBody(postMock as unknown as MockCallSource, 3); + expect(firstAttempt.enforce_nonce).toBe(true); + expect(retryAttempt.nonce).toBe(firstAttempt.nonce); + expect(nextChunk.enforce_nonce).toBe(true); + expect(nextChunk.nonce).not.toBe(firstAttempt.nonce); + }); + it("keeps created non-forum thread details when initial message send fails", async () => { const { rest, getMock, postMock } = makeDiscordRest(); getMock.mockResolvedValue({ type: ChannelType.GuildText }); @@ -483,6 +723,7 @@ describe("sendMessageDiscord", () => { expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError); const error = requireRecord(thrown, "thread initial message error"); expect(error.name).toBe("DiscordThreadInitialMessageError"); + expect(error.message).toContain("initial message delivery could not be confirmed"); expect(error.initialMessageError).toBe("missing access"); expect(error.thread).toEqual({ id: "t1", name: "thread", type: ChannelType.PublicThread }); }); @@ -507,8 +748,9 @@ describe("sendMessageDiscord", () => { expect(requestPath(postMock as unknown as MockCallSource, 1)).toBe( Routes.channelMessages("t1"), ); - expect(requestBody(postMock as unknown as MockCallSource, 1)).toEqual({ + expect(requestBody(postMock as unknown as MockCallSource, 1)).toMatchObject({ content: "Discussion here", + enforce_nonce: true, }); }); diff --git a/extensions/discord/src/send.messages.ts b/extensions/discord/src/send.messages.ts index 63699b2708f7..d5a9d459b39e 100644 --- a/extensions/discord/src/send.messages.ts +++ b/extensions/discord/src/send.messages.ts @@ -3,7 +3,6 @@ import type { APIChannel, APIMessage } from "discord-api-types/v10"; import { ChannelType } from "discord-api-types/v10"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { - createChannelMessage, createThread, deleteChannelMessage, editChannelMessage, @@ -18,7 +17,17 @@ import { unpinChannelMessage, } from "./internal/discord.js"; import { parseDiscordRetryAfterBodySeconds } from "./retry-after.js"; -import { resolveDiscordRest } from "./send.shared.js"; +import { + classifyDiscordDeliveryFailure, + recordDiscordMessageCreateAmbiguity, + type DiscordRetryRunner, +} from "./retry.js"; +import { + buildDiscordTextChunks, + createDiscordClient, + resolveDiscordRest, + sendDiscordText, +} from "./send.shared.js"; import type { DiscordMessageEdit, DiscordMessageQuery, @@ -28,6 +37,29 @@ import type { DiscordThreadList, } from "./send.types.js"; +const DISCORD_THREAD_TRANSPORT_ONLY_MAX_LINES = Number.MAX_SAFE_INTEGER; + +type DiscordThreadInitialMessageDelivery = Readonly<{ + starterMessageDelivered: boolean; + deliveredChunkCount: number; + deliveredMessageIds: readonly string[]; + failedChunkDelivery: "not_delivered" | "unknown"; + failedChunkIndex: number; + totalChunkCount: number; +}>; + +function resolveDiscordThreadStarterMessageId(thread: APIChannel): string { + const starterMessage = "message" in thread ? thread.message : undefined; + if ( + starterMessage && + typeof starterMessage === "object" && + "id" in starterMessage && + typeof starterMessage.id === "string" + ) { + return starterMessage.id; + } + return thread.id; +} function assertDiscordResponseArray(value: unknown, label: string): T[] { if (!Array.isArray(value)) { throw new Error(`Unexpected Discord response for ${label}: expected array.`); @@ -49,17 +81,43 @@ function resolveDefaultThreadAutoArchiveDuration(channel?: APIChannel): number | return channel.default_auto_archive_duration; } +function describeDiscordThreadInitialMessageFailure( + delivery?: DiscordThreadInitialMessageDelivery, +): string { + if (delivery?.failedChunkDelivery === "unknown") { + return delivery.deliveredChunkCount > 0 + ? "Discord thread was created, but delivery of the remaining initial content could not be confirmed" + : "Discord thread was created, but initial message delivery could not be confirmed"; + } + return delivery && delivery.deliveredChunkCount > 0 + ? "Discord thread was created, but its initial content was only partially delivered" + : "Discord thread was created, but sending the initial message failed"; +} + export class DiscordThreadInitialMessageError extends Error { + readonly initialMessageDelivery?: DiscordThreadInitialMessageDelivery; readonly initialMessageError: string; + readonly initialMessageWarning: string; readonly thread: APIChannel; - constructor(thread: APIChannel, error: unknown) { + constructor( + thread: APIChannel, + error: unknown, + initialMessageDelivery?: DiscordThreadInitialMessageDelivery, + ) { const initialMessageError = formatErrorMessage(error); - super( - `Discord thread was created, but sending the initial message failed: ${initialMessageError}`, - ); + const initialMessageWarning = + describeDiscordThreadInitialMessageFailure(initialMessageDelivery); + super(`${initialMessageWarning}: ${initialMessageError}`, { cause: error }); this.name = "DiscordThreadInitialMessageError"; + this.initialMessageDelivery = initialMessageDelivery + ? { + ...initialMessageDelivery, + deliveredMessageIds: [...initialMessageDelivery.deliveredMessageIds], + } + : undefined; this.initialMessageError = initialMessageError; + this.initialMessageWarning = initialMessageWarning; this.thread = thread; } } @@ -161,7 +219,7 @@ export async function createThreadDiscord( payload: DiscordThreadCreate, opts: DiscordReactOpts, ) { - const rest = resolveDiscordRest(opts); + const { rest, request } = createDiscordClient(opts); const body: Record = { name: payload.name }; if (!payload.messageId && payload.type !== undefined) { body.type = payload.type; @@ -183,8 +241,18 @@ export async function createThreadDiscord( } const isForumLike = channel?.type === ChannelType.GuildForum || channel?.type === ChannelType.GuildMedia; + const initialMessageContent = isForumLike + ? payload.content?.trim() + ? payload.content + : payload.name + : payload.content?.trim() + ? payload.content + : ""; + const initialMessageChunks = buildDiscordTextChunks(initialMessageContent, { + maxLinesPerMessage: DISCORD_THREAD_TRANSPORT_ONLY_MAX_LINES, + }); if (isForumLike) { - const starterContent = payload.content?.trim() ? payload.content : payload.name; + const starterContent = initialMessageChunks[0] ?? payload.name; body.message = { content: starterContent }; if (payload.appliedTags?.length) { body.applied_tags = payload.appliedTags; @@ -198,15 +266,56 @@ export async function createThreadDiscord( } const thread = await createThread(rest, channelId, { body }, payload.messageId); - // For non-forum channels, send the initial message separately after thread creation. - // Forum channels handle this via the `message` field in the request body. - if (!isForumLike && payload.content?.trim() && "id" in thread) { - try { - await createChannelMessage(rest, thread.id, { - body: { content: payload.content }, - }); - } catch (error) { - throw new DiscordThreadInitialMessageError(thread, error); + // Forum creation accepts exactly one starter message, so keep the first chunk in the + // create request and deliver any remainder after Discord returns the new thread. + const followupChunks = isForumLike ? initialMessageChunks.slice(1) : initialMessageChunks; + if (followupChunks.length && "id" in thread) { + const deliveredMessageIds = isForumLike ? [resolveDiscordThreadStarterMessageId(thread)] : []; + let deliveredChunkCount = isForumLike ? 1 : 0; + const firstFollowupChunkIndex = isForumLike ? 1 : 0; + for (const [followupIndex, content] of followupChunks.entries()) { + let chunkMayHaveDelivered = false; + const trackedRequest: DiscordRetryRunner = (fn, label, options) => + request( + async () => { + try { + return await fn(); + } catch (error) { + chunkMayHaveDelivered ||= classifyDiscordDeliveryFailure(error) === "ambiguous"; + throw error; + } + }, + label, + options, + ); + try { + const result = await sendDiscordText({ + rest, + request: trackedRequest, + channelId: thread.id, + text: content, + maxLinesPerMessage: DISCORD_THREAD_TRANSPORT_ONLY_MAX_LINES, + }); + deliveredMessageIds.push(...result.platformMessageIds); + deliveredChunkCount += 1; + } catch (error) { + const finalFailure = classifyDiscordDeliveryFailure(error); + const failedChunkDelivery = + chunkMayHaveDelivered || finalFailure === "ambiguous" || finalFailure === "unknown" + ? "unknown" + : "not_delivered"; + if (failedChunkDelivery === "unknown") { + recordDiscordMessageCreateAmbiguity(error); + } + throw new DiscordThreadInitialMessageError(thread, error, { + starterMessageDelivered: isForumLike, + deliveredChunkCount, + deliveredMessageIds, + failedChunkDelivery, + failedChunkIndex: firstFollowupChunkIndex + followupIndex, + totalChunkCount: initialMessageChunks.length, + }); + } } } diff --git a/extensions/github-copilot/api.ts b/extensions/github-copilot/api.ts deleted file mode 100644 index eade8e3e3dc5..000000000000 --- a/extensions/github-copilot/api.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Github Copilot API module exposes the plugin public contract. -export { githubCopilotLoginCommand } from "./login.js"; diff --git a/extensions/github-copilot/domain.test.ts b/extensions/github-copilot/domain.test.ts index fcf173197c47..4fd14bdfd61e 100644 --- a/extensions/github-copilot/domain.test.ts +++ b/extensions/github-copilot/domain.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - PUBLIC_GITHUB_COPILOT_DOMAIN, - resolveGithubCopilotDomain, - withGithubCopilotDomainConfig, -} from "./domain.js"; +import { PUBLIC_GITHUB_COPILOT_DOMAIN, resolveGithubCopilotDomain } from "./domain.js"; describe("github-copilot domain resolution", () => { const withDomain = (githubDomain: string) => @@ -40,32 +36,3 @@ describe("github-copilot domain resolution", () => { ); }); }); - -describe("withGithubCopilotDomainConfig", () => { - const tenantConfig = { - models: { - providers: { "github-copilot": { params: { githubDomain: "acme.ghe.com" } } }, - }, - } as never; - - it("persists the tenant domain when login minted a tenant token", () => { - const next = withGithubCopilotDomainConfig({} as never, "acme.ghe.com"); - expect( - (next as { models?: { providers?: Record }> } }) - .models?.providers?.["github-copilot"]?.params?.githubDomain, - ).toBe("acme.ghe.com"); - }); - - it("clears a stale tenant domain after public login", () => { - const next = withGithubCopilotDomainConfig(tenantConfig, "github.com"); - const params = ( - next as { models?: { providers?: Record }> } } - ).models?.providers?.["github-copilot"]?.params; - expect(params && "githubDomain" in params).toBe(false); - }); - - it("leaves config untouched for public login without persisted domain", () => { - const cfg = {} as never; - expect(withGithubCopilotDomainConfig(cfg, "github.com")).toBe(cfg); - }); -}); diff --git a/extensions/github-copilot/domain.ts b/extensions/github-copilot/domain.ts index 1a8d1971cb96..47223bf60700 100644 --- a/extensions/github-copilot/domain.ts +++ b/extensions/github-copilot/domain.ts @@ -54,37 +54,3 @@ export function resolveGithubCopilotDomain(params?: { } return normalizeGithubCopilotDomain(readConfiguredGithubCopilotDomain(params?.config)); } - -// Shortcut login must persist its token's tenant. A missing domain would route -// the tenant token back to github.com after the environment override is removed. -export function withGithubCopilotDomainConfig(cfg: OpenClawConfig, domain: string): OpenClawConfig { - const models: NonNullable = cfg.models ?? {}; - const providers: NonNullable = models.providers ?? {}; - const provider = providers["github-copilot"]; - const params = provider?.params; - const isDefault = domain === PUBLIC_GITHUB_COPILOT_DOMAIN; - if (isDefault && !(params && "githubDomain" in params)) { - return cfg; - } - const nextParams: Record = { ...params }; - if (isDefault) { - delete nextParams.githubDomain; - } else { - nextParams.githubDomain = domain; - } - const nextProviders = { ...providers }; - if (provider) { - nextProviders["github-copilot"] = { ...provider, params: nextParams }; - } else { - // Source config accepts partial provider inputs; catalog materialization - // supplies baseUrl/models before runtime consumption. - Object.assign(nextProviders, { "github-copilot": { params: nextParams } }); - } - return { - ...cfg, - models: { - ...models, - providers: nextProviders, - }, - }; -} diff --git a/extensions/github-copilot/login.ts b/extensions/github-copilot/login.ts index 8e9a371b5fd4..0b532590d9e9 100644 --- a/extensions/github-copilot/login.ts +++ b/extensions/github-copilot/login.ts @@ -1,27 +1,14 @@ // Github Copilot plugin module implements login behavior. -import { intro, note, outro, spinner } from "@clack/prompts"; -import { stylePromptTitle } from "openclaw/plugin-sdk/cli-runtime"; -import { logConfigUpdated, updateConfig } from "openclaw/plugin-sdk/config-mutation"; import { resolveExpiresAtMsFromDurationMs, nonNegativeSecondsToSafeMilliseconds, positiveSecondsToSafeMilliseconds, resolveTimerTimeoutMs, } from "openclaw/plugin-sdk/number-runtime"; -import { - applyAuthProfileConfig, - ensureAuthProfileStore, - normalizeGithubCopilotDomain, -} from "openclaw/plugin-sdk/provider-auth"; -import { upsertAuthProfileWithLockOrThrow } from "openclaw/plugin-sdk/provider-auth-api-key"; +import { normalizeGithubCopilotDomain } from "openclaw/plugin-sdk/provider-auth"; import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; -import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; -import { - PUBLIC_GITHUB_COPILOT_DOMAIN, - resolveGithubCopilotDomain, - withGithubCopilotDomainConfig, -} from "./domain.js"; +import { PUBLIC_GITHUB_COPILOT_DOMAIN } from "./domain.js"; const CLIENT_ID = "Iv1.b507a08c87ecfe98"; const GITHUB_DEVICE_FLOW_REQUEST_TIMEOUT_MS = 30_000; @@ -360,90 +347,3 @@ export async function runGitHubCopilotDeviceFlow( throw err; } } - -export async function githubCopilotLoginCommand( - opts: { profileId?: string; yes?: boolean; agentDir?: string }, - runtime: RuntimeEnv, -) { - if (!process.stdin.isTTY) { - throw new Error("github-copilot login requires an interactive TTY."); - } - - intro(stylePromptTitle("GitHub Copilot login")); - - const profileId = opts.profileId?.trim() || "github-copilot:github"; - const store = ensureAuthProfileStore(opts.agentDir, { - allowKeychainPrompt: false, - }); - - if (store.profiles[profileId] && !opts.yes) { - note( - `Auth profile already exists: ${profileId}\nRe-running will overwrite it.`, - stylePromptTitle("Existing credentials"), - ); - } - - // Mint against the same host the runtime will route to. resolveGithubCopilotDomain - // is env-authoritative (COPILOT_GITHUB_DOMAIN wins), and runtime authentication - // uses the same resolver, so honoring it here keeps the minted token and the - // runtime endpoint on the same tenant instead of minting a public token that - // then 401s against api.. - const domain = resolveGithubCopilotDomain(); - if (domain !== PUBLIC_GITHUB_COPILOT_DOMAIN) { - note( - `Using the GitHub Enterprise domain from COPILOT_GITHUB_DOMAIN (${domain}). Unset it to log in against github.com.`, - stylePromptTitle("GitHub Copilot"), - ); - } - - const spin = spinner(); - spin.start(`Requesting device code from ${domain}...`); - const device = await requestDeviceCode({ - scope: "read:user", - domain, - }); - spin.stop("Device code ready"); - - note( - [`Visit: ${device.verificationUri}`, `Code: ${device.userCode}`].join("\n"), - stylePromptTitle("Authorize"), - ); - - const intervalMs = Math.max(1000, device.intervalMs); - - const polling = spinner(); - polling.start("Waiting for GitHub authorization..."); - const accessToken = await pollForAccessToken({ - deviceCode: device.deviceCode, - intervalMs, - expiresAt: device.expiresAt, - domain, - }); - polling.stop("GitHub access token acquired"); - - await upsertAuthProfileWithLockOrThrow({ - profileId, - credential: { - type: "token", - provider: "github-copilot", - token: accessToken, - }, - agentDir: opts.agentDir, - }); - - await updateConfig((cfg) => - withGithubCopilotDomainConfig( - applyAuthProfileConfig(cfg, { - provider: "github-copilot", - profileId, - mode: "token", - }), - domain, - ), - ); - - logConfigUpdated(runtime); - runtime.log(`Auth profile: ${profileId} (github-copilot/token)`); - - outro("Done"); -} diff --git a/extensions/github-copilot/package.json b/extensions/github-copilot/package.json index e42b82423b40..3c5db97aec70 100644 --- a/extensions/github-copilot/package.json +++ b/extensions/github-copilot/package.json @@ -4,9 +4,6 @@ "private": true, "description": "OpenClaw GitHub Copilot provider plugin", "type": "module", - "dependencies": { - "@clack/prompts": "1.7.0" - }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" }, diff --git a/extensions/memory-core/src/memory/manager-async-state.test.ts b/extensions/memory-core/src/memory/manager-async-state.test.ts index 48c9414859ad..c946d543ad08 100644 --- a/extensions/memory-core/src/memory/manager-async-state.test.ts +++ b/extensions/memory-core/src/memory/manager-async-state.test.ts @@ -66,4 +66,45 @@ describe("memory manager async state", () => { }); expect(syncMock).not.toHaveBeenCalled(); }); + + it("reports background search sync failures", async () => { + const syncError = new Error("sync failed"); + const onError = vi.fn(); + + await startAsyncSearchSync({ + enabled: true, + dirty: false, + sessionsDirty: true, + sync: vi.fn(async () => { + throw syncError; + }), + onError, + }); + + await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(syncError)); + }); + + it("waits for ordinary dirty sync", async () => { + let releaseSync = () => {}; + const pendingSync = new Promise((resolve) => { + releaseSync = () => resolve(); + }); + const syncMock = vi.fn(async () => await pendingSync); + let settled = false; + + const searchSync = startAsyncSearchSync({ + enabled: true, + dirty: true, + sessionsDirty: false, + sync: syncMock, + onError: vi.fn(), + }).then(() => { + settled = true; + }); + + await vi.waitFor(() => expect(syncMock).toHaveBeenCalledWith({ reason: "search" })); + expect(settled).toBe(false); + releaseSync(); + await searchSync; + }); }); diff --git a/extensions/memory-core/src/memory/manager-async-state.ts b/extensions/memory-core/src/memory/manager-async-state.ts index ab1a240f3197..da4e5bd94e3d 100644 --- a/extensions/memory-core/src/memory/manager-async-state.ts +++ b/extensions/memory-core/src/memory/manager-async-state.ts @@ -9,6 +9,12 @@ export async function startAsyncSearchSync(params: { if (!params.enabled || (!params.dirty && !params.sessionsDirty)) { return; } + if (params.sessionsDirty && !params.dirty) { + // Session reconciliation can enumerate and parse a large transcript corpus. Keep + // the existing sync admission/close ownership while letting indexed searches proceed. + void params.sync({ reason: "search" }).catch(params.onError); + return; + } try { await params.sync({ reason: "search" }); } catch (err: unknown) { diff --git a/extensions/memory-core/src/memory/manager-search-orchestration.test.ts b/extensions/memory-core/src/memory/manager-search-orchestration.test.ts index fa30f37e58c8..e56e02ef0bbf 100644 --- a/extensions/memory-core/src/memory/manager-search-orchestration.test.ts +++ b/extensions/memory-core/src/memory/manager-search-orchestration.test.ts @@ -336,6 +336,40 @@ describe("memory index", () => { expect(providerFixture.providerCalls).toHaveLength(0); }); + it("does not block querying on session reconciliation", async () => { + const manager = await getPersistentManager( + createCfg({ provider: "none", minScore: 0, onSearch: true, hybrid: { enabled: true } }), + ); + await manager.sync({ reason: "test" }); + + let releaseSync = () => {}; + const pendingSync = new Promise((resolve) => { + releaseSync = () => resolve(); + }); + const syncAdmitted = vi + .spyOn( + manager as unknown as { + syncAdmitted: (params: { reason: string }) => Promise; + }, + "syncAdmitted", + ) + .mockImplementation(async () => await pendingSync); + + Reflect.set(manager, "dirty", false); + Reflect.set(manager, "sessionsDirty", true); + + const searchPromise = manager.search("zebra", { + maxResults: 5, + minScore: 0, + }); + await vi.waitFor(() => expect(syncAdmitted).toHaveBeenCalledWith({ reason: "search" })); + + const results = await searchPromise; + expect(results.some((entry) => entry.path === "memory/2026-01-12.md")).toBe(true); + releaseSync(); + await pendingSync; + }); + it("waits for dirty sync before querying", async () => { providerFixture.forceNoProvider = true; const manager = await getPersistentManager( diff --git a/extensions/oc-path/src/oc-path/tests/scenarios/perf-determinism.test.ts b/extensions/oc-path/src/oc-path/tests/scenarios/perf-determinism.test.ts index 742c1fa516be..1579cd7ba624 100644 --- a/extensions/oc-path/src/oc-path/tests/scenarios/perf-determinism.test.ts +++ b/extensions/oc-path/src/oc-path/tests/scenarios/perf-determinism.test.ts @@ -5,9 +5,18 @@ import { parseMd } from "../../parse.js"; import { resolveMdOcPath as resolveOcPath } from "../../resolve.js"; const perfBudgetMultiplier = process.env.CI ? 4 : 1; +const perfSampleCount = 3; -function expectWithinPerfBudget(elapsedMs: number, localBudgetMs: number) { - expect(elapsedMs).toBeLessThan(localBudgetMs * perfBudgetMultiplier); +function expectWithinPerfBudget(run: () => void, localBudgetMs: number) { + // Loaded shared-vCPU CI can pause any single sample, so use the best of a few runs. + // The minimum still catches consistently slow regressions without treating contention as one. + let bestElapsedMs = Number.POSITIVE_INFINITY; + for (let sample = 0; sample < perfSampleCount; sample++) { + const start = performance.now(); + run(); + bestElapsedMs = Math.min(bestElapsedMs, performance.now() - start); + } + expect(bestElapsedMs).toBeLessThan(localBudgetMs * perfBudgetMultiplier); } describe("perf + determinism", () => { @@ -20,32 +29,27 @@ describe("perf + determinism", () => { } } const raw = lines.join("\n"); - const start = performance.now(); - parseMd(raw); - const elapsed = performance.now() - start; - expectWithinPerfBudget(elapsed, 200); + expectWithinPerfBudget(() => parseMd(raw), 200); }); it("parses 1000 small files in under 500 ms", () => { const raw = `## H\n- a\n- b: c\n## I\n- d\n`; - const start = performance.now(); - for (let i = 0; i < 1000; i++) { - parseMd(raw); - } - const elapsed = performance.now() - start; - expectWithinPerfBudget(elapsed, 500); + expectWithinPerfBudget(() => { + for (let i = 0; i < 1000; i++) { + parseMd(raw); + } + }, 500); }); it("100k OcPath resolutions on parsed AST in under 500 ms", () => { const raw = `## A\n- a1\n- a2\n## B\n- b1\n- b2\n## C\n- c1: cv\n`; const { ast } = parseMd(raw); const path = { file: "X.md", section: "b", item: "b1" }; - const start = performance.now(); - for (let i = 0; i < 100_000; i++) { - resolveOcPath(ast, path); - } - const elapsed = performance.now() - start; - expectWithinPerfBudget(elapsed, 500); + expectWithinPerfBudget(() => { + for (let i = 0; i < 100_000; i++) { + resolveOcPath(ast, path); + } + }, 500); }); it("same input → byte-identical AST.raw across runs", () => { @@ -115,11 +119,11 @@ describe("perf + determinism", () => { lines.push(""); } const raw = lines.join("\n"); - const start = performance.now(); - const { ast } = parseMd(raw); - const out = emitMd(ast); - const elapsed = performance.now() - start; + let out = ""; + expectWithinPerfBudget(() => { + const { ast } = parseMd(raw); + out = emitMd(ast); + }, 100); expect(out).toBe(raw); - expectWithinPerfBudget(elapsed, 100); }); }); diff --git a/extensions/qa-lab/src/scenario-catalog.test.ts b/extensions/qa-lab/src/scenario-catalog.test.ts index 9cbe8d8df834..f7f11e1749cb 100644 --- a/extensions/qa-lab/src/scenario-catalog.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.test.ts @@ -1018,11 +1018,19 @@ describe("qa scenario catalog", () => { }); it("keeps portable thread relation flows on channels with native thread semantics", () => { - for (const scenarioId of ["thread-follow-up", "thread-isolation"]) { + const expectations = [ + { + scenarioId: "thread-follow-up", + channels: ["qa-channel", "buzz", "slack", "matrix"], + }, + { scenarioId: "thread-isolation", channels: ["qa-channel", "slack", "matrix"] }, + ]; + + for (const { scenarioId, channels } of expectations) { const scenario = requireFlowScenario(readQaScenarioById(scenarioId)); expect(scenario.execution.channel, scenarioId).toBeUndefined(); - expect(scenario.execution.channels, scenarioId).toEqual(["qa-channel", "slack", "matrix"]); + expect(scenario.execution.channels, scenarioId).toEqual(channels); } }); diff --git a/extensions/signal/src/core.test.ts b/extensions/signal/src/core.test.ts index 3e1a10e845a7..da6930da5399 100644 --- a/extensions/signal/src/core.test.ts +++ b/extensions/signal/src/core.test.ts @@ -218,6 +218,26 @@ describe("probeSignal", () => { expect(res.status).toBe(200); }); + it("returns ok=false when the version RPC fails after a successful check", async () => { + vi.spyOn(clientModule, "signalCheck").mockResolvedValueOnce({ + ok: true, + status: 204, + error: null, + }); + vi.spyOn(clientModule, "signalRpcRequest").mockRejectedValueOnce( + new Error("Signal RPC returned malformed JSON"), + ); + + const res = await probeSignal("http://127.0.0.1:8080", 1000); + + expect(res).toMatchObject({ + ok: false, + status: 204, + error: "Signal RPC returned malformed JSON", + version: null, + }); + }); + it("preserves every version reported by a Signal REST container", async () => { vi.spyOn(clientModule, "signalCheck").mockResolvedValueOnce({ ok: true, diff --git a/extensions/signal/src/probe.ts b/extensions/signal/src/probe.ts index f45bdd4c117d..0df8e9bffcac 100644 --- a/extensions/signal/src/probe.ts +++ b/extensions/signal/src/probe.ts @@ -79,7 +79,7 @@ async function probeSignalTransport( } catch (error) { result.error = formatErrorMessage(error); } - return { ...result, ok: true, status: check.status ?? null }; + return { ...result, ok: result.error === null, status: check.status ?? null }; }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 655116a0213f..0bd4ce87c59d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -951,10 +951,6 @@ importers: version: link:../../packages/plugin-sdk extensions/github-copilot: - dependencies: - '@clack/prompts': - specifier: 1.7.0 - version: 1.7.0 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* diff --git a/qa/scenarios/channels/thread-follow-up.yaml b/qa/scenarios/channels/thread-follow-up.yaml index b35d1b9b1adb..ec15d8fa4118 100644 --- a/qa/scenarios/channels/thread-follow-up.yaml +++ b/qa/scenarios/channels/thread-follow-up.yaml @@ -19,11 +19,12 @@ scenario: - docs/concepts/qa-e2e-automation.md codeRefs: - extensions/qa-lab/src/qa-transport.ts + - extensions/buzz/src/qa/adapter.runtime.ts - extensions/qa-lab/src/live-transports/slack/adapter.runtime.ts - extensions/qa-lab/src/live-transports/matrix/adapter.runtime.ts execution: kind: flow - channels: [qa-channel, slack, matrix] + channels: [qa-channel, buzz, slack, matrix] summary: Send a deterministic follow-up through the shared host and require native thread relation evidence. config: rootMarker: QA-THREAD-ROOT-OK diff --git a/qa/scenarios/runtime/empty-response-recovery-replay-safe-read.yaml b/qa/scenarios/runtime/empty-response-recovery-replay-safe-read.yaml index dbdaa04ed824..664fee3606fc 100644 --- a/qa/scenarios/runtime/empty-response-recovery-replay-safe-read.yaml +++ b/qa/scenarios/runtime/empty-response-recovery-replay-safe-read.yaml @@ -17,6 +17,7 @@ scenario: - The runtime injects the visible-answer continuation instruction after the empty turn. - The authenticated read result survives the empty turn and canonical retry unchanged. - The final visible reply contains the exact recovery marker. + - The host-private recovery prompt never appears in the durable transcript. docsRefs: - docs/help/testing.md codeRefs: @@ -96,6 +97,8 @@ flow: args: - ref: env - ref: sessionKey + - probeText: + ref: config.retryNeedle - if: expr: "Boolean(env.mock)" then: @@ -120,9 +123,13 @@ flow: expr: "[scenarioRequests[1], scenarioRequests[2]].every((request) => String(request.toolOutput ?? '').includes(config.evidenceNonce) && String(request.allInputText ?? '').includes(config.evidenceNonce))" message: authenticated read nonce did not survive into both post-tool requests - assert: - expr: "transcript.userMessageCount === 2 && transcript.assistantToolCallCounts.read === 1 && transcript.completedToolCallCounts.read === 1 && transcript.successfulToolCallCounts.read === 1 && transcript.finalText.includes(config.expectedReply)" + expr: "transcript.userMessageCount === 1 && transcript.assistantToolCallCounts.read === 1 && transcript.completedToolCallCounts.read === 1 && transcript.successfulToolCallCounts.read === 1 && transcript.finalText.includes(config.expectedReply)" message: expr: "`recovery transcript lost requester, read-result, or final evidence: ${JSON.stringify(transcript)}`" + - assert: + expr: "transcript.probeTextEndLine === undefined" + message: + expr: "`host-private recovery prompt leaked into the durable transcript: ${JSON.stringify(transcript)}`" - assert: expr: "scenarioOutbound.length === 1 && scenarioOutbound[0]?.text.trim() === config.expectedReply && !scenarioOutbound[0].text.trim().startsWith('⚠️')" message: diff --git a/scripts/e2e/lib/fixtures/workspace.mjs b/scripts/e2e/lib/fixtures/workspace.mjs index 2632e2434400..139d3dc599e7 100644 --- a/scripts/e2e/lib/fixtures/workspace.mjs +++ b/scripts/e2e/lib/fixtures/workspace.mjs @@ -30,6 +30,8 @@ function writeAgentsDeleteConfig() { fs.mkdirSync(sharedWorkspace, { recursive: true }); writeJson(path.join(stateDir, "openclaw.json"), { agents: { + ownership: "explicit", + defaults: { heartbeat: { agentId: "main" } }, entries: { main: { workspace: sharedWorkspace }, ops: { workspace: sharedWorkspace }, diff --git a/scripts/e2e/parallels/npm-update-scripts.ts b/scripts/e2e/parallels/npm-update-scripts.ts index e5e0ba9aad01..9f0f9427b282 100644 --- a/scripts/e2e/parallels/npm-update-scripts.ts +++ b/scripts/e2e/parallels/npm-update-scripts.ts @@ -26,6 +26,8 @@ interface NpmUpdateScriptInput { } const windowsStalePostSwapImportRegex = String.raw`node_modules\\openclaw\\dist\\[^\\]+-[A-Za-z0-9_-]+\.js`; +const startupMigrationRestartPrefix = + "OpenClaw plugin migration inputs changed during startup convergence;"; const macosGuestPath = "/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/usr/local/bin:/usr/local/sbin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin"; const macosOpenClawCommand = '"$OPENCLAW_BIN"'; @@ -155,25 +157,65 @@ $updateExit = $script:OpenClawUpdateExit $updateOutput`; } -function windowsGatewayReadyScript(): string { - return `function Wait-OpenClawGateway { +function windowsGatewayReadyScript(input: NpmUpdateScriptInput): string { + return `$gatewayLogRoot = Join-Path ([System.IO.Path]::GetTempPath()) 'openclaw-parallels-windows-gateway' +$gatewayLaunch = 0 +$gatewayRestartCount = 0 +function Start-OpenClawGateway { + $script:gatewayLaunch += 1 + $script:gatewayLogPath = "$gatewayLogRoot-$($script:gatewayLaunch).log" + Remove-Item $script:gatewayLogPath -Force -ErrorAction SilentlyContinue + $gatewayCommand = Resolve-OpenClawCommand + $gatewayCommandPath = $gatewayCommand.Path.Replace("'", "''") + $gatewayInvocation = if ($gatewayCommand.Kind -eq 'node') { + "& node.exe '$gatewayCommandPath' gateway run --bind loopback --port 18789 --force" + } else { + "& '$gatewayCommandPath' gateway run --bind loopback --port 18789 --force" + } + $gatewayScript = "\`$ErrorActionPreference = 'Continue'\`n$gatewayInvocation *>> \`$env:OPENCLAW_PARALLELS_GATEWAY_LOG\`nexit \`$LASTEXITCODE" + $gatewayEncodedScript = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($gatewayScript)) + $gatewayPowerShell = (Get-Process -Id $PID).Path + Invoke-WithScopedEnv @{ + OPENCLAW_HOME = $env:USERPROFILE + OPENCLAW_STATE_DIR = (Join-Path $env:USERPROFILE '.openclaw') + OPENCLAW_CONFIG_PATH = (Join-Path $env:USERPROFILE '.openclaw\\openclaw.json') + OPENCLAW_PARALLELS_GATEWAY_LOG = $script:gatewayLogPath + ${input.auth.apiKeyEnv} = ${psSingleQuote(input.auth.apiKeyValue)} + } { + $script:gatewayProcess = Start-Process -FilePath $gatewayPowerShell -ArgumentList @('-NoProfile', '-NonInteractive', '-EncodedCommand', $gatewayEncodedScript) -WindowStyle Hidden -PassThru + } +} +function Write-CurrentGatewayLog { + if (Test-Path $script:gatewayLogPath) { + Get-Content $script:gatewayLogPath -ErrorAction SilentlyContinue | Out-Host + } +} +function Test-CurrentGatewayStartupMigrationRefusal { + if (-not (Test-Path $script:gatewayLogPath)) { return $false } + return Select-String -Path $script:gatewayLogPath -SimpleMatch ${psSingleQuote(startupMigrationRestartPrefix)} -Quiet +} +function Wait-OpenClawGateway { $deadline = (Get-Date).AddSeconds(180) - $attempt = 0 while ((Get-Date) -lt $deadline) { Invoke-OpenClaw gateway status --deep --require-rpc --timeout 15000 if ($LASTEXITCODE -eq 0) { return } - $attempt += 1 - if ($attempt -eq 4) { - Invoke-OpenClaw gateway start *>&1 | Out-Host + if ($script:gatewayProcess.HasExited) { + $script:gatewayProcess.WaitForExit() + if ($script:gatewayRestartCount -eq 0 -and (Test-CurrentGatewayStartupMigrationRefusal)) { + $script:gatewayRestartCount = 1 + Write-Host 'gateway exited after startup migration convergence refusal; restarting once' + Start-OpenClawGateway + continue + } + Write-CurrentGatewayLog + throw "gateway exited before becoming ready after update with code $($script:gatewayProcess.ExitCode)" } Start-Sleep -Seconds 5 } + Write-CurrentGatewayLog throw "gateway did not become ready after update" } -Invoke-OpenClaw gateway restart *>&1 | Out-Host -if ($LASTEXITCODE -ne 0) { - "gateway restart exited with code $LASTEXITCODE; probing readiness before failing" | Out-Host -} +Start-OpenClawGateway Wait-OpenClawGateway`; } @@ -259,27 +301,44 @@ stop_openclaw_gateway_processes() { fi fi } +gateway_log=/tmp/openclaw-parallels-macos-gateway.log +rm -f "$gateway_log" +touch "$gateway_log" +gateway_pid= +gateway_launch_log_offset=0 +gateway_restart_count=0 start_openclaw_gateway() { stop_openclaw_gateway_processes - rm -f /tmp/openclaw-parallels-macos-gateway.log + gateway_launch_log_offset="$(wc -c <"$gateway_log" 2>/dev/null | tr -d '[:space:]' || echo 0)" trap '' HUP - with_provider_api_key /usr/bin/env OPENCLAW_HOME="$HOME" OPENCLAW_STATE_DIR="$HOME/.openclaw" OPENCLAW_CONFIG_PATH="$HOME/.openclaw/openclaw.json" "$OPENCLAW_BIN" gateway run --bind loopback --port 18789 --force >/tmp/openclaw-parallels-macos-gateway.log 2>&1 >"$gateway_log" 2>&1 /dev/null; then + if wait "$gateway_pid"; then gateway_exit_status=0; else gateway_exit_status=$?; fi + if [ "$gateway_exit_status" -le 128 ] && [ "$gateway_restart_count" -eq 0 ]; then + if tail -c +"$((gateway_launch_log_offset + 1))" "$gateway_log" 2>/dev/null | grep -F -- ${shellQuote(startupMigrationRestartPrefix)} >/dev/null; then + gateway_restart_count=1 + echo "gateway exited after startup migration convergence refusal; restarting once" + start_openclaw_gateway + continue + fi + fi + print_log_tail "$gateway_log" >&2 + echo "gateway exited before becoming ready after update (exit $gateway_exit_status)" >&2 + if [ "$gateway_exit_status" -eq 0 ]; then exit 1; fi + exit "$gateway_exit_status" fi sleep 2 done - print_log_tail /tmp/openclaw-parallels-macos-gateway.log >&2 + print_log_tail "$gateway_log" >&2 echo "gateway did not become ready after update" >&2 exit 1 } @@ -368,7 +427,7 @@ if ($updateExit -ne 0) { Write-Host "openclaw update returned a stale post-swap module import; continuing to post-update health checks" } ${windowsVersionCheck(input.expectedNeedle)} -${windowsGatewayReadyScript()} +${windowsGatewayReadyScript(input)} ${windowsAssertAgentOkScript(input)}`; } @@ -403,27 +462,44 @@ stop_openclaw_gateway_processes() { OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 OPENCLAW_ALLOW_ROOT=1 openclaw gateway stop || true pkill -f 'openclaw.*gateway' >/dev/null 2>&1 || true } +gateway_log=/tmp/openclaw-parallels-linux-gateway.log +rm -f "$gateway_log" +touch "$gateway_log" +gateway_pid= +gateway_launch_log_offset=0 +gateway_restart_count=0 start_openclaw_gateway() { pkill -f "openclaw gateway run" >/dev/null 2>&1 || true - rm -f /tmp/openclaw-parallels-linux-gateway.log + gateway_launch_log_offset="$(wc -c <"$gateway_log" 2>/dev/null | tr -d '[:space:]' || echo 0)" with_provider_api_key setsid sh -lc ${shellQuote( - "exec env OPENCLAW_HOME=/root OPENCLAW_STATE_DIR=/root/.openclaw OPENCLAW_CONFIG_PATH=/root/.openclaw/openclaw.json OPENCLAW_DISABLE_BONJOUR=1 OPENCLAW_ALLOW_ROOT=1 openclaw gateway run --bind loopback --port 18789 --force >/tmp/openclaw-parallels-linux-gateway.log 2>&1", + "exec env OPENCLAW_HOME=/root OPENCLAW_STATE_DIR=/root/.openclaw OPENCLAW_CONFIG_PATH=/root/.openclaw/openclaw.json OPENCLAW_DISABLE_BONJOUR=1 OPENCLAW_ALLOW_ROOT=1 openclaw gateway run --bind loopback --port 18789 --force >>/tmp/openclaw-parallels-linux-gateway.log 2>&1", )} >/dev/null 2>&1 < /dev/null & + gateway_pid=$! } wait_for_gateway() { deadline=$((SECONDS + 240)) - attempt=0 while [ "$SECONDS" -lt "$deadline" ]; do if openclaw gateway status --deep --require-rpc --timeout 15000; then return fi - attempt=$((attempt + 1)) - if [ "$attempt" -eq 4 ]; then - start_openclaw_gateway + if ! kill -0 "$gateway_pid" 2>/dev/null; then + if wait "$gateway_pid"; then gateway_exit_status=0; else gateway_exit_status=$?; fi + if [ "$gateway_exit_status" -le 128 ] && [ "$gateway_restart_count" -eq 0 ]; then + if tail -c +"$((gateway_launch_log_offset + 1))" "$gateway_log" 2>/dev/null | grep -F -- ${shellQuote(startupMigrationRestartPrefix)} >/dev/null; then + gateway_restart_count=1 + echo "gateway exited after startup migration convergence refusal; restarting once" + start_openclaw_gateway + continue + fi + fi + print_log_tail "$gateway_log" >&2 + echo "gateway exited before becoming ready after update (exit $gateway_exit_status)" >&2 + if [ "$gateway_exit_status" -eq 0 ]; then exit 1; fi + exit "$gateway_exit_status" fi sleep 2 done - print_log_tail /tmp/openclaw-parallels-linux-gateway.log >&2 + print_log_tail "$gateway_log" >&2 echo "gateway did not become ready after update" >&2 exit 1 } diff --git a/scripts/package-openclaw-for-docker.mts b/scripts/package-openclaw-for-docker.mts index 21defce661f1..9347666825b7 100644 --- a/scripts/package-openclaw-for-docker.mts +++ b/scripts/package-openclaw-for-docker.mts @@ -628,6 +628,7 @@ export async function prepareBundledAiRuntimePackage( ) { const packageJsonPath = path.join(sourceDir, "package.json"); const aiRuntimePackageJsonPath = path.join(sourceDir, "packages", "ai", "package.json"); + const aiRuntimeSourceDir = path.dirname(aiRuntimePackageJsonPath); const aiRuntimePath = path.join(sourceDir, "node_modules", "@openclaw", "ai"); const aiRuntimeBackupPath = path.join( sourceDir, @@ -646,6 +647,8 @@ export async function prepareBundledAiRuntimePackage( DEFAULT_PACKAGE_PACK_TIMEOUT_MS, ), })); + const prepareManifest = packageOptions.prepareManifest ?? (async () => false); + const restoreManifest = packageOptions.restoreManifest ?? (async () => false); const originalPackageJson = await fs.readFile(packageJsonPath, "utf8"); let packageJson: MutableJsonRecord & { bundleDependencies?: unknown; @@ -721,26 +724,40 @@ export async function prepareBundledAiRuntimePackage( }; try { - await runCaptureImpl( - "pnpm", - [ - "--dir", - "packages/ai", - "pack", - "--loglevel=error", - "--use-stderr", - "--pack-destination", - outputDir, - ], - sourceDir, - { - deferForwardedSignalExit: true, - timeoutMs: resolveTimeoutMs( - "OPENCLAW_DOCKER_PACKAGE_PACK_TIMEOUT_MS", - DEFAULT_PACKAGE_PACK_TIMEOUT_MS, - ), - }, - ); + let packError: Error | undefined; + await prepareManifest(aiRuntimeSourceDir); + try { + await runCaptureImpl( + "pnpm", + [ + "--dir", + "packages/ai", + "pack", + "--loglevel=error", + "--use-stderr", + "--pack-destination", + outputDir, + ], + sourceDir, + { + deferForwardedSignalExit: true, + timeoutMs: resolveTimeoutMs( + "OPENCLAW_DOCKER_PACKAGE_PACK_TIMEOUT_MS", + DEFAULT_PACKAGE_PACK_TIMEOUT_MS, + ), + }, + ); + } catch (error) { + packError = toErrorObject(error, "AI runtime package failed."); + } + try { + await restoreManifest(aiRuntimeSourceDir); + } catch (restoreError) { + throw packError ? packagePreparationRestoreError(packError, restoreError) : restoreError; + } + if (packError) { + throw packError; + } packedAiTarballs = (await fs.readdir(outputDir)) .filter(isPackedAiRuntimeTarball) .map((filename) => path.join(outputDir, filename)); @@ -924,7 +941,15 @@ export async function packOpenClawPackageForDocker( let cleanupBundledAiRuntime = async () => {}; try { await cleanPackedOpenClawTarballs(outputPath); - cleanupBundledAiRuntime = await prepareBundledAiRuntime(sourcePath, outputPath, runCaptureImpl); + cleanupBundledAiRuntime = await prepareBundledAiRuntime( + sourcePath, + outputPath, + runCaptureImpl, + { + prepareManifest, + restoreManifest, + }, + ); const packArgs = packTool === "pnpm" ? ["pack", "--silent", "--config.ignore-scripts=true", "--pack-destination", outputPath] diff --git a/src/agents/agent-bundle-mcp-runtime.test.ts b/src/agents/agent-bundle-mcp-runtime.test.ts index db0ffe3d0e4a..0a8dc45249a4 100644 --- a/src/agents/agent-bundle-mcp-runtime.test.ts +++ b/src/agents/agent-bundle-mcp-runtime.test.ts @@ -28,6 +28,15 @@ import type { SessionMcpRuntime } from "./agent-bundle-mcp-types.js"; import { writeExecutable } from "./bundle-mcp-shared.test-harness.js"; import { updateMcpAppModelContext } from "./mcp-app-model-context.js"; +const pluginToolMetadata = vi.hoisted(() => new WeakMap()); + +vi.mock("../plugins/tools.js", () => ({ + getPluginToolMeta: (tool: object) => pluginToolMetadata.get(tool), + setPluginToolMeta: (tool: object, metadata: unknown) => { + pluginToolMetadata.set(tool, metadata); + }, +})); + vi.mock("./embedded-agent-mcp.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -51,6 +60,13 @@ vi.mock("./embedded-agent-mcp.js", async (importOriginal) => { }; }); +vi.mock("./mcp-auth-profile.js", () => ({ + resolveMcpAuthProfileId: () => undefined, + withMcpAuthProfileBearer: () => { + throw new Error("Unexpected auth-profile transport in MCP runtime test"); + }, +})); + const tempDirs: string[] = []; const tempDirTracker = useAutoCleanupTempDirTracker(afterEach); @@ -1483,46 +1499,6 @@ describe("session MCP runtime", () => { } }); - it("rejects adversarial MCP tool filters without regex backtracking", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "bundle-mcp-linear-filter-")); - const serverPath = path.join(tempDir, "linear-filter.mjs"); - const logPath = path.join(tempDir, "server.log"); - await writeListToolsMcpServer({ - filePath: serverPath, - logPath, - tools: [ - { - name: `${"a".repeat(64)}c`, - inputSchema: { type: "object", properties: {} }, - }, - ], - }); - - const runtime = await getOrCreateSessionMcpRuntime({ - sessionId: "session-linear-tool-filter", - sessionKey: "agent:test:session-linear-tool-filter", - workspaceDir: "/workspace", - cfg: { - mcp: { - servers: { - docs: { - command: process.execPath, - args: [serverPath], - toolFilter: { include: [`${"*a".repeat(24)}*b`] }, - }, - }, - }, - }, - }); - - try { - expect((await runtime.getCatalog()).tools).toEqual([]); - } finally { - await runtime.dispose(); - await fs.rm(tempDir, { recursive: true, force: true }); - } - }); - it("lists MCP tools from servers that omit the tools capability", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "bundle-mcp-unadvertised-tools-")); const serverPath = path.join(tempDir, "unadvertised-tools.mjs"); diff --git a/src/agents/agent-scope-config.ts b/src/agents/agent-scope-config.ts index b75cd12b363a..9cf73cada839 100644 --- a/src/agents/agent-scope-config.ts +++ b/src/agents/agent-scope-config.ts @@ -1,6 +1,9 @@ /** Resolves configured agent ids, directories, workspaces, and merged agent defaults. */ import path from "node:path"; -import { readStringValue } from "@openclaw/normalization-core/string-coerce"; +import { + normalizeOptionalString, + readStringValue, +} from "@openclaw/normalization-core/string-coerce"; import { getRetainedLegacyDefaultAgentId } from "../config/legacy.default-agent-owner-state.js"; import { hasExplicitModelPolicyAllow } from "../config/model-policy-allowlist-migration.js"; import { resolveStateDir } from "../config/paths.js"; @@ -216,6 +219,26 @@ export function tryResolveLegacyCompatibilityAgentId(cfg: OpenClawConfig): strin : tryResolveDefaultAgentId(cfg); } +/** Resolves the configured owner for ambient system work and explicit consults. */ +export function resolveSystemAgentTargetAgentId( + cfg: OpenClawConfig, + requestedAgentId?: string, +): string { + const configuredAgentId = + normalizeOptionalString(requestedAgentId) ?? + normalizeOptionalString(cfg.agents?.defaults?.systemAgent?.agentId); + if (configuredAgentId) { + return normalizeAgentId(configuredAgentId); + } + return normalizeAgentId( + tryResolveLegacyCompatibilityAgentId(cfg) ?? + resolveDefaultAgentId(cfg, { + surface: "system-agent consult routing", + hint: "Set agents.defaults.systemAgent.agentId or pass an explicit consult agent id.", + }), + ); +} + /** @deprecated Use resolveSoleAgentId; accepts raw shipped markers only for input compatibility. */ export function resolveDefaultAgentId( cfg: OpenClawConfig, diff --git a/src/agents/code-mode-headless.ts b/src/agents/code-mode-headless.ts index 4b3dcef61553..ceb68dcd9b03 100644 --- a/src/agents/code-mode-headless.ts +++ b/src/agents/code-mode-headless.ts @@ -99,10 +99,9 @@ async function runHeadlessWorkerLeg(params: { }): Promise { const remainingMs = remainingHeadlessMs(params.deadline); const timeoutMs = Math.max(1, Math.min(params.config.timeoutMs, remainingMs)); - const workerTimeoutMs = Math.max( - 1, - Math.min(remainingMs, timeoutMs + CODE_MODE_WORKER_WATCHDOG_GRACE_MS), - ); + // Let the headless abort scope own the wall-clock deadline. Capping the host + // watchdog to the same deadline makes its internal timeout message race the scope. + const workerTimeoutMs = timeoutMs + CODE_MODE_WORKER_WATCHDOG_GRACE_MS; return await runCodeModeWorker( { ...params.input, diff --git a/src/agents/command/attempt-execution.test.ts b/src/agents/command/attempt-execution.test.ts index f8ad27645ecf..622373f80715 100644 --- a/src/agents/command/attempt-execution.test.ts +++ b/src/agents/command/attempt-execution.test.ts @@ -4,6 +4,19 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + readClaudeCliFallbackSeed: vi.fn(), +})); + +vi.mock("../cli-runner/log.js", () => ({ + cliBackendLog: { warn: vi.fn() }, +})); + +vi.mock("../../gateway/cli-session-history.js", () => ({ + readClaudeCliFallbackSeed: mocks.readClaudeCliFallbackSeed, +})); + import { cliBackendLog } from "../cli-runner/log.js"; import { buildClaudeCliFallbackContextPrelude, @@ -41,16 +54,6 @@ describe("resolveFallbackRetryPrompt", () => { ).toBe(`[Retry after the previous model attempt failed or timed out]\n\n${originalBody}`); }); - it("preserves original body for fallback retry when session has no history (subagent spawn)", () => { - expect( - resolveFallbackRetryPrompt({ - body: originalBody, - isFallbackRetry: true, - sessionHasHistory: false, - }), - ).toBe(originalBody); - }); - it("preserves original body for fallback retry when sessionHasHistory is undefined", () => { expect( resolveFallbackRetryPrompt({ @@ -78,16 +81,6 @@ describe("resolveFallbackRetryPrompt", () => { ).toBe(originalBody); }); - it("preserves original body on fallback retry without history", () => { - expect( - resolveFallbackRetryPrompt({ - body: originalBody, - isFallbackRetry: true, - sessionHasHistory: false, - }), - ).toBe(originalBody); - }); - it("prepends priorContextPrelude before the retry marker on fallback retry", () => { const prelude = "## Prior session context (from claude-cli)\nuser: prior question"; // Claude fallback prelude must come before the retry marker so the model @@ -244,68 +237,60 @@ describe("formatClaudeCliFallbackPrelude", () => { }); describe("buildClaudeCliFallbackContextPrelude", () => { + beforeEach(() => { + mocks.readClaudeCliFallbackSeed.mockReset(); + }); + it("returns empty string when no sessionId is provided", () => { expect(buildClaudeCliFallbackContextPrelude({ cliSessionId: undefined })).toBe(""); expect(buildClaudeCliFallbackContextPrelude({ cliSessionId: " " })).toBe(""); + expect(mocks.readClaudeCliFallbackSeed).not.toHaveBeenCalled(); }); - it("returns empty string when the Claude session file does not exist", async () => { - const tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fallback-prelude-")); - try { - expect( - buildClaudeCliFallbackContextPrelude({ - cliSessionId: "missing-session", - homeDir: tmpHome, - }), - ).toBe(""); - } finally { - await fs.rm(tmpHome, { recursive: true, force: true }); - } + it("returns empty string when the Claude session loader finds no seed", () => { + mocks.readClaudeCliFallbackSeed.mockReturnValue(undefined); + + expect( + buildClaudeCliFallbackContextPrelude({ + cliSessionId: "missing-session", + homeDir: "/tmp/test-home", + }), + ).toBe(""); + expect(mocks.readClaudeCliFallbackSeed).toHaveBeenCalledWith({ + cliSessionId: "missing-session", + homeDir: "/tmp/test-home", + }); }); - it("reads a real Claude JSONL fixture and emits a labeled prelude end-to-end", async () => { - const tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fallback-prelude-")); - const sessionId = "e2e-session"; - const projectsDir = path.join(tmpHome, ".claude", "projects", "demo"); - try { - // Use Claude's JSONL shape directly so parser and formatter behavior stay - // aligned with real CLI transcripts rather than synthetic message arrays. - await fs.mkdir(projectsDir, { recursive: true }); - const lines = [ + it("formats the Claude session loader seed into a labeled fallback prelude", () => { + mocks.readClaudeCliFallbackSeed.mockReturnValue({ + recentTurns: [ { - type: "user", - uuid: "u1", - message: { role: "user", content: "prior question about deploys" }, + role: "user", + content: "prior question about deploys", }, { - type: "assistant", - uuid: "a1", - message: { - role: "assistant", - model: "claude-sonnet-4-6", - content: [ - { type: "text", text: "prior answer about blue-green" }, - { type: "tool_use", id: "toolu_1", name: "Bash", input: { command: "pwd" } }, - ], - }, + role: "assistant", + content: [ + { type: "text", text: "prior answer about blue-green" }, + { type: "toolcall", name: "Bash" }, + ], }, - ]; - await fs.writeFile( - path.join(projectsDir, `${sessionId}.jsonl`), - `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, - "utf-8", - ); - const prelude = buildClaudeCliFallbackContextPrelude({ - cliSessionId: sessionId, - homeDir: tmpHome, - }); - expect(prelude).toContain("## Prior session context (from claude-cli)"); - expect(prelude).toContain("user: prior question about deploys"); - expect(prelude).toContain("assistant: prior answer about blue-green"); - expect(prelude).toContain("(tool call: Bash)"); - } finally { - await fs.rm(tmpHome, { recursive: true, force: true }); - } + ], + }); + + const prelude = buildClaudeCliFallbackContextPrelude({ + cliSessionId: " e2e-session ", + homeDir: "/tmp/test-home", + }); + expect(mocks.readClaudeCliFallbackSeed).toHaveBeenCalledWith({ + cliSessionId: "e2e-session", + homeDir: "/tmp/test-home", + }); + expect(prelude).toContain("## Prior session context (from claude-cli)"); + expect(prelude).toContain("user: prior question about deploys"); + expect(prelude).toContain("assistant: prior answer about blue-green"); + expect(prelude).toContain("(tool call: Bash)"); }); }); @@ -505,26 +490,6 @@ describe("claudeCliSessionTranscriptHasContent", () => { const GRACE_MS = 250; - it("returns false when the Claude project transcript is missing or empty", async () => { - const workspaceDir = await makeWorkspace(); - expect( - await claudeCliSessionTranscriptHasContent({ - sessionId: "missing-session", - workspaceDir, - homeDir: tmpDir, - }), - ).toBe(false); - - await writeClaudeProjectFile(workspaceDir, "empty-session", ""); - expect( - await claudeCliSessionTranscriptHasContent({ - sessionId: "empty-session", - workspaceDir, - homeDir: tmpDir, - }), - ).toBe(false); - }); - it("returns true when the Claude project transcript has an assistant message", async () => { const workspaceDir = await makeWorkspace(); await writeClaudeProjectFile( diff --git a/src/agents/embedded-agent-runner/run/attempt-native-video-transcript.test.ts b/src/agents/embedded-agent-runner/run/attempt-native-video-transcript.test.ts new file mode 100644 index 000000000000..3d1a8a52c393 --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-native-video-transcript.test.ts @@ -0,0 +1,83 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { readSessionTranscriptRawDelta } from "openclaw/plugin-sdk/session-transcript-runtime"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.js"; +import { + appendTranscriptMessage, + upsertSessionEntryCore, +} from "../../../config/sessions/session-accessor.js"; +import { buildPersistedUserTurnMessage } from "../../../sessions/user-turn-transcript.js"; +import { captureEnv, setTestEnvValue } from "../../../test-utils/env.js"; +import { convertToLlm } from "../../sessions/messages.js"; +import { SessionManager } from "../../sessions/session-manager.js"; +import { materializeProviderContext } from "./images.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); +const MP4 = Buffer.from("0000001c6674797069736f6d0000000069736f6d0000000000000000", "hex"); + +describe("native video transcript replay", () => { + it("replays native video after reopening the canonical transcript", async () => { + const stateDir = tempDirs.make("openclaw-video-transcript-replay-"); + const inboundDir = path.join(stateDir, "media", "inbound"); + await fs.mkdir(inboundDir, { recursive: true }); + await fs.writeFile(path.join(inboundDir, "history.mp4"), MP4); + const env = captureEnv(["OPENCLAW_STATE_DIR"]); + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); + const target = { + agentId: "main", + sessionId: "video-replay", + sessionKey: "agent:main:video-replay", + storePath: path.join(stateDir, "sessions.json"), + }; + const persisted = buildPersistedUserTurnMessage({ + text: "inspect historical video", + media: [ + { + kind: "video", + contentType: "video/mp4", + sizeBytes: MP4.length, + url: "media://inbound/history.mp4", + hydrationSuppressed: true, + }, + ], + }); + const serialized = JSON.stringify(persisted); + expect(serialized).toContain("media://inbound/history.mp4"); + expect(serialized).not.toContain(MP4.toString("base64")); + expect(serialized).not.toContain(stateDir); + + try { + await upsertSessionEntryCore(target, { + sessionId: target.sessionId, + updatedAt: 1, + }); + await appendTranscriptMessage(target, { + cwd: stateDir, + eventId: "historical-user", + message: persisted, + now: 1, + }); + + const reopened = SessionManager.open(target, stateDir).buildSessionContext(); + const provider = await materializeProviderContext({ + context: { systemPrompt: "system", messages: convertToLlm(reopened.messages), tools: [] }, + workspaceDir: stateDir, + }); + expect(provider.messages[0]?.content).toEqual([ + { type: "text", text: "inspect historical video" }, + { type: "video", data: MP4.toString("base64"), mimeType: "video/mp4" }, + ]); + + const raw = await readSessionTranscriptRawDelta({ + ...target, + maxBytes: 100_000, + maxEvents: 100, + }); + expect(JSON.stringify(raw)).toContain("media://inbound/history.mp4"); + expect(JSON.stringify(raw)).not.toContain(MP4.toString("base64")); + } finally { + env.restore(); + } + }); +}); diff --git a/src/agents/embedded-agent-runner/run/attempt-transcript-persistence.e2e.test.ts b/src/agents/embedded-agent-runner/run/attempt-transcript-persistence.e2e.test.ts deleted file mode 100644 index eded61f6d4dd..000000000000 --- a/src/agents/embedded-agent-runner/run/attempt-transcript-persistence.e2e.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { readSessionTranscriptRawDelta } from "openclaw/plugin-sdk/session-transcript-runtime"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.js"; -import { - appendTranscriptMessage, - upsertSessionEntryCore, -} from "../../../config/sessions/session-accessor.js"; -import { buildPersistedUserTurnMessage } from "../../../sessions/user-turn-transcript.js"; -import { captureEnv, setTestEnvValue } from "../../../test-utils/env.js"; -import { createOpenClawAgentHarness } from "../../harness/builtin-openclaw.js"; -import { guardSessionManager } from "../../session-tool-result-guard-wrapper.js"; -import { convertToLlm } from "../../sessions/messages.js"; -import { SessionManager } from "../../sessions/session-manager.js"; -import { flushSessionManagerTranscript } from "./attempt-transcript-helpers.js"; -import { materializeProviderContext } from "./images.js"; - -const runEmbeddedAttempt = vi.hoisted(() => vi.fn()); - -vi.mock("./attempt.js", () => ({ runEmbeddedAttempt })); - -const tempDirs = useAutoCleanupTempDirTracker(afterEach); -const MP4 = Buffer.from("0000001c6674797069736f6d0000000069736f6d0000000000000000", "hex"); - -function buildAssistantMessage(text: string) { - return { - role: "assistant" as const, - content: [{ type: "text" as const, text }], - api: "openai-responses" as const, - provider: "openai", - model: "test-model", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - total: 0, - }, - }, - stopReason: "stop" as const, - timestamp: Date.now(), - }; -} - -describe("embedded attempt transcript persistence", () => { - it("omits the host-private settled-turn recovery prompt from the raw transcript", async () => { - const dir = tempDirs.make("openclaw-settled-turn-finalization-"); - const target = { - agentId: "main", - sessionId: "settled-turn-finalization", - sessionKey: "agent:main:settled-turn-finalization", - storePath: path.join(dir, "sessions.json"), - }; - const recoveryPrompt = - "The previous assistant turn completed its tool calls but did not produce a user-visible answer. Continue from the current transcript and produce the final user-visible answer now. Do not repeat completed tool calls or restart from scratch."; - const finalAssistant = buildAssistantMessage("Recovered final answer."); - await upsertSessionEntryCore(target, { - sessionId: target.sessionId, - updatedAt: 1, - }); - await appendTranscriptMessage(target, { - cwd: dir, - eventId: "original-user", - message: { role: "user", content: "Original operator request." }, - now: 1, - }); - - runEmbeddedAttempt.mockImplementationOnce(async (attempt) => { - const finalization = attempt as { - prompt: string; - suppressNextUserMessagePersistence?: boolean; - }; - const sessionManager = guardSessionManager(SessionManager.open(target, dir), { - skipBeforeMessageWriteHooks: true, - suppressNextUserMessagePersistence: finalization.suppressNextUserMessagePersistence, - }); - sessionManager.appendMessage({ - role: "user", - content: [{ type: "text", text: finalization.prompt }], - timestamp: Date.now(), - }); - sessionManager.appendMessage(finalAssistant); - flushSessionManagerTranscript(sessionManager); - return { - terminal: { kind: "ok" }, - sessionIdUsed: target.sessionId, - messagesSnapshot: [finalAssistant], - assistantTexts: ["Recovered final answer."], - toolMetas: [], - lastAssistant: finalAssistant, - currentAttemptAssistant: finalAssistant, - currentAttemptCompletedAssistant: finalAssistant, - didSendViaMessagingTool: false, - didDeliverSourceReplyViaMessageTool: false, - didSendDeterministicApprovalPrompt: false, - messagingToolSentTexts: [], - messagingToolSentMediaUrls: [], - messagingToolSentTargets: [], - messagingToolSourceReplyPayloads: [], - hasToolMediaBlockReply: false, - cloudCodeAssistFormatError: false, - replayMetadata: { hadPotentialSideEffects: false, replaySafe: true }, - currentAttemptReplayMetadata: { hadPotentialSideEffects: false, replaySafe: true }, - itemLifecycle: { startedCount: 0, completedCount: 0, activeCount: 0 }, - } as never; - }); - - await createOpenClawAgentHarness().finalizeSettledTurn?.({ - attempt: { prompt: recoveryPrompt } as never, - settledAttempt: {} as never, - }); - - const raw = await readSessionTranscriptRawDelta({ - ...target, - maxBytes: 100_000, - maxEvents: 100, - }); - const serialized = JSON.stringify(raw); - expect(serialized).toContain("Original operator request."); - expect(serialized).toContain("Recovered final answer."); - expect(serialized).not.toContain(recoveryPrompt); - }); - - it("replays native video after reopening the canonical transcript", async () => { - const stateDir = tempDirs.make("openclaw-video-transcript-replay-"); - const inboundDir = path.join(stateDir, "media", "inbound"); - await fs.mkdir(inboundDir, { recursive: true }); - await fs.writeFile(path.join(inboundDir, "history.mp4"), MP4); - const env = captureEnv(["OPENCLAW_STATE_DIR"]); - setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); - const target = { - agentId: "main", - sessionId: "video-replay", - sessionKey: "agent:main:video-replay", - storePath: path.join(stateDir, "sessions.json"), - }; - const persisted = buildPersistedUserTurnMessage({ - text: "inspect historical video", - media: [ - { - kind: "video", - contentType: "video/mp4", - sizeBytes: MP4.length, - url: "media://inbound/history.mp4", - hydrationSuppressed: true, - }, - ], - }); - const serialized = JSON.stringify(persisted); - expect(serialized).toContain("media://inbound/history.mp4"); - expect(serialized).not.toContain(MP4.toString("base64")); - expect(serialized).not.toContain(stateDir); - - try { - await upsertSessionEntryCore(target, { - sessionId: target.sessionId, - updatedAt: 1, - }); - await appendTranscriptMessage(target, { - cwd: stateDir, - eventId: "historical-user", - message: persisted, - now: 1, - }); - - const reopened = SessionManager.open(target, stateDir).buildSessionContext(); - const provider = await materializeProviderContext({ - context: { systemPrompt: "system", messages: convertToLlm(reopened.messages), tools: [] }, - workspaceDir: stateDir, - }); - expect(provider.messages[0]?.content).toEqual([ - { type: "text", text: "inspect historical video" }, - { type: "video", data: MP4.toString("base64"), mimeType: "video/mp4" }, - ]); - - const raw = await readSessionTranscriptRawDelta({ - ...target, - maxBytes: 100_000, - maxEvents: 100, - }); - expect(JSON.stringify(raw)).toContain("media://inbound/history.mp4"); - expect(JSON.stringify(raw)).not.toContain(MP4.toString("base64")); - } finally { - env.restore(); - } - }); - - it("resumes a raw cursor after append-only attempt settlement", async () => { - const dir = tempDirs.make("openclaw-attempt-transcript-"); - const storePath = path.join(dir, "sessions.json"); - const target = { - agentId: "main", - sessionId: "embedded-generation", - sessionKey: "agent:main:embedded-generation", - storePath, - }; - await upsertSessionEntryCore(target, { - sessionId: target.sessionId, - updatedAt: 1, - }); - await appendTranscriptMessage(target, { - cwd: dir, - eventId: "first-user", - message: { role: "user", content: "first turn" }, - now: 1, - }); - - const bootstrap = await readSessionTranscriptRawDelta({ - ...target, - maxBytes: 100_000, - maxEvents: 100, - }); - expect(bootstrap.kind).toBe("page"); - if (bootstrap.kind !== "page") { - throw new Error(`expected bootstrap page, got ${bootstrap.kind}`); - } - - const sessionManager = SessionManager.open(target, dir); - sessionManager.appendMessage({ - role: "user", - content: "second turn", - timestamp: Date.now(), - }); - sessionManager.appendMessage(buildAssistantMessage("second answer")); - - // Production settlement invokes this barrier immediately before afterTurn. - flushSessionManagerTranscript(sessionManager); - - const resumed = await readSessionTranscriptRawDelta({ - ...target, - cursor: bootstrap.cursor, - maxBytes: 100_000, - maxEvents: 100, - }); - expect(resumed.kind).toBe("page"); - if (resumed.kind !== "page") { - throw new Error(`expected append page, got ${resumed.kind}`); - } - expect( - resumed.events - .map((row) => row.event) - .filter((event): event is { message: { content: unknown }; type: "message" } => - Boolean( - event && typeof event === "object" && "type" in event && event.type === "message", - ), - ) - .map((event) => event.message.content), - ).toEqual(["second turn", [{ type: "text", text: "second answer" }]]); - }); -}); diff --git a/src/agents/tools/terminal-tool.test.ts b/src/agents/tools/terminal-tool.test.ts index eb48355d9c7e..1a04f51b2491 100644 --- a/src/agents/tools/terminal-tool.test.ts +++ b/src/agents/tools/terminal-tool.test.ts @@ -168,7 +168,7 @@ describe("terminal tool", () => { spawn: async () => backends.shift() ?? makeBackend(), }); const agentSessionKey = "agent:main:shared-task-session"; - const lookupTaskByRunId = vi.fn(async (runId: string) => + const lookupTaskByRunIdForChildSession = vi.fn(async (runId: string) => runId === "conversation-run" ? undefined : { @@ -182,7 +182,7 @@ describe("terminal tool", () => { agentId: "main", agentSessionKey, runId, - lookupTaskByRunId, + lookupTaskByRunIdForChildSession, getGatewayContext: () => makeContext(manager), }); @@ -190,7 +190,11 @@ describe("terminal tool", () => { await createTaskTool("run-2").execute("open", { action: "open", show: false }); await createTaskTool("conversation-run").execute("open", { action: "open", show: false }); - expect(lookupTaskByRunId.mock.calls).toEqual([["run-1"], ["run-2"], ["conversation-run"]]); + expect(lookupTaskByRunIdForChildSession.mock.calls).toEqual([ + ["run-1", agentSessionKey], + ["run-2", agentSessionKey], + ["conversation-run", agentSessionKey], + ]); expect(manager.closeAgentSessions("task-1")).toBe(1); expect(firstBackend.killed).toBe(true); expect(secondBackend.killed).toBe(false); @@ -198,6 +202,42 @@ describe("terminal tool", () => { expect(manager.listAgent(agentSessionKey, "main")).toHaveLength(2); }); + it("binds the matching child session when task run ids collide", async () => { + const backend = makeBackend(); + const manager = new TerminalSessionManager({ emit: vi.fn(), spawn: async () => backend }); + const agentSessionKey = "agent:main:shared-run-task-2"; + const tasks = [ + { + taskId: "task-1", + status: "running" as const, + childSessionKey: "agent:main:shared-run-task-1", + }, + { + taskId: "task-2", + status: "running" as const, + childSessionKey: agentSessionKey, + }, + ]; + const lookupTaskByRunIdForChildSession = vi.fn( + async (_runId: string, childSessionKey: string) => + tasks.find((task) => task.childSessionKey === childSessionKey), + ); + const tool = createTerminalTool({ + agentId: "main", + agentSessionKey, + runId: "shared-run", + lookupTaskByRunIdForChildSession, + getGatewayContext: () => makeContext(manager), + }); + + await tool.execute("open", { action: "open", show: false }); + + expect(lookupTaskByRunIdForChildSession).toHaveBeenCalledWith("shared-run", agentSessionKey); + expect(manager.closeAgentSessions("task-2")).toBe(1); + expect(manager.closeAgentSessions("task-1")).toBe(0); + expect(backend.killed).toBe(true); + }); + it("maps a cron agent run to its detached task before terminal lookup", async () => { const backend = makeBackend(); const manager = new TerminalSessionManager({ emit: vi.fn(), spawn: async () => backend }); @@ -212,7 +252,7 @@ describe("terminal tool", () => { throw new Error("expected cron agent run claim"); } expect(bindAgentRunTaskRunId("cron-agent-run", claimId, "detached-task-run")).toBe(true); - const lookupTaskByRunId = vi.fn(async () => ({ + const lookupTaskByRunIdForChildSession = vi.fn(async () => ({ taskId: "cron-task", status: "running" as const, childSessionKey: agentSessionKey, @@ -221,14 +261,17 @@ describe("terminal tool", () => { agentId: "main", agentSessionKey, runId: "cron-agent-run", - lookupTaskByRunId, + lookupTaskByRunIdForChildSession, getGatewayContext: () => makeContext(manager), }); try { await tool.execute("open", { action: "open", show: false }); - expect(lookupTaskByRunId).toHaveBeenCalledWith("detached-task-run"); + expect(lookupTaskByRunIdForChildSession).toHaveBeenCalledWith( + "detached-task-run", + agentSessionKey, + ); expect(manager.closeAgentSessions("cron-task")).toBe(1); expect(backend.killed).toBe(true); } finally { @@ -246,7 +289,7 @@ describe("terminal tool", () => { agentId: "main", agentSessionKey: "agent:main:completed-task", runId: "completed-run", - lookupTaskByRunId: vi.fn(async () => ({ + lookupTaskByRunIdForChildSession: vi.fn(async () => ({ taskId: "task-completed", status: "succeeded" as const, childSessionKey: "agent:main:completed-task", diff --git a/src/agents/tools/terminal-tool.ts b/src/agents/tools/terminal-tool.ts index 5b9ff5f9bcbc..8aec240c4ffa 100644 --- a/src/agents/tools/terminal-tool.ts +++ b/src/agents/tools/terminal-tool.ts @@ -82,18 +82,21 @@ type TerminalToolOptions = { agentId?: string; agentSessionKey?: string; runId?: string; - lookupTaskByRunId?: ( + lookupTaskByRunIdForChildSession?: ( runId: string, + childSessionKey: string, ) => Promise | undefined>; callGateway?: InProcessGatewayCaller; getGatewayContext?: () => TerminalToolGatewayContext | undefined; }; -async function lookupTaskByRunId( +async function lookupTaskByRunIdForChildSession( runId: string, + childSessionKey: string, ): Promise | undefined> { - const { findTaskByRunIdForStatus } = await import("../../tasks/task-status-access.js"); - return findTaskByRunIdForStatus(runId); + const { findTaskByRunIdForChildSessionForStatus } = + await import("../../tasks/task-status-access.js"); + return findTaskByRunIdForChildSessionForStatus(runId, childSessionKey); } function readDimension( @@ -161,7 +164,7 @@ function launchBlockMessage( export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool { const gatewayCall = opts.callGateway ?? callInProcessGatewayTool; const getContext = opts.getGatewayContext ?? getInProcessGatewayToolContext; - const findOwnerTask = opts.lookupTaskByRunId ?? lookupTaskByRunId; + const findOwnerTask = opts.lookupTaskByRunIdForChildSession ?? lookupTaskByRunIdForChildSession; return { label: "Terminal", name: "terminal", @@ -206,9 +209,7 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool }); const runId = opts.runId?.trim(); const taskLookupId = runId ? (getAgentRunTaskRunId(runId) ?? runId) : undefined; - const candidateTask = taskLookupId ? await findOwnerTask(taskLookupId) : undefined; - const task = - candidateTask?.childSessionKey?.trim() === agentSessionKey ? candidateTask : undefined; + const task = taskLookupId ? await findOwnerTask(taskLookupId, agentSessionKey) : undefined; if (task && isTerminalTaskStatus(task.status)) { throw new ToolInputError("terminal task already ended"); } diff --git a/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts index b0e976753b74..5ffc6fea6715 100644 --- a/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.hooks-and-send-policy.test-utils.ts @@ -2671,6 +2671,15 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => }, }, }, + { + label: "unavailable exec approvals", + payload: { + text: "Exec approval is unavailable.", + channelData: { + execApprovalUnavailable: { reason: "no-approval-route" }, + }, + }, + }, { label: "ask-user prompts", payload: { diff --git a/src/auto-reply/reply/dispatch-from-config.payloads.ts b/src/auto-reply/reply/dispatch-from-config.payloads.ts index a39cf0382816..0c0e8eb3e949 100644 --- a/src/auto-reply/reply/dispatch-from-config.payloads.ts +++ b/src/auto-reply/reply/dispatch-from-config.payloads.ts @@ -51,6 +51,10 @@ export function hasExecApprovalPayload(payload: ReplyPayload): boolean { return isRecord(payload.channelData?.execApproval); } +export function hasExecApprovalUnavailablePayload(payload: ReplyPayload): boolean { + return isRecord(payload.channelData?.execApprovalUnavailable); +} + export function hasAskUserPayload(payload: ReplyPayload): boolean { return isRecord(payload.channelData?.askUser); } @@ -59,6 +63,7 @@ export function requiresDurableToolResultDelivery(payload: ReplyPayload): boolea return ( resolveSendableOutboundReplyParts(payload).hasMedia || hasExecApprovalPayload(payload) || + hasExecApprovalUnavailablePayload(payload) || hasAskUserPayload(payload) ); } diff --git a/src/auto-reply/reply/dispatch-from-config.prepare-execution.ts b/src/auto-reply/reply/dispatch-from-config.prepare-execution.ts index 7c03c47bacae..516ed4fd8130 100644 --- a/src/auto-reply/reply/dispatch-from-config.prepare-execution.ts +++ b/src/auto-reply/reply/dispatch-from-config.prepare-execution.ts @@ -13,7 +13,11 @@ import { normalizeMessageChannel } from "../../utils/message-channel.js"; import type { GetReplyOptions } from "../get-reply-options.types.js"; import type { ReplyPayload } from "../reply-payload.js"; import type { ChooseDispatchRouteReadyState } from "./dispatch-from-config.choose-route.js"; -import { hasAskUserPayload, hasExecApprovalPayload } from "./dispatch-from-config.payloads.js"; +import { + hasAskUserPayload, + hasExecApprovalPayload, + hasExecApprovalUnavailablePayload, +} from "./dispatch-from-config.payloads.js"; import { extendPreparedDispatchState } from "./dispatch-from-config.phase-state.js"; import { loadGetReplyFromConfigRuntime } from "./dispatch-from-config.runtime-loaders.js"; import { withFullRuntimeReplyConfig } from "./get-reply-fast-path.js"; @@ -121,7 +125,7 @@ export async function prepareDispatchExecution(state: ChooseDispatchRouteReadySt if (shouldSendToolSummaries()) { return payload; } - if (hasExecApprovalPayload(payload)) { + if (hasExecApprovalPayload(payload) || hasExecApprovalUnavailablePayload(payload)) { return payload; } if (hasAskUserPayload(payload)) { diff --git a/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts index 70a76cc9353d..a43be13e9577 100644 --- a/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts @@ -427,6 +427,31 @@ describe("dispatchReplyFromConfig", () => { ).toBe(true); }); + it("delivers approval-unavailable notices when verbose tool progress is disabled", async () => { + setNoAbort(); + const payload = { + text: "Exec approval is unavailable.", + channelData: { + execApprovalUnavailable: { reason: "no-approval-route" }, + }, + } satisfies ReplyPayload; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ Provider: "telegram", ChatType: "direct" }); + const replyResolver = async ( + _ctx: MsgContext, + opts?: GetReplyOptions, + _cfg?: OpenClawConfig, + ) => { + await requireToolResultHandler(opts?.onToolResult)(payload); + return undefined; + }; + + await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver }); + + expect(dispatcher.sendToolResult).toHaveBeenCalledWith(payload); + expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); + }); + it("drops ask_user prompts that terminalize before dispatcher delivery", async () => { setNoAbort(); askUserMocks.isAskUserPromptPending.mockResolvedValue(false); diff --git a/src/auto-reply/reply/followup-turn-execution.test.ts b/src/auto-reply/reply/followup-turn-execution.test.ts index d27973f31870..cac797fea71d 100644 --- a/src/auto-reply/reply/followup-turn-execution.test.ts +++ b/src/auto-reply/reply/followup-turn-execution.test.ts @@ -329,6 +329,15 @@ describe("executeFollowupTurn", () => { }, }, }, + { + label: "unavailable exec approvals", + payload: { + text: "Exec approval is unavailable.", + channelData: { + execApprovalUnavailable: { reason: "no-approval-route" }, + }, + }, + }, { label: "ask-user prompts", payload: { diff --git a/src/commands/doctor/shared/default-agent-role-materialization.test.ts b/src/commands/doctor/shared/default-agent-role-materialization.test.ts index 828ecc833a37..915b693db842 100644 --- a/src/commands/doctor/shared/default-agent-role-materialization.test.ts +++ b/src/commands/doctor/shared/default-agent-role-materialization.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; import { listAgentEntries, resolveDefaultAgentId } from "../../../agents/agent-scope-config.js"; +import { resolveSystemAgentTargetAgentId } from "../../../agents/agent-scope-config.js"; import { materializeLegacyDefaultAgentRoles } from "../../../config/legacy.default-agent-roles.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { resolveCronJobEffectiveAgentId } from "../../../cron/agent-id.js"; import { resolveHeartbeatAgents } from "../../../infra/heartbeat-runner.js"; import { resolveAgentRoute } from "../../../routing/resolve-route.js"; -import { resolveSystemAgentTargetAgentId } from "../../../system-agent/inference-route.js"; import { resolveTalkSessionAgentId, resolveTalkTargetAgentId } from "../../../talk/agent-target.js"; function materializeDefaultAgentRoles(cfg: OpenClawConfig) { diff --git a/src/config/sessions/main-session.runtime.ts b/src/config/sessions/main-session.runtime.ts index 9eca6d7401e2..4951307bd488 100644 --- a/src/config/sessions/main-session.runtime.ts +++ b/src/config/sessions/main-session.runtime.ts @@ -1,8 +1,8 @@ // Runtime main-session lookup binds the config-backed helper for callers without config access. import { getRuntimeConfig } from "../io.js"; -import { resolveMainSessionKey } from "./main-session.js"; +import { resolveSystemMainSessionKey } from "./main-session.js"; /** Resolves the main session key from the active runtime config. */ export function resolveMainSessionKeyFromConfig(): string { - return resolveMainSessionKey(getRuntimeConfig()); + return resolveSystemMainSessionKey(getRuntimeConfig()); } diff --git a/src/config/sessions/main-session.ts b/src/config/sessions/main-session.ts index 49e731489f0d..38d401f21dd5 100644 --- a/src/config/sessions/main-session.ts +++ b/src/config/sessions/main-session.ts @@ -1,4 +1,8 @@ -import { listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope-config.js"; +import { + listAgentIds, + resolveDefaultAgentId, + resolveSystemAgentTargetAgentId, +} from "../../agents/agent-scope-config.js"; // Main-session keys normalize configured agents and legacy aliases into store keys. import { normalizeAgentId, @@ -33,6 +37,27 @@ export function resolveMainSessionKey(cfg: OpenClawConfig): string { }); } +/** Resolves the owner and canonical session target for ambient system work. */ +export function resolveSystemMainSessionTarget(cfg: OpenClawConfig): { + agentId: string; + sessionKey: string; +} { + const agentId = resolveSystemAgentTargetAgentId(cfg); + return { + agentId, + sessionKey: resolveCanonicalMainSessionKey({ + agentId, + mainKey: cfg.session?.mainKey, + sessionScope: cfg.session?.scope, + }), + }; +} + +/** Resolves the main session owned by configured ambient system work. */ +export function resolveSystemMainSessionKey(cfg: OpenClawConfig): string { + return resolveSystemMainSessionTarget(cfg).sessionKey; +} + /** Stable fingerprint for the config values that canonicalize chat session keys. */ export function resolveSessionRoutingContract(cfg: OpenClawConfig): string { const scope = cfg?.session?.scope ?? "per-sender"; diff --git a/src/config/sessions/session-accessor.conformance.test.ts b/src/config/sessions/session-accessor.conformance.test.ts index 36d303641025..a226bc533f69 100644 --- a/src/config/sessions/session-accessor.conformance.test.ts +++ b/src/config/sessions/session-accessor.conformance.test.ts @@ -1660,6 +1660,77 @@ describe("sqlite session normalization", () => { ).toEqual(["agent:main:newer", "agent:main:newest"]); }); + it("preserves pinned SQLite entries and transcripts during write-triggered capping", async () => { + vi.mocked(getRuntimeConfig).mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "365d", + maxEntries: 2, + }, + }, + }); + const env = { ...process.env, OPENCLAW_STATE_DIR: paths.stateDir }; + const scopeFor = (sessionKey: string) => ({ + agentId: "main", + env, + sessionKey, + storePath: paths.sqlitePath, + }); + const pinnedKey = "agent:main:pinned-dashboard"; + const pinnedSessionId = "pinned-dashboard-session"; + const pinnedTranscriptEvent = { + id: "pinned-event", + timestamp: new Date().toISOString(), + type: "metadata", + }; + + await patchSessionEntryCore( + scopeFor(pinnedKey), + () => ({ sessionId: pinnedSessionId, updatedAt: 1, pinnedAt: 2 }), + { + fallbackEntry: { sessionId: pinnedSessionId, updatedAt: 1, pinnedAt: 2 }, + replaceEntry: true, + skipMaintenance: true, + }, + ); + await appendTranscriptEvent( + { ...scopeFor(pinnedKey), sessionId: pinnedSessionId }, + pinnedTranscriptEvent, + ); + await patchSessionEntryCore( + scopeFor("agent:main:recent-dashboard"), + () => ({ sessionId: "recent-dashboard-session", updatedAt: 3 }), + { + fallbackEntry: { sessionId: "recent-dashboard-session", updatedAt: 3 }, + replaceEntry: true, + skipMaintenance: true, + }, + ); + + await patchSessionEntryCore( + scopeFor("agent:main:maintenance-trigger"), + () => ({ sessionId: "maintenance-trigger-session", updatedAt: 4 }), + { + fallbackEntry: { sessionId: "maintenance-trigger-session", updatedAt: 4 }, + replaceEntry: true, + }, + ); + + expect(loadSessionEntry(scopeFor(pinnedKey))).toMatchObject({ + pinnedAt: 2, + sessionId: pinnedSessionId, + }); + await expect( + loadTranscriptEvents({ + agentId: "main", + env, + sessionId: pinnedSessionId, + storePath: paths.sqlitePath, + }), + ).resolves.toEqual([pinnedTranscriptEvent]); + }); + it("preserves an admitted SQLite session when another session triggers maintenance", async () => { vi.mocked(getRuntimeConfig).mockReturnValue({ session: { diff --git a/src/config/sessions/store-maintenance.ts b/src/config/sessions/store-maintenance.ts index ecabc57142d0..70dbd34cefae 100644 --- a/src/config/sessions/store-maintenance.ts +++ b/src/config/sessions/store-maintenance.ts @@ -428,8 +428,8 @@ export function shouldPreserveMaintenanceEntry(params: { entry: SessionEntry | undefined; preserveKeys?: ReadonlySet; }): boolean { - // Archived sessions are user-shelved; only an explicit sessions.delete may remove them. - if (params.entry?.archivedAt !== undefined) { + // Archived and pinned sessions are user-retained; only an explicit user action may release them. + if (params.entry?.archivedAt !== undefined || params.entry?.pinnedAt !== undefined) { return true; } // A model lock is durable harness ownership, not merely a UI restriction. diff --git a/src/config/sessions/store.pruning.test.ts b/src/config/sessions/store.pruning.test.ts index a5ef6e11a16a..5ad86652ac26 100644 --- a/src/config/sessions/store.pruning.test.ts +++ b/src/config/sessions/store.pruning.test.ts @@ -142,6 +142,20 @@ describe("pruneStaleEntries", () => { expect(pruneStaleEntries(store, 30 * DAY_MS)).toBe(1); expect(store.archived).toBeUndefined(); }); + + it("preserves pinned entries until they are unpinned", () => { + const now = Date.now(); + const store = makeStore([ + ["pinned", { ...makeEntry(now - 31 * DAY_MS), pinnedAt: now - DAY_MS }], + ]); + + expect(pruneStaleEntries(store, 30 * DAY_MS)).toBe(0); + expect(store).toHaveProperty("pinned"); + + delete store.pinned?.pinnedAt; + expect(pruneStaleEntries(store, 30 * DAY_MS)).toBe(1); + expect(store.pinned).toBeUndefined(); + }); }); describe("resolveQuotaSuspensionEntryMaintenance", () => { @@ -710,6 +724,20 @@ describe("capEntryCount", () => { expect(store.old).toBeUndefined(); }); + it("preserves pinned sessions when capping", () => { + const now = Date.now(); + const store = makeStore([ + ["pinned", { ...makeEntry(now - 10 * DAY_MS), pinnedAt: now - 5 * DAY_MS }], + ["recent", makeEntry(now)], + ["old", makeEntry(now - DAY_MS)], + ]); + + expect(capEntryCount(store, 2)).toBe(1); + expect(store).toHaveProperty("pinned"); + expect(store).toHaveProperty("recent"); + expect(store.old).toBeUndefined(); + }); + it("preserves runtime-provided pending subagent sessions when capping", () => { const now = Date.now(); const childKey = "agent:main:subagent:child"; diff --git a/src/gateway/server-methods/health.owner-routing.test.ts b/src/gateway/server-methods/health.owner-routing.test.ts new file mode 100644 index 000000000000..38eff260af82 --- /dev/null +++ b/src/gateway/server-methods/health.owner-routing.test.ts @@ -0,0 +1,64 @@ +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../../config/config.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { withStateDirEnv } from "../../test-helpers/state-dir-env.js"; +import { resolveRequestedSessionAgentId } from "../session-request-agent.js"; +import { healthHandlers } from "./health.js"; + +afterEach(() => { + resetConfigRuntimeState(); +}); + +async function callStatus(config: OpenClawConfig) { + setRuntimeConfigSnapshot(config, config); + const respond = vi.fn(); + await healthHandlers.status!({ + req: {} as never, + params: { includeChannelSummary: false }, + respond: respond as never, + context: {} as never, + client: { connect: { role: "operator", scopes: ["operator.read"] } } as never, + isWebchatConnect: () => false, + }); + return respond; +} + +describe("Gateway status owner routing", () => { + it("uses the configured system owner without making public main aliases implicit", async () => { + await withStateDirEnv("openclaw-gateway-status-owner-", async ({ stateDir }) => { + const config = { + agents: { + ownership: "explicit", + defaults: { systemAgent: { agentId: "main" } }, + entries: { main: {}, molty: {} }, + }, + session: { store: path.join(stateDir, "agents", "{agentId}", "sessions.json") }, + } satisfies OpenClawConfig; + + const respond = await callStatus(config); + + expect(respond).toHaveBeenCalledTimes(1); + expect(respond.mock.calls[0]?.[0]).toBe(true); + expect(respond.mock.calls[0]?.[2]).toBeUndefined(); + expect(resolveRequestedSessionAgentId(config, "main")).toMatchObject({ ok: false }); + expect(resolveRequestedSessionAgentId(config, "agent:molty:main")).toEqual({ + ok: true, + agentId: "molty", + }); + }); + }); + + it("keeps single-agent status unchanged", async () => { + await withStateDirEnv("openclaw-gateway-status-single-", async ({ stateDir }) => { + const respond = await callStatus({ + agents: { entries: { main: {} } }, + session: { store: path.join(stateDir, "sessions.json") }, + }); + + expect(respond).toHaveBeenCalledTimes(1); + expect(respond.mock.calls[0]?.[0]).toBe(true); + expect(respond.mock.calls[0]?.[2]).toBeUndefined(); + }); + }); +}); diff --git a/src/gateway/server-methods/system-event-routing.test.ts b/src/gateway/server-methods/system-event-routing.test.ts index 6778f6d21b3f..0e567e383549 100644 --- a/src/gateway/server-methods/system-event-routing.test.ts +++ b/src/gateway/server-methods/system-event-routing.test.ts @@ -107,6 +107,44 @@ describe("system-event routing", () => { expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); }); + it("keeps ambient explicit-owner events on the global session queue", async () => { + const respond = vi.fn(); + const request = { + params: { text: "Wake the system owner.", wake: true }, + respond, + context: { + broadcast: vi.fn(), + incrementPresenceVersion: vi.fn(() => 1), + getHealthVersion: vi.fn(() => 1), + getRuntimeConfig: vi.fn(() => ({ + session: { scope: "global" }, + agents: { + ownership: "explicit", + defaults: { systemAgent: { agentId: "main" } }, + entries: { main: {}, molty: {} }, + }, + })), + }, + } as unknown as GatewayRequestHandlerOptions; + + await expectDefined( + systemHandlers["system-event"], + 'systemHandlers["system-event"] test invariant', + )(request); + + expect(peekSystemEvents("global")).toEqual(["Wake the system owner."]); + expect(peekSystemEvents("agent:main:main")).toEqual([]); + expect(mocks.requestHeartbeat).toHaveBeenCalledWith({ + source: "notifications-event", + intent: "immediate", + reason: "wake", + agentId: "main", + sessionKey: "global", + heartbeat: { target: "last" }, + }); + expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined); + }); + it("rejects immediate wakes for unconfigured agents", async () => { const respond = vi.fn(); const request = { diff --git a/src/gateway/server-methods/system.ts b/src/gateway/server-methods/system.ts index d6db6d50c0be..fbf2008ad434 100644 --- a/src/gateway/server-methods/system.ts +++ b/src/gateway/server-methods/system.ts @@ -23,7 +23,7 @@ import { } from "../../agents/utility-model.js"; import { tryResolveLegacyCompatibilityAgentId } from "../../config/legacy.default-agent-owner.js"; import { resolveGatewayPort, resolveStateDir } from "../../config/paths.js"; -import { resolveMainSessionKeyFromConfig } from "../../config/sessions.js"; +import { resolveSystemMainSessionTarget } from "../../config/sessions.js"; import { resolveAdvertisedLanHostCore } from "../../infra/advertised-lan-host.js"; import { loadOrCreateProcessDeviceIdentity, @@ -171,7 +171,10 @@ export const systemHandlers: GatewayRequestHandlers = { respond(false, undefined, requestedOwner.error); return; } - const sessionKey = requestedSessionKey ?? resolveMainSessionKeyFromConfig(); + const systemTarget = requestedSessionKey + ? { agentId: requestedOwner?.agentId, sessionKey: requestedSessionKey } + : resolveSystemMainSessionTarget(cfg); + const { agentId: eventOwnerAgentId, sessionKey } = systemTarget; const wake = params.wake === true; const isNodePresenceLine = text.startsWith("Node:"); if (wake && isNodePresenceLine) { @@ -183,21 +186,23 @@ export const systemHandlers: GatewayRequestHandlers = { return; } if (wake && requestedSessionKey) { - const targetAgentId = normalizeAgentId( + const requestedAgentId = normalizeAgentId( requestedOwner?.agentId ?? resolveAgentIdFromSessionKey(requestedSessionKey), ); const configuredAgentIds = listAgentIds(cfg).map(normalizeAgentId); - if (!configuredAgentIds.includes(targetAgentId)) { + if (!configuredAgentIds.includes(requestedAgentId)) { respond( false, undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${targetAgentId}"`), + errorShape(ErrorCodes.INVALID_REQUEST, `Unknown agent id "${requestedAgentId}"`), ); return; } // A targeted wake starts a model run. Require a live persisted session // so malformed keys cannot create phantom work under agent defaults. - const targetSession = loadGatewaySessionRow(requestedSessionKey, { agentId: targetAgentId }); + const targetSession = loadGatewaySessionRow(requestedSessionKey, { + agentId: requestedAgentId, + }); if (!targetSession || targetSession.archived) { respond( false, @@ -300,8 +305,8 @@ export const systemHandlers: GatewayRequestHandlers = { }; enqueueSystemEvent( deltaText, - requestedOwner - ? withSystemEventOwner(eventOptions, requestedOwner.agentId) + eventOwnerAgentId + ? withSystemEventOwner(eventOptions, eventOwnerAgentId) : eventOptions, ); } @@ -310,7 +315,7 @@ export const systemHandlers: GatewayRequestHandlers = { const eventOptions = { sessionKey }; enqueueSystemEvent( text, - requestedOwner ? withSystemEventOwner(eventOptions, requestedOwner.agentId) : eventOptions, + eventOwnerAgentId ? withSystemEventOwner(eventOptions, eventOwnerAgentId) : eventOptions, ); if (wake) { // Targeted admin events may need a proactive response. Carry the exact @@ -321,6 +326,7 @@ export const systemHandlers: GatewayRequestHandlers = { // The dispatcher recognizes "wake" as a payload-bearing run, so an // empty HEARTBEAT.md cannot suppress this queued system event. reason: "wake", + ...(!requestedSessionKey && eventOwnerAgentId ? { agentId: eventOwnerAgentId } : {}), sessionKey, heartbeat: { target: "last" }, }); diff --git a/src/gateway/server/hooks.agent-trust.test.ts b/src/gateway/server/hooks.agent-trust.test.ts index b1d98523a399..51a0b3f52dbe 100644 --- a/src/gateway/server/hooks.agent-trust.test.ts +++ b/src/gateway/server/hooks.agent-trust.test.ts @@ -16,6 +16,10 @@ const enqueueSystemEventMock = vi.fn(); const requestHeartbeatMock = vi.fn(); const runCronIsolatedAgentTurnMock = vi.fn(); const resolveMainSessionKeyMock = vi.fn(() => "main-session"); +const resolveAgentMainSessionKeyMock = vi.fn( + (params: { cfg?: { session?: { mainKey?: string } }; agentId: string }) => + `agent:${params.agentId}:${params.cfg?.session?.mainKey ?? "main"}`, +); const mainRosterConfig = (): OpenClawConfig => ({ agents: { entries: { main: {} } }, }); @@ -51,10 +55,7 @@ vi.mock("../../config/sessions.js", () => ({ resolveMainSessionKey: vi.fn((cfg?: { session?: { mainKey?: string; scope?: string } }) => cfg?.session?.scope === "global" ? "global" : `agent:main:${cfg?.session?.mainKey ?? "main"}`, ), - resolveAgentMainSessionKey: vi.fn( - (params: { cfg?: { session?: { mainKey?: string } }; agentId: string }) => - `agent:${params.agentId}:${params.cfg?.session?.mainKey ?? "main"}`, - ), + resolveAgentMainSessionKey: resolveAgentMainSessionKeyMock, })); vi.mock("../../config/io.js", () => ({ getRuntimeConfig: loadConfigMock, @@ -221,6 +222,33 @@ describe("dispatchAgentHook trust handling", () => { }); }); + it("keeps the resolved owner when a multi-agent wake omits agentId", () => { + loadConfigMock.mockReturnValue({ + agents: { + ownership: "explicit", + defaults: { systemAgent: { agentId: "main" } }, + entries: { main: {}, molty: {} }, + }, + }); + + dispatchWakeHook({ text: "Mapped wake", mode: "now" }, "molty"); + + expect(resolveAgentMainSessionKeyMock).toHaveBeenCalledWith({ + cfg: expect.any(Object), + agentId: "molty", + }); + expect(enqueueSystemEventMock).toHaveBeenCalledWith("Mapped wake", { + sessionKey: "agent:molty:main", + }); + expect(requestHeartbeatMock).toHaveBeenCalledWith({ + source: "hook", + intent: "immediate", + reason: "hook:wake", + agentId: "molty", + sessionKey: "agent:molty:main", + }); + }); + it("passes normalized delivery through to the isolated CronJob", async () => { const delivery = { mode: "announce" as const, @@ -890,10 +918,9 @@ describe("dispatchAgentHook trust handling", () => { expect(requestHeartbeatMock.mock.calls[0]?.[0]?.sessionKey).toBeUndefined(); }); - it("omits the default agentId from the announce wake when no agent is named", async () => { - // An unnamed hook resolves its event session from fresh config at announce - // time; pairing the dispatch-time default agent with that session could - // wake a stale agent after a default-agent reload. + it("carries the accepted owner on an unnamed hook announce wake", async () => { + // Admission freezes the effective owner. Keep it paired with the scoped + // event session instead of rediscovering an ambient default at completion. runCronIsolatedAgentTurnMock.mockResolvedValueOnce({ status: "ok", summary: "done", @@ -913,9 +940,9 @@ describe("dispatchAgentHook trust handling", () => { source: "hook", intent: "immediate", reason: expect.stringMatching(/^hook:[0-9a-f-]+$/), + agentId: "main", sessionKey: "agent:main:main", }); - expect(wake.agentId).toBeUndefined(); }); it("keeps global-scope error events and wakes on the selected agent", async () => { diff --git a/src/gateway/server/hooks.ts b/src/gateway/server/hooks.ts index a9b877c9cd38..df907e4b47c2 100644 --- a/src/gateway/server/hooks.ts +++ b/src/gateway/server/hooks.ts @@ -13,7 +13,6 @@ import { getRuntimeConfig } from "../../config/io.js"; import { canonicalizeMainSessionAlias, resolveAgentMainSessionKey, - resolveMainSessionKey, resolveMainSessionKeyFromConfig, } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -62,7 +61,6 @@ type HookEventTarget = { function resolveHookEventTarget(params: { cfg: OpenClawConfig; resolvedAgentId: string; - explicitAgentId?: string; sessionKey?: string; }): HookEventTarget { if (params.cfg.session?.scope === "global") { @@ -83,15 +81,10 @@ function resolveHookEventTarget(params: { mainKey: params.cfg.session?.mainKey, }), }) - : params.explicitAgentId - ? resolveAgentMainSessionKey({ cfg: params.cfg, agentId: params.explicitAgentId }) - : resolveMainSessionKey(params.cfg); + : resolveAgentMainSessionKey({ cfg: params.cfg, agentId: params.resolvedAgentId }); return { eventSessionKey, - heartbeatTarget: { - ...(params.explicitAgentId ? { agentId: params.explicitAgentId } : {}), - sessionKey: eventSessionKey, - }, + heartbeatTarget: { agentId: params.resolvedAgentId, sessionKey: eventSessionKey }, }; } @@ -453,12 +446,11 @@ export function createGatewayHooksRequestHandler(params: { }); return; } - // Keep an omitted agent omitted for event routing so global session scope - // stays global; runner identity is frozen separately via accepted agentId. + // The accepted agent is the stable owner. Global scope stays global; + // other events keep that owner in their agent-qualified session key. hookEventTarget = resolveHookEventTarget({ cfg, resolvedAgentId: agentId, - explicitAgentId: acceptedValue.agentId, }); const { runCronIsolatedAgentTurn } = await loadIsolatedAgentModule(); // Lazy module loading is the last Gateway-owned async boundary before diff --git a/src/gateway/tool-resolution.terminal.test.ts b/src/gateway/tool-resolution.terminal.test.ts new file mode 100644 index 000000000000..c919842bb207 --- /dev/null +++ b/src/gateway/tool-resolution.terminal.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { GatewayRequestContext } from "./server-methods/types.js"; +import { setFallbackGatewayContext } from "./server-plugin-fallback-context.js"; +import { TerminalSessionManager } from "./terminal/session-manager.js"; +import { makeFakePty } from "./terminal/session-manager.test-helpers.js"; +import { resolveGatewayScopedTools } from "./tool-resolution.js"; + +type TaskLookupRecord = { + taskId: string; + runId: string; + childSessionKey: string; + status: "running"; +}; + +const taskStatusMocks = vi.hoisted(() => ({ + findTaskByRunIdForChildSessionForStatus: vi.fn( + (_runId: string, _childSessionKey: string): TaskLookupRecord | undefined => undefined, + ), + findTaskByRunIdForStatus: vi.fn((_runId: string): TaskLookupRecord | undefined => undefined), +})); + +vi.mock("../tasks/task-status-access.js", () => taskStatusMocks); + +describe("resolveGatewayScopedTools terminal ownership", () => { + it("binds a loopback terminal to the matching child when run ids collide", async () => { + const backend = makeFakePty(); + const manager = new TerminalSessionManager({ emit: vi.fn(), spawn: async () => backend }); + const childSessionKey = "agent:main:loopback-task-2"; + const tasks: TaskLookupRecord[] = [ + { + taskId: "task-1", + runId: "shared-run", + childSessionKey: "agent:main:loopback-task-1", + status: "running", + }, + { taskId: "task-2", runId: "shared-run", childSessionKey, status: "running" }, + ]; + taskStatusMocks.findTaskByRunIdForChildSessionForStatus.mockImplementation( + (runId, sessionKey) => + tasks.find((task) => task.runId === runId && task.childSessionKey === sessionKey), + ); + const clearContext = setFallbackGatewayContext({ + terminalSessions: manager, + isTerminalEnabled: () => true, + resolveTerminalLaunchPolicy: () => ({ + ok: true, + plan: { agentId: "main", cwd: "/tmp", shell: "/bin/sh", args: [] }, + }), + } as unknown as GatewayRequestContext); + + try { + const result = resolveGatewayScopedTools({ + cfg: { tools: { allow: ["terminal"] } } as OpenClawConfig, + sessionKey: childSessionKey, + runId: "shared-run", + senderIsOwner: true, + surface: "loopback", + }); + const terminal = result.tools.find((tool) => tool.name === "terminal"); + if (!terminal?.execute) { + throw new Error("expected loopback terminal tool"); + } + + await terminal.execute("terminal-open", { action: "open", show: false }); + + expect(taskStatusMocks.findTaskByRunIdForChildSessionForStatus).toHaveBeenCalledWith( + "shared-run", + childSessionKey, + ); + expect(manager.closeAgentSessions("task-2")).toBe(1); + expect(manager.closeAgentSessions("task-1")).toBe(0); + expect(backend.killed).toBe(true); + } finally { + clearContext(); + manager.disposeAll(); + taskStatusMocks.findTaskByRunIdForChildSessionForStatus.mockReset(); + taskStatusMocks.findTaskByRunIdForStatus.mockReset(); + } + }); +}); diff --git a/src/gateway/tool-resolution.ts b/src/gateway/tool-resolution.ts index 48c52bf36930..b276808c70aa 100644 --- a/src/gateway/tool-resolution.ts +++ b/src/gateway/tool-resolution.ts @@ -272,6 +272,7 @@ export function resolveGatewayScopedTools(params: { const openClawTools = createOpenClawTools({ agentSessionKey: params.sessionKey, + runId: params.runId, requesterAgentIdOverride: sessionAgentId, agentChannel: params.messageProvider ?? undefined, agentAccountId: params.accountId, diff --git a/src/infra/exec-approval-reply.test.ts b/src/infra/exec-approval-reply.test.ts index 172e51dae0ff..69bac1be7042 100644 --- a/src/infra/exec-approval-reply.test.ts +++ b/src/infra/exec-approval-reply.test.ts @@ -688,18 +688,23 @@ describe("exec approval reply helpers", () => { }), ).toEqual({ text: "Careful.\n\nApproval required. I sent approval DMs to the approvers for this account.", + channelData: { + execApprovalUnavailable: { + reason: "no-approval-route", + }, + }, }); }); it.each(unavailableReasonCases)( "builds unavailable payload for reason $reason", ({ reason, channelLabel, expected }) => { - expect( - buildExecApprovalUnavailableReplyPayload({ - reason, - channelLabel, - }).text, - ).toContain(expected); + const payload = buildExecApprovalUnavailableReplyPayload({ + reason, + channelLabel, + }); + expect(payload.text).toContain(expected); + expect(payload.channelData).toEqual({ execApprovalUnavailable: { reason } }); }, ); }); diff --git a/src/infra/exec-approval-reply.ts b/src/infra/exec-approval-reply.ts index 784492fb45d2..482c215d422a 100644 --- a/src/infra/exec-approval-reply.ts +++ b/src/infra/exec-approval-reply.ts @@ -475,6 +475,11 @@ export function buildExecApprovalUnavailableReplyPayload( params: ExecApprovalUnavailableReplyParams, ): ReplyPayload { const lines: string[] = []; + const channelData = { + execApprovalUnavailable: { + reason: params.reason, + }, + }; const warningText = params.warningText?.trim(); if (warningText) { lines.push(warningText); @@ -484,6 +489,7 @@ export function buildExecApprovalUnavailableReplyPayload( lines.push(getExecApprovalApproverDmNoticeText()); return { text: lines.join("\n\n"), + channelData, }; } @@ -535,5 +541,6 @@ export function buildExecApprovalUnavailableReplyPayload( return { text: lines.join("\n\n"), + channelData, }; } diff --git a/src/node-host/invoke-worker-supervisor.test.ts b/src/node-host/invoke-worker-supervisor.test.ts index d861533def30..fd14df0b3c2c 100644 --- a/src/node-host/invoke-worker-supervisor.test.ts +++ b/src/node-host/invoke-worker-supervisor.test.ts @@ -161,13 +161,17 @@ describe("node-host worker supervisor commands", () => { expect(payload).not.toHaveProperty("errorText"); }); - it("omits completed worker output from the durable wire receipt", async () => { + it("returns completed worker output without internal process fields", async () => { const input = launchInput(); - const privatePath = "/private/workspaces/session-1/secret.txt"; + const resultJson = JSON.stringify({ + status: "completed", + transcriptLeafId: "leaf-1", + transcriptNextSeq: 2, + }); const receipt: NodeWorkerLaunchReceipt = { ...fullReceipt(input), state: "completed", - resultJson: JSON.stringify({ argv: ["worker", "--internal-worker-ipc"], privatePath }), + resultJson, completedAtMs: 12, }; @@ -177,10 +181,7 @@ describe("node-host worker supervisor commands", () => { supervisor: supervisorWith(receipt), }); - const raw = result?.payloadJSON ?? ""; - expect(raw).not.toContain("argv"); - expect(raw).not.toContain(privatePath); - expect(JSON.parse(raw)).toEqual({ + expect(JSON.parse(result?.payloadJSON ?? "{}")).toEqual({ launchId: input.launchId, planHash: receipt.planHash, environmentId: input.descriptor.admission.environmentId, @@ -189,6 +190,42 @@ describe("node-host worker supervisor commands", () => { placementGeneration: input.placementGeneration, runId: input.descriptor.assignment.runId, state: "completed", + resultJson, + }); + const payload = JSON.parse(result?.payloadJSON ?? "{}") as Record; + expect(payload).not.toHaveProperty("supervisor"); + expect(payload).not.toHaveProperty("worker"); + expect(payload).not.toHaveProperty("gatewayNamespace"); + expect(payload).not.toHaveProperty("descriptor"); + expect(payload).not.toHaveProperty("errorText"); + }); + + it("returns failed worker diagnostics without completed output", async () => { + const input = launchInput(); + const receipt: NodeWorkerLaunchReceipt = { + ...fullReceipt(input), + state: "failed", + worker: null, + errorText: "worker exited before completion", + completedAtMs: 12, + }; + + const { result } = await invokePrivate({ + command: NODE_WORKER_SUPERVISOR_STATUS_COMMAND, + paramsJSON: JSON.stringify({ launchId: input.launchId }), + supervisor: supervisorWith(receipt), + }); + + expect(JSON.parse(result?.payloadJSON ?? "{}")).toEqual({ + launchId: input.launchId, + planHash: receipt.planHash, + environmentId: input.descriptor.admission.environmentId, + sessionId: input.descriptor.admission.sessionId, + ownerEpoch: input.descriptor.admission.ownerEpoch, + placementGeneration: input.placementGeneration, + runId: input.descriptor.assignment.runId, + state: "failed", + errorText: receipt.errorText, }); }); @@ -226,6 +263,27 @@ describe("node-host worker supervisor commands", () => { expect(mocks.cancel.mock.calls).toHaveLength(0); }); + it("fails closed when a durable terminal receipt is inconsistent", async () => { + const input = launchInput(); + const receipt: NodeWorkerLaunchReceipt = { + ...fullReceipt(input), + state: "completed", + resultJson: null, + completedAtMs: 12, + }; + + const { result } = await invokePrivate({ + command: NODE_WORKER_SUPERVISOR_STATUS_COMMAND, + paramsJSON: JSON.stringify({ launchId: input.launchId }), + supervisor: supervisorWith(receipt), + }); + + expect(result).toMatchObject({ + ok: false, + error: { code: "UNAVAILABLE", message: "node worker supervisor command failed" }, + }); + }); + it("returns a bounded generic error without leaking supervisor details", async () => { const leaked = `/private/path/${"secret".repeat(2_000)}`; const supervisor = supervisorWith(fullReceipt()); diff --git a/src/node-host/node-worker-launch-store.ts b/src/node-host/node-worker-launch-store.ts index 877a6c125867..dcbe16ddb513 100644 --- a/src/node-host/node-worker-launch-store.ts +++ b/src/node-host/node-worker-launch-store.ts @@ -11,11 +11,11 @@ import { type OpenClawStateDatabaseOptions, } from "../state/openclaw-state-db.js"; import { OPENCLAW_STATE_SCHEMA_SQL } from "../state/openclaw-state-schema.js"; +import type { NodeWorkerSupervisorIdentity } from "../worker/node-supervisor-protocol.js"; import { inspectNodeWorkerProcessIdentity, type NodeWorkerProcessIdentity, } from "./node-worker-process-identity.js"; -import type { NodeWorkerSupervisorIdentity } from "./node-worker-supervisor-identity.js"; type NodeWorkerLaunchState = | "pending" diff --git a/src/node-host/node-worker-supervisor-contract.ts b/src/node-host/node-worker-supervisor-contract.ts index d23ef6a53746..ba087e02c020 100644 --- a/src/node-host/node-worker-supervisor-contract.ts +++ b/src/node-host/node-worker-supervisor-contract.ts @@ -1,30 +1,22 @@ -import { createHash } from "node:crypto"; -import { stableStringify } from "@openclaw/normalization-core"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { - parseWorkerLaunchDescriptor, - type WorkerLaunchDescriptor, -} from "../worker/launch-descriptor.js"; + parseNodeWorkerSupervisorReceipt, + type NodeWorkerLaunchInput, + type NodeWorkerSupervisorIdentity, + type NodeWorkerSupervisorReceipt, +} from "../worker/node-supervisor-protocol.js"; import type { NodeWorkerLaunchReceipt } from "./node-worker-launch-store.js"; -import type { NodeWorkerSupervisorIdentity } from "./node-worker-supervisor-identity.js"; -export type { NodeWorkerSupervisorIdentity } from "./node-worker-supervisor-identity.js"; - -const IDENTIFIER_MAX_CHARS = 256; -const GATEWAY_NAMESPACE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; -const NODE_WORKER_SUPERVISOR_CANCEL_REQUEST_MAX_BYTES = 4 * 1024; - -export type NodeWorkerLaunchInput = { - launchId: string; - gatewayNamespace: string; - bundleHash: string; - placementGeneration: number; - descriptor: WorkerLaunchDescriptor; -}; - -export type NodeWorkerSupervisorReceipt = NodeWorkerSupervisorIdentity & { - state: "pending" | "running" | "completed" | "failed" | "interrupted" | "cancelled"; -}; +export { + nodeWorkerPlanHash, + parseNodeWorkerCancelInput, + parseNodeWorkerLaunchInput, + parseNodeWorkerLookupInput, +} from "../worker/node-supervisor-protocol.js"; +export type { + NodeWorkerLaunchInput, + NodeWorkerSupervisorIdentity, + NodeWorkerSupervisorReceipt, +} from "../worker/node-supervisor-protocol.js"; export type NodeWorkerSupervisorControl = { launch(input: NodeWorkerLaunchInput): Promise; @@ -32,152 +24,10 @@ export type NodeWorkerSupervisorControl = { cancel(expected: NodeWorkerSupervisorIdentity): Promise; }; -function hasExactKeys(value: Record, keys: readonly string[]): boolean { - return ( - Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)) - ); -} - -function requireIdentifier(value: unknown, label: string): string { - if ( - typeof value !== "string" || - value.length === 0 || - value.length > IDENTIFIER_MAX_CHARS || - value.trim() !== value || - value.includes("\0") - ) { - throw new Error(`INVALID_REQUEST: ${label} must be a bounded non-empty identifier`); - } - return value; -} - -function requireNonNegativeInteger(value: unknown, label: string): number { - if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { - throw new Error(`INVALID_REQUEST: ${label} must be a non-negative safe integer`); - } - return value; -} - -function decodeRequest(raw?: string | null): unknown { - if (!raw) { - throw new Error("INVALID_REQUEST: paramsJSON required"); - } - try { - return JSON.parse(raw) as unknown; - } catch { - throw new Error("INVALID_REQUEST: paramsJSON malformed JSON"); - } -} - -export function parseNodeWorkerLaunchInput(raw?: string | null): NodeWorkerLaunchInput { - const value = decodeRequest(raw); - if ( - !isRecord(value) || - !hasExactKeys(value, [ - "launchId", - "gatewayNamespace", - "bundleHash", - "placementGeneration", - "descriptor", - ]) - ) { - throw new Error("INVALID_REQUEST: invalid node worker launch request"); - } - const launchId = requireIdentifier(value.launchId, "launchId"); - const gatewayNamespace = requireIdentifier(value.gatewayNamespace, "gatewayNamespace"); - if (!GATEWAY_NAMESPACE_PATTERN.test(gatewayNamespace)) { - throw new Error("INVALID_REQUEST: gatewayNamespace must be a safe bounded path component"); - } - if (typeof value.bundleHash !== "string" || !/^[a-f0-9]{64}$/u.test(value.bundleHash)) { - throw new Error("INVALID_REQUEST: bundleHash must be 64 lowercase hexadecimal characters"); - } - let descriptor: WorkerLaunchDescriptor; - try { - descriptor = parseWorkerLaunchDescriptor(value.descriptor); - } catch { - throw new Error("INVALID_REQUEST: invalid worker launch descriptor"); - } - if (descriptor.admission.handshake.bundleHash !== value.bundleHash) { - throw new Error("INVALID_REQUEST: descriptor bundle hash does not match bundleHash"); - } - return { - launchId, - gatewayNamespace, - bundleHash: value.bundleHash, - placementGeneration: requireNonNegativeInteger( - value.placementGeneration, - "placementGeneration", - ), - descriptor, - }; -} - -export function parseNodeWorkerLookupInput(raw?: string | null): { launchId: string } { - const value = decodeRequest(raw); - if (!isRecord(value) || !hasExactKeys(value, ["launchId"])) { - throw new Error("INVALID_REQUEST: invalid node worker lookup request"); - } - return { launchId: requireIdentifier(value.launchId, "launchId") }; -} - -export function parseNodeWorkerCancelInput(raw?: string | null): NodeWorkerSupervisorIdentity { - if (!raw || Buffer.byteLength(raw, "utf8") > NODE_WORKER_SUPERVISOR_CANCEL_REQUEST_MAX_BYTES) { - throw new Error("INVALID_REQUEST: invalid node worker cancel request"); - } - const value = decodeRequest(raw); - if ( - !isRecord(value) || - !hasExactKeys(value, [ - "launchId", - "planHash", - "environmentId", - "sessionId", - "ownerEpoch", - "placementGeneration", - "runId", - ]) - ) { - throw new Error("INVALID_REQUEST: invalid node worker cancel request"); - } - if (typeof value.planHash !== "string" || !/^[a-f0-9]{64}$/u.test(value.planHash)) { - throw new Error("INVALID_REQUEST: planHash must be 64 lowercase hexadecimal characters"); - } - return { - launchId: requireIdentifier(value.launchId, "launchId"), - planHash: value.planHash, - environmentId: requireIdentifier(value.environmentId, "environmentId"), - sessionId: requireIdentifier(value.sessionId, "sessionId"), - ownerEpoch: requireNonNegativeInteger(value.ownerEpoch, "ownerEpoch"), - placementGeneration: requireNonNegativeInteger( - value.placementGeneration, - "placementGeneration", - ), - runId: requireIdentifier(value.runId, "runId"), - }; -} - -export function nodeWorkerPlanHash( - input: Pick< - NodeWorkerLaunchInput, - "bundleHash" | "descriptor" | "gatewayNamespace" | "placementGeneration" - >, -): string { - return createHash("sha256") - .update( - stableStringify({ - bundleHash: input.bundleHash, - descriptor: input.descriptor, - gatewayNamespace: input.gatewayNamespace, - placementGeneration: input.placementGeneration, - }), - ) - .digest("hex"); -} - export function projectNodeWorkerSupervisorReceipt( receipt: NodeWorkerLaunchReceipt, ): NodeWorkerSupervisorReceipt { - return { + const identity = { launchId: receipt.launchId, planHash: receipt.planHash, environmentId: receipt.environmentId, @@ -185,6 +35,18 @@ export function projectNodeWorkerSupervisorReceipt( ownerEpoch: receipt.ownerEpoch, placementGeneration: receipt.placementGeneration, runId: receipt.runId, - state: receipt.state, }; + const projected = + receipt.state === "completed" + ? { ...identity, state: receipt.state, resultJson: receipt.resultJson } + : receipt.state === "failed" || + receipt.state === "interrupted" || + receipt.state === "cancelled" + ? { ...identity, state: receipt.state, errorText: receipt.errorText } + : { ...identity, state: receipt.state }; + const parsed = parseNodeWorkerSupervisorReceipt(projected); + if (!parsed) { + throw new Error("node worker supervisor durable receipt is inconsistent"); + } + return parsed; } diff --git a/src/node-host/node-worker-supervisor-identity.ts b/src/node-host/node-worker-supervisor-identity.ts deleted file mode 100644 index 9176ee6e948e..000000000000 --- a/src/node-host/node-worker-supervisor-identity.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type NodeWorkerSupervisorIdentity = { - launchId: string; - planHash: string; - environmentId: string; - sessionId: string; - ownerEpoch: number; - placementGeneration: number; - runId: string; -}; diff --git a/src/plugin-sdk/github-copilot-login.ts b/src/plugin-sdk/github-copilot-login.ts deleted file mode 100644 index 88f90b2e6d2f..000000000000 --- a/src/plugin-sdk/github-copilot-login.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Manual facade. Keep loader boundary explicit. -import type { RuntimeEnv } from "../runtime.js"; -import { loadBundledPluginPublicSurfaceModuleSyncCore } from "./facade-loader.js"; - -type FacadeModule = { - githubCopilotLoginCommand: ( - opts: { profileId?: string; yes?: boolean; agentDir?: string }, - runtime: RuntimeEnv, - ) => Promise; -}; - -function loadFacadeModule(): FacadeModule { - return loadBundledPluginPublicSurfaceModuleSyncCore({ - dirName: "github-copilot", - artifactBasename: "api.js", - }); -} - -/** @deprecated GitHub Copilot provider-owned login helper; use provider auth hooks instead. */ -export const githubCopilotLoginCommand: FacadeModule["githubCopilotLoginCommand"] = ((...args) => - loadFacadeModule()["githubCopilotLoginCommand"]( - ...args, - )) as FacadeModule["githubCopilotLoginCommand"]; diff --git a/src/plugin-sdk/provider-auth-login.runtime.ts b/src/plugin-sdk/provider-auth-login.runtime.ts deleted file mode 100644 index d0c7123d40aa..000000000000 --- a/src/plugin-sdk/provider-auth-login.runtime.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** @deprecated Provider-owned login helpers; use provider auth hooks instead. */ -export { loginOpenAICodexOAuth } from "../plugins/provider-openai-chatgpt-oauth.js"; -/** @deprecated Provider-owned login helpers; use provider auth hooks instead. */ -export { githubCopilotLoginCommand } from "./github-copilot-login.js"; diff --git a/src/plugins/compat/deprecation-marking.ts b/src/plugins/compat/deprecation-marking.ts index 58a3621f65d3..25114576f2ba 100644 --- a/src/plugins/compat/deprecation-marking.ts +++ b/src/plugins/compat/deprecation-marking.ts @@ -119,10 +119,7 @@ export const DEPRECATION_MARKING_COMPAT_RECORDS = [ "openclaw/plugin-sdk/provider-auth DEFAULT_COPILOT_API_BASE_URL", "openclaw/plugin-sdk/provider-auth deriveCopilotApiBaseUrlFromToken", "openclaw/plugin-sdk/provider-auth resolveCopilotApiToken", - "openclaw/plugin-sdk/provider-auth-login.runtime loginOpenAICodexOAuth", - "openclaw/plugin-sdk/provider-auth-login.runtime githubCopilotLoginCommand", "openclaw/plugin-sdk/provider-auth-copilot-cache CachedCopilotToken", - "openclaw/plugin-sdk/github-copilot-login githubCopilotLoginCommand", "openclaw/plugin-sdk/oauth-utils toFormUrlEncoded", "openclaw/plugin-sdk/oauth-utils generatePkceVerifierChallenge", "openclaw/plugin-sdk/provider-oauth-runtime OAuthProvider", diff --git a/src/plugins/compat/registry.test.ts b/src/plugins/compat/registry.test.ts index 1bc4fc7789b6..71d0b64dfd84 100644 --- a/src/plugins/compat/registry.test.ts +++ b/src/plugins/compat/registry.test.ts @@ -50,7 +50,7 @@ const deprecationMarkingCodes = [ const deprecationMarkingSurfaceCounts: Record<(typeof deprecationMarkingCodes)[number], number> = { "plugin-sdk-channel-setup-input-fields": 22, "plugin-sdk-broad-runtime-barrels": 12, - "plugin-sdk-provider-owned-helper-shims": 34, + "plugin-sdk-provider-owned-helper-shims": 31, "message-presentation-legacy-bridges": 21, "plugin-sdk-focused-compat-aliases": 23, "agent-harness-terminal-result-aliases": 10, diff --git a/src/plugins/management-service.owner-routing.test.ts b/src/plugins/management-service.owner-routing.test.ts new file mode 100644 index 000000000000..eebb0d51654b --- /dev/null +++ b/src/plugins/management-service.owner-routing.test.ts @@ -0,0 +1,35 @@ +import { expect, it, vi } from "vitest"; + +const metadata = vi.hoisted(() => vi.fn()); + +vi.mock("./plugin-metadata-snapshot.js", () => ({ + loadPluginMetadataSnapshot: (...args: unknown[]) => metadata(...args), + resolvePluginMetadataSnapshot: (...args: unknown[]) => metadata(...args), +})); + +const { listManagedPlugins } = await import("./management-service.js"); + +it("loads plugin metadata from the explicit system-owner workspace", async () => { + const config = { + agents: { + ownership: "explicit" as const, + defaults: { systemAgent: { agentId: "research" } }, + entries: { main: {}, research: { workspace: "~/research-workspace" } }, + }, + }; + const env = { HOME: "/tmp/openclaw-managed-plugin-home" }; + metadata.mockReturnValue({ + index: { plugins: [], installRecords: {} }, + byPluginId: new Map(), + diagnostics: [], + normalizePluginId: (pluginId: string) => pluginId, + }); + + await listManagedPlugins({ config, env, officialCatalog: { entries: [] } }); + + expect(metadata).toHaveBeenCalledWith({ + config, + env, + workspaceDir: "/tmp/openclaw-managed-plugin-home/research-workspace", + }); +}); diff --git a/src/plugins/management-service.ts b/src/plugins/management-service.ts index 6d401b98a510..17fb189d6e7b 100644 --- a/src/plugins/management-service.ts +++ b/src/plugins/management-service.ts @@ -3,7 +3,10 @@ import path from "node:path"; import { asSafeIntegerInRange } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope-config.js"; +import { + resolveAgentWorkspaceDir, + resolveSystemAgentTargetAgentId, +} from "../agents/agent-scope-config.js"; import { MANIFEST_KEY } from "../compat/legacy-names.js"; import { collectChangedPaths } from "../config/config-change-paths.js"; import { @@ -653,7 +656,7 @@ function resolveManagedPluginMetadataParams(config: OpenClawConfig, env: NodeJS. return { config, env, - workspaceDir: resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config), env), + workspaceDir: resolveAgentWorkspaceDir(config, resolveSystemAgentTargetAgentId(config), env), }; } diff --git a/src/state/openclaw-state-db-schema-repair.ts b/src/state/openclaw-state-db-schema-repair.ts index 301d968540fa..1c30559efa85 100644 --- a/src/state/openclaw-state-db-schema-repair.ts +++ b/src/state/openclaw-state-db-schema-repair.ts @@ -3,7 +3,9 @@ import type { DatabaseSync } from "node:sqlite"; import { openNodeSqliteDatabase } from "../infra/node-sqlite.js"; import { assertSqliteSchemaContains, + collectSqliteNamedIndexContract, collectSqliteSchemaIssues, + getCanonicalSqliteNamedIndexContracts, type SqliteSchemaCompatibility, } from "../infra/sqlite-schema-contract.js"; import { quoteSqliteIdentifier } from "../infra/sqlite-schema-sql.js"; @@ -81,7 +83,7 @@ CREATE INDEX idx_commitments_agent_sent ON commitments(agent_id, status, sent_at_ms, session_key); `; -const ADDITIVE_RETIRED_COMMITMENTS_SCHEMA_SQL = ` +const SHIPPED_RETIRED_COMMITMENTS_SCHEMA_SQL = ` CREATE TABLE commitments ( id TEXT NOT NULL PRIMARY KEY, agent_id TEXT NOT NULL, @@ -91,22 +93,22 @@ CREATE TABLE commitments ( recipient_id TEXT, thread_id TEXT, sender_id TEXT, - kind TEXT NOT NULL DEFAULT 'followup', - sensitivity TEXT NOT NULL DEFAULT 'normal', - source TEXT NOT NULL DEFAULT 'unknown', + kind TEXT NOT NULL, + sensitivity TEXT NOT NULL, + source TEXT NOT NULL, status TEXT NOT NULL, - reason TEXT NOT NULL DEFAULT '', - suggested_text TEXT NOT NULL DEFAULT '', - dedupe_key TEXT NOT NULL DEFAULT '', - confidence REAL NOT NULL DEFAULT 0, + reason TEXT NOT NULL, + suggested_text TEXT NOT NULL, + dedupe_key TEXT NOT NULL, + confidence REAL NOT NULL, due_earliest_ms INTEGER NOT NULL, due_latest_ms INTEGER NOT NULL, - due_timezone TEXT NOT NULL DEFAULT 'UTC', + due_timezone TEXT NOT NULL, source_message_id TEXT, source_run_id TEXT, - created_at_ms INTEGER NOT NULL DEFAULT 0, + created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, - attempts INTEGER NOT NULL DEFAULT 0, + attempts INTEGER NOT NULL, last_attempt_at_ms INTEGER, sent_at_ms INTEGER, dismissed_at_ms INTEGER, @@ -118,21 +120,16 @@ CREATE INDEX idx_commitments_scope_due ON commitments(agent_id, session_key, status, due_earliest_ms, due_latest_ms); CREATE INDEX idx_commitments_status_due ON commitments(status, due_earliest_ms, due_latest_ms); -CREATE INDEX idx_commitments_agent_due - ON commitments(agent_id, status, due_earliest_ms, due_latest_ms, session_key); CREATE INDEX idx_commitments_scope_dedupe ON commitments(agent_id, session_key, channel, dedupe_key, status); -CREATE INDEX idx_commitments_agent_sent - ON commitments(agent_id, status, sent_at_ms, session_key); `; -const RETIRED_COMMITMENTS_INDEX_NAMES = [ - "idx_commitments_agent_due", - "idx_commitments_agent_sent", - "idx_commitments_scope_dedupe", - "idx_commitments_scope_due", - "idx_commitments_status_due", -] as const; +const RETIRED_COMMITMENTS_INDEX_FINGERPRINTS = new Map( + getCanonicalSqliteNamedIndexContracts(RETIRED_COMMITMENTS_SCHEMA_SQL).map( + ({ fingerprint, name }) => [name, JSON.stringify(fingerprint)], + ), +); +const RETIRED_COMMITMENTS_INDEX_NAMES = [...RETIRED_COMMITMENTS_INDEX_FINGERPRINTS.keys()]; const RETIRED_COMMITMENTS_ADDITIVE_COLUMNS = [ "commitments.account_id", @@ -178,11 +175,6 @@ const RETIRED_COMMITMENTS_SCHEMA_COMPATIBILITY: SqliteSchemaCompatibility = { allowedMissingIndexes: RETIRED_COMMITMENTS_INDEX_NAMES, }; -const ADDITIVE_RETIRED_COMMITMENTS_SCHEMA_COMPATIBILITY: SqliteSchemaCompatibility = { - allowedMissingColumns: RETIRED_COMMITMENTS_ADDITIVE_COLUMNS, - allowedMissingIndexes: RETIRED_COMMITMENTS_INDEX_NAMES, -}; - function hasSupportedRetiredCommitmentsSchema( db: DatabaseSync, schemaSql: string, @@ -201,9 +193,11 @@ function hasSupportedRetiredCommitmentsSchema( ORDER BY type, name`, ) .all() as Array<{ name: string; type: string }>; - const expectedIndexes = new Set(RETIRED_COMMITMENTS_INDEX_NAMES); return attachedObjects.every( - (object) => object.type === "index" && expectedIndexes.has(object.name), + (object) => + object.type === "index" && + JSON.stringify(collectSqliteNamedIndexContract(db, object.name)) === + RETIRED_COMMITMENTS_INDEX_FINGERPRINTS.get(object.name), ); } @@ -231,8 +225,8 @@ function hasRecognizedRetiredCommitmentsSchema(db: DatabaseSync): boolean { ) || hasSupportedRetiredCommitmentsSchema( db, - ADDITIVE_RETIRED_COMMITMENTS_SCHEMA_SQL, - ADDITIVE_RETIRED_COMMITMENTS_SCHEMA_COMPATIBILITY, + SHIPPED_RETIRED_COMMITMENTS_SCHEMA_SQL, + RETIRED_COMMITMENTS_SCHEMA_COMPATIBILITY, ) ); } diff --git a/src/state/openclaw-state-db.test.ts b/src/state/openclaw-state-db.test.ts index 4e69002b11d4..aba95b4790a5 100644 --- a/src/state/openclaw-state-db.test.ts +++ b/src/state/openclaw-state-db.test.ts @@ -311,6 +311,63 @@ function seedAdditiveV6CommitmentSchema(database: DatabaseSync): void { markStateDatabaseVersion(database, 6); } +function seedV2026_7_1_2CommitmentSchema(database: DatabaseSync): void { + database.exec(` + CREATE TABLE commitments ( + id TEXT NOT NULL PRIMARY KEY, + agent_id TEXT NOT NULL, + session_key TEXT NOT NULL, + channel TEXT NOT NULL, + account_id TEXT, + recipient_id TEXT, + thread_id TEXT, + sender_id TEXT, + kind TEXT NOT NULL, + sensitivity TEXT NOT NULL, + source TEXT NOT NULL, + status TEXT NOT NULL, + reason TEXT NOT NULL, + suggested_text TEXT NOT NULL, + dedupe_key TEXT NOT NULL, + confidence REAL NOT NULL, + due_earliest_ms INTEGER NOT NULL, + due_latest_ms INTEGER NOT NULL, + due_timezone TEXT NOT NULL, + source_message_id TEXT, + source_run_id TEXT, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + attempts INTEGER NOT NULL, + last_attempt_at_ms INTEGER, + sent_at_ms INTEGER, + dismissed_at_ms INTEGER, + snoozed_until_ms INTEGER, + expired_at_ms INTEGER, + record_json TEXT NOT NULL + ); + CREATE INDEX idx_commitments_scope_due + ON commitments(agent_id, session_key, status, due_earliest_ms, due_latest_ms); + CREATE INDEX idx_commitments_status_due + ON commitments(status, due_earliest_ms, due_latest_ms); + CREATE INDEX idx_commitments_scope_dedupe + ON commitments(agent_id, session_key, channel, dedupe_key, status); + INSERT INTO commitments ( + id, agent_id, session_key, channel, kind, sensitivity, source, status, + reason, suggested_text, dedupe_key, confidence, due_earliest_ms, + due_latest_ms, due_timezone, created_at_ms, updated_at_ms, attempts, record_json + ) VALUES ( + 'released-commitment', 'main', 'agent:main:main', 'telegram', 'followup', + 'normal', 'message', 'pending', 'inert', 'follow up', 'released-dedupe', + 1.0, 10, 20, 'UTC', 1, 1, 0, '{}' + ); + INSERT INTO state_leases ( + scope, lease_key, owner, expires_at, heartbeat_at, payload_json, created_at, updated_at + ) VALUES ('test', 'released-preserved-lease', 'migration-test', 100, 50, '{}', 1, 2); + PRAGMA user_version = 1; + UPDATE schema_meta SET schema_version = 1 WHERE meta_key = 'primary'; + `); +} + function seedPartiallyAdditiveV6CommitmentSchema(database: DatabaseSync): void { database.exec(` CREATE TABLE commitments ( @@ -1546,6 +1603,79 @@ describe("openclaw state database", () => { }, ); + it.each(["runtime open", "doctor repair"] as const)( + "retires the shipped v2026.7.1-2 commitments layout through %s", + (migrationPath) => { + const stateDir = createTempStateDir(); + const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; + const databasePath = materializeCurrentStateDatabase(stateDir); + const { DatabaseSync } = requireNodeSqlite(); + const released = new DatabaseSync(databasePath); + seedV2026_7_1_2CommitmentSchema(released); + expect(readSqliteNumberPragma(released, "user_version")).toBe(1); + expect( + released.prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary'").get(), + ).toEqual({ schema_version: 1 }); + expect( + released.prepare("SELECT strict FROM pragma_table_list WHERE name = 'commitments'").get(), + ).toEqual({ strict: 0 }); + expect( + released + .prepare( + `SELECT name + FROM sqlite_schema + WHERE type = 'index' + AND tbl_name = 'commitments' + AND sql IS NOT NULL + ORDER BY name`, + ) + .all(), + ).toEqual([ + { name: "idx_commitments_scope_dedupe" }, + { name: "idx_commitments_scope_due" }, + { name: "idx_commitments_status_due" }, + ]); + released.close(); + + if (migrationPath === "doctor repair") { + const result = repairOpenClawStateDatabaseSchema(options); + expect(result.warnings).toEqual([]); + expect(result.changes).toContain("Retired shared state commitments table and indexes"); + } + const migrated = openOpenClawStateDatabase(options); + expect(readSqliteNumberPragma(migrated.db, "user_version")).toBe(7); + expect( + migrated.db + .prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary'") + .get(), + ).toEqual({ schema_version: 7 }); + for (const name of RETIRED_COMMITMENT_SCHEMA_OBJECTS) { + expect( + migrated.db.prepare("SELECT name FROM sqlite_schema WHERE name = ?").get(name), + ).toBeUndefined(); + } + expect( + migrated.db + .prepare( + `SELECT scope, lease_key, owner, expires_at, heartbeat_at, payload_json, + created_at, updated_at + FROM state_leases + WHERE scope = 'test' AND lease_key = 'released-preserved-lease'`, + ) + .get(), + ).toEqual({ + scope: "test", + lease_key: "released-preserved-lease", + owner: "migration-test", + expires_at: 100, + heartbeat_at: 50, + payload_json: "{}", + created_at: 1, + updated_at: 2, + }); + }, + ); + it.each(["runtime open", "doctor repair"] as const)( "preserves a foreign commitments table and colliding index through %s", (migrationPath) => { @@ -1623,6 +1753,14 @@ describe("openclaw state database", () => { AFTER DELETE ON commitments BEGIN SELECT 1; END;`, type: "trigger", }, + { + label: "drifted optional agent-due index", + name: "idx_commitments_agent_due", + sql: `DROP INDEX idx_commitments_agent_due; + CREATE INDEX idx_commitments_agent_due + ON commitments(agent_id, status, session_key);`, + type: "index", + }, ])("preserves an $label on the final v6 commitments layout", ({ name, sql, type }) => { const stateDir = createTempStateDir(); const options = { env: { OPENCLAW_STATE_DIR: stateDir } }; diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index 33beb6953142..cf29f9394ecc 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -81,7 +81,7 @@ import { import { getOpenClawStateRuntimeSchema } from "./openclaw-state-schema-compatibility.js"; import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.js"; -const OPENCLAW_STATE_MIGRATION_ASSERTIONS = { +const STATE_MIGRATION_ASSERTIONS = { 5: assertOpenClawStateDatabaseV5ForMigration, 6: assertOpenClawStateDatabaseV6ForMigration, } as const; @@ -178,7 +178,7 @@ function repairOpenClawStateDatabaseSchemaWithWriteAccess( allowedMissingTables: LAZY_ADDITIVE_STATE_TABLES, }); } else if (previousVersion === 5 || previousVersion === 6) { - OPENCLAW_STATE_MIGRATION_ASSERTIONS[previousVersion](db, { pathname }); + STATE_MIGRATION_ASSERTIONS[previousVersion](db, { pathname }); } if (rebuiltIndexNames.size === 0) { assertSqliteIntegrity(db, pathname); @@ -366,7 +366,7 @@ function ensureSchema(db: DatabaseSync, pathname: string, env: NodeJS.ProcessEnv ensureAdditiveStateColumns(db); assertCurrentStateRuntimeSchema(db, pathname); } else if (previousVersion === 5 || previousVersion === 6) { - OPENCLAW_STATE_MIGRATION_ASSERTIONS[previousVersion](db, { pathname }); + STATE_MIGRATION_ASSERTIONS[previousVersion](db, { pathname }); } dropLegacyStateTables(db); migrateRetiredCommitmentsSchema(db, previousVersion); diff --git a/src/status/summary.ts b/src/status/summary.ts index a80b9a640c76..6f8e200d299d 100644 --- a/src/status/summary.ts +++ b/src/status/summary.ts @@ -6,7 +6,7 @@ import { resolveAgentConfig } from "../agents/agent-scope.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { areRuntimeModelRefsEquivalent } from "../agents/model-runtime-aliases.js"; import { getRuntimeConfig, projectConfigOntoRuntimeSourceSnapshot } from "../config/config.js"; -import { resolveMainSessionKey } from "../config/sessions/main-session.js"; +import { resolveSystemMainSessionKey } from "../config/sessions/main-session.js"; import { hasSessionActiveAutoModelFallback, hasSessionAutoModelFallbackProvenance, @@ -376,7 +376,7 @@ export async function getStatusSummary( }), ) : []; - const mainSessionKey = resolveMainSessionKey(cfg); + const mainSessionKey = resolveSystemMainSessionKey(cfg); const queuedSystemEvents = peekSystemEvents(mainSessionKey); const taskMaintenanceModule = await loadTaskRegistryMaintenanceModule(); taskMaintenanceModule.configureTaskRegistryMaintenance(); diff --git a/src/system-agent/inference-fallback.ts b/src/system-agent/inference-fallback.ts index 3363c8930c85..4a1e7cee16fb 100644 --- a/src/system-agent/inference-fallback.ts +++ b/src/system-agent/inference-fallback.ts @@ -1,5 +1,6 @@ // Provider-neutral live inference ladder for OpenClaw sessions. import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { resolveSystemAgentTargetAgentId } from "../agents/agent-scope-config.js"; import { listAgentIds, tryResolveDefaultAgentId } from "../agents/agent-scope.js"; import { hasAvailableAuthForProvider } from "../agents/model-auth.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -7,7 +8,6 @@ import { normalizeAgentId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveSystemAgentConfiguredRouteFromConfig, - resolveSystemAgentTargetAgentId, type SystemAgentConfiguredRoute, } from "./inference-route.js"; import { verifySetupInference, type BoundVerifySetupInferenceResult } from "./setup-inference.js"; diff --git a/src/system-agent/inference-route.ts b/src/system-agent/inference-route.ts index 0ecb4eb87d74..e937b28c04cb 100644 --- a/src/system-agent/inference-route.ts +++ b/src/system-agent/inference-route.ts @@ -1,12 +1,10 @@ // Resolves the configured default agent route shared by OpenClaw inference calls. import { isDeepStrictEqual } from "node:util"; import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { listAgentEntries, - resolveDefaultAgentId, + resolveSystemAgentTargetAgentId, toAgentEntriesRecord, - tryResolveLegacyCompatibilityAgentId, } from "../agents/agent-scope-config.js"; import { cliBackendAcceptsAuthProfileForwarding, @@ -33,25 +31,6 @@ export type SystemAgentConfiguredRoute = { } ); -export function resolveSystemAgentTargetAgentId( - config: OpenClawConfig, - requestedAgentId?: string, -): string { - const configuredAgentId = - normalizeOptionalString(requestedAgentId) ?? - normalizeOptionalString(config.agents?.defaults?.systemAgent?.agentId); - if (configuredAgentId) { - return normalizeAgentId(configuredAgentId); - } - return normalizeAgentId( - tryResolveLegacyCompatibilityAgentId(config) ?? - resolveDefaultAgentId(config, { - surface: "system-agent consult routing", - hint: "Set agents.defaults.systemAgent.agentId or pass an explicit consult agent id.", - }), - ); -} - export type SystemAgentConfiguredRouteDeps = { readConfigFileSnapshot?: typeof import("../config/config.js").readConfigFileSnapshot; loadAuthProfileStoreForRuntime?: typeof import("../agents/auth-profiles/store.js").loadAuthProfileStoreForRuntime; diff --git a/src/tasks/task-status-access.ts b/src/tasks/task-status-access.ts index 96416de955da..6044132f7cbb 100644 --- a/src/tasks/task-status-access.ts +++ b/src/tasks/task-status-access.ts @@ -5,6 +5,7 @@ import { listActiveGeneratedMediaTaskIdsForSessionKey, } from "./generated-media-task-activity.js"; import { isTerminalTaskStatus } from "./task-executor-policy.js"; +import { getTasksByRunScope, pickPreferredRunIdTask } from "./task-registry-state.js"; // Filters task status visibility by requester, owner, and flow scope. import { findTaskByRunId, @@ -58,6 +59,30 @@ export function findTaskByRunIdForStatus(runId: string): TaskRecord | undefined return findTaskByRunId(runId); } +export function findTaskByRunIdForChildSessionForStatus( + runId: string, + childSessionKey: string, +): Pick | undefined { + const normalizedChildSessionKey = childSessionKey.trim(); + if (!normalizedChildSessionKey) { + return undefined; + } + // A run id can span multiple task scopes. Terminal ownership must stay on + // the exact child session instead of adopting the registry's global preference. + const task = pickPreferredRunIdTask( + getTasksByRunScope({ runId, sessionKey: normalizedChildSessionKey }).filter( + (candidate) => candidate.childSessionKey?.trim() === normalizedChildSessionKey, + ), + ); + return task + ? { + taskId: task.taskId, + status: task.status, + childSessionKey: task.childSessionKey, + } + : undefined; +} + /** Snapshots generated-media task ids so replay guards stay attempt-local. */ export function getGeneratedMediaTaskIdsForSessionKey( sessionKey: string | undefined, diff --git a/src/worker/node-supervisor-protocol.test.ts b/src/worker/node-supervisor-protocol.test.ts new file mode 100644 index 000000000000..04829f84ce0e --- /dev/null +++ b/src/worker/node-supervisor-protocol.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + parseNodeWorkerSupervisorReceipt, + type NodeWorkerSupervisorIdentity, +} from "./node-supervisor-protocol.js"; + +const RESULT_JSON_MAX_BYTES = 64 * 1024; +const ERROR_TEXT_MAX_BYTES = 4 * 1024; + +const identity: NodeWorkerSupervisorIdentity = { + launchId: "launch-1", + planHash: "a".repeat(64), + environmentId: "environment-1", + sessionId: "session-1", + ownerEpoch: 3, + placementGeneration: 4, + runId: "run-1", +}; + +describe("node worker supervisor wire receipt", () => { + it.each([ + { ...identity, state: "pending" }, + { ...identity, state: "running" }, + { + ...identity, + state: "completed", + resultJson: JSON.stringify({ status: "completed", transcriptNextSeq: 2 }), + }, + { ...identity, state: "failed", errorText: "worker exited before completion" }, + { ...identity, state: "interrupted", errorText: "node host stopped" }, + { ...identity, state: "cancelled", errorText: "node worker launch cancelled" }, + ])("round-trips the closed $state receipt", (receipt) => { + expect(parseNodeWorkerSupervisorReceipt(receipt)).toEqual(receipt); + }); + + it.each([ + { name: "extra field", receipt: { ...identity, state: "running", workerPid: 123 } }, + { name: "missing plan hash", receipt: { ...identity, planHash: undefined, state: "running" } }, + { name: "completed without output", receipt: { ...identity, state: "completed" } }, + { + name: "completed with malformed output", + receipt: { ...identity, state: "completed", resultJson: "{" }, + }, + { + name: "oversized completed output", + receipt: { + ...identity, + state: "completed", + resultJson: JSON.stringify({ text: "x".repeat(RESULT_JSON_MAX_BYTES) }), + }, + }, + { name: "failed without error", receipt: { ...identity, state: "failed" } }, + { + name: "multiline error", + receipt: { ...identity, state: "failed", errorText: "first\nsecond" }, + }, + { + name: "oversized error", + receipt: { + ...identity, + state: "failed", + errorText: "x".repeat(ERROR_TEXT_MAX_BYTES + 1), + }, + }, + ])("rejects $name", ({ receipt }) => { + expect(parseNodeWorkerSupervisorReceipt(receipt)).toBeNull(); + }); + + it("rejects non-object values without throwing", () => { + expect(parseNodeWorkerSupervisorReceipt("{")).toBeNull(); + expect(parseNodeWorkerSupervisorReceipt(null)).toBeNull(); + }); +}); diff --git a/src/worker/node-supervisor-protocol.ts b/src/worker/node-supervisor-protocol.ts new file mode 100644 index 000000000000..4e76d6096bb2 --- /dev/null +++ b/src/worker/node-supervisor-protocol.ts @@ -0,0 +1,288 @@ +import { createHash } from "node:crypto"; +import { stableStringify } from "@openclaw/normalization-core"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { parseWorkerLaunchDescriptor, type WorkerLaunchDescriptor } from "./launch-descriptor.js"; + +const IDENTIFIER_MAX_CHARS = 256; +const GATEWAY_NAMESPACE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; +const NODE_WORKER_SUPERVISOR_CANCEL_REQUEST_MAX_BYTES = 4 * 1024; +const NODE_WORKER_RESULT_JSON_MAX_BYTES = 64 * 1024; +const NODE_WORKER_ERROR_TEXT_MAX_BYTES = 4 * 1024; + +export type NodeWorkerLaunchInput = { + launchId: string; + gatewayNamespace: string; + bundleHash: string; + placementGeneration: number; + descriptor: WorkerLaunchDescriptor; +}; + +export type NodeWorkerSupervisorIdentity = { + launchId: string; + planHash: string; + environmentId: string; + sessionId: string; + ownerEpoch: number; + placementGeneration: number; + runId: string; +}; + +type NodeWorkerSupervisorActiveReceipt = NodeWorkerSupervisorIdentity & { + state: "pending" | "running"; +}; + +type NodeWorkerSupervisorCompletedReceipt = NodeWorkerSupervisorIdentity & { + state: "completed"; + resultJson: string; +}; + +type NodeWorkerSupervisorErrorReceipt = NodeWorkerSupervisorIdentity & { + state: "failed" | "interrupted" | "cancelled"; + errorText: string; +}; + +export type NodeWorkerSupervisorReceipt = + | NodeWorkerSupervisorActiveReceipt + | NodeWorkerSupervisorCompletedReceipt + | NodeWorkerSupervisorErrorReceipt; + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + return ( + Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)) + ); +} + +function isIdentifier(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= IDENTIFIER_MAX_CHARS && + value.trim() === value && + !value.includes("\0") + ); +} + +function requireIdentifier(value: unknown, label: string): string { + if (!isIdentifier(value)) { + throw new Error(`INVALID_REQUEST: ${label} must be a bounded non-empty identifier`); + } + return value; +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function requireNonNegativeInteger(value: unknown, label: string): number { + if (!isNonNegativeInteger(value)) { + throw new Error(`INVALID_REQUEST: ${label} must be a non-negative safe integer`); + } + return value; +} + +function isPlanHash(value: unknown): value is string { + return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value); +} + +function decodeRequest(raw?: string | null): unknown { + if (!raw) { + throw new Error("INVALID_REQUEST: paramsJSON required"); + } + try { + return JSON.parse(raw) as unknown; + } catch { + throw new Error("INVALID_REQUEST: paramsJSON malformed JSON"); + } +} + +export function parseNodeWorkerLaunchInput(raw?: string | null): NodeWorkerLaunchInput { + const value = decodeRequest(raw); + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "launchId", + "gatewayNamespace", + "bundleHash", + "placementGeneration", + "descriptor", + ]) + ) { + throw new Error("INVALID_REQUEST: invalid node worker launch request"); + } + const launchId = requireIdentifier(value.launchId, "launchId"); + const gatewayNamespace = requireIdentifier(value.gatewayNamespace, "gatewayNamespace"); + if (!GATEWAY_NAMESPACE_PATTERN.test(gatewayNamespace)) { + throw new Error("INVALID_REQUEST: gatewayNamespace must be a safe bounded path component"); + } + if (!isPlanHash(value.bundleHash)) { + throw new Error("INVALID_REQUEST: bundleHash must be 64 lowercase hexadecimal characters"); + } + let descriptor: WorkerLaunchDescriptor; + try { + descriptor = parseWorkerLaunchDescriptor(value.descriptor); + } catch { + throw new Error("INVALID_REQUEST: invalid worker launch descriptor"); + } + if (descriptor.admission.handshake.bundleHash !== value.bundleHash) { + throw new Error("INVALID_REQUEST: descriptor bundle hash does not match bundleHash"); + } + return { + launchId, + gatewayNamespace, + bundleHash: value.bundleHash, + placementGeneration: requireNonNegativeInteger( + value.placementGeneration, + "placementGeneration", + ), + descriptor, + }; +} + +export function parseNodeWorkerLookupInput(raw?: string | null): { launchId: string } { + const value = decodeRequest(raw); + if (!isRecord(value) || !hasExactKeys(value, ["launchId"])) { + throw new Error("INVALID_REQUEST: invalid node worker lookup request"); + } + return { launchId: requireIdentifier(value.launchId, "launchId") }; +} + +export function parseNodeWorkerCancelInput(raw?: string | null): NodeWorkerSupervisorIdentity { + if (!raw || Buffer.byteLength(raw, "utf8") > NODE_WORKER_SUPERVISOR_CANCEL_REQUEST_MAX_BYTES) { + throw new Error("INVALID_REQUEST: invalid node worker cancel request"); + } + const value = decodeRequest(raw); + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "launchId", + "planHash", + "environmentId", + "sessionId", + "ownerEpoch", + "placementGeneration", + "runId", + ]) + ) { + throw new Error("INVALID_REQUEST: invalid node worker cancel request"); + } + if (!isPlanHash(value.planHash)) { + throw new Error("INVALID_REQUEST: planHash must be 64 lowercase hexadecimal characters"); + } + return { + launchId: requireIdentifier(value.launchId, "launchId"), + planHash: value.planHash, + environmentId: requireIdentifier(value.environmentId, "environmentId"), + sessionId: requireIdentifier(value.sessionId, "sessionId"), + ownerEpoch: requireNonNegativeInteger(value.ownerEpoch, "ownerEpoch"), + placementGeneration: requireNonNegativeInteger( + value.placementGeneration, + "placementGeneration", + ), + runId: requireIdentifier(value.runId, "runId"), + }; +} + +export function nodeWorkerPlanHash( + input: Pick< + NodeWorkerLaunchInput, + "bundleHash" | "descriptor" | "gatewayNamespace" | "placementGeneration" + >, +): string { + return createHash("sha256") + .update( + stableStringify({ + bundleHash: input.bundleHash, + descriptor: input.descriptor, + gatewayNamespace: input.gatewayNamespace, + placementGeneration: input.placementGeneration, + }), + ) + .digest("hex"); +} + +const RECEIPT_IDENTITY_KEYS = [ + "launchId", + "planHash", + "environmentId", + "sessionId", + "ownerEpoch", + "placementGeneration", + "runId", +] as const; + +function parseReceiptIdentity(value: Record): NodeWorkerSupervisorIdentity | null { + if ( + !isIdentifier(value.launchId) || + !isPlanHash(value.planHash) || + !isIdentifier(value.environmentId) || + !isIdentifier(value.sessionId) || + !isNonNegativeInteger(value.ownerEpoch) || + !isNonNegativeInteger(value.placementGeneration) || + !isIdentifier(value.runId) + ) { + return null; + } + return { + launchId: value.launchId, + planHash: value.planHash, + environmentId: value.environmentId, + sessionId: value.sessionId, + ownerEpoch: value.ownerEpoch, + placementGeneration: value.placementGeneration, + runId: value.runId, + }; +} + +function isBoundedResultJson(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length === 0 || + Buffer.byteLength(value, "utf8") > NODE_WORKER_RESULT_JSON_MAX_BYTES + ) { + return false; + } + try { + return isRecord(JSON.parse(value) as unknown); + } catch { + return false; + } +} + +function isBoundedErrorText(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + Buffer.byteLength(value, "utf8") <= NODE_WORKER_ERROR_TEXT_MAX_BYTES && + !/[\r\n]/u.test(value) + ); +} + +export function parseNodeWorkerSupervisorReceipt( + value: unknown, +): NodeWorkerSupervisorReceipt | null { + if (!isRecord(value) || typeof value.state !== "string") { + return null; + } + const identity = parseReceiptIdentity(value); + if (!identity) { + return null; + } + if (value.state === "pending" || value.state === "running") { + return hasExactKeys(value, [...RECEIPT_IDENTITY_KEYS, "state"]) + ? { ...identity, state: value.state } + : null; + } + if (value.state === "completed") { + return hasExactKeys(value, [...RECEIPT_IDENTITY_KEYS, "state", "resultJson"]) && + isBoundedResultJson(value.resultJson) + ? { ...identity, state: value.state, resultJson: value.resultJson } + : null; + } + if (value.state === "failed" || value.state === "interrupted" || value.state === "cancelled") { + return hasExactKeys(value, [...RECEIPT_IDENTITY_KEYS, "state", "errorText"]) && + isBoundedErrorText(value.errorText) + ? { ...identity, state: value.state, errorText: value.errorText } + : null; + } + return null; +} diff --git a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts index 38efd090f6fb..fb279da38110 100644 --- a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts @@ -7,6 +7,10 @@ import { pathToFileURL } from "node:url"; import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV } from "../../../../scripts/lib/bundled-plugin-build-entries.mjs"; +import { + preparePackageManifest, + restorePackageManifest, +} from "../../../../scripts/package-manifest.mjs"; import { buildPackageArtifacts, packOpenClawPackageForDocker, @@ -483,8 +487,11 @@ describe("package-openclaw-for-docker", () => { 2, )}\n`; const installedAiPath = path.join(sourceDir, "node_modules", "@openclaw", "ai"); + const aiPackageJsonPath = path.join(sourceDir, "packages", "ai", "package.json"); + const originalAiPackageJson = + '{"name":"@openclaw/ai","version":"2026.6.17","devDependencies":{"@openclaw/normalization-core":"workspace:*"}}\n'; fs.mkdirSync(path.join(sourceDir, "packages", "ai"), { recursive: true }); - fs.writeFileSync(path.join(sourceDir, "packages", "ai", "package.json"), "{}\n"); + fs.writeFileSync(aiPackageJsonPath, originalAiPackageJson); fs.mkdirSync(installedAiPath, { recursive: true }); fs.writeFileSync(path.join(installedAiPath, "original-marker"), "workspace package"); fs.writeFileSync(packageJsonPath, originalPackageJson); @@ -507,6 +514,9 @@ describe("package-openclaw-for-docker", () => { command: "pnpm", cwd: sourceDir, }); + expect( + JSON.parse(fs.readFileSync(aiPackageJsonPath, "utf8")).devDependencies, + ).toBeUndefined(); fs.writeFileSync(path.join(outputDir, "openclaw-ai-2026.6.17.tgz"), "ai package"); return ""; }, @@ -525,9 +535,12 @@ describe("package-openclaw-for-docker", () => { ); fs.writeFileSync(path.join(destination, "runtime.js"), "export {};\n"); }, + prepareManifest: preparePackageManifest, + restoreManifest: restorePackageManifest, }, ); + expect(fs.readFileSync(aiPackageJsonPath, "utf8")).toBe(originalAiPackageJson); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { bundleDependencies: string[]; dependencies: Record; @@ -565,27 +578,47 @@ describe("package-openclaw-for-docker", () => { dependencies: { "@openclaw/ai": "workspace:*" }, name: "openclaw", })}\n`; + const aiPackageJsonPath = path.join(sourceDir, "packages", "ai", "package.json"); + const originalAiPackageJson = + '{"name":"@openclaw/ai","devDependencies":{"@openclaw/normalization-core":"workspace:*"}}\n'; fs.mkdirSync(path.join(sourceDir, "packages", "ai"), { recursive: true }); - fs.writeFileSync( - path.join(sourceDir, "packages", "ai", "package.json"), - '{"name":"@openclaw/ai"}\n', - ); + fs.writeFileSync(aiPackageJsonPath, originalAiPackageJson); fs.writeFileSync(packageJsonPath, originalPackageJson); - let stderr = ""; - const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { - stderr += String(chunk); - return true; - }); + const packError = new Error("AI pack failed"); - try { - await expect(prepareBundledAiRuntimePackage(sourceDir, outputDir)).rejects.toThrow( - "pnpm --dir packages/ai pack --loglevel=error --use-stderr", - ); - expect(stderr).toContain("ERR_PNPM_PACKAGE_VERSION_NOT_FOUND"); - expect(fs.readFileSync(packageJsonPath, "utf8")).toBe(originalPackageJson); - } finally { - stderrWrite.mockRestore(); - } + await expect( + prepareBundledAiRuntimePackage( + sourceDir, + outputDir, + async () => { + throw packError; + }, + { + prepareManifest: preparePackageManifest, + restoreManifest: restorePackageManifest, + }, + ), + ).rejects.toBe(packError); + expect(fs.readFileSync(aiPackageJsonPath, "utf8")).toBe(originalAiPackageJson); + expect(fs.readFileSync(packageJsonPath, "utf8")).toBe(originalPackageJson); + + const restoreError = new Error("AI manifest restore failed"); + await expect( + prepareBundledAiRuntimePackage( + sourceDir, + outputDir, + async () => { + throw packError; + }, + { + prepareManifest: preparePackageManifest, + restoreManifest: async (cwd) => { + await restorePackageManifest(cwd); + throw restoreError; + }, + }, + ), + ).rejects.toMatchObject({ cause: packError, errors: [packError, restoreError] }); }); it("reuses the source manifest lifecycle for ignore-scripts package artifacts", async () => { @@ -605,17 +638,31 @@ describe("package-openclaw-for-docker", () => { null, 2, )}\n`; + const aiPackageJsonPath = path.join(sourceDir, "packages", "ai", "package.json"); + const originalAiPackageJson = + '{"name":"@openclaw/ai","devDependencies":{"@openclaw/normalization-core":"workspace:*"}}\n'; fs.mkdirSync(scriptsDir); + fs.mkdirSync(path.dirname(aiPackageJsonPath), { recursive: true }); fs.copyFileSync( path.join(process.cwd(), "scripts", "package-manifest.mjs"), path.join(scriptsDir, "package-manifest.mjs"), ); fs.writeFileSync(packageJsonPath, originalPackageJson); + fs.writeFileSync(aiPackageJsonPath, originalAiPackageJson); try { const tarball = await packOpenClawPackageForDocker(sourceDir, outputDir, { ...skipDocsMapLifecycle, - prepareBundledAiRuntime: skipBundledAiRuntime, + prepareBundledAiRuntime: async (_source, _output, _runCapture, options) => { + const aiDir = path.dirname(aiPackageJsonPath); + expect(options).toBeDefined(); + await options?.prepareManifest?.(aiDir); + expect( + JSON.parse(fs.readFileSync(aiPackageJsonPath, "utf8")).devDependencies, + ).toBeUndefined(); + await options?.restoreManifest?.(aiDir); + return async () => {}; + }, prepareChangelog: async () => {}, restoreChangelog: async () => {}, runCaptureImpl: async () => { @@ -623,6 +670,7 @@ describe("package-openclaw-for-docker", () => { devDependencies?: Record; }; expect(packageJson.devDependencies).toEqual({ vitest: "4.1.10" }); + expect(fs.readFileSync(aiPackageJsonPath, "utf8")).toBe(originalAiPackageJson); const packedPath = path.join(outputDir, "openclaw-2026.8.1.tgz"); fs.writeFileSync(packedPath, "package"); return `${path.basename(packedPath)}\n`; @@ -631,6 +679,7 @@ describe("package-openclaw-for-docker", () => { expect(tarball).toBe(path.join(outputDir, "openclaw-2026.8.1.tgz")); expect(fs.readFileSync(packageJsonPath, "utf8")).toBe(originalPackageJson); + expect(fs.readFileSync(aiPackageJsonPath, "utf8")).toBe(originalAiPackageJson); expect( fs.existsSync( path.join(sourceDir, ".artifacts", "package-manifest", "package.json.prepack-backup"), diff --git a/test/scripts/fixtures-workspace.test.ts b/test/scripts/fixtures-workspace.test.ts index dbc6865f4983..ed4808b75987 100644 --- a/test/scripts/fixtures-workspace.test.ts +++ b/test/scripts/fixtures-workspace.test.ts @@ -21,6 +21,17 @@ function runAgentsDeleteAssert(root: string, outputPath: string, env: Record { + it("writes explicit owners for the shared-workspace agents", () => { + const root = tempDirs.make("openclaw-fixture-workspace-"); + const { result, stateDir, workspace } = runAgentsDeleteConfig(root); + + expect(result.status).toBe(0); + expect(JSON.parse(readFileSync(path.join(stateDir, "openclaw.json"), "utf8")).agents).toEqual({ + ownership: "explicit", + defaults: { heartbeat: { agentId: "main" } }, + entries: { main: { workspace }, ops: { workspace } }, + }); + }); + it("prepares Open WebUI without retired workspace setup state", () => { const root = mkdtempSync(path.join(tmpdir(), "openclaw-fixture-workspace-")); const workspaceDir = path.join(root, "workspace"); diff --git a/test/scripts/parallels-npm-update-smoke.test.ts b/test/scripts/parallels-npm-update-smoke.test.ts index f08549a7bd53..f083a07120fb 100644 --- a/test/scripts/parallels-npm-update-smoke.test.ts +++ b/test/scripts/parallels-npm-update-smoke.test.ts @@ -371,7 +371,7 @@ exit 1 expect(windowsUpdateScript(input)).toContain(`NPM_CONFIG_REGISTRY = '${registry}'`); }); - it("relaunches POSIX gateways after a transient post-update startup failure", () => { + it("restarts POSIX gateways only after an exact current-launch migration refusal", () => { const input = { auth: TEST_AUTH, expectedNeedle: "2026.7.2-beta.5", @@ -379,11 +379,41 @@ exit 1 }; for (const script of [macosUpdateScript(input), linuxUpdateScript(input)]) { - expect(script).toContain("attempt=$((attempt + 1))"); - expect(script).toContain('if [ "$attempt" -eq 4 ]; then\n start_openclaw_gateway'); + expect(script).toContain( + "OpenClaw plugin migration inputs changed during startup convergence;", + ); + expect(script).toContain("gateway_launch_log_offset="); + expect(script).toContain("gateway_pid=$!"); + expect(script).toContain('if ! kill -0 "$gateway_pid" 2>/dev/null; then'); + expect(script).toContain('tail -c +"$((gateway_launch_log_offset + 1))" "$gateway_log"'); + expect(script).toContain( + 'if [ "$gateway_exit_status" -le 128 ] && [ "$gateway_restart_count" -eq 0 ]; then', + ); + expect(script).toContain("gateway_restart_count=1"); + expect(script).not.toContain('if [ "$attempt" -eq 4 ]'); } }); + it("restarts the Windows gateway only after its current launch exits with the exact refusal", () => { + const script = windowsUpdateScript({ + auth: TEST_AUTH, + expectedNeedle: "2026.7.2-beta.5", + updateTarget: "2026.7.2-beta.5", + }); + + expect(script).toContain( + "OpenClaw plugin migration inputs changed during startup convergence;", + ); + expect(script).toContain("$script:gatewayProcess.HasExited"); + expect(script).toContain("$script:gatewayProcess.WaitForExit()"); + expect(script).toContain("$script:gatewayRestartCount -eq 0"); + expect(script).toContain("Test-CurrentGatewayStartupMigrationRefusal"); + expect(script).toContain("Select-String -Path $script:gatewayLogPath -SimpleMatch"); + expect(script).toContain("$script:gatewayRestartCount = 1"); + expect(script).not.toContain("$attempt -eq 4"); + expect(script).not.toContain("Invoke-OpenClaw gateway restart"); + }); + it("keeps POSIX provider secrets out of executable command lines", () => { const input = { auth: TEST_AUTH, @@ -515,8 +545,7 @@ exit 1 expect(scripts).toContain("print_log_tail()"); expect(scripts).toContain("OPENCLAW_PARALLELS_NPM_UPDATE_LOG_TAIL_BYTES"); expect(scripts).toContain('print_log_tail "$output_file"'); - expect(scripts).toContain("print_log_tail /tmp/openclaw-parallels-macos-gateway.log >&2"); - expect(scripts).toContain("print_log_tail /tmp/openclaw-parallels-linux-gateway.log >&2"); + expect(scripts).toContain('print_log_tail "$gateway_log" >&2'); expect(scripts).not.toContain('cat "$output_file"'); expect(scripts).not.toContain("cat /tmp/openclaw-parallels-"); }); @@ -1222,14 +1251,14 @@ exit 7 "Invoke-WithScopedEnv @{ OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS", ); const versionIndex = script.indexOf("Invoke-OpenClaw --version", scopedIndex); - const restartIndex = script.indexOf("Invoke-OpenClaw gateway restart"); + const startIndex = script.indexOf("\nStart-OpenClawGateway\n", updateIndex); const agentIndex = script.indexOf("Invoke-OpenClaw agent --local"); expect(updateIndex).toBeGreaterThanOrEqual(0); expect(scopedIndex).toBeGreaterThanOrEqual(0); expect(updateIndex).toBeGreaterThan(scopedIndex); expect(versionIndex).toBeGreaterThan(updateIndex); - expect(restartIndex).toBeGreaterThan(updateIndex); + expect(startIndex).toBeGreaterThan(updateIndex); expect(agentIndex).toBeGreaterThan(updateIndex); expect(script).not.toContain("OPENCLAW_DISABLE_BUNDLED_PLUGINS"); }); diff --git a/ui/src/components/channel-icon.ts b/ui/src/components/channel-icon.ts new file mode 100644 index 000000000000..e0b87a0464b2 --- /dev/null +++ b/ui/src/components/channel-icon.ts @@ -0,0 +1,30 @@ +import { html } from "lit"; +import { + pluginArtPath, + pluginFallbackGradient, + pluginMonogram, +} from "../pages/plugins/presentation.ts"; +import "../styles/channels.css"; + +/** Bundled channel art reuses the plugin art set because channel ids match plugin slugs. */ +export function renderChannelIcon( + channelId: string, + label: string, + variant: "tile" | "cover" | "picker", +) { + const artVariant = variant === "picker" ? "tile" : variant; + const art = pluginArtPath(channelId); + const [from, to] = art ? ["", ""] : pluginFallbackGradient(channelId); + const style = `${variant === "picker" ? "--channels-art-size:24px;" : ""}${ + art ? "" : `--channels-art-a:${from};--channels-art-b:${to}` + }`; + return html``; +} diff --git a/ui/src/components/channel-picker.test.ts b/ui/src/components/channel-picker.test.ts new file mode 100644 index 000000000000..55dd805d6e9f --- /dev/null +++ b/ui/src/components/channel-picker.test.ts @@ -0,0 +1,64 @@ +/* @vitest-environment jsdom */ +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { renderChannelPicker } from "./channel-picker.ts"; + +describe("renderChannelPicker", () => { + it("renders neutral and channel artwork while preserving a missing current channel", () => { + const container = document.createElement("div"); + render( + renderChannelPicker({ + label: "Channel", + value: "retired-channel", + options: [ + { value: "last", label: "last", kind: "neutral" }, + { value: "telegram", label: "Telegram" }, + ], + onChange: vi.fn(), + }), + container, + ); + + expect(container.querySelector('wa-option[value="last"] [slot="start"]')).toBeNull(); + expect(container.querySelector('wa-option[value="telegram"] img')).not.toBeNull(); + expect(container.querySelector('wa-option[value="retired-channel"]')?.textContent).toContain( + "retired-channel", + ); + expect( + container.querySelector('wa-option[value="retired-channel"] .channels-tile--fallback'), + ).not.toBeNull(); + }); + + it("honors disabled choices and reports enabled changes", () => { + const container = document.createElement("div"); + const onChange = vi.fn(); + render( + renderChannelPicker({ + label: "Channel", + value: "telegram", + options: [ + { value: "telegram", label: "Telegram" }, + { value: "disabled", label: "Disabled", disabled: true }, + ], + onChange, + }), + container, + ); + + const picker = container.querySelector("wa-select"); + expect(container.querySelector('wa-option[value="disabled"]')?.hasAttribute("disabled")).toBe( + true, + ); + if (!picker) { + return; + } + Object.defineProperty(picker, "value", { configurable: true, value: "disabled" }); + picker.dispatchEvent(new Event("change", { bubbles: true })); + Reflect.deleteProperty(picker, "value"); + expect(onChange).not.toHaveBeenCalled(); + Object.defineProperty(picker, "value", { configurable: true, value: "telegram" }); + picker.dispatchEvent(new Event("change", { bubbles: true })); + Reflect.deleteProperty(picker, "value"); + expect(onChange).toHaveBeenCalledWith("telegram"); + }); +}); diff --git a/ui/src/components/channel-picker.ts b/ui/src/components/channel-picker.ts new file mode 100644 index 000000000000..21942b21c9f6 --- /dev/null +++ b/ui/src/components/channel-picker.ts @@ -0,0 +1,17 @@ +import { nothing } from "lit"; +import { renderChannelIcon } from "./channel-icon.ts"; +import { renderPicker, type PickerOption, type PickerParams } from "./select-picker.ts"; + +export type ChannelPickerOption = PickerOption & { + /** Neutral choices such as "last" or "all" are routing policy, not transports. */ + kind?: "channel" | "neutral"; +}; + +export function renderChannelPicker(params: PickerParams) { + return renderPicker({ + ...params, + className: "channel-picker", + renderLeading: (option) => + option.kind === "neutral" ? nothing : renderChannelIcon(option.value, option.label, "picker"), + }); +} diff --git a/ui/src/components/model-picker.test.ts b/ui/src/components/model-picker.test.ts new file mode 100644 index 000000000000..38340f8ca361 --- /dev/null +++ b/ui/src/components/model-picker.test.ts @@ -0,0 +1,78 @@ +/* @vitest-environment jsdom */ +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { renderModelPicker } from "./model-picker.ts"; + +describe("renderModelPicker", () => { + it("renders provider details and caller sentinels while preserving an unknown current model", () => { + const container = document.createElement("div"); + render( + renderModelPicker({ + label: "Model", + value: "legacy/model", + options: [ + { value: "", label: "Automatic" }, + { + value: "openai/gpt-5.6-luna", + label: "GPT-5.6 Luna", + provider: "openai", + detail: "Fast · 128k", + disabled: true, + }, + ], + onChange: vi.fn(), + }), + container, + ); + + expect(container.querySelector('wa-option[value=""] [slot="start"]')).toBeNull(); + const openai = container.querySelector('wa-option[value="openai/gpt-5.6-luna"]'); + expect(openai?.querySelector('[data-provider-icon="codex"]')).not.toBeNull(); + expect(openai?.textContent).toContain("Fast · 128k"); + expect(openai?.hasAttribute("disabled")).toBe(true); + expect(container.querySelector('wa-option[value="legacy/model"]')?.textContent).toContain( + "legacy/model", + ); + }); + + it("reveals free-form entry without leaking its internal option value", () => { + const container = document.createElement("div"); + const onChange = vi.fn(); + render( + renderModelPicker({ + label: "Model", + value: "openai/gpt-5.6-luna", + options: [ + { value: "", label: "Default" }, + { value: "openai/gpt-5.6-luna", label: "GPT-5.6 Luna", provider: "openai" }, + ], + custom: { label: "Custom model…", placeholder: "provider/model" }, + onChange, + }), + container, + ); + + const customOption = Array.from(container.querySelectorAll("wa-option")).find( + (option) => option.textContent?.trim() === "Custom model…", + ); + const picker = container.querySelector("wa-select"); + const input = container.querySelector("input"); + expect(customOption).not.toBeNull(); + expect(input?.hidden).toBe(true); + if (!customOption || !picker || !input) { + return; + } + Object.defineProperty(picker, "value", { + configurable: true, + value: customOption.getAttribute("value"), + }); + picker.dispatchEvent(new Event("change", { bubbles: true })); + Reflect.deleteProperty(picker, "value"); + expect(input.hidden).toBe(false); + + input.value = "vendor/model with spaces"; + input.dispatchEvent(new Event("input", { bubbles: true })); + expect(onChange).toHaveBeenCalledWith("vendor/model with spaces"); + expect(onChange).not.toHaveBeenCalledWith(customOption.getAttribute("value")); + }); +}); diff --git a/ui/src/components/model-picker.ts b/ui/src/components/model-picker.ts new file mode 100644 index 000000000000..56205b8b5b26 --- /dev/null +++ b/ui/src/components/model-picker.ts @@ -0,0 +1,99 @@ +import { html, nothing } from "lit"; +import { renderProviderBrandIcon } from "./provider-icon.ts"; +import { renderPicker } from "./select-picker.ts"; + +export type ModelPickerOption = { + value: string; + label: string; + provider?: string; + detail?: string; + disabled?: boolean; +}; + +type ModelPickerParams = { + id?: string; + label: string; + value: string; + options: readonly ModelPickerOption[]; + disabled?: boolean; + title?: string; + className?: string; + placement?: "top" | "bottom"; + custom?: { + label: string; + placeholder?: string; + commit?: "input" | "change"; + id?: string; + invalid?: boolean; + describedBy?: string; + }; + onChange: (value: string) => void; +}; + +export function renderModelPicker(params: ModelPickerParams) { + let customValue = "__openclaw_custom_model__"; + const values = new Set([params.value, ...params.options.map((option) => option.value)]); + while (values.has(customValue)) { + customValue += "_"; + } + const currentIsKnown = params.options.some((option) => option.value === params.value); + const options: Array = [ + ...params.options.map((option) => ({ ...option, description: option.detail })), + ...(params.custom ? [{ value: customValue, label: params.custom.label }] : []), + ]; + return html` +
+ ${renderPicker({ + id: params.id, + label: params.label, + value: params.value, + options, + disabled: params.disabled, + title: params.title, + placement: params.placement, + className: `model-picker__select ${params.className ?? ""}`, + renderLeading: (option) => + option.provider + ? renderProviderBrandIcon(option.provider, { className: "model-picker__provider-icon" }) + : nothing, + onChange: params.onChange, + onChangeTarget: (value, select) => { + const wrapper = select.closest(".model-picker"); + const input = wrapper?.querySelector(".model-picker__custom"); + if (value === customValue && input) { + input.hidden = false; + queueMicrotask(() => input.focus()); + return; + } + if (input) { + input.hidden = true; + } + params.onChange(value); + }, + })} + ${params.custom + ? html` { + if (params.custom?.commit !== "change") { + params.onChange((event.currentTarget as HTMLInputElement).value); + } + }} + @change=${(event: Event) => { + if (params.custom?.commit === "change") { + params.onChange((event.currentTarget as HTMLInputElement).value); + } + }} + />` + : nothing} +
+ `; +} diff --git a/ui/src/components/select-picker.ts b/ui/src/components/select-picker.ts new file mode 100644 index 000000000000..61853d6769de --- /dev/null +++ b/ui/src/components/select-picker.ts @@ -0,0 +1,82 @@ +import { html, nothing } from "lit"; +import "./web-awesome-select.ts"; + +export type PickerOption = { + value: string; + label: string; + description?: string; + disabled?: boolean; +}; + +export type PickerParams