mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
Merge remote-tracking branch 'origin/main' into fix/inline-slash-skills
This commit is contained in:
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"2ba58871e5ca61397755a3bdb98bd6beca75d6e3b5400331c6381041f3aa3d6f","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
{"contentHash":"b9c074a11791688e63213f51099ea21745b0e554f63ed13e694ea34a3e4328ac","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"279ca366d222d694f91fdaa9233c86f0ee6c388bdffffeb2b5093aed27a4ab4c","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"}
|
||||
{"contentHash":"0a014810b900e53959679fa292ed45cb0f1a1a3cc6b1129021bfa63b871f8e68","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"b9fc667192c85df94b1768bc92bc4b621f19afad4e9838fca3886f3bb36b50c9","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
{"contentHash":"d8b7c867b29b4651375455e938ae634e7213e3e8c17444dc2b0ef091e46fda6c","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"6c0acdac3db1c698033694ca41efad5bdbe7edf8634f78c5ac9e333ddb94124f","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
{"contentHash":"2f85610859f047db1d76efbe52d9e63d8126ce8e944d53d6fdafb4e7f503b5dc","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"20da7d32e9a7937993a7529a5744b6b3457d00c92c7a5ec44f4524459ce7400c","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
{"contentHash":"6b1f575e77dc71e72c4b2575f366e7e9990d391e85364844e0ecec2146e7f902","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"eabfc0558c4a4b904d69fcb0b77c67eee1b5f09e8f2d7c8ed297aae7978db3b7","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"}
|
||||
{"contentHash":"9bcbdee2499e17f5411a42bd45919dfcd7cac92f12e7b0d034b519bbc46c70c5","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"0c33e9b6da738eb7627dbcdb324cc90d93c24c844d8777ee7eff9573e5e90a3b","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"}
|
||||
{"contentHash":"770e079b242b9c56e8876f8fd08b402a78f1870367dca19d006ef147f9d493a4","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"167ca860b15990c05eaad49e7f2a39e38812d1245011f85e2d536b7d7eb93265","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
{"contentHash":"2ccfe8eb85378f3be3f78f79f2638b00faa943e351ff69464bfed44ed63e09c1","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"c5c5a37bc949bfb4166f511a846a4e8273fb639c46f9fa4e5bb78a4ab71dbfab","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
{"contentHash":"d23cd5df763235dbe35ede1c23d26735f4c183b03dc216ac212aefe75c0c1f96","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"1335062907a7948a67c59f4320899aaaf33bbea92001675c12ec392e552d1411","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
{"contentHash":"5008615c80d86383e18527d71442f1501e4f02bdb3faa1494a48edf40fd66f84","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"7233fde521fda9b80333647953bcb0611133e044920b25bd1554b56d2efe4af9","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
{"contentHash":"6b73741c889f68cc19a5d1f8703df2eca650fe57803795045cff122d746cbb6a","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"644411a0c68bc01afaa7d8ad9d7a00ce03d8000268b4ceca19ca94642591b8ae","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
{"contentHash":"15704fc50b81cebceb677a5a69a7bc8c97afd962081c647de58a027bd816f19d","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"e2ef3ba2f2196a04530aea5fdcdbf02e064f6c4cf193fcf56cbbc84869e69109","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"}
|
||||
{"contentHash":"2c97bf2bccffb72065e3432432788926559cdc97f77a129786c4cc91246882a4","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"d77aac23178bb63cad9dcfc7d8e91f3fd37b5d4f44a4221ea1fa2ff259ccf64c","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
{"contentHash":"8b0c71c8abe13163eee28ca65e6afb490d0ae3c16288292143ad021975b96ca6","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"291f8c385ad37dbafeeab36f8725a9b0e459e670f5db17d5d77a683f87ce830e","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
{"contentHash":"eb0096fd25551a7f4a58dcf6c6b53120f3f1ddc1248b2b962cca60c75f6daa12","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
|
||||
@@ -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 | — |
|
||||
|
||||
@@ -287,7 +287,7 @@ select it to open the owning Approvals page.
|
||||
</Accordion>
|
||||
<Accordion title="Cron, tasks, plugins, skills, devices, exec approvals">
|
||||
- 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.
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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<string, string>();
|
||||
const busMessageIds = new Map<string, string>();
|
||||
// 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<ReturnType<typeof createBuzzQaRelayDriver>>;
|
||||
|
||||
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();
|
||||
},
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof DiscordThreadInitialMessageError>[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<typeof DiscordThreadInitialMessageError>[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", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<T>(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<string, unknown> = { 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Github Copilot API module exposes the plugin public contract.
|
||||
export { githubCopilotLoginCommand } from "./login.js";
|
||||
@@ -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<string, { params?: Record<string, unknown> }> } })
|
||||
.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<string, { params?: Record<string, unknown> }> } }
|
||||
).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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<OpenClawConfig["models"]> = cfg.models ?? {};
|
||||
const providers: NonNullable<typeof models.providers> = 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<string, unknown> = { ...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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.<tenant>.
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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:*"
|
||||
},
|
||||
|
||||
@@ -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<void>((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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<void>((resolve) => {
|
||||
releaseSync = () => resolve();
|
||||
});
|
||||
const syncAdmitted = vi
|
||||
.spyOn(
|
||||
manager as unknown as {
|
||||
syncAdmitted: (params: { reason: string }) => Promise<void>;
|
||||
},
|
||||
"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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Generated
-4
@@ -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:*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 </dev/null &
|
||||
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 >>"$gateway_log" 2>&1 </dev/null &
|
||||
gateway_pid=$!
|
||||
sleep 1
|
||||
}
|
||||
wait_for_gateway() {
|
||||
deadline=$((SECONDS + 240))
|
||||
attempt=0
|
||||
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||
if "$OPENCLAW_BIN" 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-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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<object, unknown>());
|
||||
|
||||
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<typeof import("./embedded-agent-mcp.js")>();
|
||||
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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -99,10 +99,9 @@ async function runHeadlessWorkerLeg(params: {
|
||||
}): Promise<CodeModeWorkerResult> {
|
||||
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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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" }]]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -82,18 +82,21 @@ type TerminalToolOptions = {
|
||||
agentId?: string;
|
||||
agentSessionKey?: string;
|
||||
runId?: string;
|
||||
lookupTaskByRunId?: (
|
||||
lookupTaskByRunIdForChildSession?: (
|
||||
runId: string,
|
||||
childSessionKey: string,
|
||||
) => Promise<Pick<TaskRecord, "taskId" | "status" | "childSessionKey"> | undefined>;
|
||||
callGateway?: InProcessGatewayCaller;
|
||||
getGatewayContext?: () => TerminalToolGatewayContext | undefined;
|
||||
};
|
||||
|
||||
async function lookupTaskByRunId(
|
||||
async function lookupTaskByRunIdForChildSession(
|
||||
runId: string,
|
||||
childSessionKey: string,
|
||||
): Promise<Pick<TaskRecord, "taskId" | "status" | "childSessionKey"> | 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");
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -428,8 +428,8 @@ export function shouldPreserveMaintenanceEntry(params: {
|
||||
entry: SessionEntry | undefined;
|
||||
preserveKeys?: ReadonlySet<string>;
|
||||
}): 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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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());
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<NodeWorkerLaunchReceipt>;
|
||||
@@ -32,152 +24,10 @@ export type NodeWorkerSupervisorControl = {
|
||||
cancel(expected: NodeWorkerSupervisorIdentity): Promise<NodeWorkerLaunchReceipt | undefined>;
|
||||
};
|
||||
|
||||
function hasExactKeys(value: Record<string, unknown>, 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;
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export type NodeWorkerSupervisorIdentity = {
|
||||
launchId: string;
|
||||
planHash: string;
|
||||
environmentId: string;
|
||||
sessionId: string;
|
||||
ownerEpoch: number;
|
||||
placementGeneration: number;
|
||||
runId: string;
|
||||
};
|
||||
@@ -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<void>;
|
||||
};
|
||||
|
||||
function loadFacadeModule(): FacadeModule {
|
||||
return loadBundledPluginPublicSurfaceModuleSyncCore<FacadeModule>({
|
||||
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"];
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>(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,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TaskRecord, "taskId" | "status" | "childSessionKey"> | 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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>, 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<string, unknown>): 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;
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
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"),
|
||||
|
||||
@@ -21,6 +21,17 @@ function runAgentsDeleteAssert(root: string, outputPath: string, env: Record<str
|
||||
});
|
||||
}
|
||||
|
||||
function runAgentsDeleteConfig(root: string) {
|
||||
const stateDir = path.join(root, "state");
|
||||
const workspace = path.join(root, "workspace");
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
const result = spawnSync(process.execPath, [FIXTURE_SCRIPT, "agents-delete-config"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir, SHARED_WORKSPACE: workspace },
|
||||
});
|
||||
return { result, stateDir, workspace };
|
||||
}
|
||||
|
||||
function runOpenWebUiWorkspace(workspaceDir: string) {
|
||||
return spawnSync(process.execPath, [FIXTURE_SCRIPT, "openwebui-workspace"], {
|
||||
encoding: "utf8",
|
||||
@@ -32,6 +43,18 @@ function runOpenWebUiWorkspace(workspaceDir: string) {
|
||||
}
|
||||
|
||||
describe("workspace fixture assertions", () => {
|
||||
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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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`<span
|
||||
class=${`channels-${artVariant}${art ? "" : ` channels-${artVariant}--fallback`}`}
|
||||
style=${style}
|
||||
aria-hidden="true"
|
||||
>
|
||||
${art
|
||||
? html`<img src=${art} alt="" loading="lazy" decoding="async" />`
|
||||
: html`<span>${pluginMonogram(label)}</span>`}
|
||||
</span>`;
|
||||
}
|
||||
@@ -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<HTMLElement & { value: string }>("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");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user