Compare commits

..

21 Commits

Author SHA1 Message Date
Dallin Romney
f29dbd3ebd test(qa): speed up smoke profile (#96340) 2026-06-24 09:30:59 -07:00
xingzhou
3217165be7 fix(telegram): preserve inline buttons for empty capabilities (#96468)
Merged via squash.

Prepared head SHA: 5e55b5dd30
Co-authored-by: zhangguiping-xydt <275915537+zhangguiping-xydt@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-25 00:09:45 +08:00
Vincent Koc
dbe2802cdc fix(sdk): refresh API baseline hash 2026-06-25 00:04:34 +08:00
ly-wang19
5f25651fd9 fix(ui): roll usage-metrics formatTokens over to "M" at the 999,950 boundary (#96450)
Merged via squash.

Prepared head SHA: fe9881afe7
Co-authored-by: ly-wang19 <94427531+ly-wang19@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-25 00:03:16 +08:00
joshavant
d7c69da6a6 docs(ios): document live activity review flow 2026-06-24 11:00:04 -05:00
joshavant
e77994ed5a fix(ios): clarify camera purpose string 2026-06-24 11:00:04 -05:00
ly-wang19
db3307b02a fix(canvas): stop self-closing embed from starting a greedy block match (#96449)
Merged via squash.

Prepared head SHA: 7253bb298e
Co-authored-by: ly-wang19 <94427531+ly-wang19@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 23:46:20 +08:00
linhongkuan
6b1755aa2b fix(media-core): accept unpadded inline base64 images (#96437)
Merged via squash.

Prepared head SHA: dc4693b7bf
Co-authored-by: lin-hongkuan <234943746+lin-hongkuan@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 23:44:05 +08:00
Yufeng He
fa2379dbc8 fix(telegram): clip progress text on code-point boundaries to avoid lone surrogates (#96456)
Merged via squash.

Prepared head SHA: 765d6c08ac
Co-authored-by: he-yufeng <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 23:27:59 +08:00
Galin Iliev
ce6d97d580 fix(plugins): suppress metadata cache hit scan spans (#86796)
Merged via squash.

Prepared head SHA: a4907bf285
Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 23:09:12 +08:00
Alix-007
d1c2934d0d fix(ollama): bound model-discovery JSON response reads (#96027)
* fix(ollama): bound model-discovery JSON response reads

The /api/tags and /api/show discovery reads in extensions/ollama/src/provider-models.ts
parsed their HTTP responses with an unbounded await response.json(). Ollama base URLs
are user-supplied and can point at remote/cloud endpoints, so a hostile or buggy server
(or one reachable via SSRF) could stream an unbounded or never-ending JSON body and drive
model discovery into OOM.

Route both reads through the shared @openclaw/media-core byte-bounded reader
(readResponseWithLimit, re-exported via openclaw/plugin-sdk/response-limit-runtime) under
a single 16 MiB cap before JSON.parse, cancelling the stream on overflow. Overflow throws a
bounded error that the existing fail-soft handlers swallow, so a capped endpoint degrades
gracefully: /api/tags returns { reachable: false, models: [] } and /api/show returns {}.

Symmetric counterpart to the #95103/#95108 response-limit campaign.

AI-assisted.

* fix(ollama): reuse shared bounded JSON reader for model discovery

Replace the local readOllamaDiscoveryJson helper with the shared
readProviderJsonResponse (from openclaw/plugin-sdk/provider-http), which
already enforces the 16 MiB cap, cancels the stream on overflow, and wraps
malformed JSON with the caller label. The /api/tags and /api/show discovery
reads now go through it directly while keeping the existing fail-soft
handlers ({ reachable: false, models: [] } and {}).

Add a focused regression test: when a discovery stream exceeds the JSON byte
cap, fetchOllamaModels returns { reachable: false, models: [] },
queryOllamaModelShowInfo returns {}, and the bounded reader cancels the body
mid-flight so less than the full advertised stream is read.
2026-06-24 10:58:13 -04:00
Alix-007
605aede38c fix(exa): bound untrusted search JSON response reads (#96038)
Exa search success responses were read via an unbounded `await
response.json()`, so a misbehaving or hostile endpoint could stream an
arbitrarily large body into memory before parsing. Read the success
body through the shared bounded reader (16 MiB cap, the same limit other
bundled providers use) and cancel the stream on overflow. This mirrors
the error-body bound already in place and the #95103/#95108 response
-limit campaign on the success-JSON side.

AI-assisted.
2026-06-24 10:57:37 -04:00
Alix-007
6163b1977b fix(parallel): bound successful web-search JSON response reads (#96035)
* fix(parallel): bound successful web-search JSON response reads

The Parallel web_search provider parsed its /v1/search success body with an
unbounded await res.json(). The body comes from an external web-search
upstream, so a hostile or malfunctioning endpoint streaming an unbounded JSON
payload could force the runtime to buffer the whole response before parsing,
creating memory pressure or a hang on the provider path.

Read the success body through the shared readProviderJsonResponse helper with a
16 MiB cap (matching the provider JSON cap from #95218); on overflow the stream
is cancelled and a bounded error is thrown. The error-body path was already
bounded (readResponseTextLimited, 8 KiB). Symmetric follow-up to the
#95103/#95108 response-limit campaign.

* docs(parallel): drop upstream PR ref from response-cap comment

Replace the PR-specific '#95218' annotation with a neutral description of
the shared provider JSON cap so the comment stays accurate independent of
upstream PR numbering.
2026-06-24 10:57:24 -04:00
Vincent Koc
eabc12b7d6 fix(sandbox): install supported node in common image 2026-06-24 22:54:00 +08:00
Josh Lehman
b58e6e0734 refactor: route session status through session accessors (#96460) 2026-06-24 07:44:19 -07:00
Vincent Koc
d83cd282c6 fix(qa): record checked-out ref in evidence (#96434)
Merged via squash.

Prepared head SHA: 86b3df6e59
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 22:37:41 +08:00
狼哥
374076b5a8 fix(plugins): retain plugin tool registry after replacement (#82562)
Merged via squash.

Prepared head SHA: 1bcbbbfbc1
Co-authored-by: luoyanglang <238804951+luoyanglang@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 22:22:29 +08:00
杨浩宇0668001029
242fbf1a67 test(telegram): pass outbound sanitizer payload 2026-06-24 07:13:32 -07:00
杨浩宇0668001029
434d752dd6 fix(telegram): sanitize outbound tool traces 2026-06-24 07:13:32 -07:00
Ayaan Zaidi
3179692f0e fix(messages): apply response usage to followups 2026-06-24 07:12:33 -07:00
Peter Lindsey
6add1cc969 feat(messages): config-level default for the persistent /usage footer
Adds `messages.responseUsage` (precedence session -> channel -> config default
-> off) so the persistent /usage footer can default-on, with three distinct
states: explicit on (tokens/full), explicit off (persisted), and unset (inherit
the configured default).

Unifies effective-value resolution behind a single channel-aware resolver
`resolveEffectiveResponseUsage` used by reply rendering, the no-arg /usage
toggle, the ACP control, and the gateway session-row builder; the row builder's
`effectiveResponseUsage` is carried through sessions.changed events, chat
snapshots, and the UI row so live consumers never go stale. `/usage reset`
(aliases inherit/clear/default) clears the override to inherit; only explicit
off persists; a full session reset preserves the preference. ACP "Usage detail"
gains an "inherit" option for unset sessions. Docs/help/completions updated; "on"
documented as a legacy alias; config-doc baseline regenerated.
2026-06-24 07:12:33 -07:00
115 changed files with 2660 additions and 1487 deletions

View File

@@ -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'

View File

@@ -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.

View File

@@ -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>

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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:

View File

@@ -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.

View File

@@ -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">

View File

@@ -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>`

View File

@@ -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 });
}

View File

@@ -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,

View File

@@ -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);
});
});

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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.

View File

@@ -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",

View File

@@ -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: [

View File

@@ -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({

View File

@@ -1863,6 +1863,7 @@ export async function runDiscordQaLive(params: {
generatedAt: finishedAt,
primaryModel,
providerMode,
repoRoot,
transportId: "discord",
});
await fs.writeFile(

View File

@@ -2037,6 +2037,7 @@ export async function runSlackQaLive(params: {
generatedAt: finishedAt,
primaryModel,
providerMode,
repoRoot,
transportId: "slack",
});
await fs.writeFile(

View File

@@ -2188,6 +2188,7 @@ export async function runTelegramQaLive(params: {
generatedAt: finishedAt,
primaryModel,
providerMode,
repoRoot,
checks: scenarioResults,
transportId: "telegram",
});

View File

@@ -3282,6 +3282,7 @@ export async function runWhatsAppQaLive(params: {
generatedAt: finishedAt,
primaryModel,
providerMode,
repoRoot,
transportId: "whatsapp",
});
await fs.writeFile(

View File

@@ -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: {

View File

@@ -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)

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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({

View File

@@ -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 };
}

View File

@@ -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(
{

View File

@@ -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) {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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",
);

View File

@@ -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,

View File

@@ -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>";

View 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);
});
});

View 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()}`;
}

View File

@@ -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)'),

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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; \

View File

@@ -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,

View File

@@ -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}" \

View File

@@ -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)." },

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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();

View File

@@ -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 },

View File

@@ -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) => {

View 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);
}

View File

@@ -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}`,
},
});

View File

@@ -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) {

View File

@@ -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({

View File

@@ -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.");
});
});

View File

@@ -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 });
}

View File

@@ -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";

View File

@@ -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,
];
}
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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
});
});

View File

@@ -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,

View 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");
});
});

View File

@@ -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;

View File

@@ -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 }
: {}),

View File

@@ -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");

View File

@@ -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":

View File

@@ -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",

View File

@@ -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";

View File

@@ -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 },

View File

@@ -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 {

View File

@@ -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). */

View File

@@ -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,

View File

@@ -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',
);
});

View File

@@ -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>>()

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 } : {}),
};
}

View File

@@ -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,

View File

@@ -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 () => {

View File

@@ -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");
});

View File

@@ -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,

View File

@@ -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" },

View File

@@ -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,

View File

@@ -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;

View File

@@ -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({

View File

@@ -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;
}
}

View 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();
}
});
});

View File

@@ -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);

View File

@@ -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(

View File

@@ -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