mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-25 00:34:16 +08:00
Compare commits
21 Commits
codex/watc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f29dbd3ebd | ||
|
|
3217165be7 | ||
|
|
dbe2802cdc | ||
|
|
5f25651fd9 | ||
|
|
d7c69da6a6 | ||
|
|
e77994ed5a | ||
|
|
db3307b02a | ||
|
|
6b1755aa2b | ||
|
|
fa2379dbc8 | ||
|
|
ce6d97d580 | ||
|
|
d1c2934d0d | ||
|
|
605aede38c | ||
|
|
6163b1977b | ||
|
|
eabc12b7d6 | ||
|
|
b58e6e0734 | ||
|
|
d83cd282c6 | ||
|
|
374076b5a8 | ||
|
|
242fbf1a67 | ||
|
|
434d752dd6 | ||
|
|
3179692f0e | ||
|
|
6add1cc969 |
5
.github/workflows/sandbox-common-smoke.yml
vendored
5
.github/workflows/sandbox-common-smoke.yml
vendored
@@ -57,11 +57,10 @@ jobs:
|
||||
BASE_IMAGE="openclaw-sandbox-smoke-base:bookworm-slim" \
|
||||
TARGET_IMAGE="openclaw-sandbox-common-smoke:bookworm-slim" \
|
||||
PACKAGES="ca-certificates" \
|
||||
INSTALL_PNPM=0 \
|
||||
INSTALL_BUN=0 \
|
||||
INSTALL_BREW=0 \
|
||||
FINAL_USER=sandbox \
|
||||
scripts/sandbox-common-setup.sh
|
||||
|
||||
u="$(timeout --kill-after=30s 2m docker run --rm openclaw-sandbox-common-smoke:bookworm-slim sh -lc 'id -un')"
|
||||
test "$u" = "sandbox"
|
||||
timeout --kill-after=30s 2m docker run --rm openclaw-sandbox-common-smoke:bookworm-slim sh -lc \
|
||||
'set -e; test "$(id -un)" = sandbox; node --version; pnpm --version'
|
||||
|
||||
@@ -105,6 +105,19 @@ Reopen OpenClaw, confirm Talk is still active, then tap `Stop Talk`.
|
||||
4. Confirm at least one `agent` row is connected.
|
||||
5. Confirm the iPhone review device appears in the connected instances list.
|
||||
|
||||
## Live Activity / Dynamic Island
|
||||
|
||||
1. Tap `Settings`.
|
||||
2. Tap `Reconnect`.
|
||||
3. Immediately send OpenClaw to the background by returning to the Home Screen
|
||||
or locking the iPhone.
|
||||
4. Watch the Lock Screen or Dynamic Island while the Gateway reconnects.
|
||||
|
||||
Expected result: while reconnecting, iOS can show an `OpenClaw` Live Activity
|
||||
with connection status such as `Connecting...` or `Reconnecting...`. On a fast
|
||||
network this status may be brief because OpenClaw ends the Live Activity after
|
||||
the Gateway reconnects successfully.
|
||||
|
||||
## Push Notification
|
||||
|
||||
1. Tap the `Chat` tab.
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<key>NSCalendarsWriteOnlyAccessUsageDescription</key>
|
||||
<string>OpenClaw uses your calendars to add events when you enable calendar access.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>OpenClaw can capture photos or short video clips when requested via the gateway.</string>
|
||||
<string>OpenClaw uses the camera when you scan a Gateway setup QR code or ask your paired Gateway or assistant to capture a photo or short video from this iPhone, for example to connect to your Gateway or show your assistant a document, device screen, or workspace.</string>
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<string>OpenClaw uses your contacts so you can search and reference people while using the assistant.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
|
||||
@@ -156,7 +156,7 @@ targets:
|
||||
NSAllowsLocalNetworking: true
|
||||
NSBonjourServices:
|
||||
- _openclaw-gw._tcp
|
||||
NSCameraUsageDescription: OpenClaw can capture photos or short video clips when requested via the gateway.
|
||||
NSCameraUsageDescription: OpenClaw uses the camera when you scan a Gateway setup QR code or ask your paired Gateway or assistant to capture a photo or short video from this iPhone, for example to connect to your Gateway or show your assistant a document, device screen, or workspace.
|
||||
NSCalendarsUsageDescription: OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.
|
||||
NSCalendarsFullAccessUsageDescription: OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.
|
||||
NSCalendarsWriteOnlyAccessUsageDescription: OpenClaw uses your calendars to add events when you enable calendar access.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
9246475f5771612a5fd12de38b153783c4a4cbb8b2682a5c40115916661c90f2 config-baseline.json
|
||||
6349131baaa1828f2a071f42e4d7b17c8966c59b6588c8a4c1a32ea5ea4dcd5e config-baseline.core.json
|
||||
f5a5855ddd7aa8c23a732f257eceaa20fd163b1d5f342c909f4aef15aa8643cf config-baseline.json
|
||||
b8dffdb1a328aaf728a0707ab04d21c65f1a225a2360042e10832aa608699716 config-baseline.core.json
|
||||
671979e86e4c4f59415d0a20879e838f9bbd883b3d29eeb02cb5131db8d187fe config-baseline.channel.json
|
||||
94529978588d6e3776a86780b22cf9ff46a6f9957f2f178d3829403fad451ca7 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
ebb0ae07e4d6f6ea1faccba7604c9da71a5401b3aa2bc3618963e1e44a8dbcce plugin-sdk-api-baseline.json
|
||||
9b7aee16d91c6a1b042a7d7e6f92a77b3e234337cc5fcf5a797de05fa9e9a02e plugin-sdk-api-baseline.jsonl
|
||||
8736e8cf5200a1dda3e3257d91de87510097a2b8369ce06c9891dbbf823863c4 plugin-sdk-api-baseline.json
|
||||
4902be589e5ac9d77014e5b1473e8e4345def7817ae8ea7b205959d4d604c981 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -30,6 +30,68 @@ title: "Usage tracking"
|
||||
- CLI: `openclaw channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
||||
- macOS menu bar: "Usage" section under Context (only if available).
|
||||
|
||||
## Default usage footer mode
|
||||
|
||||
`/usage off|tokens|full` sets the footer for a session and is remembered for that
|
||||
session. `messages.responseUsage` seeds that mode for sessions that have not
|
||||
chosen one, so the footer can be on by default without typing `/usage` each time.
|
||||
|
||||
Set one mode for every channel, or a per-channel map with a `default` fallback:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"messages": {
|
||||
"responseUsage": "tokens",
|
||||
// or: { "default": "off", "discord": "full" }
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Three distinct session states
|
||||
|
||||
A session's `responseUsage` field has three representable states, each with
|
||||
different semantics:
|
||||
|
||||
| State | Stored value | Effective mode |
|
||||
| ------------------- | ------------------------------- | --------------------------------------------------------------------- |
|
||||
| **Unset / inherit** | `undefined` (absent) | Falls through to `messages.responseUsage` config default, then `off`. |
|
||||
| **Explicit off** | `"off"` (stored) | Always off — a non-off config default cannot re-enable the footer. |
|
||||
| **Explicit on** | `"tokens"` or `"full"` (stored) | That mode, regardless of config default. |
|
||||
|
||||
### Precedence
|
||||
|
||||
Effective mode = session override → channel config entry → `default` → `off`.
|
||||
|
||||
An explicit `/usage off` is **persisted** as the literal value `"off"` in the
|
||||
session, not the same as "unset." This means a non-off `messages.responseUsage`
|
||||
default cannot turn the footer back on once the user has explicitly disabled it.
|
||||
|
||||
### Resetting vs. turning off
|
||||
|
||||
- `/usage off` — forces the footer off and persists that choice. A configured
|
||||
non-off default cannot override this.
|
||||
- `/usage reset` (aliases: `inherit`, `clear`, `default`) — clears the session
|
||||
override. The session then **inherits** the effective config default
|
||||
(`messages.responseUsage`). If no default is configured, the footer is off
|
||||
(unchanged from before). Use this to "go back to default" without explicitly
|
||||
turning the footer on.
|
||||
- A full session reset (`/reset` or `/new`) or a session rollover **preserves**
|
||||
the explicit usage-mode preference so the user's display choice survives
|
||||
session rollovers. Only `/usage reset` (and its aliases) actually clears the
|
||||
override.
|
||||
|
||||
### Toggle behavior
|
||||
|
||||
`/usage` with no arguments cycles: off → tokens → full → off. The starting point
|
||||
for the cycle is the **effective** current mode (session override falling through
|
||||
to the config default when unset), so the cycle is always consistent with what
|
||||
the user sees in the footer.
|
||||
|
||||
### Config
|
||||
|
||||
With no config the prior behavior holds (footer off until `/usage`). Use
|
||||
`/usage reset` to clear a session override and re-inherit the configured default.
|
||||
|
||||
## Custom `/usage full` footer
|
||||
|
||||
`/usage full` shows a built-in compact footer with model, reasoning, fast/slow,
|
||||
|
||||
@@ -528,25 +528,13 @@ candidate contains redacted secret placeholders such as `***`.
|
||||
and re-checked, so a path that lexically lives in a config dir but whose
|
||||
real target escapes every allowed root is still rejected.
|
||||
- **Error handling**: clear errors for missing files, parse errors, circular includes, invalid path format, and excessive length
|
||||
- **Hot reload**: edits to regular include files successfully resolved by the
|
||||
last valid config are watched, including nested includes. Changing an
|
||||
authored `$include` target inside a watched file re-resolves the graph.
|
||||
Paths that were missing or invalid during the last successful resolution,
|
||||
and filesystem or symlink retargets that do not modify a watched file, are
|
||||
not discovered automatically; edit `openclaw.json` or restart the Gateway
|
||||
to resolve the graph again.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Config hot reload
|
||||
|
||||
The Gateway watches `~/.openclaw/openclaw.json` plus the canonical include files
|
||||
successfully resolved by the last valid config, and applies changes
|
||||
automatically - no manual restart needed for most settings. Invalid candidates
|
||||
keep the last valid watch set. Missing or invalid paths outside that set, plus
|
||||
filesystem or symlink retargets that do not modify a watched file, require an
|
||||
`openclaw.json` edit or a Gateway restart before they can be discovered.
|
||||
The Gateway watches `~/.openclaw/openclaw.json` and applies changes automatically - no manual restart needed for most settings.
|
||||
|
||||
Direct file edits are treated as untrusted until they validate. The watcher waits
|
||||
for editor temp-write/rename churn to settle, reads the final file, and rejects
|
||||
|
||||
@@ -415,7 +415,7 @@ If you installed OpenClaw via `npm install -g openclaw`, use the inline `docker
|
||||
|
||||
</Step>
|
||||
<Step title="Optional: build the common image">
|
||||
For a more functional sandbox image with common tooling (for example `curl`, `jq`, `nodejs`, `python3`, `git`):
|
||||
For a more functional sandbox image with common tooling (for example `curl`, `jq`, Node 24, pnpm, `python3`, and `git`):
|
||||
|
||||
From a source checkout:
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ Use these in chat:
|
||||
configured for the active model.
|
||||
- `/usage off|tokens|full` → appends a **per-response usage footer** to every reply.
|
||||
- Persists per session (stored as `responseUsage`).
|
||||
- `/usage reset` (aliases: `inherit`, `clear`, `default`) — clears the session
|
||||
override so the session re-inherits the configured default.
|
||||
- `/usage full` shows estimated cost only when OpenClaw has usage metadata and
|
||||
local pricing for the active model. Otherwise it shows tokens only.
|
||||
- `/usage cost` → shows a local cost summary from OpenClaw session logs.
|
||||
|
||||
@@ -240,7 +240,7 @@ plugins.
|
||||
| `/tasks` | List active/recent background tasks for the current session |
|
||||
| `/context [list\|detail\|map\|json]` | Explain how context is assembled |
|
||||
| `/whoami` | Show your sender id. Alias: `/id` |
|
||||
| `/usage off\|tokens\|full\|cost` | Control the per-response usage footer or print a local cost summary |
|
||||
| `/usage off\|tokens\|full\|reset\|cost` | Control the per-response usage footer (`reset`/`inherit`/`clear`/`default` clears the session override to re-inherit the configured default) or print a local cost summary |
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Skills, allowlists, approvals">
|
||||
|
||||
@@ -126,7 +126,7 @@ Session controls:
|
||||
- `/verbose <on|full|off>`
|
||||
- `/trace <on|off>`
|
||||
- `/reasoning <on|off|stream>`
|
||||
- `/usage <off|tokens|full>`
|
||||
- `/usage <off|tokens|full|reset>` (`reset`/`inherit`/`clear`/`default` clears the session override)
|
||||
- `/goal [status] | /goal start <objective> | /goal pause|resume|complete|block|clear`
|
||||
- `/elevated <on|off|ask|full>` (alias: `/elev`)
|
||||
- `/activation <mention|always>`
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
wrapWebContent,
|
||||
writeCachedSearchPayload,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
@@ -30,6 +31,10 @@ const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "i
|
||||
const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const;
|
||||
const EXA_MAX_SEARCH_COUNT = 100;
|
||||
const EXA_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
// Exa search responses are untrusted external bodies. Cap the success JSON the
|
||||
// same way other bundled providers do (16 MiB) so a misbehaving or hostile
|
||||
// endpoint cannot stream an unbounded body into memory before we parse it.
|
||||
const EXA_SEARCH_JSON_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
type ExaConfig = {
|
||||
apiKey?: string;
|
||||
@@ -70,9 +75,17 @@ type ExaSearchResponse = {
|
||||
results?: unknown;
|
||||
};
|
||||
|
||||
async function readExaSearchResults(response: Response): Promise<ExaSearchResult[]> {
|
||||
async function readExaSearchResults(
|
||||
response: Response,
|
||||
opts?: { maxBytes?: number },
|
||||
): Promise<ExaSearchResult[]> {
|
||||
const maxBytes = opts?.maxBytes ?? EXA_SEARCH_JSON_MAX_BYTES;
|
||||
const bytes = await readResponseWithLimit(response, maxBytes, {
|
||||
onOverflow: ({ maxBytes: maxBytesLocal }) =>
|
||||
new Error(`Exa API response exceeds ${maxBytesLocal} bytes`),
|
||||
});
|
||||
try {
|
||||
return normalizeExaResults(await response.json());
|
||||
return normalizeExaResults(JSON.parse(new TextDecoder().decode(bytes)));
|
||||
} catch (cause) {
|
||||
throw new Error("Exa API returned malformed JSON", { cause });
|
||||
}
|
||||
|
||||
@@ -26,6 +26,33 @@ function cancelTrackedResponse(
|
||||
};
|
||||
}
|
||||
|
||||
function streamingJsonResponse(params: { chunkCount: number; chunkSize: number }): {
|
||||
response: Response;
|
||||
getReadCount: () => number;
|
||||
} {
|
||||
// Streaming fixture proves an oversized success body stops being read before
|
||||
// the whole payload is buffered into memory.
|
||||
let reads = 0;
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (reads >= params.chunkCount) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
reads += 1;
|
||||
controller.enqueue(encoder.encode("a".repeat(params.chunkSize)));
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
getReadCount: () => reads,
|
||||
};
|
||||
}
|
||||
|
||||
describe("exa web search provider", () => {
|
||||
it("exposes the expected metadata and selection wiring", () => {
|
||||
const provider = createExaWebSearchProvider();
|
||||
@@ -265,6 +292,27 @@ describe("exa web search provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("parses well-formed Exa search JSON under the byte cap", async () => {
|
||||
const response = new Response(
|
||||
JSON.stringify({ results: [{ url: "https://example.com", title: "Example" }] }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
await expect(testing.readExaSearchResults(response)).resolves.toEqual([
|
||||
{ url: "https://example.com", title: "Example" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("caps oversized Exa search JSON instead of buffering the whole body", async () => {
|
||||
const streamed = streamingJsonResponse({ chunkCount: 64, chunkSize: 1024 });
|
||||
|
||||
await expect(
|
||||
testing.readExaSearchResults(streamed.response, { maxBytes: 4096 }),
|
||||
).rejects.toThrow(/Exa API response exceeds 4096 bytes/);
|
||||
|
||||
expect(streamed.getReadCount()).toBeLessThan(64);
|
||||
});
|
||||
|
||||
it("bounds Exa API error bodies without using response.text()", async () => {
|
||||
const tracked = cancelTrackedResponse(`${"exa upstream unavailable ".repeat(1024)}tail`, {
|
||||
status: 503,
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
buildOllamaProvider,
|
||||
buildOllamaModelDefinition,
|
||||
enrichOllamaModelsWithContext,
|
||||
fetchOllamaModels,
|
||||
parseOllamaNumCtxParameter,
|
||||
queryOllamaModelShowInfo,
|
||||
resetOllamaModelShowInfoCacheForTest,
|
||||
resolveOllamaApiBase,
|
||||
type OllamaTagModel,
|
||||
@@ -380,4 +382,57 @@ describe("ollama provider models", () => {
|
||||
expect(parseOllamaNumCtxParameter('stop "<|eot_id|>"')).toBeUndefined();
|
||||
expect(parseOllamaNumCtxParameter({ num_ctx: 8192 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails soft and stops reading when discovery streams exceed the JSON byte cap", async () => {
|
||||
// Larger than the shared 16 MiB readProviderJsonResponse cap so the bounded reader cancels
|
||||
// the stream mid-flight; if the cap were removed the reader would buffer the whole payload.
|
||||
const ONE_MIB = 1024 * 1024;
|
||||
const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
|
||||
const chunk = new Uint8Array(ONE_MIB);
|
||||
|
||||
let bytesPulled = 0;
|
||||
let canceled = false;
|
||||
const makeOversizedJsonResponse = (): Response => {
|
||||
bytesPulled = 0;
|
||||
canceled = false;
|
||||
let pulled = 0;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (pulled >= TOTAL_CHUNKS) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
pulled += 1;
|
||||
bytesPulled += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => makeOversizedJsonResponse()),
|
||||
);
|
||||
const tags = await fetchOllamaModels("http://127.0.0.1:11434");
|
||||
expect(tags).toEqual({ reachable: false, models: [] });
|
||||
expect(canceled).toBe(true);
|
||||
// Only the bounded prefix is pulled, never the full advertised 32 MiB stream.
|
||||
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => makeOversizedJsonResponse()),
|
||||
);
|
||||
const showInfo = await queryOllamaModelShowInfo("http://127.0.0.1:11434", "evil-model:latest");
|
||||
expect(showInfo).toEqual({});
|
||||
expect(canceled).toBe(true);
|
||||
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
OLLAMA_DEFAULT_BASE_URL,
|
||||
@@ -146,11 +147,11 @@ export async function queryOllamaModelShowInfo(
|
||||
if (!response.ok) {
|
||||
return {};
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
const data = await readProviderJsonResponse<{
|
||||
model_info?: Record<string, unknown>;
|
||||
capabilities?: unknown;
|
||||
parameters?: unknown;
|
||||
};
|
||||
}>(response, "ollama-provider-models.show");
|
||||
|
||||
let contextWindow: number | undefined;
|
||||
if (data.model_info) {
|
||||
@@ -314,7 +315,10 @@ export async function fetchOllamaModels(
|
||||
if (!response.ok) {
|
||||
return { reachable: true, models: [] };
|
||||
}
|
||||
const data = (await response.json()) as OllamaTagsResponse;
|
||||
const data = await readProviderJsonResponse<OllamaTagsResponse>(
|
||||
response,
|
||||
"ollama-provider-models.tags",
|
||||
);
|
||||
const models = (data.models ?? []).filter((m) => m.name);
|
||||
return { reachable: true, models };
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
mergeScopedSearchConfig,
|
||||
@@ -36,6 +39,12 @@ import {
|
||||
const PARALLEL_BASE_URL = "https://api.parallel.ai";
|
||||
const PARALLEL_SEARCH_PATHNAME = "/v1/search";
|
||||
const PARALLEL_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
// Parallel's /v1/search returns a bounded result set, but the body is external
|
||||
// (web-search upstream) and untrusted. Cap the successful JSON read so a
|
||||
// hostile or malfunctioning endpoint streaming an unbounded body cannot force
|
||||
// the runtime to buffer the whole payload before parsing. 16 MiB matches the
|
||||
// shared provider JSON cap (readProviderJsonResponse default).
|
||||
const PARALLEL_SEARCH_RESPONSE_LIMIT_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const PLUGIN_VERSION = readPluginPackageVersion({ require });
|
||||
@@ -151,11 +160,9 @@ async function runParallelSearch(params: {
|
||||
);
|
||||
throw new Error(`Parallel API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
try {
|
||||
return (await res.json()) as ParallelSearchResponse;
|
||||
} catch (cause) {
|
||||
throw new Error("Parallel API returned malformed JSON", { cause });
|
||||
}
|
||||
return await readProviderJsonResponse<ParallelSearchResponse>(res, "Parallel API", {
|
||||
maxBytes: PARALLEL_SEARCH_RESPONSE_LIMIT_BYTES,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -282,6 +289,7 @@ export const testing = {
|
||||
resolveParallelSearchCount,
|
||||
resolveParallelSearchEndpoint,
|
||||
PARALLEL_ERROR_BODY_LIMIT_BYTES,
|
||||
PARALLEL_SEARCH_RESPONSE_LIMIT_BYTES,
|
||||
USER_AGENT,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -59,6 +59,40 @@ function cancelTrackedResponse(
|
||||
};
|
||||
}
|
||||
|
||||
function streamedJsonResponse(params: { chunkCount: number; chunkSize: number }): {
|
||||
response: Response;
|
||||
getReadCount: () => number;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
// Multi-chunk fixture: proves the bounded read stops pulling chunks before
|
||||
// the whole (here syntactically broken / unbounded) body is buffered, and
|
||||
// that the stream is cancelled on overflow.
|
||||
let reads = 0;
|
||||
let canceled = false;
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (reads >= params.chunkCount) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
reads += 1;
|
||||
controller.enqueue(encoder.encode("a".repeat(params.chunkSize)));
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
getReadCount: () => reads,
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
import { testing } from "../test-api.js";
|
||||
import { createParallelWebSearchProvider as createContractParallelWebSearchProvider } from "../web-search-contract-api.js";
|
||||
import { createParallelWebSearchProvider } from "./parallel-web-search-provider.js";
|
||||
@@ -583,6 +617,65 @@ describe("parallel web search provider", () => {
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds successful Parallel JSON bodies instead of buffering the whole response", async () => {
|
||||
// 200-chunk x 1 MiB body (~200 MiB) caps at 16 MiB: the bounded reader must
|
||||
// stop pulling chunks and cancel the stream well before draining it, then
|
||||
// surface a bounded error rather than buffering the whole payload.
|
||||
const streamed = streamedJsonResponse({ chunkCount: 200, chunkSize: 1024 * 1024 });
|
||||
endpointMockState.responses.push(streamed.response);
|
||||
const provider = createParallelWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: { parallel: { apiKey: "par-secret" } },
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
const error = await tool
|
||||
.execute({
|
||||
objective: `parallel-success-body-${Date.now()}-${Math.random()}`,
|
||||
search_queries: ["openclaw"],
|
||||
})
|
||||
.catch((cause: unknown) => cause);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toMatch(
|
||||
new RegExp(
|
||||
`Parallel API: JSON response exceeds ${testing.PARALLEL_SEARCH_RESPONSE_LIMIT_BYTES} bytes`,
|
||||
),
|
||||
);
|
||||
// Stopped well before draining all 200 chunks, and cancelled the stream.
|
||||
expect(streamed.getReadCount()).toBeLessThan(200);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
});
|
||||
|
||||
it("parses a well-formed Parallel JSON body under the byte cap", async () => {
|
||||
endpointMockState.responses.push(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
search_id: "ok",
|
||||
session_id: "ok-session",
|
||||
results: [{ url: "https://example.com/a", title: "A", excerpts: ["alpha"] }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
const provider = createParallelWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {},
|
||||
searchConfig: { parallel: { apiKey: "par-secret" } },
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
const result = (await tool.execute({
|
||||
objective: `parallel-success-ok-${Date.now()}-${Math.random()}`,
|
||||
search_queries: ["openclaw"],
|
||||
})) as { provider?: string; searchId?: string; count?: number };
|
||||
expect(result).toMatchObject({ provider: "parallel", searchId: "ok", count: 1 });
|
||||
});
|
||||
|
||||
it("does not surface a Parallel-generated sessionId on a cache hit", async () => {
|
||||
// Unique objective so this test does not collide with the SDK's
|
||||
// module-level web-search cache across other cases.
|
||||
|
||||
@@ -558,6 +558,8 @@ describe("qa cli runtime", () => {
|
||||
"qa-channel-reconnect-dedupe",
|
||||
"reaction-edit-delete",
|
||||
"thread-follow-up",
|
||||
"claude-cli-provider-capabilities",
|
||||
"claude-cli-provider-capabilities-subscription",
|
||||
"image-generation-roundtrip",
|
||||
"image-understanding-attachment",
|
||||
"native-image-generation",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Qa Lab tests cover QA evidence summary behavior.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
QA_EVIDENCE_SUMMARY_KIND,
|
||||
@@ -123,6 +124,29 @@ describe("evidence summary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the checked-out ref over an inherited GitHub event SHA", () => {
|
||||
const repoRoot = process.cwd();
|
||||
const checkedOutRef = execFileSync("git", ["rev-parse", "--verify", "HEAD"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
const evidence = buildQaSuiteEvidenceSummary({
|
||||
artifactPaths: [],
|
||||
channelId: "qa-channel",
|
||||
env: {
|
||||
GITHUB_SHA: "bd479958c04a1eadbda8b6105e0722588d71e9ad",
|
||||
} as NodeJS.ProcessEnv,
|
||||
generatedAt: "2026-06-24T12:00:00.000Z",
|
||||
primaryModel: "mock-openai/gpt-5.5",
|
||||
providerMode: "mock-openai",
|
||||
repoRoot,
|
||||
scenarioDefinitions: [{ id: "ref-probe", title: "Ref probe" }],
|
||||
scenarioResults: [{ name: "Ref probe", status: "pass" }],
|
||||
});
|
||||
|
||||
expect(evidence.entries[0]?.execution?.environment.ref).toBe(checkedOutRef);
|
||||
});
|
||||
|
||||
it("builds Telegram live transport evidence entries", () => {
|
||||
const evidence = buildLiveTransportEvidenceSummary({
|
||||
artifactPaths: [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Qa Lab plugin module implements QA evidence summary behavior.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { z } from "zod";
|
||||
import { splitQaModelRef } from "./model-selection.js";
|
||||
import { getQaProvider, type QaProviderMode } from "./providers/index.js";
|
||||
@@ -288,6 +289,7 @@ type QaEvidenceBuildBase = {
|
||||
channelDriver?: string;
|
||||
packageSource?: QaEvidencePackageSource;
|
||||
profile?: QaEvidenceProfile;
|
||||
repoRoot?: string;
|
||||
runner?: string;
|
||||
};
|
||||
|
||||
@@ -388,9 +390,31 @@ function resolveQaEvidenceChannelDriver(params: { env?: NodeJS.ProcessEnv; fallb
|
||||
return id ? { id } : undefined;
|
||||
}
|
||||
|
||||
function resolveQaEvidenceEnvironment(env: NodeJS.ProcessEnv | undefined) {
|
||||
function resolveQaEvidenceCheckoutRef(repoRoot?: string) {
|
||||
try {
|
||||
const ref = execFileSync("git", ["rev-parse", "--verify", "HEAD"], {
|
||||
cwd: repoRoot ?? process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
return ref || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveQaEvidenceEnvironment(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
repoRoot?: string;
|
||||
}) {
|
||||
return {
|
||||
ref: env?.OPENCLAW_QA_REF?.trim() || env?.GITHUB_SHA?.trim() || null,
|
||||
// GitHub's GITHUB_SHA describes the workflow event, not necessarily the
|
||||
// checked-out ref selected by a manual or remote QA run.
|
||||
ref:
|
||||
params.env?.OPENCLAW_QA_REF?.trim() ||
|
||||
resolveQaEvidenceCheckoutRef(params.repoRoot) ||
|
||||
params.env?.GITHUB_SHA?.trim() ||
|
||||
null,
|
||||
os: process.platform,
|
||||
nodeVersion: process.version,
|
||||
};
|
||||
@@ -550,7 +574,10 @@ export function buildQaSuiteEvidenceSummary(
|
||||
},
|
||||
): QaEvidenceSummaryJson {
|
||||
const provider = buildQaEvidenceProvider(params);
|
||||
const environment = resolveQaEvidenceEnvironment(params.env);
|
||||
const environment = resolveQaEvidenceEnvironment({
|
||||
env: params.env,
|
||||
repoRoot: params.repoRoot,
|
||||
});
|
||||
const packageSource = resolveQaEvidenceBuildPackageSource(params);
|
||||
const runner = resolveQaEvidenceRunner({ env: params.env, fallback: params.runner });
|
||||
const profile = resolveQaEvidenceProfile({
|
||||
@@ -622,7 +649,10 @@ function buildTestRunnerEvidenceSummary(
|
||||
},
|
||||
): QaEvidenceSummaryJson {
|
||||
const provider = buildQaEvidenceProvider(params);
|
||||
const environment = resolveQaEvidenceEnvironment(params.env);
|
||||
const environment = resolveQaEvidenceEnvironment({
|
||||
env: params.env,
|
||||
repoRoot: params.repoRoot,
|
||||
});
|
||||
const packageSource = resolveQaEvidenceBuildPackageSource(params);
|
||||
const runner = resolveQaEvidenceRunner({
|
||||
env: params.env,
|
||||
@@ -726,7 +756,10 @@ export function buildLiveTransportEvidenceSummary(
|
||||
},
|
||||
): QaEvidenceSummaryJson {
|
||||
const provider = buildQaEvidenceProvider(params);
|
||||
const environment = resolveQaEvidenceEnvironment(params.env);
|
||||
const environment = resolveQaEvidenceEnvironment({
|
||||
env: params.env,
|
||||
repoRoot: params.repoRoot,
|
||||
});
|
||||
const packageSource = resolveQaEvidenceBuildPackageSource(params);
|
||||
const runner = resolveQaEvidenceRunner({ env: params.env, fallback: params.runner });
|
||||
const profile = resolveQaEvidenceProfile({
|
||||
|
||||
@@ -1863,6 +1863,7 @@ export async function runDiscordQaLive(params: {
|
||||
generatedAt: finishedAt,
|
||||
primaryModel,
|
||||
providerMode,
|
||||
repoRoot,
|
||||
transportId: "discord",
|
||||
});
|
||||
await fs.writeFile(
|
||||
|
||||
@@ -2037,6 +2037,7 @@ export async function runSlackQaLive(params: {
|
||||
generatedAt: finishedAt,
|
||||
primaryModel,
|
||||
providerMode,
|
||||
repoRoot,
|
||||
transportId: "slack",
|
||||
});
|
||||
await fs.writeFile(
|
||||
|
||||
@@ -2188,6 +2188,7 @@ export async function runTelegramQaLive(params: {
|
||||
generatedAt: finishedAt,
|
||||
primaryModel,
|
||||
providerMode,
|
||||
repoRoot,
|
||||
checks: scenarioResults,
|
||||
transportId: "telegram",
|
||||
});
|
||||
|
||||
@@ -3282,6 +3282,7 @@ export async function runWhatsAppQaLive(params: {
|
||||
generatedAt: finishedAt,
|
||||
primaryModel,
|
||||
providerMode,
|
||||
repoRoot,
|
||||
transportId: "whatsapp",
|
||||
});
|
||||
await fs.writeFile(
|
||||
|
||||
@@ -1846,6 +1846,52 @@ describe("qa mock openai server", () => {
|
||||
expect(memorySearch.status).toBe(200);
|
||||
expect(await memorySearch.text()).toContain('"name":"memory_search"');
|
||||
|
||||
const memoryGetFromPathOnlySearchResult = await fetch(`${server.baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: "Memory tools check: what is the hidden project codename stored only in memory? Use memory tools first.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "function_call_output",
|
||||
output: JSON.stringify({
|
||||
results: [
|
||||
{
|
||||
path: "MEMORY.md",
|
||||
snippet: "Hidden QA fact: the project codename is ORBIT-9.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: "Protocol note: acknowledged. Continue with the QA scenario plan.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(memoryGetFromPathOnlySearchResult.status).toBe(200);
|
||||
const memoryGetText = await memoryGetFromPathOnlySearchResult.text();
|
||||
expect(memoryGetText).toContain('"name":"memory_get"');
|
||||
expect(memoryGetText).toContain('\\"path\\":\\"MEMORY.md\\"');
|
||||
expect(memoryGetText).toContain('\\"from\\":1');
|
||||
|
||||
const image = await fetch(`${server.baseUrl}/v1/images/generations`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -2612,8 +2612,8 @@ async function buildResponsesPayload(
|
||||
});
|
||||
}
|
||||
}
|
||||
if (/memory tools check/i.test(prompt)) {
|
||||
if (!toolOutput) {
|
||||
if (/memory tools check/i.test(allInputText)) {
|
||||
if (!scenarioToolOutput) {
|
||||
return buildToolCallEventsWithArgs("memory_search", {
|
||||
query: "project codename ORBIT-9",
|
||||
maxResults: 3,
|
||||
@@ -2623,10 +2623,7 @@ async function buildResponsesPayload(
|
||||
? (toolJson.results as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const first = results[0];
|
||||
if (
|
||||
typeof first?.path === "string" &&
|
||||
(typeof first.startLine === "number" || typeof first.endLine === "number")
|
||||
) {
|
||||
if (typeof first?.path === "string") {
|
||||
const from =
|
||||
typeof first.startLine === "number"
|
||||
? Math.max(1, first.startLine)
|
||||
|
||||
@@ -469,6 +469,94 @@ describe("qa suite runtime launcher", () => {
|
||||
expect(runQaTestFileScenarios).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts native suite proof before isolated flow work fills the weighted queue", async () => {
|
||||
const repoRoot = await makeTempRepo("qa-suite-native-before-isolated-");
|
||||
let releaseShared!: () => void;
|
||||
let markSharedStarted!: () => void;
|
||||
const sharedStarted = new Promise<void>((resolve) => {
|
||||
markSharedStarted = resolve;
|
||||
});
|
||||
const sharedBlocked = new Promise<void>((resolve) => {
|
||||
releaseShared = resolve;
|
||||
});
|
||||
let releaseTestFile!: () => void;
|
||||
let markTestFileStarted!: () => void;
|
||||
const testFileStarted = new Promise<void>((resolve) => {
|
||||
markTestFileStarted = resolve;
|
||||
});
|
||||
const testFileBlocked = new Promise<void>((resolve) => {
|
||||
releaseTestFile = resolve;
|
||||
});
|
||||
runQaFlowSuite.mockImplementationOnce(
|
||||
async (params: { outputDir?: string; scenarioIds?: string[] } | undefined) => {
|
||||
markSharedStarted();
|
||||
await sharedBlocked;
|
||||
const outputDir = params?.outputDir ?? "/tmp/qa-flow";
|
||||
const evidencePath = path.join(outputDir, "qa-evidence.json");
|
||||
await writeEvidence(evidencePath);
|
||||
const scenarioIds = params?.scenarioIds ?? ["channel-chat-baseline"];
|
||||
return {
|
||||
outputDir,
|
||||
evidencePath,
|
||||
reportPath: path.join(outputDir, "qa-suite-report.md"),
|
||||
summaryPath: path.join(outputDir, "qa-suite-summary.json"),
|
||||
report: "# QA Suite Report\n",
|
||||
scenarios: scenarioIds.map((scenarioId) => ({
|
||||
name: scenarioId,
|
||||
status: "pass",
|
||||
steps: [],
|
||||
})),
|
||||
watchUrl: "http://127.0.0.1:43124",
|
||||
};
|
||||
},
|
||||
);
|
||||
runQaTestFileScenarios.mockImplementationOnce(
|
||||
async (params: {
|
||||
outputDir: string;
|
||||
scenarios: Array<{ id: string; execution: { kind: "script" | "vitest" | "playwright" } }>;
|
||||
}) => {
|
||||
markTestFileStarted();
|
||||
await testFileBlocked;
|
||||
const evidencePath = path.join(params.outputDir, "qa-evidence.json");
|
||||
await writeEvidence(evidencePath);
|
||||
return {
|
||||
outputDir: params.outputDir,
|
||||
executionKind: params.scenarios[0]?.execution.kind ?? "playwright",
|
||||
evidencePath,
|
||||
results: params.scenarios.map((scenarioItem) => ({
|
||||
durationMs: 1,
|
||||
logPath: path.join(params.outputDir, `${scenarioItem.id}.log`),
|
||||
scenario: scenarioItem,
|
||||
status: "pass",
|
||||
})),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const runPromise = runQaSuite({
|
||||
repoRoot,
|
||||
outputDir: ".artifacts/qa-e2e/native-before-isolated",
|
||||
concurrency: 2,
|
||||
scenarioIds: [
|
||||
"channel-chat-baseline",
|
||||
"group-visible-reply-tool",
|
||||
"control-ui-chat-flow-playwright",
|
||||
],
|
||||
});
|
||||
await sharedStarted;
|
||||
await testFileStarted;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(runQaFlowSuite).toHaveBeenCalledTimes(1);
|
||||
expect(runQaTestFileScenarios).toHaveBeenCalledTimes(1);
|
||||
|
||||
releaseTestFile();
|
||||
releaseShared();
|
||||
await runPromise;
|
||||
|
||||
expect(runQaFlowSuite).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("waits for already-started partitions before rejecting a unified suite", async () => {
|
||||
const repoRoot = await makeTempRepo("qa-suite-reject-settle-");
|
||||
let releaseTestFile!: () => void;
|
||||
|
||||
@@ -448,7 +448,9 @@ async function runUnifiedQaSuite(params: {
|
||||
);
|
||||
const evidenceSummaries: QaEvidenceSummaryJson[] = [];
|
||||
const scenarioResultsById = new Map<string, QaSuiteScenarioResult>();
|
||||
const partitionTasks: QaUnifiedPartitionTask[] = [];
|
||||
const sharedFlowPartitionTasks: QaUnifiedPartitionTask[] = [];
|
||||
const isolatedFlowPartitionTasks: QaUnifiedPartitionTask[] = [];
|
||||
const testFilePartitionTasks: QaUnifiedPartitionTask[] = [];
|
||||
if (params.plan.flowScenarios.length > 0) {
|
||||
const sharedFlowScenarios = params.plan.flowScenarios.filter(
|
||||
(scenario) => !scenarioRequiresIsolatedQaSuiteWorker(scenario),
|
||||
@@ -488,7 +490,7 @@ async function runUnifiedQaSuite(params: {
|
||||
for (const partition of flowPartitions) {
|
||||
const isolatedPartition =
|
||||
partition.kind === "isolated" || partition.kind.startsWith("isolated-");
|
||||
partitionTasks.push({
|
||||
const task = {
|
||||
weight: partition.concurrency,
|
||||
run: async () => {
|
||||
const result = await runFlowSuite({
|
||||
@@ -525,11 +527,16 @@ async function runUnifiedQaSuite(params: {
|
||||
scenarioResults,
|
||||
};
|
||||
},
|
||||
});
|
||||
} satisfies QaUnifiedPartitionTask;
|
||||
if (isolatedPartition) {
|
||||
isolatedFlowPartitionTasks.push(task);
|
||||
} else {
|
||||
sharedFlowPartitionTasks.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (params.plan.testFileScenariosByKind.size > 0) {
|
||||
partitionTasks.push({
|
||||
testFilePartitionTasks.push({
|
||||
weight: 1,
|
||||
run: async () => {
|
||||
const testFileEvidenceSummaries: QaEvidenceSummaryJson[] = [];
|
||||
@@ -561,6 +568,11 @@ async function runUnifiedQaSuite(params: {
|
||||
},
|
||||
});
|
||||
}
|
||||
const partitionTasks = [
|
||||
...sharedFlowPartitionTasks,
|
||||
...testFilePartitionTasks,
|
||||
...isolatedFlowPartitionTasks,
|
||||
];
|
||||
const partitionResults = await runWeightedUnifiedPartitionTasks(partitionTasks, concurrency);
|
||||
for (const partitionResult of partitionResults) {
|
||||
for (const scenarioResult of partitionResult.scenarioResults) {
|
||||
|
||||
@@ -848,6 +848,7 @@ async function runQaRuntimeParitySuite(params: {
|
||||
const finishedAt = new Date();
|
||||
const { evidence, evidencePath, report, reportPath, summaryPath } = await writeQaSuiteArtifacts(
|
||||
{
|
||||
repoRoot: params.repoRoot,
|
||||
outputDir: params.outputDir,
|
||||
startedAt: params.startedAt,
|
||||
finishedAt,
|
||||
@@ -900,6 +901,7 @@ async function runQaRuntimeParitySuite(params: {
|
||||
}
|
||||
|
||||
async function writeQaSuiteArtifacts(params: {
|
||||
repoRoot?: string;
|
||||
outputDir: string;
|
||||
startedAt: Date;
|
||||
finishedAt: Date;
|
||||
@@ -974,6 +976,7 @@ async function writeQaSuiteArtifacts(params: {
|
||||
generatedAt: params.finishedAt.toISOString(),
|
||||
primaryModel: params.primaryModel,
|
||||
providerMode: params.providerMode,
|
||||
repoRoot: params.repoRoot,
|
||||
scenarioDefinitions: params.scenarioDefinitions,
|
||||
scenarioResults: params.scenarios,
|
||||
})
|
||||
@@ -1296,6 +1299,7 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
|
||||
.then(async () => {
|
||||
const partialFinishedAt = new Date();
|
||||
const { report, reportPath } = await writeQaSuiteArtifacts({
|
||||
repoRoot,
|
||||
outputDir,
|
||||
startedAt,
|
||||
finishedAt: partialFinishedAt,
|
||||
@@ -1448,6 +1452,7 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
|
||||
});
|
||||
const { evidence, evidencePath, report, reportPath, summaryPath } =
|
||||
await writeQaSuiteArtifacts({
|
||||
repoRoot,
|
||||
outputDir,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
@@ -1720,6 +1725,7 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
|
||||
});
|
||||
const { evidence, evidencePath, report, reportPath, summaryPath } = await writeQaSuiteArtifacts(
|
||||
{
|
||||
repoRoot,
|
||||
outputDir,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
|
||||
@@ -555,6 +555,7 @@ function buildTestFileEvidence(params: {
|
||||
kind: QaTestFileExecutionKind;
|
||||
primaryModel: string;
|
||||
providerMode: QaProviderMode;
|
||||
repoRoot: string;
|
||||
results: readonly QaTestFileScenarioResult[];
|
||||
evidenceMode?: QaScorecardEvidenceMode;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -581,6 +582,7 @@ function buildTestFileEvidence(params: {
|
||||
generatedAt: params.generatedAt,
|
||||
primaryModel: params.primaryModel,
|
||||
providerMode: params.providerMode,
|
||||
repoRoot: params.repoRoot,
|
||||
targets: fallbackResults.map((result) => buildScenarioEvidenceTarget(result.scenario)),
|
||||
results: fallbackResults.map((result) => ({
|
||||
id: result.scenario.id,
|
||||
@@ -616,6 +618,7 @@ function buildTestFileEvidence(params: {
|
||||
generatedAt: params.generatedAt,
|
||||
primaryModel: params.primaryModel,
|
||||
providerMode: params.providerMode,
|
||||
repoRoot: params.repoRoot,
|
||||
targets: params.results.map((result) => buildScenarioEvidenceTarget(result.scenario)),
|
||||
results: params.results.map((result) => ({
|
||||
id: result.scenario.id,
|
||||
@@ -802,6 +805,7 @@ export async function runQaTestFileScenarios(
|
||||
kind,
|
||||
primaryModel: params.primaryModel,
|
||||
providerMode: params.providerMode,
|
||||
repoRoot: params.repoRoot,
|
||||
results,
|
||||
});
|
||||
const paths = await writeTestFileEvidenceFile({
|
||||
|
||||
@@ -90,6 +90,10 @@ export function mergeTelegramAccountConfig(
|
||||
baseAllowFrom: base.allowFrom,
|
||||
accountAllowFrom: account.allowFrom,
|
||||
});
|
||||
const capabilities =
|
||||
Array.isArray(account.capabilities) && account.capabilities.length === 0
|
||||
? base.capabilities
|
||||
: (account.capabilities ?? base.capabilities);
|
||||
|
||||
return { ...base, ...account, allowFrom, groups };
|
||||
return { ...base, ...account, allowFrom, capabilities, groups };
|
||||
}
|
||||
|
||||
@@ -1703,6 +1703,25 @@ describe("handleTelegramAction", () => {
|
||||
expect(sendMessageTelegram).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows inline buttons when legacy capabilities are empty", async () => {
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "@testchannel",
|
||||
content: "Choose",
|
||||
presentation: {
|
||||
blocks: [{ type: "buttons", buttons: [{ label: "Ok", value: "cmd:ok" }] }],
|
||||
},
|
||||
},
|
||||
telegramConfig({ capabilities: [] }),
|
||||
);
|
||||
const call = mockCall(sendMessageTelegram, 0, "empty legacy capabilities");
|
||||
expect(call[0]).toBe("@testchannel");
|
||||
expect(requireRecord(call[2], "empty legacy capabilities options").buttons).toEqual([
|
||||
[{ text: "Ok", callback_data: "cmd:ok" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses interactive button labels as fallback text when message text is omitted", async () => {
|
||||
await handleTelegramAction(
|
||||
{
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
resolveDefaultModelForAgent,
|
||||
} from "./bot-message-dispatch.agent.runtime.js";
|
||||
import { deduplicateBlockSentMedia } from "./bot-message-dispatch.media-dedup.js";
|
||||
import { clipTelegramProgressText } from "./truncate.js";
|
||||
import {
|
||||
generateTopicLabel,
|
||||
getAgentScopedMediaLocalRoots,
|
||||
@@ -364,22 +365,14 @@ async function mirrorTelegramAssistantReplyToTranscript(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_PROGRESS_MARKDOWN_TEXT_CHARS = 300;
|
||||
const TELEGRAM_GENERAL_TOPIC_ID = 1;
|
||||
|
||||
function clipProgressMarkdownText(text: string): string {
|
||||
if (text.length <= MAX_PROGRESS_MARKDOWN_TEXT_CHARS) {
|
||||
return text;
|
||||
}
|
||||
return `${text.slice(0, MAX_PROGRESS_MARKDOWN_TEXT_CHARS - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function sanitizeProgressMarkdownText(text: string): string {
|
||||
return text.replaceAll("`", "'");
|
||||
}
|
||||
|
||||
function formatProgressAsMarkdownCode(text: string): string {
|
||||
const clipped = clipProgressMarkdownText(text);
|
||||
const clipped = clipTelegramProgressText(text);
|
||||
return `\`${sanitizeProgressMarkdownText(clipped)}\``;
|
||||
}
|
||||
|
||||
@@ -399,7 +392,7 @@ function escapeTelegramProgressHtml(text: string): string {
|
||||
}
|
||||
|
||||
function renderTelegramProgressStringLine(text: string): string {
|
||||
const clipped = clipProgressMarkdownText(text.trim());
|
||||
const clipped = clipTelegramProgressText(text.trim());
|
||||
const italic = clipped.match(/^_(.*)_$/u);
|
||||
if (italic) {
|
||||
return `<i>${escapeTelegramProgressHtml(italic[1] ?? "")}</i>`;
|
||||
@@ -418,7 +411,7 @@ function renderTelegramProgressLine(line: ChannelProgressDraftCompositorLine): s
|
||||
const parts = [`<b>${escapeTelegramProgressHtml(label)}</b>`];
|
||||
const detail = line.detail && line.detail !== line.label ? line.detail : undefined;
|
||||
if (detail) {
|
||||
parts.push(`<code>${escapeTelegramProgressHtml(clipProgressMarkdownText(detail))}</code>`);
|
||||
parts.push(`<code>${escapeTelegramProgressHtml(clipTelegramProgressText(detail))}</code>`);
|
||||
} else {
|
||||
const text = line.text.trim();
|
||||
if (text && text !== label) {
|
||||
|
||||
@@ -43,6 +43,36 @@ describe("telegram actions contract", () => {
|
||||
expect(capabilities?.includes("richText")).toBe(expected);
|
||||
});
|
||||
|
||||
it("advertises inline buttons when legacy Telegram capabilities are empty", () => {
|
||||
const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "123:telegram-test-token",
|
||||
capabilities: [],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(capabilities).toContain("inlineButtons");
|
||||
});
|
||||
|
||||
it("does not advertise inline buttons for non-empty legacy Telegram capabilities without inlineButtons", () => {
|
||||
const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "123:telegram-test-token",
|
||||
capabilities: ["vision"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(capabilities).not.toContain("inlineButtons");
|
||||
});
|
||||
|
||||
it("uses the selected Telegram account's rich text setting", () => {
|
||||
const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({
|
||||
cfg: {
|
||||
|
||||
@@ -109,6 +109,39 @@ describe("resolveTelegramInlineButtonsScope (#75433 SecretRef tolerance)", () =>
|
||||
expect(isTelegramInlineButtonsEnabled({ cfg })).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves the default inline-buttons scope when legacy capabilities are empty", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: { source: "exec", provider: "default", id: "telegram-token" },
|
||||
capabilities: [],
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expect(resolveTelegramInlineButtonsScope({ cfg })).toBe("allowlist");
|
||||
expect(isTelegramInlineButtonsEnabled({ cfg })).toBe(true);
|
||||
});
|
||||
|
||||
it("inherits the channel scope when an account legacy capabilities array is empty", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
capabilities: { inlineButtons: "off" },
|
||||
accounts: {
|
||||
ops: {
|
||||
botToken: "123:telegram-ops-token",
|
||||
capabilities: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expect(resolveTelegramInlineButtonsScope({ cfg, accountId: "ops" })).toBe("off");
|
||||
expect(isTelegramInlineButtonsEnabled({ cfg, accountId: "ops" })).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves configured "off" when botToken is an unresolved SecretRef', () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@@ -47,6 +47,9 @@ export function resolveTelegramInlineButtonsScopeFromCapabilities(
|
||||
return DEFAULT_INLINE_BUTTONS_SCOPE;
|
||||
}
|
||||
if (Array.isArray(capabilities)) {
|
||||
if (capabilities.length === 0) {
|
||||
return DEFAULT_INLINE_BUTTONS_SCOPE;
|
||||
}
|
||||
const enabled = capabilities.some(
|
||||
(entry) => normalizeLowercaseStringOrEmpty(String(entry)) === "inlinebuttons",
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { OutboundDeliveryFormattingOptions } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
resolveOutboundSendDep,
|
||||
sanitizeForPlainText,
|
||||
type OutboundSendDeps,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
sendPayloadMediaSequenceOrFallback,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
|
||||
import type { TelegramInlineButtons } from "./button-types.js";
|
||||
import { resolveTelegramInlineButtons } from "./button-types.js";
|
||||
import { splitTelegramHtmlChunks } from "./format.js";
|
||||
@@ -198,6 +200,7 @@ export function createTelegramOutboundAdapter(
|
||||
chunkerMode: "markdown",
|
||||
extractMarkdownImages: true,
|
||||
textChunkLimit: TELEGRAM_TEXT_CHUNK_LIMIT,
|
||||
sanitizeText: ({ text }) => sanitizeForPlainText(sanitizeAssistantVisibleText(text)),
|
||||
shouldSuppressLocalPayloadPrompt: options.shouldSuppressLocalPayloadPrompt,
|
||||
beforeDeliverPayload: options.beforeDeliverPayload,
|
||||
shouldTreatDeliveredTextAsVisible: options.shouldTreatDeliveredTextAsVisible,
|
||||
|
||||
@@ -29,10 +29,23 @@ describe("telegramPlugin outbound", () => {
|
||||
expect(telegramOutbound.presentationCapabilities?.limits?.text?.markdownDialect).toBe(
|
||||
"markdown",
|
||||
);
|
||||
expect(telegramOutbound.sanitizeText).toBeUndefined();
|
||||
expect(telegramOutbound.pollMaxOptions).toBe(10);
|
||||
});
|
||||
|
||||
it("strips assistant-visible tool traces before outbound delivery", () => {
|
||||
clearTelegramRuntime();
|
||||
const text = 'Done.\n⚠️ 🛠️ `search "Pipeline" in ~/.openclaw/workspace-* (agent)` failed';
|
||||
|
||||
expect(telegramOutbound.sanitizeText?.({ text, payload: { text } })).toBe("Done.");
|
||||
});
|
||||
|
||||
it("preserves ordinary outbound text while sanitizing", () => {
|
||||
clearTelegramRuntime();
|
||||
const text = "The pipeline has 3 deals.";
|
||||
|
||||
expect(telegramOutbound.sanitizeText?.({ text, payload: { text } })).toBe(text);
|
||||
});
|
||||
|
||||
it("preserves explicit HTML parse mode before chunking", () => {
|
||||
clearTelegramRuntime();
|
||||
const text = "<b>hi</b>";
|
||||
|
||||
48
extensions/telegram/src/truncate.test.ts
Normal file
48
extensions/telegram/src/truncate.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// Telegram tests cover progress text clipping behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clipTelegramProgressText, TELEGRAM_PROGRESS_MAX_CHARS } from "./truncate.js";
|
||||
|
||||
describe("clipTelegramProgressText", () => {
|
||||
it("drops a surrogate-pair emoji whole when it straddles the limit", () => {
|
||||
// 😀 is U+1F600, encoded as two UTF-16 code units (high \uD83D + low \uDE00).
|
||||
// Placing the emoji at positions [MAX-2, MAX-1] (0-indexed) puts its high
|
||||
// surrogate right on the .slice(0, MAX-1) cut edge. A raw .slice keeps only
|
||||
// \uD83D — an unpaired high surrogate — which is invalid in a Telegram payload.
|
||||
const base = "a".repeat(TELEGRAM_PROGRESS_MAX_CHARS - 2); // 298 'a's
|
||||
const out = clipTelegramProgressText(`${base}😀tail`);
|
||||
expect(out).toBe(`${base}…`);
|
||||
// No dangling high surrogate (high not followed by a low surrogate).
|
||||
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(out)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an emoji that fits entirely before the cut", () => {
|
||||
// 296 'a's + '😀' (2 units) + 'xyz' (3 units) = 301 total > 300.
|
||||
// The emoji sits at [296, 297] — entirely before the cut at 299 — so it stays.
|
||||
const base = "a".repeat(TELEGRAM_PROGRESS_MAX_CHARS - 4); // 296 'a's
|
||||
const out = clipTelegramProgressText(`${base}😀xyz`);
|
||||
expect(out).toBe(`${base}😀x…`);
|
||||
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(out)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns text unchanged when it is within the limit", () => {
|
||||
const short = "hello 😀 world";
|
||||
expect(clipTelegramProgressText(short)).toBe(short);
|
||||
});
|
||||
|
||||
it("trims trailing whitespace before the ellipsis", () => {
|
||||
// The sliced portion may end in spaces when trailing spaces straddle the cut.
|
||||
const text = `${"a".repeat(TELEGRAM_PROGRESS_MAX_CHARS - 2)} rest`;
|
||||
const out = clipTelegramProgressText(text);
|
||||
expect(out).not.toContain(" …");
|
||||
expect(out.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles plain ASCII that fills exactly to the limit", () => {
|
||||
const exact = "x".repeat(TELEGRAM_PROGRESS_MAX_CHARS);
|
||||
expect(clipTelegramProgressText(exact)).toBe(exact);
|
||||
const oneOver = `${"x".repeat(TELEGRAM_PROGRESS_MAX_CHARS)}y`;
|
||||
const out = clipTelegramProgressText(oneOver);
|
||||
expect(out.length).toBeLessThanOrEqual(TELEGRAM_PROGRESS_MAX_CHARS);
|
||||
expect(out.endsWith("…")).toBe(true);
|
||||
});
|
||||
});
|
||||
20
extensions/telegram/src/truncate.ts
Normal file
20
extensions/telegram/src/truncate.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Telegram tests cover progress text clipping behavior.
|
||||
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
|
||||
export const TELEGRAM_PROGRESS_MAX_CHARS = 300;
|
||||
|
||||
/**
|
||||
* Clips Telegram progress text to at most {@link TELEGRAM_PROGRESS_MAX_CHARS} UTF-16 code units,
|
||||
* slicing on a code-point boundary so a surrogate pair straddling the limit is
|
||||
* dropped whole rather than leaving a lone high surrogate in the payload.
|
||||
*/
|
||||
export function clipTelegramProgressText(text: string): string {
|
||||
if (text.length <= TELEGRAM_PROGRESS_MAX_CHARS) {
|
||||
return text;
|
||||
}
|
||||
// Slice on a code-point boundary so an emoji (or any astral character) that
|
||||
// straddles the limit is dropped whole instead of leaving a lone \uD83D-style
|
||||
// high surrogate before the ellipsis, which serializes to an invalid character
|
||||
// in the Telegram Bot API payload.
|
||||
return `${sliceUtf16Safe(text, 0, TELEGRAM_PROGRESS_MAX_CHARS - 1).trimEnd()}…`;
|
||||
}
|
||||
@@ -13,6 +13,16 @@ describe("base64 helpers", () => {
|
||||
actual: canonicalizeBase64(" SGV s bG8= \n"),
|
||||
expected: "SGVsbG8=",
|
||||
},
|
||||
{
|
||||
name: "canonicalizeBase64 pads valid unpadded base64",
|
||||
actual: canonicalizeBase64("SGVsbG8"),
|
||||
expected: "SGVsbG8=",
|
||||
},
|
||||
{
|
||||
name: "canonicalizeBase64 rejects impossible unpadded length",
|
||||
actual: canonicalizeBase64("S"),
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "canonicalizeBase64 rejects invalid base64 characters",
|
||||
actual: canonicalizeBase64('SGVsbG8=" onerror="alert(1)'),
|
||||
|
||||
@@ -74,8 +74,15 @@ export function canonicalizeBase64(base64: string): string | undefined {
|
||||
}
|
||||
cleaned += base64[i];
|
||||
}
|
||||
if (!cleaned || cleaned.length % 4 !== 0) {
|
||||
if (!cleaned) {
|
||||
return undefined;
|
||||
}
|
||||
const remainder = cleaned.length % 4;
|
||||
if (remainder !== 0) {
|
||||
if (sawPadding || remainder === 1) {
|
||||
return undefined;
|
||||
}
|
||||
cleaned += "=".repeat(4 - remainder);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,13 @@ describe("inline image data URL sanitizer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("canonicalizes valid unpadded image data URLs", () => {
|
||||
const unpaddedPng = PNG_1X1.replace(/=+$/u, "");
|
||||
expect(sanitizeInlineImageDataUrl(`data:image/png;base64,${unpaddedPng}`)).toBe(
|
||||
`data:image/png;base64,${PNG_1X1}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects image data URLs for formats that require conversion before provider transport", () => {
|
||||
expect(sanitizeInlineImageDataUrl(`data:image/bmp;base64,${BMP_HEADER}`)).toBeUndefined();
|
||||
expect(sanitizeInlineImageDataUrl(`data:image/heic;base64,${HEIC_HEADER}`)).toBeUndefined();
|
||||
|
||||
@@ -8,10 +8,9 @@ scenario:
|
||||
- memory.tools
|
||||
secondary:
|
||||
- channels.group-messages
|
||||
objective: Verify the agent uses memory_search and memory_get in a shared channel when the answer lives only in memory files, not the live transcript.
|
||||
objective: Verify the agent uses memory tools in a shared channel when the answer lives only in memory files, not the live transcript.
|
||||
successCriteria:
|
||||
- Agent uses memory_search before answering.
|
||||
- Agent narrows with memory_get before answering.
|
||||
- Final reply returns the memory-only fact correctly in-channel.
|
||||
docsRefs:
|
||||
- docs/concepts/memory.md
|
||||
@@ -21,7 +20,7 @@ scenario:
|
||||
- extensions/qa-lab/src/suite.ts
|
||||
execution:
|
||||
kind: flow
|
||||
summary: Verify the agent uses memory_search and memory_get in a shared channel when the answer lives only in memory files, not the live transcript.
|
||||
summary: Verify the agent uses memory tools in a shared channel when the answer lives only in memory files, not the live transcript.
|
||||
config:
|
||||
channelId: qa-memory-room
|
||||
channelTitle: QA Memory Room
|
||||
@@ -33,7 +32,7 @@ scenario:
|
||||
|
||||
flow:
|
||||
steps:
|
||||
- name: uses memory_search plus memory_get before answering in-channel
|
||||
- name: uses memory_search before answering in-channel
|
||||
actions:
|
||||
- call: reset
|
||||
- call: fs.writeFile
|
||||
@@ -80,7 +79,4 @@ flow:
|
||||
- assert:
|
||||
expr: "!env.mock || (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).filter((request) => String(request.allInputText ?? '').includes(config.promptSnippet)).some((request) => request.plannedToolName === 'memory_search')"
|
||||
message: expected memory_search in mock request plan
|
||||
- assert:
|
||||
expr: "!env.mock || (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).some((request) => request.plannedToolName === 'memory_get')"
|
||||
message: expected memory_get in mock request plan
|
||||
detailsExpr: outbound.text
|
||||
|
||||
@@ -31,6 +31,7 @@ scenario:
|
||||
summary: Run with `pnpm openclaw qa suite --provider-mode live-frontier --cli-auth-mode subscription --model claude-cli/claude-sonnet-4-6 --alt-model claude-cli/claude-sonnet-4-6 --scenario claude-cli-provider-capabilities-subscription`.
|
||||
config:
|
||||
authMode: subscription
|
||||
requiredProviderMode: live-frontier
|
||||
requiredProvider: claude-cli
|
||||
chatPrompt: "Claude CLI provider marker check. Reply exactly: CLAUDE-CLI-CHAT-OK"
|
||||
chatExpected: CLAUDE-CLI-CHAT-OK
|
||||
|
||||
@@ -31,6 +31,7 @@ scenario:
|
||||
summary: Run with `pnpm openclaw qa suite --provider-mode live-frontier --cli-auth-mode api-key --model claude-cli/claude-sonnet-4-6 --alt-model claude-cli/claude-sonnet-4-6 --scenario claude-cli-provider-capabilities`.
|
||||
config:
|
||||
authMode: api-key
|
||||
requiredProviderMode: live-frontier
|
||||
requiredProvider: claude-cli
|
||||
chatPrompt: "Claude CLI provider marker check. Reply exactly: CLAUDE-CLI-CHAT-OK"
|
||||
chatExpected: CLAUDE-CLI-CHAT-OK
|
||||
|
||||
@@ -18,47 +18,8 @@ scenario:
|
||||
- docs/gateway/protocol.md
|
||||
codeRefs:
|
||||
- src/mcp/plugin-tools-serve.ts
|
||||
- extensions/qa-lab/src/suite.ts
|
||||
- src/mcp/plugin-tools-mcp-client.test.ts
|
||||
execution:
|
||||
kind: flow
|
||||
kind: vitest
|
||||
path: src/mcp/plugin-tools-mcp-client.test.ts
|
||||
summary: Verify OpenClaw can expose plugin tools over MCP and a real MCP client can call one successfully.
|
||||
config:
|
||||
memoryFact: "MCP fact: the codename is ORBIT-9."
|
||||
query: "ORBIT-9 codename"
|
||||
expectedNeedle: "ORBIT-9"
|
||||
|
||||
flow:
|
||||
steps:
|
||||
- name: serves and calls memory_search over MCP
|
||||
actions:
|
||||
- call: fs.writeFile
|
||||
args:
|
||||
- expr: "path.join(env.gateway.workspaceDir, 'MEMORY.md')"
|
||||
- expr: "`${config.memoryFact}\\n`"
|
||||
- utf8
|
||||
- call: forceMemoryIndex
|
||||
args:
|
||||
- env:
|
||||
ref: env
|
||||
query:
|
||||
expr: config.query
|
||||
expectedNeedle:
|
||||
expr: config.expectedNeedle
|
||||
- call: callPluginToolsMcp
|
||||
saveAs: result
|
||||
args:
|
||||
- env:
|
||||
ref: env
|
||||
toolName: memory_search
|
||||
args:
|
||||
query:
|
||||
expr: config.query
|
||||
maxResults: 3
|
||||
- set: text
|
||||
value:
|
||||
expr: "JSON.stringify(result.content ?? [])"
|
||||
- assert:
|
||||
expr: "text.includes(config.expectedNeedle)"
|
||||
message:
|
||||
expr: "`MCP memory_search missed expected fact: ${text}`"
|
||||
detailsExpr: text
|
||||
|
||||
@@ -117,6 +117,7 @@ export const migratedSessionAccessorFiles = new Set([
|
||||
"src/gateway/session-reset-service.ts",
|
||||
"src/infra/outbound/message-action-tts.ts",
|
||||
"src/agents/tools/embedded-gateway-stub.ts",
|
||||
"src/agents/tools/session-status-tool.ts",
|
||||
"src/agents/tools/sessions-list-tool.ts",
|
||||
"src/plugins/host-hook-state.ts",
|
||||
"src/status/status-message.ts",
|
||||
@@ -142,6 +143,7 @@ export const migratedSessionAccessorWriteFiles = new Set([
|
||||
"src/auto-reply/reply/abort.ts",
|
||||
"src/agents/subagent-control.ts",
|
||||
"src/agents/subagent-registry-helpers.ts",
|
||||
"src/agents/tools/session-status-tool.ts",
|
||||
"src/auto-reply/reply/abort-cutoff.runtime.ts",
|
||||
"src/auto-reply/reply/agent-runner-cli-dispatch.ts",
|
||||
"src/auto-reply/reply/agent-runner-execution.ts",
|
||||
|
||||
@@ -7,7 +7,9 @@ USER root
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ARG PACKAGES="curl wget jq coreutils grep nodejs npm python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file"
|
||||
ARG PACKAGES="curl wget jq coreutils grep python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file"
|
||||
ARG INSTALL_NODE=1
|
||||
ARG NODE_MAJOR=24
|
||||
ARG INSTALL_PNPM=1
|
||||
ARG INSTALL_BUN=1
|
||||
ARG BUN_INSTALL_DIR=/opt/bun
|
||||
@@ -26,7 +28,18 @@ RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/ap
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ${PACKAGES}
|
||||
|
||||
RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi
|
||||
RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-sandbox-common-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
if [ "${INSTALL_NODE}" = "1" ]; then \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends ca-certificates curl; \
|
||||
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash -; \
|
||||
apt-get install -y --no-install-recommends nodejs; \
|
||||
node --version; \
|
||||
npm --version; \
|
||||
fi
|
||||
|
||||
RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm && pnpm --version; fi
|
||||
|
||||
RUN if [ "${INSTALL_BUN}" = "1" ]; then \
|
||||
curl -fsSL https://bun.sh/install | bash; \
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
QA_EVIDENCE_FILENAME,
|
||||
QA_EVIDENCE_SUMMARY_KIND,
|
||||
QA_EVIDENCE_SUMMARY_SCHEMA_VERSION,
|
||||
resolveQaEvidenceEnvironment,
|
||||
validateQaEvidenceSummaryJson,
|
||||
type QaEvidenceStatus,
|
||||
type QaEvidenceSummaryEntry,
|
||||
@@ -174,15 +175,15 @@ function sanitizeArtifactText(
|
||||
|
||||
function buildExecution(params: {
|
||||
artifacts: MatrixCell["artifacts"];
|
||||
repoRoot: string;
|
||||
source: string;
|
||||
}): QaEvidenceSummaryEntry["execution"] {
|
||||
return {
|
||||
runner: "ux-matrix-script-producer",
|
||||
environment: {
|
||||
ref: process.env.OPENCLAW_QA_REF?.trim() || process.env.GITHUB_SHA?.trim() || null,
|
||||
os: process.platform,
|
||||
nodeVersion: process.version,
|
||||
},
|
||||
environment: resolveQaEvidenceEnvironment({
|
||||
env: process.env,
|
||||
repoRoot: params.repoRoot,
|
||||
}),
|
||||
provider: {
|
||||
id: "ux-matrix",
|
||||
live: false,
|
||||
@@ -202,7 +203,7 @@ function buildExecution(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildEvidenceEntry(cell: MatrixCell): QaEvidenceSummaryEntry {
|
||||
function buildEvidenceEntry(cell: MatrixCell, repoRoot: string): QaEvidenceSummaryEntry {
|
||||
const source = `ux-matrix:${cell.surface}:${cell.stage}`;
|
||||
return {
|
||||
test: {
|
||||
@@ -221,6 +222,7 @@ function buildEvidenceEntry(cell: MatrixCell): QaEvidenceSummaryEntry {
|
||||
],
|
||||
execution: buildExecution({
|
||||
artifacts: cell.artifacts,
|
||||
repoRoot,
|
||||
source,
|
||||
}),
|
||||
result: {
|
||||
@@ -243,13 +245,14 @@ function buildEvidenceEntry(cell: MatrixCell): QaEvidenceSummaryEntry {
|
||||
function buildEvidenceSummary(params: {
|
||||
cells: readonly MatrixCell[];
|
||||
generatedAt: string;
|
||||
repoRoot: string;
|
||||
}): QaEvidenceSummaryJson {
|
||||
return validateQaEvidenceSummaryJson({
|
||||
kind: QA_EVIDENCE_SUMMARY_KIND,
|
||||
schemaVersion: QA_EVIDENCE_SUMMARY_SCHEMA_VERSION,
|
||||
generatedAt: params.generatedAt,
|
||||
evidenceMode: "full",
|
||||
entries: params.cells.map(buildEvidenceEntry),
|
||||
entries: params.cells.map((cell) => buildEvidenceEntry(cell, params.repoRoot)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -693,6 +696,7 @@ export async function runUxMatrixEvidenceProducer(options: ProducerOptions) {
|
||||
const previewEvidence = buildEvidenceSummary({
|
||||
cells: initialCells,
|
||||
generatedAt: new Date().toISOString(),
|
||||
repoRoot: options.repoRoot,
|
||||
});
|
||||
const screenshotLog = await fs.readFile(path.join(screenshotCellDir, "logs.txt"), "utf8");
|
||||
await writeProducerArtifactFixtureHtml({
|
||||
@@ -753,7 +757,11 @@ export async function runUxMatrixEvidenceProducer(options: ProducerOptions) {
|
||||
...initialCells,
|
||||
];
|
||||
|
||||
const evidence = buildEvidenceSummary({ cells, generatedAt: new Date().toISOString() });
|
||||
const evidence = buildEvidenceSummary({
|
||||
cells,
|
||||
generatedAt: new Date().toISOString(),
|
||||
repoRoot: options.repoRoot,
|
||||
});
|
||||
await writeProducerArtifactFixtureHtml({
|
||||
artifactBase: options.artifactBase,
|
||||
evidence,
|
||||
|
||||
@@ -6,7 +6,9 @@ source "$ROOT_DIR/scripts/lib/docker-build.sh"
|
||||
|
||||
BASE_IMAGE="${BASE_IMAGE:-openclaw-sandbox:bookworm-slim}"
|
||||
TARGET_IMAGE="${TARGET_IMAGE:-openclaw-sandbox-common:bookworm-slim}"
|
||||
PACKAGES="${PACKAGES:-curl wget jq coreutils grep nodejs npm python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file}"
|
||||
PACKAGES="${PACKAGES:-curl wget jq coreutils grep python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file}"
|
||||
INSTALL_NODE="${INSTALL_NODE:-1}"
|
||||
NODE_MAJOR="${NODE_MAJOR:-24}"
|
||||
INSTALL_PNPM="${INSTALL_PNPM:-1}"
|
||||
INSTALL_BUN="${INSTALL_BUN:-1}"
|
||||
BUN_INSTALL_DIR="${BUN_INSTALL_DIR:-/opt/bun}"
|
||||
@@ -30,6 +32,8 @@ docker_build_exec \
|
||||
-f "$ROOT_DIR/scripts/docker/sandbox/Dockerfile.common" \
|
||||
--build-arg BASE_IMAGE="${BASE_IMAGE}" \
|
||||
--build-arg PACKAGES="${PACKAGES}" \
|
||||
--build-arg INSTALL_NODE="${INSTALL_NODE}" \
|
||||
--build-arg NODE_MAJOR="${NODE_MAJOR}" \
|
||||
--build-arg INSTALL_PNPM="${INSTALL_PNPM}" \
|
||||
--build-arg INSTALL_BUN="${INSTALL_BUN}" \
|
||||
--build-arg BUN_INSTALL_DIR="${BUN_INSTALL_DIR}" \
|
||||
|
||||
@@ -16,7 +16,7 @@ const BASE_AVAILABLE_COMMANDS: AvailableCommand[] = [
|
||||
{ name: "subagents", description: "List or manage sub-agents." },
|
||||
{ name: "config", description: "Read or write config (owner-only)." },
|
||||
{ name: "debug", description: "Set runtime-only overrides (owner-only)." },
|
||||
{ name: "usage", description: "Toggle usage footer (off|tokens|full)." },
|
||||
{ name: "usage", description: "Toggle usage footer (off|tokens|full|reset). 'reset'/'inherit'/'clear'/'default' clears the session override to re-inherit the configured default." },
|
||||
{ name: "stop", description: "Stop the current run." },
|
||||
{ name: "restart", description: "Restart the gateway (if enabled)." },
|
||||
{ name: "activation", description: "Set group activation (mention|always)." },
|
||||
|
||||
@@ -221,9 +221,9 @@ export function buildSessionPresentation(params: {
|
||||
id: ACP_RESPONSE_USAGE_CONFIG_ID,
|
||||
name: "Usage detail",
|
||||
description:
|
||||
"Controls how much usage information OpenClaw attaches to responses for the session.",
|
||||
currentValue: normalizeOptionalString(row.responseUsage) || "off",
|
||||
values: ["off", "tokens", "full"],
|
||||
"Controls how much usage information OpenClaw attaches to responses for the session. 'inherit' follows the configured default; 'off' explicitly disables it for this session.",
|
||||
currentValue: normalizeOptionalString(row.responseUsage) || "inherit",
|
||||
values: ["inherit", "off", "tokens", "full"],
|
||||
}),
|
||||
buildSelectConfigOption({
|
||||
id: ACP_ELEVATED_LEVEL_CONFIG_ID,
|
||||
|
||||
@@ -358,4 +358,106 @@ describe("acp setSessionConfigOption bridge behavior", () => {
|
||||
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
|
||||
it('maps response_usage "inherit" selection to sessions.patch with responseUsage: null', async () => {
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const connection = createAcpConnection();
|
||||
const request = vi.fn(async (method: string, _params?: unknown) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
path: "/tmp/sessions.json",
|
||||
count: 1,
|
||||
defaults: { modelProvider: null, model: null, contextTokens: null },
|
||||
sessions: [
|
||||
{
|
||||
key: "usage-inherit-session",
|
||||
kind: "direct",
|
||||
updatedAt: Date.now(),
|
||||
thinkingLevel: "minimal",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
responseUsage: "tokens",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "sessions.patch") {
|
||||
expect(requireRecord(_params, "sessions.patch params")).toMatchObject({
|
||||
key: "usage-inherit-session",
|
||||
responseUsage: null,
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
}) as GatewayClient["request"];
|
||||
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
await agent.loadSession(createLoadSessionRequest("usage-inherit-session"));
|
||||
|
||||
const result = await agent.setSessionConfigOption(
|
||||
createSetSessionConfigOptionRequest("usage-inherit-session", "response_usage", "inherit"),
|
||||
);
|
||||
|
||||
// After selecting "inherit", the ACP config option should report "inherit" (unset).
|
||||
expectConfigOption(result.configOptions, "response_usage", { currentValue: "inherit" });
|
||||
expect(
|
||||
(request as unknown as MockCallSource).mock.calls.some(
|
||||
([method]) => method === "sessions.patch",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
|
||||
it('maps response_usage "off" selection to sessions.patch with responseUsage: "off"', async () => {
|
||||
const sessionStore = createInMemorySessionStore();
|
||||
const connection = createAcpConnection();
|
||||
const request = vi.fn(async (method: string, _params?: unknown) => {
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
path: "/tmp/sessions.json",
|
||||
count: 1,
|
||||
defaults: { modelProvider: null, model: null, contextTokens: null },
|
||||
sessions: [
|
||||
{
|
||||
key: "usage-off-session",
|
||||
kind: "direct",
|
||||
updatedAt: Date.now(),
|
||||
thinkingLevel: "minimal",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "sessions.patch") {
|
||||
expect(requireRecord(_params, "sessions.patch params")).toMatchObject({
|
||||
key: "usage-off-session",
|
||||
responseUsage: "off",
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
}) as GatewayClient["request"];
|
||||
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
await agent.loadSession(createLoadSessionRequest("usage-off-session"));
|
||||
|
||||
const result = await agent.setSessionConfigOption(
|
||||
createSetSessionConfigOptionRequest("usage-off-session", "response_usage", "off"),
|
||||
);
|
||||
|
||||
expectConfigOption(result.configOptions, "response_usage", { currentValue: "off" });
|
||||
expect(
|
||||
(request as unknown as MockCallSource).mock.calls.some(
|
||||
([method]) => method === "sessions.patch",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,7 +98,8 @@ describe("acp session UX bridge behavior", () => {
|
||||
});
|
||||
expectConfigOption(result.configOptions, "verbose_level", { currentValue: "off" });
|
||||
expectConfigOption(result.configOptions, "reasoning_level", { currentValue: "off" });
|
||||
expectConfigOption(result.configOptions, "response_usage", { currentValue: "off" });
|
||||
// Unset session inherits the configured default → control reads "inherit", not "off".
|
||||
expectConfigOption(result.configOptions, "response_usage", { currentValue: "inherit" });
|
||||
expectConfigOption(result.configOptions, "elevated_level", { currentValue: "off" });
|
||||
|
||||
sessionStore.clearAllSessionsForTest();
|
||||
|
||||
@@ -801,8 +801,7 @@ export class AcpGatewayAgent implements Agent {
|
||||
const promptKey = this.pendingPromptKey(params.sessionId, runId);
|
||||
if (
|
||||
isGatewayCloseError(err) &&
|
||||
(this.getPendingPrompt(params.sessionId, runId) ||
|
||||
this.settlingPromptKeys.has(promptKey))
|
||||
(this.getPendingPrompt(params.sessionId, runId) || this.settlingPromptKeys.has(promptKey))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -1592,7 +1591,7 @@ export class AcpGatewayAgent implements Agent {
|
||||
value: string | boolean,
|
||||
): {
|
||||
overrides: Partial<GatewaySessionPresentationRow>;
|
||||
patch?: Record<string, string | boolean>;
|
||||
patch?: Record<string, string | boolean | null>;
|
||||
} {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(
|
||||
@@ -1630,11 +1629,13 @@ export class AcpGatewayAgent implements Agent {
|
||||
patch: { reasoningLevel: value },
|
||||
overrides: { reasoningLevel: value },
|
||||
};
|
||||
case ACP_RESPONSE_USAGE_CONFIG_ID:
|
||||
case ACP_RESPONSE_USAGE_CONFIG_ID: {
|
||||
const next = value === "inherit" ? null : value;
|
||||
return {
|
||||
patch: { responseUsage: value },
|
||||
overrides: { responseUsage: value as GatewaySessionPresentationRow["responseUsage"] },
|
||||
patch: { responseUsage: next },
|
||||
overrides: { responseUsage: next as GatewaySessionPresentationRow["responseUsage"] },
|
||||
};
|
||||
}
|
||||
case ACP_ELEVATED_LEVEL_CONFIG_ID:
|
||||
return {
|
||||
patch: { elevatedLevel: value },
|
||||
|
||||
@@ -99,9 +99,77 @@ function installScopedSessionStores(syncUpdates = false) {
|
||||
async function createSessionsModuleMock() {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../config/sessions.js")>("../config/sessions.js");
|
||||
const resolveMockStorePath = (_store: string | undefined, opts?: { agentId?: string }) =>
|
||||
opts?.agentId === "support" ? "/tmp/support/sessions.json" : "/tmp/main/sessions.json";
|
||||
const cloneEntry = (entry: SessionEntry): SessionEntry => structuredClone(entry);
|
||||
return {
|
||||
...actual,
|
||||
loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath),
|
||||
patchSessionEntryWithKey: async (
|
||||
scope: { agentId?: string; sessionKey: string; storePath?: string },
|
||||
update: (
|
||||
entry: SessionEntry,
|
||||
context: { existingEntry?: SessionEntry },
|
||||
) => Promise<Partial<SessionEntry> | null> | Partial<SessionEntry> | null,
|
||||
options?: { fallbackEntry?: SessionEntry; replaceEntry?: boolean },
|
||||
) => {
|
||||
const storePath =
|
||||
scope.storePath ?? resolveMockStorePath(undefined, { agentId: scope.agentId });
|
||||
const store = loadSessionStoreMock(storePath) as Record<string, SessionEntry>;
|
||||
const resolved = actual.resolveSessionStoreEntry({ store, sessionKey: scope.sessionKey });
|
||||
const existing = resolved.existing ?? options?.fallbackEntry;
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
const patch = await update(cloneEntry(existing), {
|
||||
existingEntry: resolved.existing ? cloneEntry(resolved.existing) : undefined,
|
||||
});
|
||||
if (!patch) {
|
||||
return { sessionKey: resolved.normalizedKey, entry: cloneEntry(existing) };
|
||||
}
|
||||
const next = options?.replaceEntry
|
||||
? cloneEntry(patch as SessionEntry)
|
||||
: actual.mergeSessionEntry(existing, patch);
|
||||
store[resolved.normalizedKey] = next;
|
||||
updateSessionStoreMock(storePath, store);
|
||||
return { sessionKey: resolved.normalizedKey, entry: cloneEntry(next) };
|
||||
},
|
||||
resolveSessionEntryCandidateTarget: (scope: {
|
||||
agentId: string;
|
||||
candidateKeys: readonly string[];
|
||||
cfg: { session?: { store?: string } };
|
||||
fallback?: { sessionKey: string; entry: SessionEntry };
|
||||
}) => {
|
||||
const storePath = resolveMockStorePath(scope.cfg.session?.store, { agentId: scope.agentId });
|
||||
const store = loadSessionStoreMock(storePath) as Record<string, SessionEntry>;
|
||||
const candidates = [...new Set(scope.candidateKeys.map((key) => key.trim()))];
|
||||
for (const candidateKey of candidates) {
|
||||
if (!candidateKey) {
|
||||
continue;
|
||||
}
|
||||
const resolved = actual.resolveSessionStoreEntry({ store, sessionKey: candidateKey });
|
||||
if (!resolved.existing) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
agentId: scope.agentId,
|
||||
candidateKey,
|
||||
entry: cloneEntry(resolved.existing),
|
||||
persisted: true,
|
||||
sessionKey: resolved.normalizedKey,
|
||||
};
|
||||
}
|
||||
const fallbackKey = scope.fallback?.sessionKey.trim();
|
||||
return fallbackKey && scope.fallback
|
||||
? {
|
||||
agentId: scope.agentId,
|
||||
candidateKey: fallbackKey,
|
||||
entry: cloneEntry(scope.fallback.entry),
|
||||
persisted: false,
|
||||
sessionKey: fallbackKey,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
updateSessionStore: async (
|
||||
storePath: string,
|
||||
mutator: (store: Record<string, unknown>) => Promise<void> | void,
|
||||
@@ -111,8 +179,7 @@ async function createSessionsModuleMock() {
|
||||
updateSessionStoreMock(storePath, store);
|
||||
return store;
|
||||
},
|
||||
resolveStorePath: (_store: string | undefined, opts?: { agentId?: string }) =>
|
||||
opts?.agentId === "support" ? "/tmp/support/sessions.json" : "/tmp/main/sessions.json",
|
||||
resolveStorePath: resolveMockStorePath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1191,6 +1258,41 @@ describe("session_status tool", () => {
|
||||
expect(saved.sessionId).toMatch(UUID_RE);
|
||||
});
|
||||
|
||||
it("preserves an existing legacy main row when implicit fallback mutates model state", async () => {
|
||||
resetSessionStore({
|
||||
main: {
|
||||
sessionId: "legacy-main-session",
|
||||
updatedAt: 10,
|
||||
label: "Legacy Main",
|
||||
lastChannel: "telegram",
|
||||
},
|
||||
});
|
||||
|
||||
const tool = getSessionStatusTool("agent:main:main");
|
||||
|
||||
const result = await tool.execute("call-legacy-main-fallback-model", {
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
});
|
||||
const details = result.details as {
|
||||
ok?: boolean;
|
||||
sessionKey?: string;
|
||||
modelOverride?: string | null;
|
||||
};
|
||||
expect(details.ok).toBe(true);
|
||||
expect(details.sessionKey).toBe("main");
|
||||
expect(details.modelOverride).toBe("anthropic/claude-sonnet-4-6");
|
||||
expect(updateSessionStoreMock).toHaveBeenCalledTimes(1);
|
||||
const savedStore = latestMockCallArg(updateSessionStoreMock, 1) as Record<string, SessionEntry>;
|
||||
expect(savedStore.main).toMatchObject({
|
||||
sessionId: "legacy-main-session",
|
||||
label: "Legacy Main",
|
||||
lastChannel: "telegram",
|
||||
providerOverride: "anthropic",
|
||||
modelOverride: "claude-sonnet-4-6",
|
||||
liveModelSwitchPending: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("fires session:patch when session_status changes the persisted session model", async () => {
|
||||
const events: InternalHookEvent[] = [];
|
||||
registerInternalHook("session:patch", async (event) => {
|
||||
|
||||
154
src/agents/tools/session-status-session-resolve.ts
Normal file
154
src/agents/tools/session-status-session-resolve.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// Status-tool session resolution helpers keep storage lookup out of the tool body.
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import { resolveSessionEntryCandidateTarget, type SessionEntry } from "../../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
DEFAULT_AGENT_ID,
|
||||
parseAgentSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
import { resolveInternalSessionKey } from "./sessions-helpers.js";
|
||||
|
||||
export type ResolvedStatusSessionEntry = {
|
||||
entry: SessionEntry;
|
||||
key: string;
|
||||
persisted: boolean;
|
||||
};
|
||||
|
||||
/** Resolves one status lookup against ordered tool-local session key candidates. */
|
||||
export function resolveSessionStatusEntry(params: {
|
||||
agentId: string;
|
||||
alias: string;
|
||||
cfg: OpenClawConfig;
|
||||
includeAliasFallback?: boolean;
|
||||
keyRaw: string;
|
||||
mainKey: string;
|
||||
requesterInternalKey?: string;
|
||||
}): ResolvedStatusSessionEntry | null {
|
||||
const keyRaw = params.keyRaw.trim();
|
||||
if (!keyRaw) {
|
||||
return null;
|
||||
}
|
||||
const includeAliasFallback = params.includeAliasFallback ?? true;
|
||||
const internal = resolveInternalSessionKey({
|
||||
key: keyRaw,
|
||||
alias: params.alias,
|
||||
mainKey: params.mainKey,
|
||||
requesterInternalKey: params.requesterInternalKey,
|
||||
});
|
||||
|
||||
const candidates: string[] = [keyRaw];
|
||||
if (!keyRaw.startsWith("agent:")) {
|
||||
candidates.push(`agent:${DEFAULT_AGENT_ID}:${keyRaw}`);
|
||||
}
|
||||
if (includeAliasFallback && internal !== keyRaw) {
|
||||
candidates.push(internal);
|
||||
}
|
||||
if (includeAliasFallback && !keyRaw.startsWith("agent:")) {
|
||||
const agentInternal = `agent:${DEFAULT_AGENT_ID}:${internal}`;
|
||||
const agentRaw = `agent:${DEFAULT_AGENT_ID}:${keyRaw}`;
|
||||
if (agentInternal !== agentRaw) {
|
||||
candidates.push(agentInternal);
|
||||
}
|
||||
}
|
||||
if (includeAliasFallback && (keyRaw === "main" || keyRaw === "current")) {
|
||||
const defaultMainKey = buildAgentMainSessionKey({
|
||||
agentId: DEFAULT_AGENT_ID,
|
||||
mainKey: params.mainKey,
|
||||
});
|
||||
if (!candidates.includes(defaultMainKey)) {
|
||||
candidates.push(defaultMainKey);
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = resolveSessionEntryCandidateTarget({
|
||||
agentId: params.agentId,
|
||||
candidateKeys: candidates,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
return resolved
|
||||
? {
|
||||
entry: resolved.entry,
|
||||
key: resolved.sessionKey,
|
||||
persisted: resolved.persisted,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Maps requester keys into the currently selected agent store's legacy main key shape. */
|
||||
export function resolveStoreScopedRequesterKey(params: {
|
||||
agentId: string;
|
||||
mainKey: string;
|
||||
requesterKey: string;
|
||||
}) {
|
||||
const parsed = parseAgentSessionKey(params.requesterKey);
|
||||
if (!parsed || parsed.agentId !== params.agentId) {
|
||||
return params.requesterKey;
|
||||
}
|
||||
return parsed.rest === params.mainKey ? params.mainKey : params.requesterKey;
|
||||
}
|
||||
|
||||
function synthesizeImplicitCurrentSessionEntry(): SessionEntry {
|
||||
return {
|
||||
sessionId: "",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns a synthesized current-session entry without writing it to storage. */
|
||||
export function resolveImplicitCurrentSessionFallback(params: {
|
||||
agentId: string;
|
||||
allowFallback: boolean;
|
||||
cfg: OpenClawConfig;
|
||||
fallbackKey: string;
|
||||
}): ResolvedStatusSessionEntry | null {
|
||||
const fallbackKey = params.fallbackKey.trim();
|
||||
if (!params.allowFallback || !fallbackKey) {
|
||||
return null;
|
||||
}
|
||||
const resolved = resolveSessionEntryCandidateTarget({
|
||||
agentId: params.agentId,
|
||||
candidateKeys: [],
|
||||
cfg: params.cfg,
|
||||
fallback: {
|
||||
sessionKey: fallbackKey,
|
||||
entry: synthesizeImplicitCurrentSessionEntry(),
|
||||
},
|
||||
});
|
||||
return resolved
|
||||
? {
|
||||
entry: resolved.entry,
|
||||
key: resolved.sessionKey,
|
||||
persisted: resolved.persisted,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Lists policy-key fallbacks for implicit default-account direct status lookups. */
|
||||
export function listImplicitDefaultDirectFallbackKeys(params: {
|
||||
keyRaw: string;
|
||||
mainKey: string;
|
||||
}): string[] {
|
||||
const parsed = parseAgentSessionKey(params.keyRaw.trim());
|
||||
if (!parsed) {
|
||||
return [];
|
||||
}
|
||||
const parts = parsed.rest.split(":");
|
||||
if (parts.length < 4 || parts[1] !== "default" || parts[2] !== "direct") {
|
||||
return [];
|
||||
}
|
||||
const channel = parts[0];
|
||||
const peerParts = parts.slice(3);
|
||||
if (!channel || peerParts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const candidates = [
|
||||
`agent:${parsed.agentId}:${channel}:direct:${peerParts.join(":")}`,
|
||||
buildAgentMainSessionKey({
|
||||
agentId: parsed.agentId,
|
||||
mainKey: params.mainKey,
|
||||
}),
|
||||
params.mainKey,
|
||||
];
|
||||
return uniqueStrings(candidates);
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* Reports and updates session runtime state, model overrides, visibility, task status, and delivery context.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import { Type } from "typebox";
|
||||
import type {
|
||||
ElevatedLevel,
|
||||
@@ -14,11 +14,9 @@ import type {
|
||||
} from "../../auto-reply/thinking.js";
|
||||
import { getRuntimeConfig } from "../../config/config.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
mergeSessionEntry,
|
||||
patchSessionEntryWithKey,
|
||||
resolveStorePath,
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js";
|
||||
@@ -26,7 +24,6 @@ import { resolveSessionModelIdentityRef } from "../../gateway/session-utils.js";
|
||||
import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
DEFAULT_AGENT_ID,
|
||||
parseAgentSessionKey,
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
@@ -59,12 +56,17 @@ import {
|
||||
} from "../tool-description-presets.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { normalizeToolModelOverride, readStringParam } from "./common.js";
|
||||
import {
|
||||
listImplicitDefaultDirectFallbackKeys,
|
||||
resolveImplicitCurrentSessionFallback,
|
||||
resolveSessionStatusEntry,
|
||||
resolveStoreScopedRequesterKey,
|
||||
} from "./session-status-session-resolve.js";
|
||||
import {
|
||||
createAgentToAgentPolicy,
|
||||
createSessionVisibilityGuard,
|
||||
resolveCurrentSessionClientAlias,
|
||||
resolveEffectiveSessionToolsVisibility,
|
||||
resolveInternalSessionKey,
|
||||
resolveSandboxedSessionToolContext,
|
||||
resolveSessionReference,
|
||||
resolveVisibleSessionReference,
|
||||
@@ -88,121 +90,6 @@ function loadCommandsStatusRuntime(): Promise<CommandsStatusRuntimeModule> {
|
||||
return commandsStatusRuntimeLoader.load();
|
||||
}
|
||||
|
||||
function resolveSessionEntry(params: {
|
||||
store: Record<string, SessionEntry>;
|
||||
keyRaw: string;
|
||||
alias: string;
|
||||
mainKey: string;
|
||||
requesterInternalKey?: string;
|
||||
includeAliasFallback?: boolean;
|
||||
}): { key: string; entry: SessionEntry } | null {
|
||||
const keyRaw = params.keyRaw.trim();
|
||||
if (!keyRaw) {
|
||||
return null;
|
||||
}
|
||||
const includeAliasFallback = params.includeAliasFallback ?? true;
|
||||
const internal = resolveInternalSessionKey({
|
||||
key: keyRaw,
|
||||
alias: params.alias,
|
||||
mainKey: params.mainKey,
|
||||
requesterInternalKey: params.requesterInternalKey,
|
||||
});
|
||||
|
||||
const candidates: string[] = [keyRaw];
|
||||
if (!keyRaw.startsWith("agent:")) {
|
||||
candidates.push(`agent:${DEFAULT_AGENT_ID}:${keyRaw}`);
|
||||
}
|
||||
if (includeAliasFallback && internal !== keyRaw) {
|
||||
candidates.push(internal);
|
||||
}
|
||||
if (includeAliasFallback && !keyRaw.startsWith("agent:")) {
|
||||
const agentInternal = `agent:${DEFAULT_AGENT_ID}:${internal}`;
|
||||
const agentRaw = `agent:${DEFAULT_AGENT_ID}:${keyRaw}`;
|
||||
if (agentInternal !== agentRaw) {
|
||||
candidates.push(agentInternal);
|
||||
}
|
||||
}
|
||||
if (includeAliasFallback && (keyRaw === "main" || keyRaw === "current")) {
|
||||
const defaultMainKey = buildAgentMainSessionKey({
|
||||
agentId: DEFAULT_AGENT_ID,
|
||||
mainKey: params.mainKey,
|
||||
});
|
||||
if (!candidates.includes(defaultMainKey)) {
|
||||
candidates.push(defaultMainKey);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of candidates) {
|
||||
const entry = params.store[key];
|
||||
if (entry) {
|
||||
return { key, entry };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveStoreScopedRequesterKey(params: {
|
||||
requesterKey: string;
|
||||
agentId: string;
|
||||
mainKey: string;
|
||||
}) {
|
||||
const parsed = parseAgentSessionKey(params.requesterKey);
|
||||
if (!parsed || parsed.agentId !== params.agentId) {
|
||||
return params.requesterKey;
|
||||
}
|
||||
return parsed.rest === params.mainKey ? params.mainKey : params.requesterKey;
|
||||
}
|
||||
|
||||
function synthesizeImplicitCurrentSessionEntry(): SessionEntry {
|
||||
return {
|
||||
sessionId: "",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveImplicitCurrentSessionFallback(params: {
|
||||
allowFallback: boolean;
|
||||
fallbackKey: string;
|
||||
}): { key: string; entry: SessionEntry } | null {
|
||||
const fallbackKey = params.fallbackKey.trim();
|
||||
if (!params.allowFallback || !fallbackKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
key: fallbackKey,
|
||||
entry: synthesizeImplicitCurrentSessionEntry(),
|
||||
};
|
||||
}
|
||||
|
||||
function listImplicitDefaultDirectFallbackKeys(params: {
|
||||
keyRaw: string;
|
||||
mainKey: string;
|
||||
}): string[] {
|
||||
const parsed = parseAgentSessionKey(params.keyRaw.trim());
|
||||
if (!parsed) {
|
||||
return [];
|
||||
}
|
||||
const parts = parsed.rest.split(":");
|
||||
if (parts.length < 4 || parts[1] !== "default" || parts[2] !== "direct") {
|
||||
return [];
|
||||
}
|
||||
const channel = parts[0];
|
||||
const peerParts = parts.slice(3);
|
||||
if (!channel || peerParts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const candidates = [
|
||||
`agent:${parsed.agentId}:${channel}:direct:${peerParts.join(":")}`,
|
||||
buildAgentMainSessionKey({
|
||||
agentId: parsed.agentId,
|
||||
mainKey: params.mainKey,
|
||||
}),
|
||||
params.mainKey,
|
||||
];
|
||||
return uniqueStrings(candidates);
|
||||
}
|
||||
|
||||
type ActiveStatusModelIdentity = { provider?: string; model: string };
|
||||
|
||||
type SessionStatusOriginDetails = {
|
||||
@@ -642,7 +529,6 @@ export function createSessionStatusTool(opts?: {
|
||||
? resolveAgentIdFromSessionKey(requestedKeyInput)
|
||||
: requesterAgentId;
|
||||
let storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
let store = loadSessionStore(storePath);
|
||||
let storeScopedRequesterKey = resolveStoreScopedRequesterKey({
|
||||
requesterKey: effectiveRequesterKey,
|
||||
agentId,
|
||||
@@ -650,8 +536,9 @@ export function createSessionStatusTool(opts?: {
|
||||
});
|
||||
|
||||
// Resolve against the requester-scoped store first to avoid leaking default agent data.
|
||||
let resolved = resolveSessionEntry({
|
||||
store,
|
||||
let resolved = resolveSessionStatusEntry({
|
||||
cfg,
|
||||
agentId,
|
||||
keyRaw: requestedKeyRaw,
|
||||
alias,
|
||||
mainKey,
|
||||
@@ -687,14 +574,14 @@ export function createSessionStatusTool(opts?: {
|
||||
requestedKeyInput = requestedKeyRaw.trim();
|
||||
agentId = resolveAgentIdFromSessionKey(visibleSession.key);
|
||||
storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
store = loadSessionStore(storePath);
|
||||
storeScopedRequesterKey = resolveStoreScopedRequesterKey({
|
||||
requesterKey: effectiveRequesterKey,
|
||||
agentId,
|
||||
mainKey,
|
||||
});
|
||||
resolved = resolveSessionEntry({
|
||||
store,
|
||||
resolved = resolveSessionStatusEntry({
|
||||
cfg,
|
||||
agentId,
|
||||
keyRaw: requestedKeyRaw,
|
||||
alias,
|
||||
mainKey,
|
||||
@@ -706,8 +593,9 @@ export function createSessionStatusTool(opts?: {
|
||||
}
|
||||
|
||||
if (!resolved && requestedKeyInput === "current" && effectiveRequesterLookupKey) {
|
||||
resolved = resolveSessionEntry({
|
||||
store,
|
||||
resolved = resolveSessionStatusEntry({
|
||||
cfg,
|
||||
agentId,
|
||||
keyRaw: effectiveRequesterLookupKey,
|
||||
alias,
|
||||
mainKey,
|
||||
@@ -717,8 +605,9 @@ export function createSessionStatusTool(opts?: {
|
||||
}
|
||||
|
||||
if (!resolved && requestedKeyInput === "current") {
|
||||
resolved = resolveSessionEntry({
|
||||
store,
|
||||
resolved = resolveSessionStatusEntry({
|
||||
cfg,
|
||||
agentId,
|
||||
keyRaw: requestedKeyRaw,
|
||||
alias,
|
||||
mainKey,
|
||||
@@ -732,8 +621,9 @@ export function createSessionStatusTool(opts?: {
|
||||
keyRaw: requestedKeyRaw,
|
||||
mainKey,
|
||||
})) {
|
||||
resolved = resolveSessionEntry({
|
||||
store,
|
||||
resolved = resolveSessionStatusEntry({
|
||||
cfg,
|
||||
agentId,
|
||||
keyRaw: fallbackKey,
|
||||
alias,
|
||||
mainKey,
|
||||
@@ -750,7 +640,9 @@ export function createSessionStatusTool(opts?: {
|
||||
if (!resolved) {
|
||||
const runSessionFallbackKey = opts?.runSessionKey?.trim();
|
||||
const fallback = resolveImplicitCurrentSessionFallback({
|
||||
agentId,
|
||||
allowFallback: isSemanticCurrentRequest || requestedKeyParam === undefined,
|
||||
cfg,
|
||||
fallbackKey:
|
||||
(isSemanticCurrentRequest || isImplicitRunSessionStatus) && runSessionFallbackKey
|
||||
? runSessionFallbackKey
|
||||
@@ -793,46 +685,66 @@ export function createSessionStatusTool(opts?: {
|
||||
sessionEntry: resolved.entry,
|
||||
agentId,
|
||||
});
|
||||
const modelSelection =
|
||||
selection.kind === "reset"
|
||||
? {
|
||||
provider: configured.provider,
|
||||
model: configured.model,
|
||||
isDefault: true,
|
||||
}
|
||||
: {
|
||||
provider: selection.provider,
|
||||
model: selection.model,
|
||||
isDefault: selection.isDefault,
|
||||
};
|
||||
const nextEntry: SessionEntry = { ...resolved.entry };
|
||||
const applied = applyModelOverrideToSessionEntry({
|
||||
entry: nextEntry,
|
||||
selection:
|
||||
selection.kind === "reset"
|
||||
? {
|
||||
provider: configured.provider,
|
||||
model: configured.model,
|
||||
isDefault: true,
|
||||
}
|
||||
: {
|
||||
provider: selection.provider,
|
||||
model: selection.model,
|
||||
isDefault: selection.isDefault,
|
||||
},
|
||||
selection: modelSelection,
|
||||
markLiveSwitchPending: true,
|
||||
});
|
||||
if (applied.updated) {
|
||||
const persistedEntry = nextEntry.sessionId.trim()
|
||||
? nextEntry
|
||||
: (() => {
|
||||
const persistedEntryPatch: Partial<SessionEntry> = { ...nextEntry };
|
||||
delete persistedEntryPatch.sessionId;
|
||||
const existingEntry = store[resolved.key];
|
||||
const existingWithValidSessionId = existingEntry?.sessionId?.trim()
|
||||
? existingEntry
|
||||
: undefined;
|
||||
return mergeSessionEntry(existingWithValidSessionId, persistedEntryPatch);
|
||||
})();
|
||||
store[resolved.key] = persistedEntry;
|
||||
await updateSessionStore(storePath, (nextStore) => {
|
||||
nextStore[resolved.key] = persistedEntry;
|
||||
});
|
||||
resolved.entry = persistedEntry;
|
||||
const patchResult = await patchSessionEntryWithKey(
|
||||
{
|
||||
agentId,
|
||||
sessionKey: resolved.key,
|
||||
storePath,
|
||||
},
|
||||
(entry, context) => {
|
||||
const persistedEntryPatch: SessionEntry = { ...entry };
|
||||
applyModelOverrideToSessionEntry({
|
||||
entry: persistedEntryPatch,
|
||||
selection: modelSelection,
|
||||
markLiveSwitchPending: true,
|
||||
});
|
||||
if (
|
||||
!persistedEntryPatch.sessionId.trim() &&
|
||||
!context.existingEntry?.sessionId?.trim()
|
||||
) {
|
||||
persistedEntryPatch.sessionId = randomUUID();
|
||||
}
|
||||
return persistedEntryPatch;
|
||||
},
|
||||
{
|
||||
fallbackEntry: resolved.persisted ? undefined : resolved.entry,
|
||||
replaceEntry: true,
|
||||
},
|
||||
);
|
||||
if (!patchResult) {
|
||||
throw new Error(`Unknown sessionKey: ${resolved.key}`);
|
||||
}
|
||||
const persistedEntry = patchResult.entry;
|
||||
resolved = {
|
||||
entry: persistedEntry,
|
||||
key: patchResult.sessionKey,
|
||||
persisted: true,
|
||||
};
|
||||
triggerSessionPatchHook({
|
||||
cfg,
|
||||
sessionEntry: persistedEntry,
|
||||
sessionKey: resolved.key,
|
||||
sessionKey: patchResult.sessionKey,
|
||||
patch: {
|
||||
key: resolved.key,
|
||||
key: patchResult.sessionKey,
|
||||
model: selection.kind === "reset" ? null : `${selection.provider}/${selection.model}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
/** Formats and appends token/cost usage lines to reply payloads. */
|
||||
import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js";
|
||||
import {
|
||||
estimateUsageCost,
|
||||
formatTokenCount,
|
||||
formatUsd,
|
||||
type ModelCostConfig,
|
||||
resolveModelCostConfig,
|
||||
} from "../../utils/usage-format.js";
|
||||
import { getReplyPayloadMetadata, setReplyPayloadMetadata } from "../reply-payload.js";
|
||||
import { resolveEffectiveResponseUsage } from "../thinking.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { buildUsageContract } from "../usage-bar/contract.js";
|
||||
import { loadUsageBarTemplate } from "../usage-bar/template.js";
|
||||
import { renderUsageBar } from "../usage-bar/translator.js";
|
||||
|
||||
/** Formats the optional usage/cost summary appended to agent replies. */
|
||||
export const formatResponseUsageLine = (params: {
|
||||
usage?: {
|
||||
input?: number;
|
||||
@@ -54,7 +60,56 @@ export const formatResponseUsageLine = (params: {
|
||||
return `Usage: ${inputLabel} in / ${outputLabel} out${cacheSuffix}${suffix}`;
|
||||
};
|
||||
|
||||
/** Appends a usage line to the last text payload while preserving payload metadata. */
|
||||
export const resolveResponseUsageLine = (params: {
|
||||
config: OpenClawConfig;
|
||||
sessionRaw?: string | null;
|
||||
channel?: string;
|
||||
usage?: NormalizedUsage;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
preserveUserFacingSessionState?: boolean;
|
||||
replyUsageState?: PluginHookReplyUsageState;
|
||||
}): string | undefined => {
|
||||
const responseUsageMode = resolveEffectiveResponseUsage(
|
||||
params.sessionRaw,
|
||||
params.config.messages?.responseUsage,
|
||||
params.channel,
|
||||
);
|
||||
if (
|
||||
responseUsageMode === "off" ||
|
||||
!hasNonzeroUsage(params.usage) ||
|
||||
params.preserveUserFacingSessionState === true
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const costConfig = resolveModelCostConfig({
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
config: params.config,
|
||||
allowPluginNormalization: false,
|
||||
});
|
||||
const showCost = responseUsageMode === "full" && costConfig !== undefined;
|
||||
const formatted = formatResponseUsageLine({
|
||||
usage: params.usage,
|
||||
showCost,
|
||||
costConfig,
|
||||
});
|
||||
const usageTemplate =
|
||||
responseUsageMode === "full" && params.replyUsageState
|
||||
? loadUsageBarTemplate(params.config.messages?.usageTemplate)
|
||||
: undefined;
|
||||
const rendered =
|
||||
usageTemplate && params.replyUsageState
|
||||
? renderUsageBar(usageTemplate, buildUsageContract(params.replyUsageState, params.channel))
|
||||
: undefined;
|
||||
|
||||
if (rendered) {
|
||||
return rendered;
|
||||
}
|
||||
return formatted ?? undefined;
|
||||
};
|
||||
|
||||
export const appendUsageLine = (payloads: ReplyPayload[], line: string): ReplyPayload[] => {
|
||||
let index = -1;
|
||||
for (let i = payloads.length - 1; i >= 0; i -= 1) {
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
queueEmbeddedAgentMessageWithOutcomeAsync,
|
||||
} from "../../agents/embedded-agent-runner/runs.js";
|
||||
import { resolveFastModeState } from "../../agents/fast-mode.js";
|
||||
import { resolveAgentIdentity } from "../../agents/identity.js";
|
||||
import { resolveModelAuthMode } from "../../agents/model-auth.js";
|
||||
import { isCliProvider } from "../../agents/model-selection.js";
|
||||
import { deriveContextPromptTokens, hasNonzeroUsage, normalizeUsage } from "../../agents/usage.js";
|
||||
@@ -40,7 +39,6 @@ import {
|
||||
} from "../../infra/diagnostic-trace-context.js";
|
||||
import { measureDiagnosticsTimelineSpan } from "../../infra/diagnostics-timeline.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js";
|
||||
import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js";
|
||||
import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
@@ -65,12 +63,9 @@ import {
|
||||
setReplyPayloadMetadata,
|
||||
} from "../reply-payload.js";
|
||||
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
|
||||
import { resolveResponseUsageMode, type VerboseLevel } from "../thinking.js";
|
||||
import type { VerboseLevel } from "../thinking.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import { buildUsageContract } from "../usage-bar/contract.js";
|
||||
import { loadUsageBarTemplate } from "../usage-bar/template.js";
|
||||
import { renderUsageBar } from "../usage-bar/translator.js";
|
||||
import {
|
||||
buildKnownAgentRunFailureReplyPayload,
|
||||
runAgentTurnWithFallback,
|
||||
@@ -89,7 +84,7 @@ import {
|
||||
hasUnbackedReminderCommitment,
|
||||
} from "./agent-runner-reminder-guard.js";
|
||||
import { resetReplyRunSession } from "./agent-runner-session-reset.js";
|
||||
import { appendUsageLine, formatResponseUsageLine } from "./agent-runner-usage-line.js";
|
||||
import { appendUsageLine, resolveResponseUsageLine } from "./agent-runner-usage-line.js";
|
||||
import { resolveQueuedReplyExecutionConfig } from "./agent-runner-utils.js";
|
||||
import { createAudioAsVoiceBuffer, createBlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||
import { resolveEffectiveBlockStreamingConfig } from "./block-streaming.js";
|
||||
@@ -126,7 +121,7 @@ import {
|
||||
} from "./reply-run-registry.js";
|
||||
import { createReplyToModeFilterForChannel, resolveReplyToMode } from "./reply-threading.js";
|
||||
import { admitReplyTurn, resolveReplyTurnKind } from "./reply-turn-admission.js";
|
||||
import { recordReplyUsageState } from "./reply-usage-state.js";
|
||||
import { buildReplyUsageState, recordReplyUsageState } from "./reply-usage-state.js";
|
||||
import { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.js";
|
||||
import { incrementRunCompactionCount, persistRunSessionUsage } from "./session-run-accounting.js";
|
||||
import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js";
|
||||
@@ -1683,7 +1678,6 @@ export async function runReplyAgent(params: {
|
||||
toolProgressDetail,
|
||||
});
|
||||
|
||||
let responseUsageLine: string | undefined;
|
||||
type SessionResetOptions = {
|
||||
failureLabel: string;
|
||||
buildLogMessage: (nextSessionId: string) => string;
|
||||
@@ -1829,80 +1823,52 @@ export async function runReplyAgent(params: {
|
||||
const providerUsed =
|
||||
runResult.meta?.agentMeta?.provider ?? fallbackProvider ?? followupRun.run.provider;
|
||||
|
||||
let replyUsageState: PluginHookReplyUsageState | undefined;
|
||||
{
|
||||
const winnerProvider = fallbackExhausted
|
||||
? undefined
|
||||
: (runResult.meta?.executionTrace?.winnerProvider ?? providerUsed);
|
||||
const winnerModel = fallbackExhausted
|
||||
? undefined
|
||||
: (runResult.meta?.executionTrace?.winnerModel ?? modelUsed);
|
||||
const ctxTokens = runResult.meta?.agentMeta?.contextTokens;
|
||||
const compactions = runResult.meta?.agentMeta?.compactionCount;
|
||||
const lastCallUsage = runResult.meta?.agentMeta?.lastCallUsage;
|
||||
replyUsageState = {
|
||||
provider: providerUsed,
|
||||
model: modelUsed,
|
||||
resolvedRef: winnerProvider && winnerModel ? `${winnerProvider}/${winnerModel}` : undefined,
|
||||
reasoningEffort:
|
||||
typeof followupRun.run.thinkLevel === "string" ? followupRun.run.thinkLevel : undefined,
|
||||
fastMode: resolveFastModeState({
|
||||
cfg,
|
||||
provider: providerUsed ?? "",
|
||||
model: modelUsed ?? "",
|
||||
agentId: followupRun.run.agentId,
|
||||
sessionEntry: activeSessionEntry,
|
||||
}).enabled,
|
||||
fallbackUsed: runResult.meta?.executionTrace?.fallbackUsed === true,
|
||||
const winnerProvider = fallbackExhausted
|
||||
? undefined
|
||||
: (runResult.meta?.executionTrace?.winnerProvider ?? providerUsed);
|
||||
const winnerModel = fallbackExhausted
|
||||
? undefined
|
||||
: (runResult.meta?.executionTrace?.winnerModel ?? modelUsed);
|
||||
const ctxTokens = runResult.meta?.agentMeta?.contextTokens;
|
||||
const compactions = runResult.meta?.agentMeta?.compactionCount;
|
||||
const lastCallUsage = runResult.meta?.agentMeta?.lastCallUsage;
|
||||
const replyUsageState = buildReplyUsageState({
|
||||
config: cfg,
|
||||
provider: providerUsed,
|
||||
model: modelUsed,
|
||||
fallbackExhausted,
|
||||
winnerProvider,
|
||||
winnerModel,
|
||||
reasoningEffort:
|
||||
typeof followupRun.run.thinkLevel === "string" ? followupRun.run.thinkLevel : undefined,
|
||||
fastMode: resolveFastModeState({
|
||||
cfg,
|
||||
provider: providerUsed ?? "",
|
||||
model: modelUsed ?? "",
|
||||
agentId: followupRun.run.agentId,
|
||||
sessionId: followupRun.run.sessionId,
|
||||
chatType: typeof sessionCtx.ChatType === "string" ? sessionCtx.ChatType : undefined,
|
||||
authMode: runResult.meta?.requestShaping?.authMode ?? undefined,
|
||||
overrideSource: activeSessionEntry?.modelOverrideSource ?? undefined,
|
||||
requested:
|
||||
followupRun.run.provider && followupRun.run.model
|
||||
? `${followupRun.run.provider}/${followupRun.run.model}`
|
||||
: undefined,
|
||||
turnUsd: hasBillableUsageBuckets
|
||||
? estimateUsageCost({
|
||||
usage,
|
||||
cost: resolveModelCostConfig({
|
||||
provider: providerUsed,
|
||||
model: modelUsed,
|
||||
config: cfg,
|
||||
}),
|
||||
})
|
||||
sessionEntry: activeSessionEntry,
|
||||
}).enabled,
|
||||
fallbackUsed: runResult.meta?.executionTrace?.fallbackUsed === true,
|
||||
agentId: followupRun.run.agentId,
|
||||
sessionId: followupRun.run.sessionId,
|
||||
chatType: typeof sessionCtx.ChatType === "string" ? sessionCtx.ChatType : undefined,
|
||||
authMode: runResult.meta?.requestShaping?.authMode ?? undefined,
|
||||
overrideSource: activeSessionEntry?.modelOverrideSource ?? undefined,
|
||||
requestedProvider: followupRun.run.provider,
|
||||
requestedModel: followupRun.run.model,
|
||||
durationMs: Date.now() - runStartedAt,
|
||||
compactionCount: typeof compactions === "number" ? compactions : undefined,
|
||||
contextTokenBudget:
|
||||
typeof ctxTokens === "number" && Number.isFinite(ctxTokens) ? ctxTokens : undefined,
|
||||
contextUsedTokens:
|
||||
typeof promptTokens === "number" && Number.isFinite(promptTokens)
|
||||
? promptTokens
|
||||
: undefined,
|
||||
durationMs: Date.now() - runStartedAt,
|
||||
identity: resolveAgentIdentity(cfg, followupRun.run.agentId),
|
||||
compactionCount: typeof compactions === "number" ? compactions : undefined,
|
||||
contextTokenBudget:
|
||||
typeof ctxTokens === "number" && Number.isFinite(ctxTokens) ? ctxTokens : undefined,
|
||||
contextUsedTokens:
|
||||
typeof promptTokens === "number" && Number.isFinite(promptTokens)
|
||||
? promptTokens
|
||||
: undefined,
|
||||
usage: usage
|
||||
? {
|
||||
input: usage.input,
|
||||
output: usage.output,
|
||||
cacheRead: usage.cacheRead,
|
||||
cacheWrite: usage.cacheWrite,
|
||||
total: usage.total,
|
||||
}
|
||||
: undefined,
|
||||
lastUsage: lastCallUsage
|
||||
? {
|
||||
input: lastCallUsage.input,
|
||||
output: lastCallUsage.output,
|
||||
cacheRead: lastCallUsage.cacheRead,
|
||||
cacheWrite: lastCallUsage.cacheWrite,
|
||||
total: lastCallUsage.total,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
recordReplyUsageState(runId, replyUsageState);
|
||||
}
|
||||
promptTokens,
|
||||
usage,
|
||||
lastCallUsage,
|
||||
});
|
||||
recordReplyUsageState(runId, replyUsageState);
|
||||
const verboseEnabled = resolvedVerboseLevel !== "off";
|
||||
const preserveUserFacingSessionState = shouldPreserveUserFacingSessionStateForInputProvenance(
|
||||
followupRun.run.inputProvenance,
|
||||
@@ -2270,39 +2236,19 @@ export async function runReplyAgent(params: {
|
||||
});
|
||||
}
|
||||
|
||||
const responseUsageRaw =
|
||||
const responseUsageSessionRaw =
|
||||
activeSessionEntry?.responseUsage ??
|
||||
(sessionKey ? activeSessionStore?.[sessionKey]?.responseUsage : undefined);
|
||||
const responseUsageMode = resolveResponseUsageMode(responseUsageRaw);
|
||||
if (responseUsageMode !== "off" && hasNonzeroUsage(usage) && !preserveUserFacingSessionState) {
|
||||
const costConfig = resolveModelCostConfig({
|
||||
provider: providerUsed,
|
||||
model: modelUsed,
|
||||
config: cfg,
|
||||
allowPluginNormalization: false,
|
||||
});
|
||||
const showCost = responseUsageMode === "full" && costConfig !== undefined;
|
||||
let formatted = formatResponseUsageLine({
|
||||
usage,
|
||||
showCost,
|
||||
costConfig,
|
||||
});
|
||||
const usageTemplate =
|
||||
responseUsageMode === "full" && replyUsageState
|
||||
? loadUsageBarTemplate(cfg.messages?.usageTemplate)
|
||||
: undefined;
|
||||
const renderedUsageLine = usageTemplate
|
||||
? renderUsageBar(usageTemplate, buildUsageContract(replyUsageState, replyToChannel))
|
||||
: undefined;
|
||||
if (renderedUsageLine) {
|
||||
formatted = renderedUsageLine;
|
||||
} else if (formatted && responseUsageMode === "full" && sessionKey) {
|
||||
formatted = `${formatted} · session \`${sessionKey}\``;
|
||||
}
|
||||
if (formatted) {
|
||||
responseUsageLine = formatted;
|
||||
}
|
||||
}
|
||||
const responseUsageLine = resolveResponseUsageLine({
|
||||
config: cfg,
|
||||
sessionRaw: responseUsageSessionRaw,
|
||||
channel: replyToChannel,
|
||||
usage,
|
||||
provider: providerUsed,
|
||||
model: modelUsed,
|
||||
preserveUserFacingSessionState,
|
||||
replyUsageState,
|
||||
});
|
||||
|
||||
if (verboseEnabled) {
|
||||
activeSessionEntry = refreshSessionEntryFromStore({
|
||||
|
||||
@@ -238,20 +238,108 @@ describe("handleUsageCommand", () => {
|
||||
expect(params.sessionEntry.responseUsage).toBe("tokens");
|
||||
});
|
||||
|
||||
it("clears usage footer mode on off updates", async () => {
|
||||
it("persists an explicit /usage off so a configured default cannot re-enable it", async () => {
|
||||
const params = buildUsageParams();
|
||||
params.command.commandBodyNormalized = "/usage off";
|
||||
params.sessionEntry = {
|
||||
sessionId: "target-session",
|
||||
updatedAt: Date.now(),
|
||||
responseUsage: "full",
|
||||
params.sessionStore = {
|
||||
[params.sessionKey]: {
|
||||
sessionId: "target-session",
|
||||
updatedAt: Date.now(),
|
||||
responseUsage: "tokens",
|
||||
},
|
||||
};
|
||||
params.sessionStore = { [params.sessionKey]: params.sessionEntry };
|
||||
|
||||
const result = await handleUsageCommand(params, true);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toBe("⚙️ Usage footer: off.");
|
||||
expect(params.sessionEntry.responseUsage).toBeUndefined();
|
||||
expect(params.sessionStore[params.sessionKey]?.responseUsage).toBe("off");
|
||||
});
|
||||
|
||||
it("no-arg toggle uses the effective mode (config default) when session is unset", async () => {
|
||||
// When session has no override, the effective mode is the config default.
|
||||
// The toggle should cycle from that effective value, not from "off".
|
||||
const params = buildUsageParams();
|
||||
params.command.commandBodyNormalized = "/usage";
|
||||
params.cfg = {
|
||||
...params.cfg,
|
||||
messages: { responseUsage: "tokens" },
|
||||
} as OpenClawConfig;
|
||||
params.sessionStore = {
|
||||
[params.sessionKey]: {
|
||||
sessionId: "target-session",
|
||||
updatedAt: Date.now(),
|
||||
// responseUsage is absent — session inherits config default "tokens"
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handleUsageCommand(params, true);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
// Effective current = "tokens" (from config), so cycle → "full"
|
||||
expect(result?.reply?.text).toBe("⚙️ Usage footer: full.");
|
||||
expect(params.sessionStore[params.sessionKey]?.responseUsage).toBe("full");
|
||||
});
|
||||
|
||||
it("/usage reset clears the session override so the config default takes over", async () => {
|
||||
const params = buildUsageParams();
|
||||
params.command.commandBodyNormalized = "/usage reset";
|
||||
params.sessionStore = {
|
||||
[params.sessionKey]: {
|
||||
sessionId: "target-session",
|
||||
updatedAt: Date.now(),
|
||||
responseUsage: "off",
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handleUsageCommand(params, true);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toBe("⚙️ Usage footer: reset to default.");
|
||||
// responseUsage is deleted (undefined) — session now inherits the config default
|
||||
expect(params.sessionStore[params.sessionKey]?.responseUsage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("/usage inherit (alias) clears the session override", async () => {
|
||||
const params = buildUsageParams();
|
||||
params.command.commandBodyNormalized = "/usage inherit";
|
||||
params.sessionStore = {
|
||||
[params.sessionKey]: {
|
||||
sessionId: "target-session",
|
||||
updatedAt: Date.now(),
|
||||
responseUsage: "full",
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handleUsageCommand(params, true);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
expect(result?.reply?.text).toBe("⚙️ Usage footer: reset to default.");
|
||||
expect(params.sessionStore[params.sessionKey]?.responseUsage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("explicit off is stored and not treated as unset — config default cannot override it", async () => {
|
||||
// This verifies the three-state distinction: "off" vs undefined.
|
||||
// When session has explicit "off", the effective value is "off" regardless of config.
|
||||
const params = buildUsageParams();
|
||||
params.command.commandBodyNormalized = "/usage";
|
||||
params.cfg = {
|
||||
...params.cfg,
|
||||
messages: { responseUsage: "tokens" },
|
||||
} as OpenClawConfig;
|
||||
params.sessionStore = {
|
||||
[params.sessionKey]: {
|
||||
sessionId: "target-session",
|
||||
updatedAt: Date.now(),
|
||||
responseUsage: "off", // explicit off — stays off despite config default "tokens"
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handleUsageCommand(params, true);
|
||||
|
||||
expect(result?.shouldContinue).toBe(false);
|
||||
// Effective current = "off" (explicit, not inherited), so cycle → "tokens"
|
||||
expect(result?.reply?.text).toBe("⚙️ Usage footer: tokens.");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
isSessionDefaultDirectiveValue,
|
||||
normalizeFastMode,
|
||||
normalizeUsageDisplay,
|
||||
resolveResponseUsageMode,
|
||||
resolveEffectiveResponseUsage,
|
||||
} from "../thinking.js";
|
||||
import { resolveCommandSurfaceChannel } from "./channel-context.js";
|
||||
import { rejectNonOwnerCommand, rejectUnauthorizedCommand } from "./command-gates.js";
|
||||
@@ -352,24 +352,40 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
|
||||
};
|
||||
}
|
||||
|
||||
if (rawArgs && !requested) {
|
||||
const isReset = rawArgs ? isSessionDefaultDirectiveValue(rawArgs) : false;
|
||||
|
||||
if (rawArgs && !requested && !isReset) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Usage: /usage off|tokens|full|cost" },
|
||||
reply: { text: "⚙️ Usage: /usage off|tokens|full|reset|cost" },
|
||||
};
|
||||
}
|
||||
|
||||
const targetSessionEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry;
|
||||
|
||||
if (isReset) {
|
||||
if (targetSessionEntry && params.sessionStore && params.sessionKey) {
|
||||
delete targetSessionEntry.responseUsage;
|
||||
params.sessionStore[params.sessionKey] = targetSessionEntry;
|
||||
await persistSessionEntry({ ...params, sessionEntry: targetSessionEntry });
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Usage footer: reset to default." },
|
||||
};
|
||||
}
|
||||
|
||||
const replyChannel = params.command.channel;
|
||||
const currentRaw = targetSessionEntry?.responseUsage;
|
||||
const current = resolveResponseUsageMode(currentRaw);
|
||||
const current = resolveEffectiveResponseUsage(
|
||||
currentRaw,
|
||||
params.cfg.messages?.responseUsage,
|
||||
replyChannel,
|
||||
);
|
||||
const next = requested ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off");
|
||||
|
||||
if (targetSessionEntry && params.sessionStore && params.sessionKey) {
|
||||
if (next === "off") {
|
||||
delete targetSessionEntry.responseUsage;
|
||||
} else {
|
||||
targetSessionEntry.responseUsage = next;
|
||||
}
|
||||
targetSessionEntry.responseUsage = next;
|
||||
params.sessionStore[params.sessionKey] = targetSessionEntry;
|
||||
await persistSessionEntry({ ...params, sessionEntry: targetSessionEntry });
|
||||
}
|
||||
|
||||
@@ -3813,6 +3813,137 @@ describe("createFollowupRunner messaging delivery and dedupe", () => {
|
||||
persistSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("appends configured responseUsage footers during followup delivery", async () => {
|
||||
const sessionKey = "main";
|
||||
const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() };
|
||||
const cfg = {
|
||||
messages: {
|
||||
responseUsage: "tokens",
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const { onBlockReply } = await runMessagingCase({
|
||||
agentResult: {
|
||||
payloads: [{ text: "hello world!" }],
|
||||
meta: {
|
||||
agentMeta: {
|
||||
usage: { input: 1_000, output: 50 },
|
||||
model: "claude-opus-4-6",
|
||||
provider: "anthropic",
|
||||
},
|
||||
},
|
||||
},
|
||||
runnerOverrides: {
|
||||
sessionEntry,
|
||||
sessionStore: { [sessionKey]: sessionEntry },
|
||||
sessionKey,
|
||||
},
|
||||
queued: createQueuedRun({
|
||||
run: {
|
||||
config: cfg,
|
||||
messageProvider: "discord",
|
||||
sessionKey,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = requireMockCallArg(onBlockReply, 0);
|
||||
expect(payload.text).toContain("hello world!");
|
||||
expect(payload.text).toContain("Usage:");
|
||||
expect(payload.text).toContain("out");
|
||||
});
|
||||
|
||||
it("renders full responseUsage followup footers without exposing the session key", async () => {
|
||||
const sessionKey = "discord:channel:user";
|
||||
const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() };
|
||||
const cfg = {
|
||||
messages: {
|
||||
responseUsage: "full",
|
||||
usageTemplate: {
|
||||
output: {
|
||||
default: [
|
||||
{
|
||||
text: "model={model.display_name} tokens={usage.input_tokens|num}/{usage.output_tokens|num}",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const { onBlockReply } = await runMessagingCase({
|
||||
agentResult: {
|
||||
payloads: [{ text: "hello world!" }],
|
||||
meta: {
|
||||
agentMeta: {
|
||||
usage: { input: 1_000, output: 50 },
|
||||
model: "claude-opus-4-6",
|
||||
provider: "anthropic",
|
||||
},
|
||||
},
|
||||
},
|
||||
runnerOverrides: {
|
||||
sessionEntry,
|
||||
sessionStore: { [sessionKey]: sessionEntry },
|
||||
sessionKey,
|
||||
},
|
||||
queued: createQueuedRun({
|
||||
run: {
|
||||
config: cfg,
|
||||
messageProvider: "discord",
|
||||
sessionKey,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = requireMockCallArg(onBlockReply, 0);
|
||||
expect(payload.text).toContain("hello world!");
|
||||
expect(payload.text).toContain("model=claude-opus-4-6 tokens=1.0k/50");
|
||||
expect(payload.text).not.toContain(sessionKey);
|
||||
});
|
||||
|
||||
it("keeps explicit responseUsage off during followup delivery", async () => {
|
||||
const sessionKey = "main";
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
responseUsage: "off",
|
||||
};
|
||||
const cfg = {
|
||||
messages: {
|
||||
responseUsage: "tokens",
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const { onBlockReply } = await runMessagingCase({
|
||||
agentResult: {
|
||||
payloads: [{ text: "hello world!" }],
|
||||
meta: {
|
||||
agentMeta: {
|
||||
usage: { input: 1_000, output: 50 },
|
||||
model: "claude-opus-4-6",
|
||||
provider: "anthropic",
|
||||
},
|
||||
},
|
||||
},
|
||||
runnerOverrides: {
|
||||
sessionEntry,
|
||||
sessionStore: { [sessionKey]: sessionEntry },
|
||||
sessionKey,
|
||||
},
|
||||
queued: createQueuedRun({
|
||||
run: {
|
||||
config: cfg,
|
||||
messageProvider: "discord",
|
||||
sessionKey,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = requireMockCallArg(onBlockReply, 0);
|
||||
expect(payload.text).toBe("hello world!");
|
||||
});
|
||||
|
||||
it("uses providerUsed for snapshot freshness when agent metadata overrides the run provider", async () => {
|
||||
const storePath = "/tmp/openclaw-followup-usage-provider.json";
|
||||
const sessionKey = "main";
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
resolveSessionRuntimeOverrideForProvider,
|
||||
} from "./agent-runner-execution.js";
|
||||
import { runPreflightCompactionIfNeeded } from "./agent-runner-memory.js";
|
||||
import { appendUsageLine, resolveResponseUsageLine } from "./agent-runner-usage-line.js";
|
||||
import {
|
||||
resolveQueuedReplyExecutionConfig,
|
||||
resolveQueuedReplyRuntimeConfig,
|
||||
@@ -90,6 +91,7 @@ import {
|
||||
import type { ReplyDispatchKind } from "./reply-dispatcher.types.js";
|
||||
import type { ReplyOperation } from "./reply-run-registry.js";
|
||||
import { admitReplyTurn } from "./reply-turn-admission.js";
|
||||
import { buildReplyUsageState } from "./reply-usage-state.js";
|
||||
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
||||
import { incrementRunCompactionCount, persistRunSessionUsage } from "./session-run-accounting.js";
|
||||
import { createTypingSignaler } from "./typing-mode.js";
|
||||
@@ -1404,6 +1406,60 @@ export function createFollowupRunner(params: {
|
||||
}
|
||||
|
||||
let deliveryPayloads = finalPayloads;
|
||||
const responseUsageSessionRaw =
|
||||
activeSessionEntry?.responseUsage ??
|
||||
(replySessionKey ? sessionStore?.[replySessionKey]?.responseUsage : undefined);
|
||||
const winnerProvider = fallbackExhausted
|
||||
? undefined
|
||||
: (runResult.meta?.executionTrace?.winnerProvider ?? providerUsed);
|
||||
const winnerModel = fallbackExhausted
|
||||
? undefined
|
||||
: (runResult.meta?.executionTrace?.winnerModel ?? modelUsed);
|
||||
const lastCallUsage = runResult.meta?.agentMeta?.lastCallUsage;
|
||||
const replyUsageState = buildReplyUsageState({
|
||||
config: runtimeConfig,
|
||||
provider: providerUsed,
|
||||
model: modelUsed,
|
||||
fallbackExhausted,
|
||||
winnerProvider,
|
||||
winnerModel,
|
||||
reasoningEffort: typeof run.thinkLevel === "string" ? run.thinkLevel : undefined,
|
||||
fallbackUsed: runResult.meta?.executionTrace?.fallbackUsed === true,
|
||||
agentId: run.agentId,
|
||||
sessionId: run.sessionId,
|
||||
chatType: queued.originatingChatType,
|
||||
authMode: runResult.meta?.requestShaping?.authMode ?? undefined,
|
||||
overrideSource: activeSessionEntry?.modelOverrideSource ?? undefined,
|
||||
requestedProvider: run.provider,
|
||||
requestedModel: run.model,
|
||||
compactionCount:
|
||||
typeof runResult.meta?.agentMeta?.compactionCount === "number"
|
||||
? runResult.meta.agentMeta.compactionCount
|
||||
: undefined,
|
||||
contextTokenBudget:
|
||||
typeof contextTokensUsed === "number" && Number.isFinite(contextTokensUsed)
|
||||
? contextTokensUsed
|
||||
: undefined,
|
||||
promptTokens,
|
||||
usage,
|
||||
lastCallUsage,
|
||||
});
|
||||
const responseUsageLine = resolveResponseUsageLine({
|
||||
config: runtimeConfig,
|
||||
sessionRaw: responseUsageSessionRaw,
|
||||
channel: resolveOriginMessageProvider({
|
||||
originatingChannel: queued.originatingChannel,
|
||||
provider: run.messageProvider,
|
||||
}),
|
||||
usage,
|
||||
provider: providerUsed,
|
||||
model: modelUsed,
|
||||
preserveUserFacingSessionState,
|
||||
replyUsageState,
|
||||
});
|
||||
if (responseUsageLine) {
|
||||
deliveryPayloads = appendUsageLine(deliveryPayloads, responseUsageLine);
|
||||
}
|
||||
if (autoCompactionCount > 0) {
|
||||
const previousSessionId = run.sessionId;
|
||||
const count = await incrementRunCompactionCount({
|
||||
@@ -1438,7 +1494,7 @@ export function createFollowupRunner(params: {
|
||||
{
|
||||
text: `🧹 Auto-compaction complete${suffix}.`,
|
||||
},
|
||||
...finalPayloads,
|
||||
...deliveryPayloads,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,109 @@
|
||||
import { resolveAgentIdentity } from "../../agents/identity.js";
|
||||
import { deriveContextPromptTokens, type NormalizedUsage } from "../../agents/usage.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js";
|
||||
import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js";
|
||||
|
||||
const TTL_MS = 5 * 60_000;
|
||||
|
||||
const store = new Map<string, { snapshot: PluginHookReplyUsageState; expiresAt: number }>();
|
||||
|
||||
export function buildReplyUsageState(params: {
|
||||
config: OpenClawConfig;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
fallbackExhausted?: boolean;
|
||||
winnerProvider?: string;
|
||||
winnerModel?: string;
|
||||
reasoningEffort?: string;
|
||||
fastMode?: boolean;
|
||||
fallbackUsed?: boolean;
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
chatType?: string;
|
||||
authMode?: string;
|
||||
overrideSource?: string;
|
||||
requestedProvider?: string;
|
||||
requestedModel?: string;
|
||||
compactionCount?: number;
|
||||
contextTokenBudget?: number;
|
||||
contextUsedTokens?: number;
|
||||
promptTokens?: number;
|
||||
usage?: NormalizedUsage;
|
||||
lastCallUsage?: NormalizedUsage;
|
||||
durationMs?: number;
|
||||
}): PluginHookReplyUsageState {
|
||||
const resolvedProvider = params.fallbackExhausted ? undefined : params.winnerProvider;
|
||||
const resolvedModel = params.fallbackExhausted ? undefined : params.winnerModel;
|
||||
const hasBillableUsageBuckets =
|
||||
params.usage &&
|
||||
(params.usage.input !== undefined ||
|
||||
params.usage.output !== undefined ||
|
||||
params.usage.cacheRead !== undefined ||
|
||||
params.usage.cacheWrite !== undefined);
|
||||
return {
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
resolvedRef:
|
||||
resolvedProvider && resolvedModel ? `${resolvedProvider}/${resolvedModel}` : undefined,
|
||||
reasoningEffort: params.reasoningEffort,
|
||||
fastMode: params.fastMode,
|
||||
fallbackUsed: params.fallbackUsed,
|
||||
agentId: params.agentId,
|
||||
sessionId: params.sessionId,
|
||||
chatType: params.chatType,
|
||||
authMode: params.authMode,
|
||||
overrideSource: params.overrideSource,
|
||||
requested:
|
||||
params.requestedProvider && params.requestedModel
|
||||
? `${params.requestedProvider}/${params.requestedModel}`
|
||||
: undefined,
|
||||
turnUsd: hasBillableUsageBuckets
|
||||
? estimateUsageCost({
|
||||
usage: params.usage,
|
||||
cost: resolveModelCostConfig({
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
config: params.config,
|
||||
}),
|
||||
})
|
||||
: undefined,
|
||||
durationMs: params.durationMs,
|
||||
identity: resolveAgentIdentity(params.config, params.agentId),
|
||||
compactionCount: params.compactionCount,
|
||||
contextTokenBudget:
|
||||
typeof params.contextTokenBudget === "number" && Number.isFinite(params.contextTokenBudget)
|
||||
? params.contextTokenBudget
|
||||
: undefined,
|
||||
contextUsedTokens:
|
||||
typeof params.contextUsedTokens === "number" && Number.isFinite(params.contextUsedTokens)
|
||||
? params.contextUsedTokens
|
||||
: deriveContextPromptTokens({
|
||||
lastCallUsage: params.lastCallUsage,
|
||||
promptTokens: params.promptTokens,
|
||||
usage: params.usage,
|
||||
}),
|
||||
usage: params.usage
|
||||
? {
|
||||
input: params.usage.input,
|
||||
output: params.usage.output,
|
||||
cacheRead: params.usage.cacheRead,
|
||||
cacheWrite: params.usage.cacheWrite,
|
||||
total: params.usage.total,
|
||||
}
|
||||
: undefined,
|
||||
lastUsage: params.lastCallUsage
|
||||
? {
|
||||
input: params.lastCallUsage.input,
|
||||
output: params.lastCallUsage.output,
|
||||
cacheRead: params.lastCallUsage.cacheRead,
|
||||
cacheWrite: params.lastCallUsage.cacheWrite,
|
||||
total: params.lastCallUsage.total,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function prune(now: number): void {
|
||||
for (const [key, value] of store) {
|
||||
if (value.expiresAt < now) {
|
||||
|
||||
@@ -182,6 +182,37 @@ export function resolveResponseUsageMode(raw?: string | null): UsageDisplayLevel
|
||||
return normalizeUsageDisplay(raw) ?? "off";
|
||||
}
|
||||
|
||||
export type ResponseUsageInput = "on" | "off" | "tokens" | "full";
|
||||
export type ResponseUsageDefaultConfig =
|
||||
| ResponseUsageInput
|
||||
| { default?: ResponseUsageInput; [channel: string]: ResponseUsageInput | undefined };
|
||||
|
||||
export function resolveMessagesResponseUsageDefault(
|
||||
configured: ResponseUsageDefaultConfig | undefined,
|
||||
channel?: string,
|
||||
): ResponseUsageInput | undefined {
|
||||
if (typeof configured === "string") {
|
||||
return configured;
|
||||
}
|
||||
if (configured && typeof configured === "object") {
|
||||
return (channel ? configured[channel] : undefined) ?? configured.default;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveEffectiveResponseUsage(
|
||||
sessionRaw: string | undefined | null,
|
||||
configured: ResponseUsageDefaultConfig | undefined,
|
||||
channel?: string,
|
||||
): UsageDisplayLevel {
|
||||
const sessionNormalized = normalizeUsageDisplay(sessionRaw);
|
||||
if (sessionNormalized !== undefined) {
|
||||
return sessionNormalized;
|
||||
}
|
||||
const configDefault = resolveMessagesResponseUsageDefault(configured, channel);
|
||||
return resolveResponseUsageMode(configDefault);
|
||||
}
|
||||
|
||||
/** Normalizes elevated execution policy values. */
|
||||
export function normalizeElevatedLevel(raw?: string | null): ElevatedLevel | undefined {
|
||||
if (!raw) {
|
||||
|
||||
@@ -25,6 +25,8 @@ const {
|
||||
formatThinkingLevels,
|
||||
resolveSupportedThinkingLevel,
|
||||
resolveThinkingDefaultForModel,
|
||||
resolveMessagesResponseUsageDefault,
|
||||
resolveEffectiveResponseUsage,
|
||||
} = await import("./thinking.js");
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -807,3 +809,71 @@ describe("normalizeReasoningLevel", () => {
|
||||
expect(normalizeReasoningLevel("streaming")).toBe("stream");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMessagesResponseUsageDefault", () => {
|
||||
it("returns undefined when unset (preserves off-by-default behavior)", () => {
|
||||
expect(resolveMessagesResponseUsageDefault(undefined)).toBeUndefined();
|
||||
expect(resolveMessagesResponseUsageDefault(undefined, "discord")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns a bare string default for any channel", () => {
|
||||
expect(resolveMessagesResponseUsageDefault("full")).toBe("full");
|
||||
expect(resolveMessagesResponseUsageDefault("full", "telegram")).toBe("full");
|
||||
});
|
||||
|
||||
it("resolves the channel entry from a map", () => {
|
||||
const cfg = { default: "off", discord: "full", telegram: "tokens" } as const;
|
||||
expect(resolveMessagesResponseUsageDefault(cfg, "discord")).toBe("full");
|
||||
expect(resolveMessagesResponseUsageDefault(cfg, "telegram")).toBe("tokens");
|
||||
});
|
||||
|
||||
it("falls back to default for an unmapped channel", () => {
|
||||
const cfg = { default: "tokens", discord: "full" } as const;
|
||||
expect(resolveMessagesResponseUsageDefault(cfg, "whatsapp")).toBe("tokens");
|
||||
});
|
||||
|
||||
it("returns undefined for a map with neither the channel nor a default", () => {
|
||||
expect(resolveMessagesResponseUsageDefault({ discord: "full" }, "telegram")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEffectiveResponseUsage", () => {
|
||||
it("returns off when session is unset and no config is provided", () => {
|
||||
expect(resolveEffectiveResponseUsage(undefined, undefined)).toBe("off");
|
||||
expect(resolveEffectiveResponseUsage(null, undefined)).toBe("off");
|
||||
});
|
||||
|
||||
it("applies config default when session is unset", () => {
|
||||
expect(resolveEffectiveResponseUsage(undefined, "tokens")).toBe("tokens");
|
||||
expect(resolveEffectiveResponseUsage(undefined, "full")).toBe("full");
|
||||
});
|
||||
|
||||
it("applies per-channel config entry when session is unset", () => {
|
||||
const cfg = { default: "off", discord: "full", telegram: "tokens" } as const;
|
||||
expect(resolveEffectiveResponseUsage(undefined, cfg, "discord")).toBe("full");
|
||||
expect(resolveEffectiveResponseUsage(undefined, cfg, "telegram")).toBe("tokens");
|
||||
// Unknown channel falls back to config default
|
||||
expect(resolveEffectiveResponseUsage(undefined, cfg, "whatsapp")).toBe("off");
|
||||
});
|
||||
|
||||
it("session explicit off overrides any config default", () => {
|
||||
// Explicit "off" is stored and wins — non-off config default cannot re-enable it.
|
||||
expect(resolveEffectiveResponseUsage("off", "tokens")).toBe("off");
|
||||
expect(resolveEffectiveResponseUsage("off", "full")).toBe("off");
|
||||
expect(resolveEffectiveResponseUsage("off", { default: "full", discord: "full" }, "discord")).toBe("off");
|
||||
});
|
||||
|
||||
it("session explicit on value overrides config default", () => {
|
||||
expect(resolveEffectiveResponseUsage("tokens", "full")).toBe("tokens");
|
||||
expect(resolveEffectiveResponseUsage("full", "off")).toBe("full");
|
||||
});
|
||||
|
||||
it("unset (undefined/null) falls through to config; explicit off does not", () => {
|
||||
// These two are distinct states:
|
||||
// - undefined = unset/inherit → gets config default
|
||||
// - "off" = explicit off → stays off
|
||||
const cfg = "tokens" as const;
|
||||
expect(resolveEffectiveResponseUsage(undefined, cfg)).toBe("tokens"); // inherits
|
||||
expect(resolveEffectiveResponseUsage("off", cfg)).toBe("off"); // explicit off persists
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,8 @@ export {
|
||||
normalizeThinkLevel,
|
||||
normalizeUsageDisplay,
|
||||
normalizeVerboseLevel,
|
||||
resolveEffectiveResponseUsage,
|
||||
resolveMessagesResponseUsageDefault,
|
||||
resolveResponseUsageMode,
|
||||
} from "./thinking.shared.js";
|
||||
export type {
|
||||
@@ -24,6 +26,8 @@ export type {
|
||||
FastMode,
|
||||
NoticeLevel,
|
||||
ReasoningLevel,
|
||||
ResponseUsageDefaultConfig,
|
||||
ResponseUsageInput,
|
||||
TraceLevel,
|
||||
ThinkLevel,
|
||||
ThinkingCatalogEntry,
|
||||
|
||||
38
src/chat/canvas-render.test.ts
Normal file
38
src/chat/canvas-render.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Canvas-render tests cover [embed] shortcode extraction and text stripping.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractCanvasShortcodes } from "./canvas-render.ts";
|
||||
|
||||
describe("extractCanvasShortcodes", () => {
|
||||
it("does not let a self-closing embed start a greedy block match", () => {
|
||||
// Regression: the block regex used to greedily swallow the span from a
|
||||
// self-closing "[embed ... /]" open tag up to a later stray "[/embed]",
|
||||
// deleting the visible text in between (" keep me ") from channel delivery.
|
||||
const input = '[embed url="https://a.com" /] keep me [/embed]';
|
||||
const { text, previews } = extractCanvasShortcodes(input);
|
||||
|
||||
expect(previews).toHaveLength(1);
|
||||
expect(previews[0]?.url).toBe("https://a.com");
|
||||
// The visible text between the self-closing embed and the stray close
|
||||
// marker must be preserved, not silently stripped.
|
||||
expect(text).toContain("keep me");
|
||||
expect(text).toBe("keep me [/embed]");
|
||||
});
|
||||
|
||||
it("still extracts a normal block embed and strips only the shortcode span", () => {
|
||||
const input = 'before [embed ref="doc1"] hi [/embed] after';
|
||||
const { text, previews } = extractCanvasShortcodes(input);
|
||||
|
||||
expect(previews).toHaveLength(1);
|
||||
expect(previews[0]?.viewId).toBe("doc1");
|
||||
expect(text).toBe("before after");
|
||||
});
|
||||
|
||||
it("still extracts a plain self-closing embed and keeps surrounding text", () => {
|
||||
const input = 'see [embed url="https://b.com" /] end';
|
||||
const { text, previews } = extractCanvasShortcodes(input);
|
||||
|
||||
expect(previews).toHaveLength(1);
|
||||
expect(previews[0]?.url).toBe("https://b.com");
|
||||
expect(text).toBe("see end");
|
||||
});
|
||||
});
|
||||
@@ -203,7 +203,10 @@ export function extractCanvasShortcodes(text: string | undefined): {
|
||||
attrs: Record<string, string>;
|
||||
body?: string;
|
||||
}> = [];
|
||||
const blockRe = /\[embed\s+([^\]]*?)\]([\s\S]*?)\[\/embed\]/gi;
|
||||
// Exclude a self-closing open tag ("[embed ... /]") from starting a block
|
||||
// match by requiring the attrs group not to end with a slash; otherwise the
|
||||
// block regex greedily swallows visible text up to a later stray [/embed].
|
||||
const blockRe = /\[embed\s+([^\]]*?[^\]/]|)\]([\s\S]*?)\[\/embed\]/gi;
|
||||
const selfClosingRe = /\[embed\s+([^\]]*?)\/\]/gi;
|
||||
for (const re of [blockRe, selfClosingRe]) {
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
@@ -1199,7 +1199,6 @@ function resolveConfigIncludesForRead(
|
||||
deps: Required<ConfigIoDeps>,
|
||||
includeFileHashesForWrite?: Record<string, string>,
|
||||
includeFileTargetsForWrite?: Record<string, string>,
|
||||
includeFilePaths?: Set<string>,
|
||||
): unknown {
|
||||
const allowedRoots = resolveIncludeRoots(deps.env, deps.homedir);
|
||||
const recordIncludeTarget = (resolvedPath: string, canonicalPath?: string) => {
|
||||
@@ -1232,10 +1231,7 @@ function resolveConfigIncludesForRead(
|
||||
resolvedPath,
|
||||
rootRealDir,
|
||||
ioFs: deps.fs,
|
||||
onResolvedPath: (canonicalPath) => {
|
||||
recordIncludeTarget(resolvedPath, canonicalPath);
|
||||
includeFilePaths?.add(path.normalize(canonicalPath));
|
||||
},
|
||||
onResolvedPath: (canonicalPath) => recordIncludeTarget(resolvedPath, canonicalPath),
|
||||
});
|
||||
if (includeFileHashesForWrite) {
|
||||
includeFileHashesForWrite[path.normalize(resolvedPath)] = hashConfigIncludeRaw(raw);
|
||||
@@ -1311,13 +1307,11 @@ type ReadConfigFileSnapshotInternalResult = {
|
||||
envSnapshotForRestore?: Record<string, string | undefined>;
|
||||
includeFileHashesForWrite?: Record<string, string>;
|
||||
includeFileTargetsForWrite?: Record<string, string>;
|
||||
includeFilePaths?: readonly string[];
|
||||
pluginMetadataSnapshot?: PluginMetadataSnapshot;
|
||||
};
|
||||
|
||||
export type ReadConfigFileSnapshotWithPluginMetadataResult = {
|
||||
snapshot: ConfigFileSnapshot;
|
||||
includeFilePaths?: readonly string[];
|
||||
pluginMetadataSnapshot?: PluginMetadataSnapshot;
|
||||
};
|
||||
|
||||
@@ -1874,7 +1868,6 @@ export function createConfigIO(
|
||||
let fallbackEnvSnapshotForRestore: Record<string, string | undefined> | undefined;
|
||||
const includeFileHashesForWrite: Record<string, string> = {};
|
||||
const includeFileTargetsForWrite: Record<string, string> = {};
|
||||
const includeFilePaths = new Set<string>();
|
||||
|
||||
try {
|
||||
const raw = await deps.measure("config.snapshot.read.file", () =>
|
||||
@@ -1923,7 +1916,6 @@ export function createConfigIO(
|
||||
deps,
|
||||
includeFileHashesForWrite,
|
||||
includeFileTargetsForWrite,
|
||||
includeFilePaths,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -2094,7 +2086,6 @@ export function createConfigIO(
|
||||
envSnapshotForRestore: readResolution.envSnapshotForRestore,
|
||||
includeFileHashesForWrite,
|
||||
includeFileTargetsForWrite,
|
||||
includeFilePaths: [...includeFilePaths].toSorted(),
|
||||
pluginMetadataSnapshot: validationPluginMetadata.getSnapshot(),
|
||||
},
|
||||
{ observe: !callerRejectedSuspiciousRecovery },
|
||||
@@ -2160,7 +2151,6 @@ export function createConfigIO(
|
||||
});
|
||||
return {
|
||||
snapshot: result.snapshot,
|
||||
...(result.snapshot.valid ? { includeFilePaths: result.includeFilePaths ?? [] } : {}),
|
||||
...(result.pluginMetadataSnapshot
|
||||
? { pluginMetadataSnapshot: result.pluginMetadataSnapshot }
|
||||
: {}),
|
||||
|
||||
@@ -1865,57 +1865,6 @@ describe("config io write", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"exposes only canonical valid include paths through the metadata wrapper",
|
||||
async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const configDir = path.join(home, ".openclaw");
|
||||
const configPath = path.join(configDir, "openclaw.json");
|
||||
const fragmentsDir = path.join(configDir, "fragments");
|
||||
const aliasDir = path.join(configDir, "alias");
|
||||
const defaultsPath = path.join(fragmentsDir, "defaults.json5");
|
||||
const nestedPath = path.join(fragmentsDir, "nested.json5");
|
||||
await fs.mkdir(fragmentsDir, { recursive: true });
|
||||
await fs.symlink(fragmentsDir, aliasDir, "dir");
|
||||
await fs.writeFile(nestedPath, '{ workspace: "~/.openclaw/workspace" }\n', "utf-8");
|
||||
await fs.writeFile(
|
||||
defaultsPath,
|
||||
'{ $include: "./nested.json5", maxConcurrent: 1 }\n',
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
'{ agents: { defaults: { $include: "./alias/defaults.json5" } } }\n',
|
||||
"utf-8",
|
||||
);
|
||||
const io = createConfigIO({
|
||||
env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv,
|
||||
homedir: () => home,
|
||||
logger: silentLogger,
|
||||
});
|
||||
|
||||
const valid = await io.readConfigFileSnapshotWithPluginMetadata();
|
||||
|
||||
expect(valid.snapshot.valid).toBe(true);
|
||||
expect(valid.includeFilePaths).toEqual(
|
||||
[await fs.realpath(defaultsPath), await fs.realpath(nestedPath)].toSorted(),
|
||||
);
|
||||
expect(valid.includeFilePaths).not.toContain(path.join(aliasDir, "defaults.json5"));
|
||||
expect(valid.snapshot).not.toHaveProperty("includeFilePaths");
|
||||
|
||||
await fs.writeFile(nestedPath, "{ malformed", "utf-8");
|
||||
const invalid = await io.readConfigFileSnapshotWithPluginMetadata();
|
||||
expect(invalid.snapshot.valid).toBe(false);
|
||||
expect(invalid).not.toHaveProperty("includeFilePaths");
|
||||
|
||||
await fs.rm(nestedPath);
|
||||
const missing = await io.readConfigFileSnapshotWithPluginMetadata();
|
||||
expect(missing.snapshot.valid).toBe(false);
|
||||
expect(missing).not.toHaveProperty("includeFilePaths");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("repairs invalid root-authored siblings without flattening included config", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||
|
||||
@@ -1875,6 +1875,8 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Prefix text prepended to outbound assistant replies before sending to channels. Use for lightweight branding/context tags and avoid long prefixes that reduce content density.",
|
||||
"messages.usageTemplate":
|
||||
"Custom /usage full footer template, either an inline object or a JSON file path. Invalid or unavailable templates fall back to the built-in usage line.",
|
||||
"messages.responseUsage":
|
||||
'Default per-reply usage footer mode ("off"|"tokens"|"full") seeded into sessions that have not chosen one via /usage. Also accepts "on" as a legacy alias for "tokens". Accepts a bare mode or a per-channel map with a "default" fallback. Precedence: session value -> channel entry -> default -> off; an explicit /usage choice (including off) is persisted and overrides the default. Use /usage reset (aliases: inherit, clear, default) to clear a session override and re-inherit this configured default.',
|
||||
"messages.groupChat":
|
||||
"Group-message handling controls including mention triggers and history window sizing. Keep mention patterns narrow so group channels do not trigger on every message.",
|
||||
"messages.groupChat.mentionPatterns":
|
||||
|
||||
@@ -968,6 +968,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"messages.visibleReplies": "Visible Replies",
|
||||
"messages.responsePrefix": "Outbound Response Prefix",
|
||||
"messages.usageTemplate": "Usage Footer Template",
|
||||
"messages.responseUsage": "Default Usage Footer Mode",
|
||||
"messages.groupChat": "Group Chat Rules",
|
||||
"messages.groupChat.mentionPatterns": "Group Mention Patterns",
|
||||
"messages.groupChat.historyLimit": "Group History Limit",
|
||||
|
||||
@@ -13,12 +13,16 @@ export * from "./sessions/reset.js";
|
||||
export {
|
||||
canonicalizeSessionEntryAliases,
|
||||
deleteSessionEntryLifecycle,
|
||||
patchSessionEntryWithKey,
|
||||
resetSessionEntryLifecycle,
|
||||
resolveSessionEntryCandidateTarget,
|
||||
type CanonicalizeSessionEntryAliasesResult,
|
||||
type DeleteSessionEntryLifecycleParams,
|
||||
type DeleteSessionEntryLifecycleResult,
|
||||
type ResolvedSessionEntryCandidateTarget,
|
||||
type ResetSessionEntryLifecycleParams,
|
||||
type ResetSessionEntryLifecycleResult,
|
||||
type SessionEntryCandidateAccessScope,
|
||||
type SessionLifecycleArchivedTranscript,
|
||||
type SessionLifecycleStoreTarget,
|
||||
} from "./sessions/session-accessor.js";
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
publishTranscriptUpdate,
|
||||
readSessionUpdatedAt,
|
||||
replaceSessionEntry,
|
||||
resolveSessionEntryCandidateTarget,
|
||||
resolveSessionEntryAccessTarget,
|
||||
restoreSessionFromCompactionCheckpoint,
|
||||
resolveSessionTranscriptReadTarget,
|
||||
@@ -226,6 +227,69 @@ describe("session accessor file-backed seam", () => {
|
||||
expect(persisted.main).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves status-style ordered candidate keys without exposing the store", async () => {
|
||||
fs.writeFileSync(
|
||||
storePath,
|
||||
JSON.stringify({
|
||||
"agent:main:current": {
|
||||
label: "literal-current",
|
||||
sessionId: "session-current",
|
||||
updatedAt: 30,
|
||||
},
|
||||
"agent:main:main": {
|
||||
label: "main",
|
||||
sessionId: "session-main",
|
||||
updatedAt: 10,
|
||||
},
|
||||
} satisfies Record<string, SessionEntry>),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const resolved = resolveSessionEntryCandidateTarget({
|
||||
agentId: "main",
|
||||
candidateKeys: ["agent:main:main", "agent:main:current"],
|
||||
cfg: { session: { store: storePath } },
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
agentId: "main",
|
||||
candidateKey: "agent:main:main",
|
||||
entry: expect.objectContaining({
|
||||
label: "main",
|
||||
sessionId: "session-main",
|
||||
}),
|
||||
persisted: true,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an implicit candidate fallback without persisting it", () => {
|
||||
const resolved = resolveSessionEntryCandidateTarget({
|
||||
agentId: "main",
|
||||
candidateKeys: ["agent:main:missing"],
|
||||
cfg: { session: { store: storePath } },
|
||||
fallback: {
|
||||
sessionKey: "agent:main:current",
|
||||
entry: {
|
||||
sessionId: "",
|
||||
updatedAt: 40,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
agentId: "main",
|
||||
candidateKey: "agent:main:current",
|
||||
entry: {
|
||||
sessionId: "",
|
||||
updatedAt: 40,
|
||||
},
|
||||
persisted: false,
|
||||
sessionKey: "agent:main:current",
|
||||
});
|
||||
expect(fs.existsSync(storePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("purges deleted-agent entries from the current locked store", async () => {
|
||||
const cfg = {
|
||||
session: { store: storePath },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import {
|
||||
acquireSessionWriteLock,
|
||||
resolveSessionWriteLockOptions,
|
||||
@@ -159,6 +160,35 @@ type ResolvedSessionEntryStoreTarget = ResolvedSessionEntryAccessTarget & {
|
||||
storePath: string;
|
||||
};
|
||||
|
||||
export type SessionEntryCandidateAccessScope = {
|
||||
/** Agent owner whose session store is searched. */
|
||||
agentId: string;
|
||||
/** Ordered session keys to test inside the resolved store. */
|
||||
candidateKeys: readonly string[];
|
||||
/** Runtime config whose session store rule selects the backend target. */
|
||||
cfg: OpenClawConfig;
|
||||
/** Environment override used when resolving agent-scoped store paths in tests/tools. */
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Optional synthesized entry returned only when no candidate exists. */
|
||||
fallback?: {
|
||||
entry: SessionEntry;
|
||||
sessionKey: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ResolvedSessionEntryCandidateTarget = {
|
||||
/** Agent owner whose session store produced this result. */
|
||||
agentId: string;
|
||||
/** Candidate key that selected the result, or the fallback key. */
|
||||
candidateKey: string;
|
||||
/** Session metadata cloned from storage or from the synthesized fallback. */
|
||||
entry: SessionEntry;
|
||||
/** False only for synthesized fallback entries that have not been written. */
|
||||
persisted: boolean;
|
||||
/** Persisted key selected by the backend, or the fallback key. */
|
||||
sessionKey: string;
|
||||
};
|
||||
|
||||
export type ResolvedSessionEntryUpdateContext = Omit<ResolvedSessionEntryAccessTarget, "entry"> & {
|
||||
/** Mutable entry inside the storage operation. */
|
||||
entry: SessionEntry;
|
||||
@@ -747,6 +777,44 @@ export function resolveSessionEntryAccessTarget(
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolves ordered candidate keys inside one agent-owned session store. */
|
||||
export function resolveSessionEntryCandidateTarget(
|
||||
scope: SessionEntryCandidateAccessScope,
|
||||
): ResolvedSessionEntryCandidateTarget | null {
|
||||
const storePath = resolveStorePath(scope.cfg.session?.store, {
|
||||
agentId: scope.agentId,
|
||||
env: scope.env,
|
||||
});
|
||||
const store = loadSessionStore(storePath);
|
||||
for (const candidateKey of uniqueStrings(scope.candidateKeys.map((key) => key.trim()))) {
|
||||
if (!candidateKey) {
|
||||
continue;
|
||||
}
|
||||
const resolved = resolveSessionStoreEntry({ store, sessionKey: candidateKey });
|
||||
if (!resolved.existing) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
agentId: scope.agentId,
|
||||
candidateKey,
|
||||
entry: structuredClone(resolved.existing),
|
||||
persisted: true,
|
||||
sessionKey: resolved.normalizedKey,
|
||||
};
|
||||
}
|
||||
const fallbackKey = scope.fallback?.sessionKey.trim();
|
||||
if (!fallbackKey || !scope.fallback) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
agentId: scope.agentId,
|
||||
candidateKey: fallbackKey,
|
||||
entry: structuredClone(scope.fallback.entry),
|
||||
persisted: false,
|
||||
sessionKey: fallbackKey,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSessionEntryStoreTarget(
|
||||
scope: LogicalSessionAccessScope,
|
||||
): ResolvedSessionEntryStoreTarget {
|
||||
|
||||
@@ -141,6 +141,23 @@ export type MessagesConfig = {
|
||||
responsePrefix?: string;
|
||||
/** Custom `/usage full` footer template, inline or JSON file path. */
|
||||
usageTemplate?: string | Record<string, unknown>;
|
||||
/**
|
||||
* Default per-reply usage footer mode (`responseUsage`) seeded into any session
|
||||
* that has not set its own via `/usage`. Precedence: session value → channel entry
|
||||
* → `default` → `off`. Absent ⇒ `off` (unchanged behavior).
|
||||
*
|
||||
* - string: one default for every channel, e.g. `"full"`.
|
||||
* - object: per-channel with a fallback, e.g. `{ "default": "off", "discord": "full" }`.
|
||||
*/
|
||||
responseUsage?:
|
||||
| "on"
|
||||
| "off"
|
||||
| "tokens"
|
||||
| "full"
|
||||
| {
|
||||
default?: "on" | "off" | "tokens" | "full";
|
||||
[channel: string]: "on" | "off" | "tokens" | "full" | undefined;
|
||||
};
|
||||
groupChat?: GroupChatConfig;
|
||||
queue?: QueueConfig;
|
||||
/** Debounce rapid inbound messages per sender (global + per-channel overrides). */
|
||||
|
||||
@@ -153,12 +153,17 @@ export const SessionSchema = z
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const ResponseUsageModeSchema = z.enum(["on", "off", "tokens", "full"]);
|
||||
|
||||
export const MessagesSchema = z
|
||||
.object({
|
||||
messagePrefix: z.string().optional(),
|
||||
visibleReplies: VisibleRepliesSchema.optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
usageTemplate: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(),
|
||||
responseUsage: z
|
||||
.union([ResponseUsageModeSchema, z.record(z.string(), ResponseUsageModeSchema)])
|
||||
.optional(),
|
||||
groupChat: GroupChatSchema,
|
||||
queue: QueueSchema,
|
||||
inbound: InboundDebounceSchema,
|
||||
|
||||
@@ -92,8 +92,11 @@ describe("docker build cache layout", () => {
|
||||
it("does not leave empty shell continuation lines in sandbox-common", async () => {
|
||||
const dockerfile = await readRepoFile("scripts/docker/sandbox/Dockerfile.common");
|
||||
expect(dockerfile).not.toContain("apt-get install -y --no-install-recommends ${PACKAGES} \\");
|
||||
expect(dockerfile).toContain("ARG INSTALL_NODE=1");
|
||||
expect(dockerfile).toContain("ARG NODE_MAJOR=24");
|
||||
expect(dockerfile).toContain('curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x"');
|
||||
expect(dockerfile).toContain(
|
||||
'RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi',
|
||||
'RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm && pnpm --version; fi',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Gateway config reload tests cover changed-path detection, reload planning,
|
||||
// plugin registry refresh, skill snapshot invalidation, and watcher behavior.
|
||||
import nodePath from "node:path";
|
||||
import chokidar from "chokidar";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
@@ -579,8 +578,8 @@ describe("resolveGatewayReloadSettings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
type WatcherHandler = (value?: string | Error) => void;
|
||||
type WatcherEvent = "add" | "change" | "unlink" | "error" | "ready";
|
||||
type WatcherHandler = () => void;
|
||||
type WatcherEvent = "add" | "change" | "unlink" | "error";
|
||||
|
||||
function createWatcherMock(effectiveUsePolling?: boolean) {
|
||||
const handlers = new Map<WatcherEvent, WatcherHandler[]>();
|
||||
@@ -593,9 +592,9 @@ function createWatcherMock(effectiveUsePolling?: boolean) {
|
||||
handlers.set(event, existing);
|
||||
return this;
|
||||
},
|
||||
emit(event: WatcherEvent, value?: string | Error) {
|
||||
emit(event: WatcherEvent) {
|
||||
for (const handler of handlers.get(event) ?? []) {
|
||||
handler(value);
|
||||
handler();
|
||||
}
|
||||
},
|
||||
close: vi.fn(async () => {}),
|
||||
@@ -660,30 +659,17 @@ function makeZeroDebounceHookWrite(persistedHash: string): ConfigWriteNotificati
|
||||
}
|
||||
|
||||
function createReloaderHarness(
|
||||
readSnapshot: () => Promise<
|
||||
ConfigFileSnapshot | { snapshot: ConfigFileSnapshot; includeFilePaths?: readonly string[] }
|
||||
>,
|
||||
readSnapshot: () => Promise<ConfigFileSnapshot>,
|
||||
options: {
|
||||
initialCompareConfig?: OpenClawConfig;
|
||||
initialInternalWriteHash?: string | null;
|
||||
initialIncludeFilePaths?: readonly string[];
|
||||
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
|
||||
initialPluginInstallRecords?: Record<string, PluginInstallRecord>;
|
||||
readPluginInstallRecords?: () => Promise<Record<string, PluginInstallRecord>>;
|
||||
watchers?: ReturnType<typeof createWatcherMock>[];
|
||||
} = {},
|
||||
) {
|
||||
const watchers = options.watchers ?? [createWatcherMock()];
|
||||
const watcher = watchers[0] ?? createWatcherMock();
|
||||
let watcherIndex = 0;
|
||||
const watchSpy = vi.spyOn(chokidar, "watch").mockImplementation((_path, watchOptions) => {
|
||||
const next = watchers[watcherIndex++];
|
||||
if (!next) {
|
||||
throw new Error("missing watcher mock");
|
||||
}
|
||||
next.options.usePolling = next.effectiveUsePolling ?? Boolean(watchOptions?.usePolling);
|
||||
return next as unknown as never;
|
||||
});
|
||||
const watcher = createWatcherMock();
|
||||
vi.spyOn(chokidar, "watch").mockReturnValue(watcher as unknown as never);
|
||||
const onHotReload = vi.fn(async (_plan: GatewayReloadPlan, _nextConfig: OpenClawConfig) => {});
|
||||
const onRestart = vi.fn((_plan: GatewayReloadPlan, _nextConfig: OpenClawConfig) => {});
|
||||
let writeListener: ((event: ConfigWriteNotification) => void) | null = null;
|
||||
@@ -704,7 +690,6 @@ function createReloaderHarness(
|
||||
initialConfig: { gateway: { reload: { debounceMs: 0 } } },
|
||||
initialCompareConfig: options.initialCompareConfig,
|
||||
initialInternalWriteHash: options.initialInternalWriteHash,
|
||||
initialIncludeFilePaths: options.initialIncludeFilePaths,
|
||||
readSnapshot,
|
||||
promoteSnapshot: options.promoteSnapshot,
|
||||
initialPluginInstallRecords: options.initialPluginInstallRecords ?? {},
|
||||
@@ -717,8 +702,6 @@ function createReloaderHarness(
|
||||
});
|
||||
return {
|
||||
watcher,
|
||||
watchers,
|
||||
watchSpy,
|
||||
onHotReload,
|
||||
onRestart,
|
||||
log,
|
||||
@@ -1016,9 +999,7 @@ describe("startGatewayConfigReloader", () => {
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("does not replay a rejected graph and accepts a later content change", async () => {
|
||||
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
|
||||
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
|
||||
it("does not promote external config edits when hot reload rejects them", async () => {
|
||||
const acceptedSnapshot = makeSnapshot({
|
||||
config: {
|
||||
gateway: { reload: { debounceMs: 0 } },
|
||||
@@ -1026,50 +1007,22 @@ describe("startGatewayConfigReloader", () => {
|
||||
},
|
||||
hash: "external-rejected-1",
|
||||
});
|
||||
const revisedSnapshot = makeSnapshot({
|
||||
config: {
|
||||
gateway: { reload: { debounceMs: 0 } },
|
||||
hooks: { enabled: false },
|
||||
},
|
||||
hash: "external-revised-2",
|
||||
});
|
||||
const readSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ snapshot: acceptedSnapshot, includeFilePaths: [nextInclude] })
|
||||
.mockResolvedValueOnce({ snapshot: acceptedSnapshot, includeFilePaths: [nextInclude] })
|
||||
.mockResolvedValueOnce({ snapshot: revisedSnapshot, includeFilePaths: [nextInclude] });
|
||||
.fn<() => Promise<ConfigFileSnapshot>>()
|
||||
.mockResolvedValueOnce(acceptedSnapshot);
|
||||
const promoteSnapshot = vi.fn(async (_snapshot: ConfigFileSnapshot, _reason: string) => true);
|
||||
const watchers = [createWatcherMock(), createWatcherMock(), createWatcherMock()];
|
||||
const { watcher, onHotReload, log, reloader } = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [oldInclude],
|
||||
promoteSnapshot,
|
||||
watchers,
|
||||
});
|
||||
onHotReload.mockRejectedValueOnce(new Error("reload refused"));
|
||||
|
||||
watcher.emit("change");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(onHotReload).toHaveBeenCalledTimes(1);
|
||||
expect(promoteSnapshot).not.toHaveBeenCalled();
|
||||
expect(log.error).toHaveBeenCalledWith("config reload failed: Error: reload refused");
|
||||
|
||||
watcher.emit("change");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(onHotReload).toHaveBeenCalledTimes(1);
|
||||
expect(promoteSnapshot).not.toHaveBeenCalled();
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
"config reload skipped (previous apply failed; waiting for config change)",
|
||||
);
|
||||
|
||||
watcher.emit("change");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(onHotReload).toHaveBeenCalledTimes(2);
|
||||
expect(promoteSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(promoteSnapshot).toHaveBeenCalledWith(revisedSnapshot, "valid-config");
|
||||
|
||||
await reloader.stop();
|
||||
});
|
||||
|
||||
@@ -1151,461 +1104,6 @@ describe("startGatewayConfigReloader", () => {
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("retains a queued include reconciliation when an in-process hot reload throws", async () => {
|
||||
const includePath = nodePath.normalize("/tmp/includes/active.json5");
|
||||
const acceptedSnapshot = makeZeroDebounceHookSnapshot("internal-reconcile-1");
|
||||
const readSnapshot = vi.fn().mockResolvedValueOnce({
|
||||
snapshot: acceptedSnapshot,
|
||||
includeFilePaths: [includePath],
|
||||
});
|
||||
const watchers = [createWatcherMock(), createWatcherMock()];
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [includePath],
|
||||
watchers,
|
||||
});
|
||||
harness.onHotReload.mockRejectedValueOnce(new Error("reload refused"));
|
||||
|
||||
harness.emitWrite(makeZeroDebounceHookWrite("internal-reconcile-1"));
|
||||
watchers[1]?.emit("ready");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(harness.log.error).toHaveBeenCalledWith("config reload failed: Error: reload refused");
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
|
||||
expect(harness.log.warn).toHaveBeenCalledWith(
|
||||
"config reload skipped (previous apply failed; waiting for config change)",
|
||||
);
|
||||
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("watches nested startup includes and does not apply root hash dedupe to include edits", async () => {
|
||||
const includePaths = [
|
||||
nodePath.normalize("/tmp/includes/outer.json5"),
|
||||
nodePath.normalize("/tmp/includes/nested.json5"),
|
||||
];
|
||||
const readSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeZeroDebounceHookSnapshot("internal-include-1"),
|
||||
includeFilePaths: includePaths,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeSnapshot({
|
||||
sourceConfig: {
|
||||
gateway: { reload: { debounceMs: 0 } },
|
||||
hooks: { enabled: false },
|
||||
},
|
||||
runtimeConfig: {
|
||||
gateway: { reload: { debounceMs: 0 } },
|
||||
hooks: { enabled: false },
|
||||
},
|
||||
config: {
|
||||
gateway: { reload: { debounceMs: 0 } },
|
||||
hooks: { enabled: false },
|
||||
},
|
||||
hash: "internal-include-1",
|
||||
}),
|
||||
includeFilePaths: includePaths,
|
||||
});
|
||||
const watchers = [createWatcherMock(), createWatcherMock(), createWatcherMock()];
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: includePaths,
|
||||
promoteSnapshot: vi.fn(async () => true),
|
||||
watchers,
|
||||
});
|
||||
|
||||
expect(harness.watchSpy.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/tmp/openclaw.json",
|
||||
nodePath.normalize("/tmp/includes/nested.json5"),
|
||||
nodePath.normalize("/tmp/includes/outer.json5"),
|
||||
]);
|
||||
|
||||
harness.emitWrite(makeZeroDebounceHookWrite("internal-include-1"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
|
||||
|
||||
watchers[2]?.emit("change", nodePath.normalize("/tmp/includes/outer.json5"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(2);
|
||||
expect(harness.onHotReload).toHaveBeenCalledTimes(2);
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("clears a stale root write hash when an include-triggered read sees different root bytes", async () => {
|
||||
const includePath = nodePath.normalize("/tmp/includes/active.json5");
|
||||
const readSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeZeroDebounceHookSnapshot("external-root-2"),
|
||||
includeFilePaths: [includePath],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeSnapshot({
|
||||
sourceConfig: { gateway: { reload: { debounceMs: 0 } }, hooks: { enabled: false } },
|
||||
runtimeConfig: { gateway: { reload: { debounceMs: 0 } }, hooks: { enabled: false } },
|
||||
config: { gateway: { reload: { debounceMs: 0 } }, hooks: { enabled: false } },
|
||||
hash: "internal-root-1",
|
||||
}),
|
||||
includeFilePaths: [includePath],
|
||||
});
|
||||
const watchers = [createWatcherMock(), createWatcherMock()];
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [includePath],
|
||||
initialInternalWriteHash: "internal-root-1",
|
||||
watchers,
|
||||
});
|
||||
|
||||
watchers[1]?.emit("change", includePath);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
|
||||
|
||||
watchers[0]?.emit("change", nodePath.normalize("/tmp/openclaw.json"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(2);
|
||||
expect(harness.onHotReload).toHaveBeenCalledTimes(2);
|
||||
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("retries a failed include watcher handoff while the prior set stays active", async () => {
|
||||
const rootPath = nodePath.normalize("/tmp/openclaw.json");
|
||||
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
|
||||
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
|
||||
const nextSnapshot = {
|
||||
snapshot: makeZeroDebounceHookSnapshot("graph-retry-1"),
|
||||
includeFilePaths: [nextInclude],
|
||||
};
|
||||
const readSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(nextSnapshot)
|
||||
.mockResolvedValueOnce(nextSnapshot);
|
||||
const watchers = [
|
||||
createWatcherMock(),
|
||||
createWatcherMock(),
|
||||
createWatcherMock(),
|
||||
createWatcherMock(),
|
||||
];
|
||||
const [rootWatcher, oldWatcher, failedCandidate, retryCandidate] = watchers;
|
||||
if (!rootWatcher || !oldWatcher || !failedCandidate || !retryCandidate) {
|
||||
throw new Error("expected watcher mocks");
|
||||
}
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [oldInclude],
|
||||
watchers,
|
||||
});
|
||||
|
||||
rootWatcher.emit("change", rootPath);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
failedCandidate.emit("error", new Error("ENOSPC"));
|
||||
failedCandidate.emit("ready");
|
||||
|
||||
expect(oldWatcher.close).not.toHaveBeenCalled();
|
||||
expect(rootWatcher.close).not.toHaveBeenCalled();
|
||||
expect(harness.watchSpy).toHaveBeenCalledTimes(3);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(harness.watchSpy).toHaveBeenCalledTimes(4);
|
||||
retryCandidate.emit("ready");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(oldWatcher.close).toHaveBeenCalledTimes(1);
|
||||
expect(rootWatcher.close).not.toHaveBeenCalled();
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(2);
|
||||
expect(harness.log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("retrying replacement (attempt 1/3 in 500ms)"),
|
||||
);
|
||||
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("uses the include watcher's effective polling mode when retries are exhausted", async () => {
|
||||
const originalVitest = process.env.VITEST;
|
||||
const originalChokidarPolling = process.env.CHOKIDAR_USEPOLLING;
|
||||
delete process.env.VITEST;
|
||||
delete process.env.CHOKIDAR_USEPOLLING;
|
||||
let harness: ReloaderHarness | undefined;
|
||||
try {
|
||||
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
|
||||
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
|
||||
const watchers = [
|
||||
createWatcherMock(false),
|
||||
createWatcherMock(false),
|
||||
createWatcherMock(true),
|
||||
createWatcherMock(true),
|
||||
createWatcherMock(true),
|
||||
createWatcherMock(true),
|
||||
];
|
||||
harness = createReloaderHarness(
|
||||
vi.fn().mockResolvedValueOnce({
|
||||
snapshot: makeZeroDebounceHookSnapshot("effective-polling"),
|
||||
includeFilePaths: [nextInclude],
|
||||
}),
|
||||
{ initialIncludeFilePaths: [oldInclude], watchers },
|
||||
);
|
||||
|
||||
watchers[0]?.emit("change", nodePath.normalize("/tmp/openclaw.json"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
for (const [index, delay] of [
|
||||
[2, 500],
|
||||
[3, 2000],
|
||||
[4, 5000],
|
||||
] as const) {
|
||||
watchers[index]?.emit("error", new Error("polling failed"));
|
||||
await vi.advanceTimersByTimeAsync(delay);
|
||||
}
|
||||
watchers[5]?.emit("error", new Error("polling failed"));
|
||||
|
||||
expect(harness.reloader.hotReloadStatus()).toBe("disabled");
|
||||
expect(harness.log.error).toHaveBeenCalledWith(expect.stringContaining("in polling mode"));
|
||||
expect(harness.log.warn).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("degrading to polling mode"),
|
||||
);
|
||||
} finally {
|
||||
if (originalVitest === undefined) {
|
||||
delete process.env.VITEST;
|
||||
} else {
|
||||
process.env.VITEST = originalVitest;
|
||||
}
|
||||
if (originalChokidarPolling === undefined) {
|
||||
delete process.env.CHOKIDAR_USEPOLLING;
|
||||
} else {
|
||||
process.env.CHOKIDAR_USEPOLLING = originalChokidarPolling;
|
||||
}
|
||||
await harness?.reloader.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("reconciles once the initial include watcher set is ready", async () => {
|
||||
const includePath = nodePath.normalize("/tmp/includes/startup.json5");
|
||||
const readSnapshot = vi.fn().mockResolvedValueOnce({
|
||||
snapshot: makeZeroDebounceHookSnapshot("startup-include-ready"),
|
||||
includeFilePaths: [includePath],
|
||||
});
|
||||
const watchers = [createWatcherMock(), createWatcherMock()];
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [includePath],
|
||||
watchers,
|
||||
});
|
||||
|
||||
watchers[1]?.emit("ready");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(1);
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("reconciles a retained initial watcher after a graph change reverts before ready", async () => {
|
||||
const rootPath = nodePath.normalize("/tmp/openclaw.json");
|
||||
const initialInclude = nodePath.normalize("/tmp/includes/initial.json5");
|
||||
const transientInclude = nodePath.normalize("/tmp/includes/transient.json5");
|
||||
const initialSnapshot = {
|
||||
snapshot: makeZeroDebounceHookSnapshot("initial-graph"),
|
||||
includeFilePaths: [initialInclude],
|
||||
};
|
||||
const readSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeZeroDebounceHookSnapshot("transient-graph"),
|
||||
includeFilePaths: [transientInclude],
|
||||
})
|
||||
.mockResolvedValueOnce(initialSnapshot)
|
||||
.mockResolvedValueOnce(initialSnapshot);
|
||||
const watchers = [createWatcherMock(), createWatcherMock(), createWatcherMock()];
|
||||
const [rootWatcher, initialWatcher, transientCandidate] = watchers;
|
||||
if (!rootWatcher || !initialWatcher || !transientCandidate) {
|
||||
throw new Error("expected watcher mocks");
|
||||
}
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [initialInclude],
|
||||
watchers,
|
||||
});
|
||||
|
||||
rootWatcher.emit("change", rootPath);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
rootWatcher.emit("change", rootPath);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(transientCandidate.close).toHaveBeenCalledTimes(1);
|
||||
initialWatcher.emit("ready");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(3);
|
||||
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("invalidates an active include watcher that errors during a newer graph handoff", async () => {
|
||||
const rootPath = nodePath.normalize("/tmp/openclaw.json");
|
||||
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
|
||||
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
|
||||
const nextSnapshot = {
|
||||
snapshot: makeZeroDebounceHookSnapshot("graph-active-error"),
|
||||
includeFilePaths: [nextInclude],
|
||||
};
|
||||
const readSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(nextSnapshot)
|
||||
.mockResolvedValueOnce(nextSnapshot);
|
||||
const watchers = [createWatcherMock(), createWatcherMock(), createWatcherMock()];
|
||||
const [rootWatcher, oldWatcher, candidateWatcher] = watchers;
|
||||
if (!rootWatcher || !oldWatcher || !candidateWatcher) {
|
||||
throw new Error("expected watcher mocks");
|
||||
}
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [oldInclude],
|
||||
watchers,
|
||||
});
|
||||
|
||||
rootWatcher.emit("change", rootPath);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
oldWatcher.emit("error", new Error("active failed"));
|
||||
|
||||
expect(oldWatcher.close).toHaveBeenCalledTimes(1);
|
||||
expect(rootWatcher.close).not.toHaveBeenCalled();
|
||||
|
||||
candidateWatcher.emit("ready");
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(2);
|
||||
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("atomically swaps changed include graphs after ready and reconciles without watcher leaks", async () => {
|
||||
const rootPath = nodePath.normalize("/tmp/openclaw.json");
|
||||
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
|
||||
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
|
||||
const finalInclude = nodePath.normalize("/tmp/includes/final.json5");
|
||||
const firstConfig: OpenClawConfig = {
|
||||
gateway: { reload: { debounceMs: 0 } },
|
||||
hooks: { enabled: true },
|
||||
};
|
||||
const finalConfig: OpenClawConfig = {
|
||||
gateway: { reload: { debounceMs: 0 } },
|
||||
hooks: { enabled: false },
|
||||
};
|
||||
const readSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeSnapshot({
|
||||
sourceConfig: firstConfig,
|
||||
runtimeConfig: firstConfig,
|
||||
config: firstConfig,
|
||||
hash: "graph-1",
|
||||
}),
|
||||
includeFilePaths: [nextInclude],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeSnapshot({
|
||||
sourceConfig: firstConfig,
|
||||
runtimeConfig: firstConfig,
|
||||
config: firstConfig,
|
||||
hash: "graph-1",
|
||||
}),
|
||||
includeFilePaths: [nextInclude],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeSnapshot({
|
||||
sourceConfig: finalConfig,
|
||||
runtimeConfig: finalConfig,
|
||||
config: finalConfig,
|
||||
hash: "graph-2",
|
||||
}),
|
||||
includeFilePaths: [finalInclude],
|
||||
});
|
||||
const watchers = [
|
||||
createWatcherMock(),
|
||||
createWatcherMock(),
|
||||
createWatcherMock(),
|
||||
createWatcherMock(),
|
||||
];
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [oldInclude],
|
||||
watchers,
|
||||
});
|
||||
const [rootWatcher, initialIncludeWatcher, replacementWatcher, pendingFinalWatcher] = watchers;
|
||||
if (!rootWatcher || !initialIncludeWatcher || !replacementWatcher || !pendingFinalWatcher) {
|
||||
throw new Error("expected watcher mocks");
|
||||
}
|
||||
|
||||
rootWatcher.emit("change", rootPath);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(harness.watchSpy.mock.calls[2]?.[0]).toBe(nextInclude);
|
||||
expect(rootWatcher.close).not.toHaveBeenCalled();
|
||||
expect(initialIncludeWatcher.close).not.toHaveBeenCalled();
|
||||
replacementWatcher.emit("change", nextInclude);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(1);
|
||||
|
||||
replacementWatcher.emit("ready");
|
||||
expect(initialIncludeWatcher.close).toHaveBeenCalledTimes(1);
|
||||
expect(rootWatcher.close).not.toHaveBeenCalled();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(2);
|
||||
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
|
||||
|
||||
initialIncludeWatcher.emit("change", oldInclude);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(2);
|
||||
|
||||
rootWatcher.emit("change", rootPath);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(harness.watchSpy.mock.calls[3]?.[0]).toBe(finalInclude);
|
||||
expect(harness.onHotReload).toHaveBeenCalledTimes(2);
|
||||
|
||||
await harness.reloader.stop();
|
||||
expect(rootWatcher.close).toHaveBeenCalledTimes(1);
|
||||
expect(initialIncludeWatcher.close).toHaveBeenCalledTimes(1);
|
||||
expect(replacementWatcher.close).toHaveBeenCalledTimes(1);
|
||||
expect(pendingFinalWatcher.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the last valid include watch set when a candidate snapshot is invalid", async () => {
|
||||
const rootPath = nodePath.normalize("/tmp/openclaw.json");
|
||||
const acceptedInclude = nodePath.normalize("/tmp/includes/accepted.json5");
|
||||
const rejectedInclude = nodePath.normalize("/tmp/includes/rejected.json5");
|
||||
const nextConfig: OpenClawConfig = {
|
||||
gateway: { reload: { debounceMs: 0 } },
|
||||
hooks: { enabled: true },
|
||||
};
|
||||
const readSnapshot = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeSnapshot({
|
||||
valid: false,
|
||||
issues: [{ path: "hooks.enabled", message: "Expected boolean" }],
|
||||
hash: "invalid-graph",
|
||||
}),
|
||||
includeFilePaths: [rejectedInclude],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
snapshot: makeSnapshot({
|
||||
sourceConfig: nextConfig,
|
||||
runtimeConfig: nextConfig,
|
||||
config: nextConfig,
|
||||
hash: "accepted-graph",
|
||||
}),
|
||||
includeFilePaths: [acceptedInclude],
|
||||
});
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [acceptedInclude],
|
||||
watchers: [createWatcherMock(), createWatcherMock()],
|
||||
});
|
||||
|
||||
harness.watcher.emit("change", rootPath);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(harness.watchSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
harness.watchers[1]?.emit("change", acceptedInclude);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
expect(harness.watchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
|
||||
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("honors in-process write intent to skip reload", async () => {
|
||||
const readSnapshot = vi
|
||||
.fn<() => Promise<ConfigFileSnapshot>>()
|
||||
@@ -2035,40 +1533,6 @@ describe("startGatewayConfigReloader", () => {
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("skips in-process promotion when includes change under the same root hash", async () => {
|
||||
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
|
||||
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
|
||||
const changedByInclude: OpenClawConfig = {
|
||||
gateway: { reload: { debounceMs: 0 } },
|
||||
hooks: { enabled: false },
|
||||
};
|
||||
const readSnapshot = vi.fn().mockResolvedValueOnce({
|
||||
snapshot: makeSnapshot({
|
||||
sourceConfig: changedByInclude,
|
||||
runtimeConfig: changedByInclude,
|
||||
config: changedByInclude,
|
||||
hash: "internal-1",
|
||||
}),
|
||||
includeFilePaths: [nextInclude],
|
||||
});
|
||||
const promoteSnapshot = vi.fn(async () => true);
|
||||
const harness = createReloaderHarness(readSnapshot, {
|
||||
initialIncludeFilePaths: [oldInclude],
|
||||
promoteSnapshot,
|
||||
watchers: [createWatcherMock(), createWatcherMock()],
|
||||
});
|
||||
|
||||
harness.emitWrite(makeZeroDebounceHookWrite("internal-1"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
|
||||
expect(readSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(promoteSnapshot).not.toHaveBeenCalled();
|
||||
expect(harness.watchSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
await harness.reloader.stop();
|
||||
});
|
||||
|
||||
it("dedupes the first watcher reread for startup internal writes", async () => {
|
||||
const readSnapshot = vi
|
||||
.fn<() => Promise<ConfigFileSnapshot>>()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Gateway config hot-reload watcher.
|
||||
// Diffs config/plugin install snapshots and dispatches hot reload or restart plans.
|
||||
import nodePath from "node:path";
|
||||
import chokidar from "chokidar";
|
||||
import type { ConfigWriteNotification } from "../config/io.js";
|
||||
import { formatConfigIssueLines } from "../config/issue-format.js";
|
||||
@@ -103,36 +102,6 @@ type GatewayConfigReloader = {
|
||||
|
||||
type PluginInstallRecords = Record<string, PluginInstallRecord>;
|
||||
|
||||
type ConfigReloadSnapshotReadResult =
|
||||
| ConfigFileSnapshot
|
||||
| {
|
||||
snapshot: ConfigFileSnapshot;
|
||||
includeFilePaths?: readonly string[];
|
||||
};
|
||||
|
||||
function unpackConfigReloadSnapshot(result: ConfigReloadSnapshotReadResult): {
|
||||
snapshot: ConfigFileSnapshot;
|
||||
includeFilePaths?: readonly string[];
|
||||
} {
|
||||
return "snapshot" in result ? result : { snapshot: result };
|
||||
}
|
||||
|
||||
function normalizeIncludeWatcherPaths(
|
||||
rootPath: string,
|
||||
includeFilePaths: readonly string[] = [],
|
||||
): string[] {
|
||||
const normalizedRoot = nodePath.normalize(rootPath);
|
||||
const includes = new Set(
|
||||
includeFilePaths.map((includePath) => nodePath.normalize(includePath)).filter(Boolean),
|
||||
);
|
||||
includes.delete(normalizedRoot);
|
||||
return [...includes].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function watcherPathsEqual(left: readonly string[], right: readonly string[]): boolean {
|
||||
return left.length === right.length && left.every((entry, index) => entry === right[index]);
|
||||
}
|
||||
|
||||
function asPluginInstallConfig(records: PluginInstallRecords): OpenClawConfig {
|
||||
return {
|
||||
plugins: {
|
||||
@@ -145,8 +114,7 @@ export function startGatewayConfigReloader(opts: {
|
||||
initialConfig: OpenClawConfig;
|
||||
initialCompareConfig?: OpenClawConfig;
|
||||
initialInternalWriteHash?: string | null;
|
||||
initialIncludeFilePaths?: readonly string[];
|
||||
readSnapshot: () => Promise<ConfigReloadSnapshotReadResult>;
|
||||
readSnapshot: () => Promise<ConfigFileSnapshot>;
|
||||
onHotReload: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => Promise<void>;
|
||||
onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
|
||||
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
|
||||
@@ -167,7 +135,6 @@ export function startGatewayConfigReloader(opts: {
|
||||
let pending = false;
|
||||
let running = false;
|
||||
let stopped = false;
|
||||
let pendingIncludeReload = false;
|
||||
let restartQueued = false;
|
||||
let missingConfigRetries = 0;
|
||||
let pendingInProcessConfig: {
|
||||
@@ -177,7 +144,6 @@ export function startGatewayConfigReloader(opts: {
|
||||
afterWrite?: ConfigWriteNotification["afterWrite"];
|
||||
} | null = null;
|
||||
let lastAppliedWriteHash = opts.initialInternalWriteHash ?? null;
|
||||
let currentApplyRejected = false;
|
||||
let currentPluginInstallRecords =
|
||||
opts.initialPluginInstallRecords ?? loadInstalledPluginIndexInstallRecordsSync();
|
||||
const readPluginInstallRecords =
|
||||
@@ -290,11 +256,7 @@ export function startGatewayConfigReloader(opts: {
|
||||
currentPluginInstallRecords = nextPluginInstallRecords;
|
||||
settings = resolveGatewayReloadSettings(nextConfig);
|
||||
if (changedPaths.length === 0) {
|
||||
if (currentApplyRejected) {
|
||||
opts.log.warn("config reload skipped (previous apply failed; waiting for config change)");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Invalidate cached skills snapshots (persisted in sessions.json) whenever
|
||||
@@ -311,21 +273,18 @@ export function startGatewayConfigReloader(opts: {
|
||||
opts.log.info(`config change detected; evaluating reload (${changedPaths.join(", ")})`);
|
||||
if (followUp.mode === "none") {
|
||||
opts.log.info(`config reload skipped by writer intent (${followUp.reason})`);
|
||||
currentApplyRejected = false;
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
const plan = buildGatewayReloadPlan(changedPaths, {
|
||||
noopPaths: pluginInstallTimestampNoopPaths,
|
||||
forceChangedPaths: pluginInstallWholeRecordPaths,
|
||||
});
|
||||
if (isNoopReloadPlan(plan) && !followUp.requiresRestart) {
|
||||
currentApplyRejected = false;
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
if (settings.mode === "off") {
|
||||
opts.log.info("config reload disabled (gateway.reload.mode=off)");
|
||||
currentApplyRejected = false;
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
if (followUp.requiresRestart) {
|
||||
queueRestart(
|
||||
@@ -336,13 +295,11 @@ export function startGatewayConfigReloader(opts: {
|
||||
},
|
||||
nextConfig,
|
||||
);
|
||||
currentApplyRejected = false;
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
if (settings.mode === "restart") {
|
||||
queueRestart(plan, nextConfig);
|
||||
currentApplyRejected = false;
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
if (plan.restartGateway) {
|
||||
if (settings.mode === "hot") {
|
||||
@@ -351,23 +308,13 @@ export function startGatewayConfigReloader(opts: {
|
||||
", ",
|
||||
)})`,
|
||||
);
|
||||
currentApplyRejected = false;
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
queueRestart(plan, nextConfig);
|
||||
currentApplyRejected = false;
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await opts.onHotReload(plan, nextConfig);
|
||||
currentApplyRejected = false;
|
||||
return true;
|
||||
} catch (err) {
|
||||
currentApplyRejected = true;
|
||||
opts.log.error(`config reload failed: ${String(err)}`);
|
||||
return false;
|
||||
}
|
||||
await opts.onHotReload(plan, nextConfig);
|
||||
};
|
||||
|
||||
const promoteAcceptedSnapshot = async (snapshot: ConfigFileSnapshot, reason: string) => {
|
||||
@@ -381,26 +328,15 @@ export function startGatewayConfigReloader(opts: {
|
||||
}
|
||||
};
|
||||
|
||||
const promoteAcceptedInProcessWrite = async (
|
||||
persistedHash: string,
|
||||
acceptedCompareConfig: OpenClawConfig,
|
||||
) => {
|
||||
const promoteAcceptedInProcessWrite = async (persistedHash: string) => {
|
||||
if (!opts.promoteSnapshot) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const snapshotRead = unpackConfigReloadSnapshot(await opts.readSnapshot());
|
||||
const snapshot = snapshotRead.snapshot;
|
||||
if (
|
||||
snapshot.hash !== persistedHash ||
|
||||
!snapshot.valid ||
|
||||
diffConfigPaths(acceptedCompareConfig, snapshot.sourceConfig).length > 0
|
||||
) {
|
||||
const snapshot = await opts.readSnapshot();
|
||||
if (snapshot.hash !== persistedHash || !snapshot.valid) {
|
||||
return;
|
||||
}
|
||||
if (snapshotRead.includeFilePaths) {
|
||||
replaceWatchedPaths(snapshotRead.includeFilePaths);
|
||||
}
|
||||
await promoteAcceptedSnapshot(snapshot, "in-process-write");
|
||||
} catch (err) {
|
||||
opts.log.warn(`config reload in-process last-known-good promotion failed: ${String(err)}`);
|
||||
@@ -425,31 +361,20 @@ export function startGatewayConfigReloader(opts: {
|
||||
const pendingWrite = pendingInProcessConfig;
|
||||
pendingInProcessConfig = null;
|
||||
missingConfigRetries = 0;
|
||||
const applied = await applySnapshot(
|
||||
await applySnapshot(
|
||||
pendingWrite.config,
|
||||
pendingWrite.compareConfig,
|
||||
pendingWrite.afterWrite,
|
||||
);
|
||||
if (!applied) {
|
||||
if (lastAppliedWriteHash === pendingWrite.persistedHash) {
|
||||
lastAppliedWriteHash = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
await promoteAcceptedInProcessWrite(pendingWrite.persistedHash, pendingWrite.compareConfig);
|
||||
await promoteAcceptedInProcessWrite(pendingWrite.persistedHash);
|
||||
return;
|
||||
}
|
||||
const bypassRootWriteHashDedupe = pendingIncludeReload;
|
||||
pendingIncludeReload = false;
|
||||
const snapshotRead = unpackConfigReloadSnapshot(await opts.readSnapshot());
|
||||
const snapshot = snapshotRead.snapshot;
|
||||
const snapshot = await opts.readSnapshot();
|
||||
if (lastAppliedWriteHash && typeof snapshot.hash === "string") {
|
||||
if (!bypassRootWriteHashDedupe && snapshot.hash === lastAppliedWriteHash) {
|
||||
if (snapshot.hash === lastAppliedWriteHash) {
|
||||
return;
|
||||
}
|
||||
if (snapshot.hash !== lastAppliedWriteHash) {
|
||||
lastAppliedWriteHash = null;
|
||||
}
|
||||
lastAppliedWriteHash = null;
|
||||
}
|
||||
if (handleMissingSnapshot(snapshot)) {
|
||||
return;
|
||||
@@ -458,13 +383,7 @@ export function startGatewayConfigReloader(opts: {
|
||||
handleInvalidSnapshot(snapshot);
|
||||
return;
|
||||
}
|
||||
const applied = await applySnapshot(snapshot.config, snapshot.sourceConfig);
|
||||
if (!applied) {
|
||||
return;
|
||||
}
|
||||
if (snapshotRead.includeFilePaths) {
|
||||
replaceWatchedPaths(snapshotRead.includeFilePaths);
|
||||
}
|
||||
await applySnapshot(snapshot.config, snapshot.sourceConfig);
|
||||
await promoteAcceptedSnapshot(snapshot, "valid-config");
|
||||
} catch (err) {
|
||||
opts.log.error(`config reload failed: ${String(err)}`);
|
||||
@@ -473,20 +392,11 @@ export function startGatewayConfigReloader(opts: {
|
||||
if (pending) {
|
||||
pending = false;
|
||||
schedule();
|
||||
} else if (pendingIncludeReload) {
|
||||
scheduleAfter(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const normalizedRootWatchPath = nodePath.normalize(opts.watchPath);
|
||||
const scheduleFromWatcher = (changedPath?: string) => {
|
||||
if (
|
||||
typeof changedPath === "string" &&
|
||||
nodePath.normalize(changedPath) !== normalizedRootWatchPath
|
||||
) {
|
||||
pendingIncludeReload = true;
|
||||
}
|
||||
const scheduleFromWatcher = () => {
|
||||
schedule();
|
||||
};
|
||||
|
||||
@@ -505,254 +415,35 @@ export function startGatewayConfigReloader(opts: {
|
||||
scheduleAfter(0);
|
||||
}) ?? (() => {});
|
||||
|
||||
type ConfigWatcher = ReturnType<typeof chokidar.watch>;
|
||||
type IncludeWatcherGroup = {
|
||||
paths: string[];
|
||||
watchers: ConfigWatcher[];
|
||||
ready: Set<ConfigWatcher>;
|
||||
usePolling: boolean;
|
||||
};
|
||||
const emptyIncludeGroup = (paths: string[] = []): IncludeWatcherGroup => ({
|
||||
paths,
|
||||
watchers: [],
|
||||
ready: new Set(),
|
||||
usePolling: false,
|
||||
});
|
||||
|
||||
let watcher: ConfigWatcher | null = null;
|
||||
let watcher: ReturnType<typeof chokidar.watch> | null = null;
|
||||
let watcherRecreateRetries = 0;
|
||||
let watcherRecreateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let rootHotReloadDisabled = false;
|
||||
let hotReloadStatus: GatewayHotReloadStatus = "active";
|
||||
let degradedToPolling = false;
|
||||
let watcherUsesPolling = false;
|
||||
|
||||
const initialIncludePaths = normalizeIncludeWatcherPaths(
|
||||
opts.watchPath,
|
||||
opts.initialIncludeFilePaths,
|
||||
);
|
||||
let activeIncludeGroup = emptyIncludeGroup(initialIncludePaths);
|
||||
let pendingIncludeGroup: IncludeWatcherGroup | null = null;
|
||||
let desiredIncludePaths = initialIncludePaths;
|
||||
let includeGeneration = 0;
|
||||
let includeReplacementRetries = 0;
|
||||
let includeReplacementTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let includeHotReloadDisabled = false;
|
||||
let includeDegradedToPolling = false;
|
||||
|
||||
const closeWatcher = (target: ConfigWatcher | null) => {
|
||||
void target?.close().catch(() => {});
|
||||
};
|
||||
|
||||
const closeIncludeGroup = (group: IncludeWatcherGroup | null) => {
|
||||
for (const target of group?.watchers ?? []) {
|
||||
closeWatcher(target);
|
||||
}
|
||||
};
|
||||
|
||||
const createWatcherInstance = (watchPath: string, usePolling: boolean): ConfigWatcher =>
|
||||
chokidar.watch(watchPath, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
|
||||
usePolling,
|
||||
});
|
||||
|
||||
const activateIncludeGroup = (group: IncludeWatcherGroup) => {
|
||||
if (stopped || group !== pendingIncludeGroup) {
|
||||
return;
|
||||
}
|
||||
const previous = activeIncludeGroup;
|
||||
activeIncludeGroup = group;
|
||||
pendingIncludeGroup = null;
|
||||
includeReplacementRetries = 0;
|
||||
includeHotReloadDisabled = false;
|
||||
closeIncludeGroup(previous);
|
||||
|
||||
// Re-read once after the handoff so edits during candidate startup are
|
||||
// reconciled without opening a gap between the old and new exact sets.
|
||||
pendingIncludeReload = true;
|
||||
schedule();
|
||||
};
|
||||
|
||||
const scheduleIncludeReplacementRetry = (
|
||||
generation: number,
|
||||
failedWithPolling: boolean,
|
||||
err: unknown,
|
||||
) => {
|
||||
if (stopped || generation !== includeGeneration) {
|
||||
return;
|
||||
}
|
||||
if (includeReplacementRetries >= WATCHER_RECREATE_MAX_RETRIES) {
|
||||
if (!failedWithPolling && resolveChokidarUsePolling(true)) {
|
||||
includeDegradedToPolling = true;
|
||||
includeReplacementRetries = 0;
|
||||
opts.log.warn(
|
||||
`config include watcher native retries exhausted; degrading to polling mode: ${String(err)}`,
|
||||
);
|
||||
includeReplacementTimer = setTimeout(() => {
|
||||
includeReplacementTimer = null;
|
||||
stageIncludeReplacement(generation);
|
||||
}, WATCHER_RECREATE_BACKOFF_MS[0] ?? 500);
|
||||
return;
|
||||
}
|
||||
const mode = failedWithPolling ? "polling mode" : "native mode";
|
||||
includeHotReloadDisabled = true;
|
||||
opts.log.error(
|
||||
`config include hot-reload disabled: watcher failed after ${WATCHER_RECREATE_MAX_RETRIES} re-create attempts in ${mode}; keeping prior paths: ${String(err)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const backoff =
|
||||
WATCHER_RECREATE_BACKOFF_MS[includeReplacementRetries] ??
|
||||
WATCHER_RECREATE_BACKOFF_MS[WATCHER_RECREATE_BACKOFF_MS.length - 1] ??
|
||||
0;
|
||||
includeReplacementRetries += 1;
|
||||
opts.log.warn(
|
||||
`config include watcher error; retrying replacement (attempt ${includeReplacementRetries}/${WATCHER_RECREATE_MAX_RETRIES} in ${backoff}ms): ${String(err)}`,
|
||||
);
|
||||
includeReplacementTimer = setTimeout(() => {
|
||||
includeReplacementTimer = null;
|
||||
stageIncludeReplacement(generation);
|
||||
}, backoff);
|
||||
};
|
||||
|
||||
const createIncludeGroup = (paths: string[], generation: number): IncludeWatcherGroup => {
|
||||
const usePolling = resolveChokidarUsePolling(includeDegradedToPolling);
|
||||
const group: IncludeWatcherGroup = {
|
||||
paths,
|
||||
watchers: [],
|
||||
ready: new Set(),
|
||||
usePolling: false,
|
||||
};
|
||||
try {
|
||||
for (const includePath of paths) {
|
||||
const next = createWatcherInstance(includePath, usePolling);
|
||||
group.watchers.push(next);
|
||||
group.usePolling ||= Boolean(next.options.usePolling);
|
||||
const scheduleIfActive = (changedPath: string) => {
|
||||
if (group === activeIncludeGroup) {
|
||||
scheduleFromWatcher(changedPath);
|
||||
}
|
||||
};
|
||||
next.on("add", scheduleIfActive);
|
||||
next.on("change", scheduleIfActive);
|
||||
next.on("unlink", scheduleIfActive);
|
||||
next.on("ready", () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
group.ready.add(next);
|
||||
if (group.ready.size !== group.watchers.length) {
|
||||
return;
|
||||
}
|
||||
if (group === pendingIncludeGroup) {
|
||||
if (generation !== includeGeneration) {
|
||||
return;
|
||||
}
|
||||
activateIncludeGroup(group);
|
||||
} else if (group === activeIncludeGroup) {
|
||||
pendingIncludeReload = true;
|
||||
schedule();
|
||||
}
|
||||
});
|
||||
next.on("error", (err) => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
if (group === pendingIncludeGroup) {
|
||||
if (generation !== includeGeneration) {
|
||||
return;
|
||||
}
|
||||
pendingIncludeGroup = null;
|
||||
closeIncludeGroup(group);
|
||||
scheduleIncludeReplacementRetry(generation, group.usePolling, err);
|
||||
return;
|
||||
}
|
||||
if (group === activeIncludeGroup) {
|
||||
activeIncludeGroup = emptyIncludeGroup();
|
||||
closeIncludeGroup(group);
|
||||
if (!pendingIncludeGroup && !includeReplacementTimer) {
|
||||
scheduleIncludeReplacementRetry(includeGeneration, group.usePolling, err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return group;
|
||||
} catch (err) {
|
||||
closeIncludeGroup(group);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
function stageIncludeReplacement(generation: number) {
|
||||
if (
|
||||
stopped ||
|
||||
generation !== includeGeneration ||
|
||||
pendingIncludeGroup ||
|
||||
watcherPathsEqual(desiredIncludePaths, activeIncludeGroup.paths)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (desiredIncludePaths.length === 0) {
|
||||
pendingIncludeGroup = emptyIncludeGroup();
|
||||
activateIncludeGroup(pendingIncludeGroup);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
pendingIncludeGroup = createIncludeGroup([...desiredIncludePaths], generation);
|
||||
} catch (err) {
|
||||
scheduleIncludeReplacementRetry(
|
||||
generation,
|
||||
resolveChokidarUsePolling(includeDegradedToPolling),
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const replaceWatchedPaths = (includeFilePaths: readonly string[]) => {
|
||||
const nextPaths = normalizeIncludeWatcherPaths(opts.watchPath, includeFilePaths);
|
||||
if (watcherPathsEqual(nextPaths, desiredIncludePaths)) {
|
||||
return;
|
||||
}
|
||||
includeGeneration += 1;
|
||||
desiredIncludePaths = nextPaths;
|
||||
includeReplacementRetries = 0;
|
||||
if (includeReplacementTimer) {
|
||||
clearTimeout(includeReplacementTimer);
|
||||
includeReplacementTimer = null;
|
||||
}
|
||||
const stagedGroup = pendingIncludeGroup;
|
||||
pendingIncludeGroup = null;
|
||||
closeIncludeGroup(stagedGroup);
|
||||
if (watcherPathsEqual(nextPaths, activeIncludeGroup.paths)) {
|
||||
includeHotReloadDisabled = false;
|
||||
return;
|
||||
}
|
||||
stageIncludeReplacement(includeGeneration);
|
||||
};
|
||||
|
||||
const createWatcher = () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
const next = createWatcherInstance(
|
||||
opts.watchPath,
|
||||
resolveChokidarUsePolling(degradedToPolling),
|
||||
);
|
||||
const usePolling = resolveChokidarUsePolling(degradedToPolling);
|
||||
const next = chokidar.watch(opts.watchPath, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
|
||||
usePolling,
|
||||
});
|
||||
next.on("add", scheduleFromWatcher);
|
||||
next.on("change", scheduleFromWatcher);
|
||||
next.on("unlink", scheduleFromWatcher);
|
||||
next.on("error", (err) => {
|
||||
handleWatcherError(next, err);
|
||||
});
|
||||
watcher = next;
|
||||
watcherUsesPolling = Boolean(next.options.usePolling);
|
||||
rootHotReloadDisabled = false;
|
||||
const scheduleIfActive = (changedPath: string) => {
|
||||
if (next === watcher) {
|
||||
scheduleFromWatcher(changedPath);
|
||||
}
|
||||
};
|
||||
next.on("add", scheduleIfActive);
|
||||
next.on("change", scheduleIfActive);
|
||||
next.on("unlink", scheduleIfActive);
|
||||
next.on("error", (err) => handleWatcherError(next, err));
|
||||
watcherUsesPolling = next.options.usePolling;
|
||||
hotReloadStatus = "active";
|
||||
};
|
||||
|
||||
const handleWatcherError = (source: ConfigWatcher, err: unknown) => {
|
||||
const handleWatcherError = (source: typeof watcher, err: unknown) => {
|
||||
// Ignore stale errors from a watcher we already replaced or stopped.
|
||||
if (stopped || source !== watcher) {
|
||||
return;
|
||||
@@ -760,7 +451,7 @@ export function startGatewayConfigReloader(opts: {
|
||||
const failedWatcherUsedPolling = watcherUsesPolling;
|
||||
watcher = null;
|
||||
watcherUsesPolling = false;
|
||||
closeWatcher(source);
|
||||
void source?.close().catch(() => {});
|
||||
if (watcherRecreateRetries >= WATCHER_RECREATE_MAX_RETRIES) {
|
||||
// All native (inotify/kqueue) retries exhausted — fall back to polling
|
||||
// mode so config hot-reload survives on hosts where inotify resources
|
||||
@@ -778,7 +469,7 @@ export function startGatewayConfigReloader(opts: {
|
||||
return;
|
||||
}
|
||||
const mode = failedWatcherUsedPolling ? "polling mode" : "native mode";
|
||||
rootHotReloadDisabled = true;
|
||||
hotReloadStatus = "disabled";
|
||||
opts.log.error(
|
||||
`config hot-reload disabled: watcher failed after ${WATCHER_RECREATE_MAX_RETRIES} re-create attempts in ${mode}: ${String(err)}`,
|
||||
);
|
||||
@@ -799,18 +490,6 @@ export function startGatewayConfigReloader(opts: {
|
||||
};
|
||||
|
||||
createWatcher();
|
||||
if (initialIncludePaths.length > 0) {
|
||||
try {
|
||||
activeIncludeGroup = createIncludeGroup(initialIncludePaths, includeGeneration);
|
||||
} catch (err) {
|
||||
activeIncludeGroup = emptyIncludeGroup();
|
||||
scheduleIncludeReplacementRetry(
|
||||
includeGeneration,
|
||||
resolveChokidarUsePolling(includeDegradedToPolling),
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stop: async () => {
|
||||
@@ -823,26 +502,11 @@ export function startGatewayConfigReloader(opts: {
|
||||
clearTimeout(watcherRecreateTimer);
|
||||
watcherRecreateTimer = null;
|
||||
}
|
||||
if (includeReplacementTimer) {
|
||||
clearTimeout(includeReplacementTimer);
|
||||
includeReplacementTimer = null;
|
||||
}
|
||||
unsubscribeFromWrites();
|
||||
const rootWatcher = watcher;
|
||||
const activeIncludes = activeIncludeGroup;
|
||||
const stagedIncludes = pendingIncludeGroup;
|
||||
const active = watcher;
|
||||
watcher = null;
|
||||
activeIncludeGroup = emptyIncludeGroup();
|
||||
pendingIncludeGroup = null;
|
||||
await Promise.all(
|
||||
[
|
||||
...(rootWatcher ? [rootWatcher] : []),
|
||||
...activeIncludes.watchers,
|
||||
...(stagedIncludes?.watchers ?? []),
|
||||
].map(async (target) => await target.close().catch(() => {})),
|
||||
);
|
||||
await active?.close().catch(() => {});
|
||||
},
|
||||
hotReloadStatus: () =>
|
||||
rootHotReloadDisabled || includeHotReloadDisabled ? "disabled" : "active",
|
||||
hotReloadStatus: () => hotReloadStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -475,6 +475,9 @@ export function createAgentEventHandler({
|
||||
contextTokens: row?.contextTokens,
|
||||
estimatedCostUsd: row?.estimatedCostUsd,
|
||||
responseUsage: row?.responseUsage,
|
||||
// Carry the row-built channel-aware effective mode so the chat snapshot
|
||||
// matches the session-event/list projections.
|
||||
effectiveResponseUsage: row?.effectiveResponseUsage,
|
||||
modelProvider: row?.modelProvider,
|
||||
model: row?.model,
|
||||
status: snapshotSource.status,
|
||||
|
||||
@@ -193,9 +193,8 @@ type ManagedGatewayConfigReloaderParams = Omit<
|
||||
initialConfig: OpenClawConfig;
|
||||
initialCompareConfig?: OpenClawConfig;
|
||||
initialInternalWriteHash: string | null;
|
||||
initialIncludeFilePaths?: readonly string[];
|
||||
watchPath: string;
|
||||
readSnapshot: typeof import("../config/config.js").readConfigFileSnapshotWithPluginMetadata;
|
||||
readSnapshot: typeof import("../config/config.js").readConfigFileSnapshot;
|
||||
promoteSnapshot: typeof import("../config/config.js").promoteConfigSnapshotToLastKnownGood;
|
||||
subscribeToWrites: typeof import("../config/config.js").registerConfigWriteListener;
|
||||
logReload: GatewayReloadLog & {
|
||||
@@ -682,7 +681,6 @@ export function startManagedGatewayConfigReloader(params: ManagedGatewayConfigRe
|
||||
initialConfig: params.initialConfig,
|
||||
initialCompareConfig: params.initialCompareConfig,
|
||||
initialInternalWriteHash: params.initialInternalWriteHash,
|
||||
initialIncludeFilePaths: params.initialIncludeFilePaths,
|
||||
readSnapshot: params.readSnapshot,
|
||||
promoteSnapshot: async (snapshot, _reason) => await params.promoteSnapshot(snapshot),
|
||||
subscribeToWrites: params.subscribeToWrites,
|
||||
|
||||
@@ -99,7 +99,6 @@ function secretsPrepareTimelineAttributes(
|
||||
export type GatewayStartupConfigSnapshotLoadResult = {
|
||||
snapshot: ConfigFileSnapshot;
|
||||
wroteConfig: boolean;
|
||||
includeFilePaths?: readonly string[];
|
||||
pluginMetadataSnapshot?: PluginMetadataSnapshot;
|
||||
};
|
||||
|
||||
@@ -144,7 +143,6 @@ export async function loadGatewayStartupConfigSnapshot(params: {
|
||||
return {
|
||||
snapshot: configSnapshot,
|
||||
wroteConfig,
|
||||
...(snapshotRead.includeFilePaths ? { includeFilePaths: snapshotRead.includeFilePaths } : {}),
|
||||
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
|
||||
};
|
||||
}
|
||||
@@ -155,7 +153,6 @@ export async function loadGatewayStartupConfigSnapshot(params: {
|
||||
return {
|
||||
snapshot: withRuntimeConfig(configSnapshot, autoEnable.config),
|
||||
wroteConfig,
|
||||
...(snapshotRead.includeFilePaths ? { includeFilePaths: snapshotRead.includeFilePaths } : {}),
|
||||
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { isRestartEnabled } from "../config/commands.flags.js";
|
||||
import {
|
||||
getRuntimeConfig,
|
||||
promoteConfigSnapshotToLastKnownGood,
|
||||
readConfigFileSnapshotWithPluginMetadata,
|
||||
readConfigFileSnapshot,
|
||||
registerConfigWriteListener,
|
||||
setRuntimeConfigSnapshot,
|
||||
type ReadConfigFileSnapshotWithPluginMetadataResult,
|
||||
@@ -638,7 +638,6 @@ export async function startGatewayServer(
|
||||
let cfgAtStart: OpenClawConfig;
|
||||
let startupInternalWriteHash: string | null = null;
|
||||
let startupLastGoodSnapshot = configSnapshot;
|
||||
let startupIncludeFilePaths = startupConfigLoad.includeFilePaths;
|
||||
const startupActivationSourceConfig = configSnapshot.sourceConfig;
|
||||
const startupRuntimeConfig = applyConfigOverrides(configSnapshot.config);
|
||||
startupTrace.setConfig(startupRuntimeConfig);
|
||||
@@ -695,15 +694,11 @@ export async function startGatewayServer(
|
||||
// Keep the old startup-write suppression path intact for compatibility with
|
||||
// callers that may still report a write, but startup itself no longer mutates config.
|
||||
if (startupConfigLoad.wroteConfig || authBootstrap.persistedGeneratedToken) {
|
||||
const startupSnapshotRead = await startupTrace.measure("config.final-snapshot", () =>
|
||||
readConfigFileSnapshotWithPluginMetadata(),
|
||||
const startupSnapshot = await startupTrace.measure("config.final-snapshot", () =>
|
||||
readConfigFileSnapshot(),
|
||||
);
|
||||
const startupSnapshot = startupSnapshotRead.snapshot;
|
||||
startupInternalWriteHash = startupSnapshot.hash ?? null;
|
||||
startupLastGoodSnapshot = startupSnapshot;
|
||||
if (startupSnapshotRead.includeFilePaths) {
|
||||
startupIncludeFilePaths = startupSnapshotRead.includeFilePaths;
|
||||
}
|
||||
}
|
||||
setRuntimeConfigSnapshot(cfgAtStart, startupLastGoodSnapshot.sourceConfig);
|
||||
const { prepareGatewayPluginBootstrap } = await loadStartupPluginsModule();
|
||||
@@ -1732,9 +1727,8 @@ export async function startGatewayServer(
|
||||
initialConfig: cfgAtStart,
|
||||
initialCompareConfig: startupLastGoodSnapshot.sourceConfig,
|
||||
initialInternalWriteHash: startupInternalWriteHash,
|
||||
initialIncludeFilePaths: startupIncludeFilePaths,
|
||||
watchPath: configSnapshot.path,
|
||||
readSnapshot: readConfigFileSnapshotWithPluginMetadata,
|
||||
readSnapshot: readConfigFileSnapshot,
|
||||
promoteSnapshot: promoteConfigSnapshotToLastKnownGood,
|
||||
subscribeToWrites: registerConfigWriteListener,
|
||||
deps,
|
||||
|
||||
@@ -209,9 +209,9 @@ async function writeMainSessionStore(options?: SessionStoreEntryOptions) {
|
||||
function expectMainPatchBroadcast(
|
||||
result: Awaited<ReturnType<typeof invokeSessionsPatch>>,
|
||||
expected: Record<string, unknown>,
|
||||
) {
|
||||
): Record<string, unknown> {
|
||||
expectFields(result.responsePayload, { ok: true, key: "agent:main:main" });
|
||||
expectChangedBroadcast(result.broadcastToConnIds, {
|
||||
return expectChangedBroadcast(result.broadcastToConnIds, {
|
||||
sessionKey: "agent:main:main",
|
||||
reason: "patch",
|
||||
...expected,
|
||||
@@ -685,7 +685,31 @@ test("sessions.changed mutation events include live session setting metadata", a
|
||||
verboseLevel: "on",
|
||||
});
|
||||
|
||||
expectMainPatchBroadcast(result, sessionSettings);
|
||||
expectMainPatchBroadcast(result, {
|
||||
...sessionSettings,
|
||||
// An explicit session override resolves to the same effective mode and the
|
||||
// sessions.changed builder carries the row-built channel-aware value.
|
||||
effectiveResponseUsage: "full",
|
||||
});
|
||||
});
|
||||
|
||||
test("sessions.changed mutation events carry the resolved effectiveResponseUsage when the session has no override", async () => {
|
||||
// No explicit responseUsage and no configured default → the row builder resolves
|
||||
// effectiveResponseUsage to "off". The event must carry that resolved value, not
|
||||
// the absent raw responseUsage, so a UI consumer's effective display stays fresh.
|
||||
await writeMainSessionStore({ verboseLevel: "on" });
|
||||
|
||||
const result = await invokeSessionsPatch({
|
||||
key: "main",
|
||||
verboseLevel: "on",
|
||||
});
|
||||
|
||||
const payload = expectMainPatchBroadcast(result, {
|
||||
effectiveResponseUsage: "off",
|
||||
});
|
||||
// Raw responseUsage is genuinely absent (no override), proving the event does not
|
||||
// merely echo the raw field.
|
||||
expect(payload.responseUsage).toBeUndefined();
|
||||
});
|
||||
|
||||
test("sessions.changed mutation events include sendPolicy metadata", async () => {
|
||||
|
||||
@@ -654,3 +654,24 @@ test("sessions.reset directly unbinds thread bindings when hooks are unavailable
|
||||
reason: "session-reset",
|
||||
});
|
||||
});
|
||||
|
||||
test("sessions.reset preserves explicit responseUsage preference across session rollover", async () => {
|
||||
// Regression: a full session reset must carry the user's display preference forward
|
||||
// so the usage footer mode survives rollovers. Only /usage reset clears the override.
|
||||
const { dir } = await createSessionStoreDir();
|
||||
await writeSingleLineSession(dir, "sess-main", "hello");
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: sessionStoreEntry("sess-main", { responseUsage: "tokens" }),
|
||||
},
|
||||
});
|
||||
|
||||
const reset = await directSessionReq<{
|
||||
ok: true;
|
||||
key: string;
|
||||
entry: { sessionId: string; responseUsage?: string };
|
||||
}>("sessions.reset", { key: "main" });
|
||||
|
||||
expect(reset.ok).toBe(true);
|
||||
expect(reset.payload?.entry.responseUsage).toBe("tokens");
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ export function buildGatewaySessionEventFields(params: {
|
||||
contextTokens: sessionRow.contextTokens,
|
||||
estimatedCostUsd: sessionRow.estimatedCostUsd,
|
||||
responseUsage: sessionRow.responseUsage,
|
||||
effectiveResponseUsage: sessionRow.effectiveResponseUsage,
|
||||
modelProvider: sessionRow.modelProvider,
|
||||
model: sessionRow.model,
|
||||
status: sessionRow.status,
|
||||
|
||||
@@ -809,6 +809,86 @@ describe("gateway session utils", () => {
|
||||
expect(row.displayName).toBe("Alice");
|
||||
});
|
||||
|
||||
test("buildGatewaySessionRow projects effectiveResponseUsage from a bare config default", () => {
|
||||
const cfg = {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
messages: { responseUsage: "tokens" },
|
||||
} as OpenClawConfig;
|
||||
const entry = { sessionId: "s1", updatedAt: 1 } as SessionEntry;
|
||||
const row = buildGatewaySessionRow({
|
||||
cfg,
|
||||
storePath: "",
|
||||
store: { "agent:main:main": entry },
|
||||
key: "agent:main:main",
|
||||
entry,
|
||||
});
|
||||
// Session has no explicit override → inherits the configured default.
|
||||
expect(row.responseUsage).toBeUndefined();
|
||||
expect(row.effectiveResponseUsage).toBe("tokens");
|
||||
});
|
||||
|
||||
test("buildGatewaySessionRow effectiveResponseUsage respects a per-channel responseUsage map", () => {
|
||||
const cfg = {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
messages: {
|
||||
responseUsage: { default: "off", discord: "full", telegram: "tokens" },
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const discordEntry = { sessionId: "d1", updatedAt: 1, channel: "discord" } as SessionEntry;
|
||||
const discordRow = buildGatewaySessionRow({
|
||||
cfg,
|
||||
storePath: "",
|
||||
store: { "agent:main:discord:direct:1": discordEntry },
|
||||
key: "agent:main:discord:direct:1",
|
||||
entry: discordEntry,
|
||||
});
|
||||
expect(discordRow.effectiveResponseUsage).toBe("full");
|
||||
|
||||
const telegramEntry = { sessionId: "t1", updatedAt: 1, channel: "telegram" } as SessionEntry;
|
||||
const telegramRow = buildGatewaySessionRow({
|
||||
cfg,
|
||||
storePath: "",
|
||||
store: { "agent:main:telegram:direct:1": telegramEntry },
|
||||
key: "agent:main:telegram:direct:1",
|
||||
entry: telegramEntry,
|
||||
});
|
||||
expect(telegramRow.effectiveResponseUsage).toBe("tokens");
|
||||
|
||||
// A channel with no entry falls back to the config "default" (off).
|
||||
const slackEntry = { sessionId: "x1", updatedAt: 1, channel: "slack" } as SessionEntry;
|
||||
const slackRow = buildGatewaySessionRow({
|
||||
cfg,
|
||||
storePath: "",
|
||||
store: { "agent:main:slack:direct:1": slackEntry },
|
||||
key: "agent:main:slack:direct:1",
|
||||
entry: slackEntry,
|
||||
});
|
||||
expect(slackRow.effectiveResponseUsage).toBe("off");
|
||||
});
|
||||
|
||||
test("buildGatewaySessionRow effectiveResponseUsage keeps an explicit session off over a channel default", () => {
|
||||
const cfg = {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
messages: { responseUsage: { default: "full", discord: "full" } },
|
||||
} as OpenClawConfig;
|
||||
const entry = {
|
||||
sessionId: "d1",
|
||||
updatedAt: 1,
|
||||
channel: "discord",
|
||||
responseUsage: "off",
|
||||
} as SessionEntry;
|
||||
const row = buildGatewaySessionRow({
|
||||
cfg,
|
||||
storePath: "",
|
||||
store: { "agent:main:discord:direct:1": entry },
|
||||
key: "agent:main:discord:direct:1",
|
||||
entry,
|
||||
});
|
||||
// Explicit off persists and wins over the per-channel default.
|
||||
expect(row.responseUsage).toBe("off");
|
||||
expect(row.effectiveResponseUsage).toBe("off");
|
||||
});
|
||||
|
||||
test("resolveSessionStoreKey maps main aliases to default agent main", () => {
|
||||
const cfg = {
|
||||
session: { mainKey: "work" },
|
||||
|
||||
@@ -55,7 +55,7 @@ import {
|
||||
RECENT_ENDED_SUBAGENT_CHILD_SESSION_MS,
|
||||
shouldKeepSubagentRunChildLink,
|
||||
} from "../agents/subagent-run-liveness.js";
|
||||
import { listThinkingLevelOptions } from "../auto-reply/thinking.js";
|
||||
import { listThinkingLevelOptions, resolveEffectiveResponseUsage } from "../auto-reply/thinking.js";
|
||||
import { getRuntimeConfig } from "../config/io.js";
|
||||
import { resolveAgentModelFallbackValues } from "../config/model-input.js";
|
||||
import {
|
||||
@@ -2193,6 +2193,11 @@ export function buildGatewaySessionRow(params: {
|
||||
parentSessionKey: subagentOwner || entry?.parentSessionKey,
|
||||
childSessions,
|
||||
responseUsage: entry?.responseUsage,
|
||||
effectiveResponseUsage: resolveEffectiveResponseUsage(
|
||||
entry?.responseUsage,
|
||||
cfg.messages?.responseUsage,
|
||||
channel,
|
||||
),
|
||||
modelProvider: rowModelProvider,
|
||||
model: rowModel,
|
||||
agentRuntime,
|
||||
|
||||
@@ -92,6 +92,8 @@ export type GatewaySessionRow = {
|
||||
parentSessionKey?: string;
|
||||
childSessions?: string[];
|
||||
responseUsage?: "on" | "off" | "tokens" | "full";
|
||||
/** Resolved effective usage mode (session override → channel config → default → off). Populated by surfaces that have config access; absent from the raw session store row. */
|
||||
effectiveResponseUsage?: "on" | "off" | "tokens" | "full";
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
agentRuntime?: GatewayAgentRuntime;
|
||||
|
||||
@@ -238,6 +238,30 @@ describe("gateway sessions patch", () => {
|
||||
expect(entry.thinkingLevel).toBeUndefined();
|
||||
});
|
||||
|
||||
test("persists responseUsage=off (does not clear)", async () => {
|
||||
const entry = expectPatchOk(
|
||||
await runPatch({
|
||||
patch: { key: MAIN_SESSION_KEY, responseUsage: "off" },
|
||||
}),
|
||||
);
|
||||
// Explicit off must persist so a configured messages.responseUsage default
|
||||
// cannot re-enable the footer the user turned off.
|
||||
expect(entry.responseUsage).toBe("off");
|
||||
});
|
||||
|
||||
test("clears responseUsage when patch sets null", async () => {
|
||||
const store: Record<string, SessionEntry> = {
|
||||
[MAIN_SESSION_KEY]: { responseUsage: "tokens" } as SessionEntry,
|
||||
};
|
||||
const entry = expectPatchOk(
|
||||
await runPatch({
|
||||
store,
|
||||
patch: { key: MAIN_SESSION_KEY, responseUsage: null },
|
||||
}),
|
||||
);
|
||||
expect(entry.responseUsage).toBeUndefined();
|
||||
});
|
||||
|
||||
test("persists reasoningLevel=off (does not clear)", async () => {
|
||||
const entry = expectPatchOk(
|
||||
await runPatch({
|
||||
|
||||
@@ -444,11 +444,7 @@ export async function projectSessionsPatchEntry(params: {
|
||||
if (!normalized) {
|
||||
return invalid('invalid responseUsage (use "off"|"tokens"|"full")');
|
||||
}
|
||||
if (normalized === "off") {
|
||||
delete next.responseUsage;
|
||||
} else {
|
||||
next.responseUsage = normalized;
|
||||
}
|
||||
next.responseUsage = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
60
src/mcp/plugin-tools-mcp-client.test.ts
Normal file
60
src/mcp/plugin-tools-mcp-client.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AnyAgentTool } from "../agents/tools/common.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { createPluginToolsMcpServer } from "./plugin-tools-serve.js";
|
||||
|
||||
describe("plugin tools MCP client bridge", () => {
|
||||
it("lists and calls a plugin tool through a real MCP client", async () => {
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
content: [{ type: "text", text: "MCP fact: the codename is ORBIT-9." }],
|
||||
});
|
||||
const tool = {
|
||||
name: "memory_search",
|
||||
description: "Search memory",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string" },
|
||||
maxResults: { type: "number" },
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
execute,
|
||||
} as unknown as AnyAgentTool;
|
||||
|
||||
const server = createPluginToolsMcpServer({
|
||||
config: { plugins: { enabled: true } } as OpenClawConfig,
|
||||
tools: [tool],
|
||||
});
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const client = new Client(
|
||||
{ name: "plugin-tools-test-client", version: "0.0.0" },
|
||||
{ capabilities: {} },
|
||||
);
|
||||
|
||||
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
||||
|
||||
try {
|
||||
const listed = await client.listTools();
|
||||
expect(listed.tools.map((listedTool) => listedTool.name)).toContain("memory_search");
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "memory_search",
|
||||
arguments: { query: "ORBIT-9 codename", maxResults: 3 },
|
||||
});
|
||||
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^mcp-\d+$/),
|
||||
{ query: "ORBIT-9 codename", maxResults: 3 },
|
||||
expect.any(AbortSignal),
|
||||
undefined,
|
||||
);
|
||||
expect(JSON.stringify(result.content)).toContain("ORBIT-9");
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -274,6 +274,36 @@ describe("loadPluginMetadataSnapshot process memo", () => {
|
||||
expect(second.byPluginId.get("demo")).toBe(second.plugins[0]);
|
||||
});
|
||||
|
||||
it("does not emit metadata scan spans for hot memo hits", () => {
|
||||
const stateDir = tempStateDir();
|
||||
const timelinePath = path.join(stateDir, "timeline", "metadata.jsonl");
|
||||
const env = {
|
||||
OPENCLAW_DIAGNOSTICS: "timeline",
|
||||
OPENCLAW_DIAGNOSTICS_TIMELINE_PATH: timelinePath,
|
||||
};
|
||||
touchPersistedIndex(stateDir);
|
||||
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
|
||||
source: "persisted",
|
||||
snapshot: makeIndex(),
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
loadPluginMetadataSnapshot({ config: {}, env, stateDir });
|
||||
loadPluginMetadataSnapshot({ config: {}, env, stateDir });
|
||||
loadPluginMetadataSnapshot({ config: {}, env, stateDir });
|
||||
|
||||
const events = fs
|
||||
.readFileSync(timelinePath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as { name?: unknown; type?: unknown });
|
||||
expect(events.map((event) => [event.type, event.name])).toEqual([
|
||||
["span.start", "plugins.metadata.scan"],
|
||||
["span.end", "plugins.metadata.scan"],
|
||||
]);
|
||||
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("skips persisted registry filesystem fingerprints after a process memo hit", () => {
|
||||
const stateDir = tempStateDir();
|
||||
touchPersistedIndex(stateDir);
|
||||
|
||||
@@ -578,16 +578,7 @@ export function loadPluginMetadataSnapshot(
|
||||
const memoKey = computePluginMetadataSnapshotMemoKey({ params, registryState });
|
||||
const memo = findPluginMetadataSnapshotMemo(memoKey);
|
||||
if (memo?.key === memoKey) {
|
||||
return measureDiagnosticsTimelineSpanSync("plugins.metadata.scan", () => memo.snapshot, {
|
||||
phase: activeTimelineSpan?.phase ?? "startup",
|
||||
config: params.config,
|
||||
env: params.env,
|
||||
attributes: {
|
||||
cacheHit: true,
|
||||
hasWorkspaceDir: params.workspaceDir !== undefined,
|
||||
hasInstalledIndex: params.index !== undefined,
|
||||
},
|
||||
});
|
||||
return memo.snapshot;
|
||||
}
|
||||
|
||||
const result = measureDiagnosticsTimelineSpanSync(
|
||||
|
||||
@@ -21,6 +21,7 @@ const metadataSnapshot = {
|
||||
workspaceDir: "/resolved-workspace",
|
||||
};
|
||||
const loadPluginMetadataSnapshotMock = vi.fn(() => metadataSnapshot);
|
||||
const isPluginMetadataSnapshotCompatibleMock = vi.fn(() => true);
|
||||
const getCurrentPluginMetadataSnapshotMock = vi.fn(() => undefined);
|
||||
const setCurrentPluginMetadataSnapshotMock = vi.fn();
|
||||
const clearCurrentPluginMetadataSnapshotMock = vi.fn();
|
||||
@@ -45,6 +46,7 @@ vi.mock("../../agents/agent-scope.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../plugin-metadata-snapshot.js", () => ({
|
||||
isPluginMetadataSnapshotCompatible: isPluginMetadataSnapshotCompatibleMock,
|
||||
loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock,
|
||||
resolvePluginMetadataSnapshot: loadPluginMetadataSnapshotMock,
|
||||
}));
|
||||
@@ -69,6 +71,8 @@ describe("resolvePluginRuntimeLoadContext", () => {
|
||||
applyPluginAutoEnableMock.mockReset();
|
||||
getCurrentPluginMetadataSnapshotMock.mockReset();
|
||||
getCurrentPluginMetadataSnapshotMock.mockReturnValue(undefined);
|
||||
isPluginMetadataSnapshotCompatibleMock.mockReset();
|
||||
isPluginMetadataSnapshotCompatibleMock.mockReturnValue(true);
|
||||
loadPluginMetadataSnapshotMock.mockClear();
|
||||
getCurrentPluginMetadataSnapshotMock.mockClear();
|
||||
setCurrentPluginMetadataSnapshotMock.mockClear();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user