refactor(config): retire redundant settings (#113174)

* refactor(config): retire redundant settings

* style: apply current formatter

* chore: update plugin sdk baseline

* fix: keep Codex tool caps context-aware

* chore: remove stale imports

* test: align WhatsApp QA debounce config

* fix(config): clean up retired config checks

* fix(ci): align config cleanup checks
This commit is contained in:
Peter Steinberger
2026-07-23 17:45:02 -07:00
committed by GitHub
parent 1603781bb0
commit bb657eec93
99 changed files with 796 additions and 1445 deletions
@@ -1,5 +1,5 @@
{
"startupJsGzipBytes": 323435,
"reason": "catalog session activity indicator",
"startupJsGzipBytes": 318587,
"reason": "move presentation preferences to browser-local state",
"updatedAt": "2026-07-23"
}
+2 -2
View File
@@ -1,5 +1,5 @@
{
"core": 2308,
"channel": 3632,
"core": 2299,
"channel": 3630,
"plugin": 3556
}
+3 -3
View File
@@ -1,4 +1,4 @@
f3efbf17f96d7bc331bf9655625c9e41387707d6e12ac1e000a5c6518581f826 config-baseline.json
686273ffd3d3d8034bf68d0cc9acee9e1ec83191e68b40b0c8f58f72521d4f97 config-baseline.core.json
8f64ad7f21d2d1aa18a956243f1a67c86f6cfa3fe2cddbf3ebfafb263ea02092 config-baseline.channel.json
54416b69e0b764d3505677b143f1698cc2454fce8e9f69dd1d59867e3747598e config-baseline.json
e57bb6bda49f3e895633269dae7ea7e1ae31a051c20eeb8db38f6b5694996ffe config-baseline.core.json
af6ca0e70007113462270d46fa14ef0551e577e2fa157d2b9f6d0a93d36f96f1 config-baseline.channel.json
28460228b14a94a2b93040ab3f43b214bdc6d4fda4139a75b21e70fc5983dd56 config-baseline.plugin.json
+2 -1
View File
@@ -667,7 +667,8 @@ Primary reference: [Configuration reference - WhatsApp](/gateway/config-channels
| Access | `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups` |
| Delivery | `textChunkLimit`, `streaming.chunkMode`, `mediaMaxMb`, `sendReadReceipts`, `ackReaction`, `reactionLevel` |
| Multi-account | `accounts.<id>.enabled`, `accounts.<id>.authDir`, and other per-account overrides |
| Operations | `configWrites`, `debounceMs`, `enabled` |
| Operations | `configWrites`, `enabled` |
| Inbound batching | `messages.inbound.debounceMs`, `messages.inbound.byChannel.whatsapp` |
| Session behavior | `session.dmScope`, `historyLimit`, `dmHistoryLimit`, `dms.<id>.historyLimit` |
| Prompts | `groups.<id>.systemPrompt`, `groups["*"].systemPrompt`, `direct.<id>.systemPrompt`, `direct["*"].systemPrompt` |
+1 -2
View File
@@ -50,7 +50,6 @@ Override the policy for one agent:
entries: {
support: {
typingMode: "message",
typingIntervalSeconds: 8,
},
},
},
@@ -63,7 +62,7 @@ Override the policy for one agent:
- `thinking` still reacts to streamed reasoning (`reasoningLevel: "stream"`), and can also start from active execution before reasoning deltas arrive.
- Heartbeat typing is a liveness signal for the resolved delivery target. It starts at heartbeat run start instead of following `message` or `thinking` stream timing. Set `typingMode: "never"` to disable it.
- Heartbeats do not show typing when the heartbeat target is `"none"`, when the target cannot be resolved, when chat delivery is disabled for the heartbeat, or when the channel does not support typing.
- `agents.defaults.typingIntervalSeconds` controls the **refresh cadence**, not the start time. Default: 6 seconds. `agents.entries.*.typingIntervalSeconds` can override it per agent.
- `agents.defaults.typingIntervalSeconds` controls the **refresh cadence** for every agent, not the start time. Default: 6 seconds.
## Related
+5 -12
View File
@@ -227,7 +227,6 @@ Shared defaults for bounded runtime context surfaces.
defaults: {
contextLimits: {
memoryGetMaxChars: 12000,
memoryGetDefaultLines: 120,
postCompactionMaxChars: 1800,
},
},
@@ -237,15 +236,10 @@ Shared defaults for bounded runtime context surfaces.
- `memoryGetMaxChars`: default `memory_get` excerpt cap before truncation
metadata and continuation notice are added.
- `memoryGetDefaultLines`: default `memory_get` line window when `lines` is
omitted.
- `toolResultMaxChars`: advanced live tool-result ceiling used for persisted
results and overflow recovery. Leave unset for the model-context auto cap:
`16000` chars below 100K tokens, `32000` chars at 100K+ tokens, and `64000`
chars at 200K+ tokens. Explicit values up to `1000000` are accepted for
long-context models, but the effective cap is still limited to about 30% of
the model context window. `openclaw doctor --deep` prints the effective cap,
and doctor warns only when an explicit override is stale or has no effect.
- When `memory_get` omits `lines`, OpenClaw uses a built-in 120-line window and
then applies `memoryGetMaxChars`.
- Live tool results use a model-context auto cap: `16000` chars below 100K
tokens, `32000` chars at 100K+ tokens, and `64000` chars at 200K+ tokens.
- `postCompactionMaxChars`: AGENTS.md excerpt cap used during post-compaction
refresh injection.
@@ -265,7 +259,6 @@ from `agents.defaults.contextLimits`.
id: "tiny-local",
contextLimits: {
memoryGetMaxChars: 6000,
toolResultMaxChars: 8000, // advanced ceiling for this agent
},
},
],
@@ -712,7 +705,7 @@ See [Streaming](/concepts/streaming) for behavior + chunking details.
- Defaults: `instant` for direct chats/mentions, `message` for unmentioned group chats.
- `typingIntervalSeconds` default: `6`.
- Per-agent overrides: `agents.entries.*.typingMode` and `agents.entries.*.typingIntervalSeconds`.
- Per-agent override: `agents.entries.*.typingMode`.
See [Typing Indicators](/concepts/typing-indicators).
+3 -4
View File
@@ -510,7 +510,6 @@ See [Plugins](/tools/plugin).
prefs: {
theme: "claw", // claw | knot | dash | custom
themeMode: "system", // light | dark | system
textScale: 100, // 90 | 100 | 110 | 125 | 140
locale: "en",
chatShowThinking: true,
chatShowToolCalls: true,
@@ -525,7 +524,7 @@ See [Plugins](/tools/plugin).
- `seamColor`: accent color for native app UI chrome (Talk Mode bubble tint, etc.).
- `assistant`: Control UI identity override. Falls back to active agent identity.
- `prefs`: operator display preferences. This is the canonical home so agents can
- `prefs`: cross-device operator preferences. This is the canonical home so agents can
change them through the approval gate and every Control UI client stays in
sync; browsers mirror the values into local storage for instant boot and keep
a device-local copy when they cannot write config (viewer scope, offline).
@@ -535,6 +534,8 @@ See [Plugins](/tools/plugin).
delivery remains separate and unchanged.
`showAdvancedSettings` defaults to `false`; Settings search may temporarily
open one matching advanced group without changing this preference.
Presentation-only preferences such as text scale, chat width, and live
sidebar activity stay browser-local and are configured in Settings.
Connected clients apply server-side changes live: the gateway broadcasts a
hash-only `config.changed` event after every persisted config write and
clients refresh their snapshot (skipped while a local settings draft has
@@ -574,7 +575,6 @@ See [Plugins](/tools/plugin).
// toolTitles: false, // opt-in AI purpose titles for tool calls (spends utility-model tokens)
// embedSandbox: "scripts", // strict | scripts | trusted
// allowExternalEmbedUrls: false, // dangerous: allow absolute external http(s) embed URLs
// chatMessageMaxWidth: "min(1280px, 82%)", // optional centered chat transcript max-width
// allowedOrigins: ["https://control.example.com"], // required for non-loopback Control UI
// dangerouslyAllowHostHeaderOriginFallback: false, // dangerous Host-header origin fallback mode
},
@@ -656,7 +656,6 @@ See [Plugins](/tools/plugin).
Default `false`.
- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required for public non-loopback browser origins. Private same-origin LAN/Tailnet UI loads from loopback, RFC1918/link-local, `.local`, `.ts.net`, or Tailscale CGNAT hosts are accepted without enabling Host-header fallback.
- `controlUi.toolTitles`: opt in to AI-generated purpose titles for tool calls in Control UI chat. Default: `false` (tool rendering stays fully deterministic with no background model calls). When enabled, the `chat.toolTitles` method labels complex calls through standard utility-model routing — the agent's `utilityModel` (an operator decision that may send bounded tool arguments to the chosen provider, like every utility task), or the session provider's declared small-model default (OpenAI → `gpt-5.6-luna`, Anthropic → `claude-haiku-4-5`) — and caches results in the per-agent state database so repeat views never re-bill. `utilityModel: \"\"` disables titles like every other utility task; titles never fall back to the primary model.
- `controlUi.chatMessageMaxWidth`: optional max-width for the centered Control UI chat transcript. Accepts constrained CSS width values such as `960px`, `82%`, `min(1280px, 82%)`, and `calc(100% - 2rem)`.
- `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy.
- `terminal.enabled`: opt in to the admin-scoped operator terminal. Default: `false`. The terminal starts a host PTY in the selected agent workspace, inherits the Gateway process environment, and is refused for agents with `sandbox.mode: "all"`. Enable it only for trusted operator deployments; changing it restarts the Gateway and updates the Control UI content security policy.
- `terminal.shell`: optional shell executable. When unset, OpenClaw uses `$SHELL` on Unix and `%ComSpec%` on Windows.
+1 -1
View File
@@ -312,7 +312,7 @@ That stages grounded durable candidates into the short-term dreaming store while
| `channels.whatsapp.messagePrefix` and legacy `messages.messagePrefix` | `channels.whatsapp.responsePrefix` |
| `channels.whatsapp.ackReaction` | global `messages.ackReaction` and `ackReactionScope` where translatable |
| `cron.failureDestination` | destination fields on `cron.failureAlert` |
| `gateway.controlUi.chatMessageMaxWidth` | `ui.prefs.chatMessageMaxWidth` |
| `gateway.controlUi.chatMessageMaxWidth`, presentation-only `ui.prefs` keys | removed (text scale, chat width, and live sidebar activity are browser-local) |
| `agents.list` | keyed `agents.entries` |
| top-level `defaultModel` | `agents.defaults.model` |
| `messages.messagePrefix` | `channels.whatsapp.responsePrefix` |
+4 -6
View File
@@ -71,19 +71,17 @@ Runtime-heavy surfaces have their own explicit caps under
| Key | Purpose |
| ------------------------ | ------------------------------------------------------------------------ |
| `memoryGetMaxChars` | Max characters `memory_get` returns before truncation. |
| `memoryGetDefaultLines` | Default `memory_get` line window when a request omits `lines`. |
| `toolResultMaxChars` | Advanced ceiling for a single live tool result (up to `1000000` chars). |
| `postCompactionMaxChars` | Max characters retained from `AGENTS.md` during post-compaction refresh. |
These are bounded runtime excerpts and injected runtime-owned blocks,
separate from bootstrap limits, startup-context limits, and skills prompt
limits.
`toolResultMaxChars` is unset by default, so OpenClaw derives the live
tool-result cap from the effective model context window: `16000` chars below
OpenClaw derives the live tool-result cap from the effective model context
window: `16000` chars below
100K tokens, `32000` chars at 100K+ tokens, `64000` chars at 200K+ tokens.
The runtime context-share guard still caps a single tool result at 30% of the
context window even when a larger explicit ceiling is configured.
The runtime context-share guard also caps a single tool result at 30% of the
context window.
Large provider windows are not enabled automatically when they materially
change cost or latency. For example, direct OpenAI GPT-5.5 and GPT-5.6 models
-5
View File
@@ -20,7 +20,6 @@ Most skills configuration lives under `skills` in
extraDirs: ["~/Projects/agent-scripts/skills"],
allowSymlinkTargets: ["~/Projects/manager/skills"],
watch: true,
watchDebounceMs: 250,
},
install: {
preferBrew: true,
@@ -73,10 +72,6 @@ Most skills configuration lives under `skills` in
change. Covers nested files under grouped skill roots.
</ParamField>
<ParamField path="skills.load.watchDebounceMs" type="number" default="250">
Debounce window for skill watcher events in milliseconds.
</ParamField>
## Install (`skills.install`)
<ParamField path="skills.install.preferBrew" type="boolean" default="true">
+2 -2
View File
@@ -555,13 +555,13 @@ aligned.
extraDirs: ["~/Projects/agent-scripts/skills"],
allowSymlinkTargets: ["~/Projects/manager/skills"],
watch: true, // default
watchDebounceMs: 250, // default
},
},
}
```
Use `allowSymlinkTargets` for intentional symlinked layouts where a skill
Watcher events use a built-in 250 ms debounce. Use `allowSymlinkTargets`
for intentional symlinked layouts where a skill
root symlink points outside the configured root, for example
`<workspace>/skills/manager -> ~/Projects/manager/skills`.
Enable `skills.workshop.allowSymlinkTargetWrites` only when Skill Workshop
+5 -13
View File
@@ -566,19 +566,11 @@ The chat transcript uses a centered readable frame aligned with the composer. As
## Chat message width
Wide-monitor deployments can override the transcript width without patching bundled CSS by setting `ui.prefs.chatMessageMaxWidth`:
```json5
{
gateway: {
controlUi: {
chatMessageMaxWidth: "min(1280px, 82%)",
},
},
}
```
The value is validated before it reaches the browser. Supported forms include plain lengths and percentages such as `960px` or `82%`, plus constrained `min(...)`, `max(...)`, `clamp(...)`, `calc(...)`, and `fit-content(...)` width expressions.
Wide-monitor users can override the transcript width under **Settings → Chat →
Message width**. The preference stays in that browser's local storage. Supported
forms include plain lengths and percentages such as `960px` or `82%`, plus
constrained `min(...)`, `max(...)`, `clamp(...)`, `calc(...)`, and
`fit-content(...)` width expressions.
## Tailnet access (recommended)
@@ -1,37 +0,0 @@
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { asOptionalRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
/** Resolves an agent override before falling back to the configured default. */
export function resolveAgentContextLimitValue(params: {
config: EmbeddedRunAttemptParams["config"] | undefined;
agentId?: string;
key: string;
}): number | undefined {
const agents = readRecord(params.config?.agents);
const defaults = readRecord(readRecord(agents?.defaults)?.contextLimits);
const defaultValue = readPositiveInteger(defaults?.[params.key]);
if (!params.agentId) {
return defaultValue;
}
const list = agents?.list;
if (!Array.isArray(list)) {
return defaultValue;
}
const normalizedAgentId = normalizeAgentId(params.agentId);
const agent = list.find((entry) => {
const entryId = readRecord(entry)?.id;
return typeof entryId === "string" && normalizeAgentId(entryId) === normalizedAgentId;
});
const agentValue = readPositiveInteger(
readRecord(readRecord(agent)?.contextLimits)?.[params.key],
);
return agentValue ?? defaultValue;
}
function readPositiveInteger(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return undefined;
}
return Math.floor(value);
}
@@ -802,8 +802,8 @@ describe("createCodexDynamicToolBridge", () => {
expectNoNamespace(bridge.specs[1]);
});
it("truncates configured text tool results before returning them to Codex", async () => {
const longText = "x".repeat(400);
it("scales oversized text tool results to the effective context window", async () => {
const longText = "x".repeat(40_000);
const bridge = createCodexDynamicToolBridge({
tools: [
createTool({
@@ -812,18 +812,7 @@ describe("createCodexDynamicToolBridge", () => {
}),
],
signal: new AbortController().signal,
hookContext: {
agentId: "main",
config: {
agents: {
defaults: {
contextLimits: {
toolResultMaxChars: 180,
},
},
},
} as never,
},
hookContext: { contextWindowTokens: 128_000 },
});
const result = await bridge.handleToolCall({
@@ -841,15 +830,43 @@ describe("createCodexDynamicToolBridge", () => {
throw new Error("expected inputText tool result");
}
const text = firstItem.text;
expect(text.length).toBeLessThanOrEqual(180);
expect(text.length).toBeLessThanOrEqual(32_000);
expect(text).toContain("OpenClaw truncated dynamic tool result");
expect(text).toContain("original 400 chars");
expect(text).toContain("original 40000 chars");
expect(text).toContain("rerun with narrower args");
});
it("keeps a whole code point when dynamic tool text crosses the configured boundary", async () => {
const maxChars = 180;
const totalChars = 400;
it("applies the context-share ceiling for small effective windows", async () => {
const bridge = createCodexDynamicToolBridge({
tools: [
createTool({
name: "small_context_lookup",
execute: vi.fn(async () => textToolResult("x".repeat(20_000))),
}),
],
signal: new AbortController().signal,
hookContext: { contextWindowTokens: 8_000 },
});
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-small-context",
namespace: null,
tool: "small_context_lookup",
arguments: {},
});
const firstItem = result.contentItems[0];
if (firstItem?.type !== "inputText" || typeof firstItem.text !== "string") {
throw new Error("expected inputText tool result");
}
expect(firstItem.text.length).toBeLessThanOrEqual(9_600);
expect(firstItem.text).toContain("OpenClaw truncated dynamic tool result");
});
it("keeps a whole code point when dynamic tool text crosses the automatic boundary", async () => {
const maxChars = 16_000;
const totalChars = 20_000;
const noticeText = `...(OpenClaw truncated dynamic tool result: original ${totalChars} chars, showing ${maxChars}; rerun with narrower args.)`;
const textBudget = maxChars - noticeText.length - 1;
const prefix = "a".repeat(textBudget - 1);
@@ -862,12 +879,6 @@ describe("createCodexDynamicToolBridge", () => {
}),
],
signal: new AbortController().signal,
hookContext: {
agentId: "main",
config: {
agents: { defaults: { contextLimits: { toolResultMaxChars: maxChars } } },
} as never,
},
});
const result = await bridge.handleToolCall({
@@ -882,123 +893,21 @@ describe("createCodexDynamicToolBridge", () => {
expect(result.contentItems).toEqual([{ type: "inputText", text: `${prefix}\n${noticeText}` }]);
});
it("honors normalized per-agent dynamic tool result caps", async () => {
const bridge = createCodexDynamicToolBridge({
tools: [
createTool({
name: "large_lookup",
execute: vi.fn(async () => textToolResult("x".repeat(400))),
}),
],
signal: new AbortController().signal,
hookContext: {
agentId: "research-bot",
config: {
agents: {
defaults: {
contextLimits: {
toolResultMaxChars: 1_000,
},
},
list: [
{
id: "Research Bot",
contextLimits: {
toolResultMaxChars: 180,
},
},
],
},
} as never,
},
});
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "large_lookup",
arguments: {},
});
expect(result.success).toBe(true);
const firstItem = result.contentItems[0];
if (firstItem?.type !== "inputText" || typeof firstItem.text !== "string") {
throw new Error("expected inputText tool result");
}
expect(firstItem.text.length).toBeLessThanOrEqual(180);
expect(firstItem.text).toContain("OpenClaw truncated dynamic tool result");
});
it("keeps truncation notices within tiny configured caps", async () => {
const bridge = createCodexDynamicToolBridge({
tools: [
createTool({
name: "large_lookup",
execute: vi.fn(async () => textToolResult("x".repeat(400))),
}),
],
signal: new AbortController().signal,
hookContext: {
agentId: "main",
config: {
agents: {
defaults: {
contextLimits: {
toolResultMaxChars: 32,
},
},
},
} as never,
},
});
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "large_lookup",
arguments: {},
});
expect(result.success).toBe(true);
const firstItem = result.contentItems[0];
if (firstItem?.type !== "inputText" || typeof firstItem.text !== "string") {
throw new Error("expected inputText tool result");
}
expect(firstItem.text.length).toBeLessThanOrEqual(32);
expect(firstItem.text).toBe("...(OpenClaw truncated dynamic tool".slice(0, 32));
});
it("budgets configured truncation across all text result blocks", async () => {
it("budgets automatic truncation across all text result blocks", async () => {
const bridge = createCodexDynamicToolBridge({
tools: [
createTool({
name: "large_lookup",
execute: vi.fn(async () => ({
content: [
{ type: "text" as const, text: "a".repeat(200) },
{ type: "text" as const, text: "b".repeat(200) },
{ type: "text" as const, text: "a".repeat(10_000) },
{ type: "text" as const, text: "b".repeat(10_000) },
],
details: {},
})),
}),
],
signal: new AbortController().signal,
hookContext: {
agentId: "main",
config: {
agents: {
defaults: {
contextLimits: {
toolResultMaxChars: 180,
},
},
},
} as never,
},
});
const result = await bridge.handleToolCall({
@@ -1014,10 +923,10 @@ describe("createCodexDynamicToolBridge", () => {
const text = result.contentItems
.map((item) => (item.type === "inputText" && typeof item.text === "string" ? item.text : ""))
.join("");
expect(text.length).toBeLessThanOrEqual(180);
expect(text.length).toBeLessThanOrEqual(16_000);
expect(text).toContain("OpenClaw truncated dynamic tool result");
expect(text).toContain("original 400 chars");
expect(text).not.toContain("b".repeat(100));
expect(text).toContain("original 20000 chars");
expect(text).not.toContain("b".repeat(10_000));
});
it.each([
@@ -47,7 +47,6 @@ import type { ImageContent, TextContent } from "openclaw/plugin-sdk/llm";
import { normalizeOpenAIToolSchemas } from "openclaw/plugin-sdk/provider-tools";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { resolveAgentContextLimitValue } from "./agent-context-limits.js";
import type { CodexDynamicToolsLoading } from "./config.js";
import {
createFailedDynamicToolResponse,
@@ -78,6 +77,7 @@ type CodexDynamicToolHookContext = {
runId?: string;
channelId?: string;
currentChannelProvider?: string;
contextWindowTokens?: number;
currentChannelId?: string;
currentMessagingTarget?: string;
currentMessageId?: string | number;
@@ -386,6 +386,28 @@ const EXPLICIT_MESSAGE_TARGET_KEYS = ["target", "to", "channelId"];
const EXPLICIT_MESSAGE_THREAD_KEYS = ["threadId", "thread_id", "messageThreadId", "topicId"];
const EXPLICIT_MESSAGE_REPLY_KEYS = ["replyTo", "replyToId", "replyToIdFull"];
const DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 16_000;
const LARGE_CONTEXT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 32_000;
const XL_CONTEXT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 64_000;
function resolveCodexDynamicToolResultMaxChars(contextWindowTokens?: number): number {
if (
typeof contextWindowTokens !== "number" ||
!Number.isFinite(contextWindowTokens) ||
contextWindowTokens <= 0
) {
return DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS;
}
const tokens = Math.floor(contextWindowTokens);
const autoCap =
tokens >= 200_000
? XL_CONTEXT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS
: tokens >= 100_000
? LARGE_CONTEXT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS
: DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS;
// Match the core live-result context-share ceiling without importing core
// internals across the bundled-plugin boundary.
return Math.min(autoCap, Math.max(1, Math.floor(tokens * 0.3) * 4));
}
function computerFrameImageIdentity(
content: AgentToolResult<unknown>["content"] | undefined,
@@ -431,7 +453,9 @@ export function createCodexDynamicToolBridge(params: {
directToolNames?: Iterable<string>;
}): CodexDynamicToolBridge {
const toolResultHookContext = toToolResultHookContext(params.hookContext);
const toolResultMaxChars = resolveCodexDynamicToolResultMaxChars(params.hookContext);
const toolResultMaxChars = resolveCodexDynamicToolResultMaxChars(
params.hookContext?.contextWindowTokens,
);
const availableProjection = projectCodexDynamicTools(params.tools);
const registeredProjection = params.registeredTools
? projectCodexDynamicTools(params.registeredTools)
@@ -1165,16 +1189,6 @@ function toToolResultHookContext(
};
}
function resolveCodexDynamicToolResultMaxChars(
ctx: CodexDynamicToolHookContext | undefined,
): number {
const configured = resolveAgentContextLimitValue({
config: ctx?.config,
agentId: ctx?.agentId,
key: "toolResultMaxChars",
});
return configured ?? DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS;
}
function composeAbortSignals(...signals: Array<AbortSignal | undefined>): AbortSignal {
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal));
if (activeSignals.length === 0) {
@@ -229,6 +229,7 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) {
hookContext: {
agentId: sessionAgentId,
config: params.config,
contextWindowTokens: params.contextTokenBudget ?? params.model.contextWindow,
workspaceDir: effectiveWorkspace,
sessionId: params.sessionId,
sessionKey: sandboxSessionKey,
@@ -1042,6 +1042,7 @@ async function createCodexSideToolBridge(input: {
hookContext: {
agentId: input.sessionAgentId,
config: input.params.cfg,
contextWindowTokens: runtimeModel.contextWindow,
sessionId: input.params.sessionId,
sessionKey: input.params.sessionKey,
runId: input.runId,
@@ -129,8 +129,7 @@ async function processDiscordMessageInner(
const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only";
const routedAgentConfig = resolveAgentConfig(cfg, route.agentId);
const configuredTypingMode = routedAgentConfig?.typingMode ?? cfg.agents?.defaults?.typingMode;
const configuredTypingInterval =
routedAgentConfig?.typingIntervalSeconds ?? cfg.agents?.defaults?.typingIntervalSeconds;
const configuredTypingInterval = cfg.agents?.defaults?.typingIntervalSeconds;
const shouldDisableCoreTypingKeepalive =
sourceRepliesAreToolOnly &&
configuredTypingMode === undefined &&
@@ -1004,7 +1004,7 @@ export class QmdMemoryManager implements MemorySearchManager {
if (params.from !== undefined || params.lines !== undefined) {
const startLine = normalizePositiveInteger(params.from, 1);
const requestedCount = normalizePositiveInteger(
params.lines ?? contextLimits?.memoryGetDefaultLines ?? DEFAULT_MEMORY_READ_LINES,
params.lines ?? DEFAULT_MEMORY_READ_LINES,
DEFAULT_MEMORY_READ_LINES,
);
const partial = await this.readPartialText(absPath, startLine, requestedCount);
@@ -1029,7 +1029,7 @@ export class QmdMemoryManager implements MemorySearchManager {
relPath,
from: params.from,
lines: params.lines,
defaultLines: contextLimits?.memoryGetDefaultLines ?? DEFAULT_MEMORY_READ_LINES,
defaultLines: DEFAULT_MEMORY_READ_LINES,
maxChars: contextLimits?.memoryGetMaxChars,
suggestReadFallback: isDefaultMemoryPath(relPath),
});
@@ -740,7 +740,6 @@ describe("getMemorySearchManager caching", () => {
workspace: "/tmp/workspace",
contextLimits: {
memoryGetMaxChars: 24_000,
memoryGetDefaultLines: 180,
},
},
],
@@ -242,6 +242,42 @@ export function buildWhatsAppQaConfig(
},
}
: {};
const messagesConfig = {
...baseCfg.messages,
...(params.overrides?.inboundDebounceMs !== undefined
? {
inbound: {
...baseCfg.messages?.inbound,
byChannel: {
...baseCfg.messages?.inbound?.byChannel,
whatsapp: params.overrides.inboundDebounceMs,
},
},
}
: {}),
...(params.groupJid
? {
groupChat: {
...baseCfg.messages?.groupChat,
visibleReplies: "automatic" as const,
mentionPatterns: [
...new Set([
...(baseCfg.messages?.groupChat?.mentionPatterns ?? []),
"\\bopenclawqa\\b",
]),
],
},
}
: {}),
...(statusReactionsEnabled
? {
statusReactions: {
...baseCfg.messages?.statusReactions,
enabled: true,
},
}
: {}),
};
return {
...baseCfg,
...approvalForwardingConfig,
@@ -263,6 +299,7 @@ export function buildWhatsAppQaConfig(
whatsapp: { enabled: true },
},
},
messages: messagesConfig,
channels: {
...baseCfg.channels,
whatsapp: {
@@ -301,11 +338,6 @@ export function buildWhatsAppQaConfig(
replyToMode: params.overrides.replyToMode,
}
: {}),
...(params.overrides?.inboundDebounceMs !== undefined
? {
debounceMs: params.overrides.inboundDebounceMs,
}
: {}),
...(params.groupJid
? {
groupPolicy,
@@ -331,34 +363,5 @@ export function buildWhatsAppQaConfig(
},
},
},
...(params.groupJid || statusReactionsEnabled
? {
messages: {
...baseCfg.messages,
...(params.groupJid
? {
groupChat: {
...baseCfg.messages?.groupChat,
visibleReplies: "automatic",
mentionPatterns: [
...new Set([
...(baseCfg.messages?.groupChat?.mentionPatterns ?? []),
"\\bopenclawqa\\b",
]),
],
},
}
: {}),
...(statusReactionsEnabled
? {
statusReactions: {
...baseCfg.messages?.statusReactions,
enabled: true,
},
}
: {}),
},
}
: {}),
};
}
@@ -2182,7 +2182,7 @@ describe("WhatsApp QA live runtime", () => {
});
expect(cfg.channels?.whatsapp?.accounts?.sut?.replyToMode).toBe("all");
expect(cfg.channels?.whatsapp?.accounts?.sut?.debounceMs).toBe(250);
expect(cfg.messages?.inbound?.byChannel?.whatsapp).toBe(250);
expect(cfg.channels?.whatsapp?.ackReaction).toMatchObject({
direct: true,
emoji: "👀",
@@ -2252,7 +2252,7 @@ describe("WhatsApp QA live runtime", () => {
expect(supportedHistoryLimit).toEqual(expect.any(Number));
expect(supportedHistoryLimit).toBeGreaterThan(0);
expect(cfg.channels?.whatsapp?.accounts?.sut?.replyToMode).toBe("all");
expect(cfg.channels?.whatsapp?.accounts?.sut?.debounceMs).toBe(0);
expect(cfg.messages?.inbound?.byChannel?.whatsapp).toBe(0);
expect(cfg.channels?.whatsapp?.accounts?.sut?.groups?.[groupJid]?.requireMention).toBe(true);
});
+1 -5
View File
@@ -75,11 +75,8 @@ describe("resolveWhatsAppAuthDir", () => {
whatsapp: {
sendReadReceipts: false,
responsePrefix: "[root]",
debounceMs: 100,
accounts: {
work: {
debounceMs: 250,
},
work: {},
},
},
},
@@ -89,7 +86,6 @@ describe("resolveWhatsAppAuthDir", () => {
expect(resolved.sendReadReceipts).toBe(false);
expect(resolved.messagePrefix).toBe("[root]");
expect(resolved.debounceMs).toBe(250);
});
it("inherits shared defaults from accounts.default for named accounts", () => {
-2
View File
@@ -44,7 +44,6 @@ export type ResolvedWhatsAppAccount = {
reactionLevel?: WhatsAppAccountConfig["reactionLevel"];
groups?: WhatsAppAccountConfig["groups"];
direct?: WhatsAppAccountConfig["direct"];
debounceMs?: number;
replyToMode?: ReplyToMode;
};
@@ -151,7 +150,6 @@ export function resolveWhatsAppAccount(params: {
reactionLevel: merged.reactionLevel,
groups: merged.groups,
direct: merged.direct,
debounceMs: merged.debounceMs,
replyToMode: merged.replyToMode,
};
}
@@ -20,11 +20,7 @@ import {
resetLoadConfigMock as _resetLoadConfigMock,
} from "./test-helpers.js";
export {
resetLoadConfigMock,
setLoadConfigMock,
setRuntimeConfigSourceSnapshotMock,
} from "./test-helpers.js";
export { resetLoadConfigMock, setLoadConfigMock } from "./test-helpers.js";
// Avoid exporting inferred vitest mock types (TS2742 under pnpm + d.ts emit).
type AnyExport = any;
@@ -23,7 +23,6 @@ import {
resetLoadConfigMock,
sendWebDirectInboundMessage,
setLoadConfigMock,
setRuntimeConfigSourceSnapshotMock,
startWebAutoReplyMonitor,
} from "./auto-reply.test-harness.js";
import {
@@ -925,73 +924,7 @@ describe("web auto-reply connection", () => {
}
});
it("passes accounts.default debounceMs into the live listener for named accounts", async () => {
const capture = createWebListenerFactoryCapture();
setLoadConfigMock({
channels: {
whatsapp: {
accounts: {
default: {
debounceMs: 250,
},
work: {
authDir: "/tmp/work",
},
},
},
},
} as OpenClawConfig);
await monitorWebChannel(
false,
capture.listenerFactory as never,
false,
async () => ({ text: "ok" }),
undefined,
undefined,
{
accountId: "work",
},
);
resetLoadConfigMock();
expect(capture.getLastOptions()?.debounceMs).toBe(250);
});
it("matches per-account debounce overrides case-insensitively", async () => {
const capture = createWebListenerFactoryCapture();
setLoadConfigMock({
channels: {
whatsapp: {
accounts: {
work: {
authDir: "/tmp/work",
debounceMs: 250,
},
},
},
},
} as OpenClawConfig);
await monitorWebChannel(
false,
capture.listenerFactory as never,
false,
async () => ({ text: "ok" }),
undefined,
undefined,
{
accountId: "Work",
},
);
resetLoadConfigMock();
expect(capture.getLastOptions()?.debounceMs).toBe(250);
});
it("keeps the global inbound debounce fallback when WhatsApp debounceMs is only the schema default", async () => {
it("passes the global inbound debounce into the live listener", async () => {
const capture = createWebListenerFactoryCapture();
setLoadConfigMock({
@@ -1010,8 +943,6 @@ describe("web auto-reply connection", () => {
},
},
} as OpenClawConfig);
setRuntimeConfigSourceSnapshotMock(null);
await monitorWebChannel(
false,
capture.listenerFactory as never,
@@ -1,7 +1,4 @@
// Whatsapp helper module supports config behavior.
export { resolveStorePath, updateLastRoute } from "openclaw/plugin-sdk/session-store-runtime";
export {
getRuntimeConfig,
getRuntimeConfigSourceSnapshot,
} from "openclaw/plugin-sdk/runtime-config-snapshot";
export { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
export { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime";
+3 -35
View File
@@ -1,6 +1,5 @@
// Whatsapp plugin module implements monitor behavior.
import type { WAMessageKey } from "baileys";
import { resolveAccountEntry } from "openclaw/plugin-sdk/account-core";
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-runtime";
import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-inbound";
import { resolveInboundDebounceMs } from "openclaw/plugin-sdk/channel-inbound-debounce";
@@ -45,7 +44,7 @@ import {
} from "../reconnect.js";
import { formatError, getWebAuthAgeMs, readWebSelfId } from "../session.js";
import { resolveWhatsAppSocketTiming } from "../socket-timing.js";
import { getRuntimeConfig, getRuntimeConfigSourceSnapshot } from "./config.runtime.js";
import { getRuntimeConfig } from "./config.runtime.js";
import { whatsappHeartbeatLog, whatsappLog } from "./loggers.js";
import { buildMentionConfig } from "./mentions.js";
import { createWebChannelStatusController } from "./monitor-state.js";
@@ -104,37 +103,12 @@ function resolveWebMonitorConfigSnapshot(params: {
return { cfg, account };
}
function normalizeReconnectAccountId(accountId?: string | null): string {
return (accountId ?? "").trim() || "default";
}
function isNoListenerReconnectError(lastError?: string): boolean {
return typeof lastError === "string" && /No active WhatsApp Web listener/i.test(lastError);
}
function resolveExplicitWhatsAppDebounceOverride(params: {
cfg: ReturnType<typeof getRuntimeConfig>;
sourceCfg?: ReturnType<typeof getRuntimeConfig> | null;
accountId: string;
}): number | undefined {
const channel = params.sourceCfg?.channels?.whatsapp;
if (!channel) {
return undefined;
}
const accountId = normalizeReconnectAccountId(params.accountId);
const accountDebounce = resolveAccountEntry(channel.accounts, accountId)?.debounceMs;
if (accountDebounce !== undefined) {
return accountDebounce;
}
if (accountId !== "default") {
const defaultAccountDebounce = resolveAccountEntry(channel.accounts, "default")?.debounceMs;
if (defaultAccountDebounce !== undefined) {
return defaultAccountDebounce;
}
}
return channel.debounceMs;
function normalizeReconnectAccountId(accountId?: string | null): string {
return (accountId ?? "").trim() || "default";
}
function isRetryableAuthUnstableError(error: unknown): error is WhatsAppAuthUnstableError {
@@ -166,7 +140,6 @@ export async function monitorWebChannel(
const heartbeatLogger = getChildLogger({ module: "web-heartbeat", runId });
const reconnectLogger = getChildLogger({ module: "web-reconnect", runId });
const baseCfg = getRuntimeConfig();
const sourceCfg = getRuntimeConfigSourceSnapshot();
const { cfg, account } = resolveWebMonitorConfigSnapshot({
cfg: baseCfg,
accountId: tuning.accountId,
@@ -256,11 +229,6 @@ export async function monitorWebChannel(
const inboundDebounceMs = resolveInboundDebounceMs({
cfg,
channel: "whatsapp",
overrideMs: resolveExplicitWhatsAppDebounceOverride({
cfg,
sourceCfg,
accountId: account.accountId,
}),
});
const shouldDebounce = (msg: WebInboundMessageInput) => {
const normalized = normalizeWebInboundMessage(msg);
@@ -82,7 +82,6 @@ describe("whatsapp config schema", () => {
const res = expectWhatsAppConfigValid({
dmPolicy: "allowlist",
groupPolicy: "open",
debounceMs: 250,
allowFrom: ["+15550001111"],
accounts: {
work: {
@@ -96,10 +95,8 @@ describe("whatsapp config schema", () => {
}
expect(res.data.dmPolicy).toBe("allowlist");
expect(res.data.groupPolicy).toBe("open");
expect(res.data.debounceMs).toBe(250);
expect(res.data.accounts?.work?.dmPolicy).toBeUndefined();
expect(res.data.accounts?.work?.groupPolicy).toBeUndefined();
expect(res.data.accounts?.work?.debounceMs).toBeUndefined();
});
it("accepts allowlist accounts inheriting allowFrom from accounts.default", () => {
+1 -5
View File
@@ -5,7 +5,7 @@ import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/core";
export const whatsAppChannelConfigUiHints = {
"": {
label: "WhatsApp",
help: "WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats.",
help: "WhatsApp channel provider configuration for access policy and direct-message routing safety.",
},
...createChannelConfigUiHints({
channelLabel: "WhatsApp",
@@ -21,10 +21,6 @@ export const whatsAppChannelConfigUiHints = {
label: "WhatsApp Self-Phone Mode",
help: "Same-phone setup (bot uses your personal WhatsApp number).",
},
debounceMs: {
label: "WhatsApp Message Debounce (ms)",
help: "Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).",
},
...createChannelConfigUiHints({ channelLabel: "WhatsApp", configWrites: true }),
"actions.calls": {
label: "WhatsApp Voice Calls",
-20
View File
@@ -10,7 +10,6 @@ import { createMockBaileys } from "../../../test/mocks/baileys.js";
// Use globalThis to store the mock config so it survives vi.mock hoisting
const CONFIG_KEY = Symbol.for("openclaw:testConfigMock");
const SOURCE_CONFIG_KEY = Symbol.for("openclaw:testSourceConfigMock");
const DEFAULT_CONFIG = {
channels: {
whatsapp: {
@@ -28,22 +27,12 @@ const DEFAULT_CONFIG = {
if (!(globalThis as Record<symbol, unknown>)[CONFIG_KEY]) {
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = () => DEFAULT_CONFIG;
}
if (!(globalThis as Record<symbol, unknown>)[SOURCE_CONFIG_KEY]) {
(globalThis as Record<symbol, unknown>)[SOURCE_CONFIG_KEY] = () => loadConfigMock();
}
export function setLoadConfigMock(fn: unknown) {
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = typeof fn === "function" ? fn : () => fn;
}
export function setRuntimeConfigSourceSnapshotMock(fn: unknown) {
(globalThis as Record<symbol, unknown>)[SOURCE_CONFIG_KEY] =
typeof fn === "function" ? fn : () => fn;
}
export function resetLoadConfigMock() {
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = () => DEFAULT_CONFIG;
(globalThis as Record<symbol, unknown>)[SOURCE_CONFIG_KEY] = () => loadConfigMock();
}
function resolveStorePathFallback(store?: string, opts?: { agentId?: string }) {
@@ -69,14 +58,6 @@ function loadConfigMock() {
return DEFAULT_CONFIG;
}
function loadRuntimeConfigSourceSnapshotMock() {
const getter = (globalThis as Record<symbol, unknown>)[SOURCE_CONFIG_KEY];
if (typeof getter === "function") {
return getter();
}
return loadConfigMock();
}
async function updateLastRouteMock(params: {
storePath: string;
sessionKey: string;
@@ -445,7 +426,6 @@ function resolveChannelGroupRequireMentionMock(params: {
vi.mock("./auto-reply/config.runtime.js", () => ({
getRuntimeConfig: loadConfigMock,
getRuntimeConfigSourceSnapshot: loadRuntimeConfigSourceSnapshotMock,
loadConfig: loadConfigMock,
updateLastRoute: updateLastRouteMock,
loadSessionStore: loadSessionStoreMock,
@@ -104,7 +104,6 @@ type MemorySearchConfig = {
/** Agent context limits that bound memory file reads. */
type AgentContextLimitsConfig = {
memoryGetMaxChars?: number;
memoryGetDefaultLines?: number;
};
/** Secret reference accepted by provider header config. */
@@ -176,7 +176,6 @@ export async function readAgentMemoryFile(params: {
relPath: params.relPath,
from: params.from,
lines: params.lines,
defaultLines: contextLimits?.memoryGetDefaultLines,
maxChars: contextLimits?.memoryGetMaxChars,
});
}
-2
View File
@@ -37,7 +37,6 @@ export type ResolvedAgentConfig = {
memory?: AgentEntry["memory"];
humanDelay?: AgentEntry["humanDelay"];
typingMode?: AgentEntry["typingMode"];
typingIntervalSeconds?: AgentEntry["typingIntervalSeconds"];
tts?: AgentEntry["tts"];
contextLimits?: AgentContextLimitsConfig;
heartbeat?: AgentEntry["heartbeat"];
@@ -156,7 +155,6 @@ export function resolveAgentConfig(
memory: entry.memory,
humanDelay: entry.humanDelay,
typingMode: entry.typingMode ?? agentDefaults?.typingMode,
typingIntervalSeconds: entry.typingIntervalSeconds ?? agentDefaults?.typingIntervalSeconds,
tts: entry.tts,
contextLimits:
typeof entry.contextLimits === "object" && entry.contextLimits
-4
View File
@@ -101,8 +101,6 @@ describe("resolveAgentConfig", () => {
defaults: {
contextLimits: {
memoryGetMaxChars: 20_000,
memoryGetDefaultLines: 180,
toolResultMaxChars: 18_000,
},
},
list: [
@@ -121,8 +119,6 @@ describe("resolveAgentConfig", () => {
expect(resolveAgentConfig(cfg, "main")?.contextLimits).toEqual({
memoryGetMaxChars: 24_000,
memoryGetDefaultLines: 180,
toolResultMaxChars: 18_000,
});
});
-2
View File
@@ -629,8 +629,6 @@ export async function runCliTurnCompactionLifecycle(params: {
reserveTokens: settingsManager.getCompactionReserveTokens(),
toolResultMaxChars: cliCompactionDeps.resolveLiveToolResultMaxChars({
contextWindowTokens: contextTokenBudget,
cfg: params.cfg,
agentId: params.sessionAgentId,
}),
});
const tokenSnapshot = resolveSessionTokenSnapshot(params.sessionEntry);
@@ -61,8 +61,6 @@ export function installEmbeddedAttemptContextGuards(input: {
);
const toolResultMaxChars = resolveLiveToolResultMaxChars({
contextWindowTokens: contextTokenBudget,
cfg: attempt.config,
agentId: input.sessionAgentId,
});
let pendingMidTurnPrecheckRequest: MidTurnPrecheckRequest | null = null;
let afterTurnCheckpoint: number | null = null;
@@ -110,8 +110,6 @@ export function prepareEmbeddedAttemptPromptContext(input: {
const contextTokenBudget = attempt.contextTokenBudget ?? DEFAULT_CONTEXT_TOKENS;
const promptToolResultMaxChars = resolveLiveToolResultMaxChars({
contextWindowTokens: contextTokenBudget,
cfg: attempt.config,
agentId: input.sessionAgentId,
});
const promptToolResultAggregateMaxChars = resolveLiveToolResultAggregateMaxChars({
contextWindowTokens: contextTokenBudget,
@@ -82,8 +82,6 @@ export function handleEmbeddedAttemptMidTurnPrecheck(input: {
const contextTokenBudget = attempt.contextTokenBudget ?? DEFAULT_CONTEXT_TOKENS;
const toolResultMaxChars = resolveLiveToolResultMaxChars({
contextWindowTokens: contextTokenBudget,
cfg: attempt.config,
agentId: input.sessionAgentId,
});
const truncationResult = truncateOversizedToolResultsInSessionManager({
sessionManager: input.sessionManager,
@@ -3641,19 +3641,7 @@ describe("runEmbeddedAttempt tool-result guard budget wiring", () => {
sessionKey,
tempPaths,
sessionMessages,
attemptOverrides: {
contextTokenBudget: 128_000,
config: {
agents: {
defaults: {
contextLimits: {
toolResultMaxChars: 2_000,
},
},
list: [{ id: "main" }],
},
} as OpenClawConfig,
},
attemptOverrides: { contextTokenBudget: 128_000 },
createSession: () => {
const session = createDefaultEmbeddedSession({ initialMessages: sessionMessages });
session.agent.streamFn = async (_model, context) => {
@@ -3675,7 +3663,7 @@ describe("runEmbeddedAttempt tool-result guard budget wiring", () => {
toolCallId: `current_call_${index}`,
toolName: "process",
content: [
{ type: "text", text: `current ${index}: ${"current output ".repeat(300)}` },
{ type: "text", text: `current ${index}: ${"current output ".repeat(3_000)}` },
],
isError: false,
timestamp: 100 + index,
@@ -3696,7 +3684,7 @@ describe("runEmbeddedAttempt tool-result guard budget wiring", () => {
expect(
submittedMessages
.filter((message) => message.role === "toolResult")
.every((message) => sumToolResultTextChars([message]) <= 2_000),
.every((message) => sumToolResultTextChars([message]) <= 32_000),
).toBe(true);
expect(JSON.stringify(submittedCurrentPromptMessages)).toContain("truncated");
expect(afterTurn).toHaveBeenCalledTimes(1);
@@ -356,8 +356,6 @@ export async function recoverEmbeddedRunOverflow(input: {
contextWindowTokens: input.contextTokenBudget,
maxCharsOverride: resolveLiveToolResultMaxChars({
contextWindowTokens: input.contextTokenBudget,
cfg: runParams.config,
agentId: input.sessionAgentId,
}),
config: runParams.config,
protectTrailingToolResults: true,
@@ -390,8 +388,6 @@ export async function recoverEmbeddedRunOverflow(input: {
if (!input.state.toolResultTruncationAttempted) {
const toolResultMaxChars = resolveLiveToolResultMaxChars({
contextWindowTokens: input.contextTokenBudget,
cfg: runParams.config,
agentId: input.sessionAgentId,
});
const hasOversized = input.attempt.messagesSnapshot
? sessionLikelyHasOversizedToolResults({
@@ -20,6 +20,10 @@ import type { SessionEntry as SessionStoreEntry } from "../../config/sessions/ty
import { onInternalSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import { formatFullOutputFooter } from "../sessions/tools/tool-contracts.js";
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
import {
calculateMaxToolResultCharsWithCap,
resolveAutoLiveToolResultMaxChars,
} from "../tool-result-limits.js";
import { buildRuntimeContextCustomMessage } from "./run/runtime-context-prompt.js";
import {
clearEmbeddedSessionPromptStates,
@@ -28,8 +32,6 @@ import {
} from "./session-prompt-state.js";
let truncateToolResultMessage: typeof import("./tool-result-truncation.js").truncateToolResultMessage;
let calculateMaxToolResultCharsWithCap: typeof import("./tool-result-truncation.js").calculateMaxToolResultCharsWithCap;
let resolveAutoLiveToolResultMaxChars: typeof import("./tool-result-truncation.js").resolveAutoLiveToolResultMaxChars;
let truncateOversizedToolResultsInMessages: typeof import("./tool-result-truncation.js").truncateOversizedToolResultsInMessages;
let truncateOversizedToolResultsInActiveTarget: typeof import("./tool-result-truncation.js").truncateOversizedToolResultsInActiveTarget;
let sessionLikelyHasOversizedToolResults: typeof import("./tool-result-truncation.js").sessionLikelyHasOversizedToolResults;
@@ -45,8 +47,6 @@ async function loadFreshToolResultTruncationModuleForTest() {
// across persisted-session and live-truncation tests.
({
truncateToolResultMessage,
calculateMaxToolResultCharsWithCap,
resolveAutoLiveToolResultMaxChars,
truncateOversizedToolResultsInMessages,
truncateOversizedToolResultsInActiveTarget,
sessionLikelyHasOversizedToolResults,
@@ -416,24 +416,6 @@ describe("calculateMaxToolResultChars", () => {
expect(result).toBe(32_000);
});
it("resolves per-agent tool-result cap overrides", () => {
const result = resolveLiveToolResultMaxChars({
contextWindowTokens: 128_000,
cfg: {
agents: {
defaults: {
contextLimits: {
toolResultMaxChars: 24_000,
},
},
list: [{ id: "writer" }],
},
},
agentId: "writer",
});
expect(result).toBe(24_000);
});
it.each([
{ contextWindowTokens: 20_000, perResultMaxChars: 16_000, aggregateMaxChars: 64_000 },
{ contextWindowTokens: 128_000, perResultMaxChars: 32_000, aggregateMaxChars: 256_000 },
@@ -12,7 +12,6 @@ import { createDedupeCache } from "../../infra/dedupe.js";
import { formatErrorMessage } from "../../infra/errors.js";
import type { TextContent } from "../../llm/types.js";
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import { resolveAgentContextLimits } from "../agent-scope.js";
import type { AgentMessage } from "../runtime/index.js";
import {
acquireSessionWriteLock,
@@ -21,6 +20,11 @@ import {
} from "../session-write-lock.js";
import { SessionManager } from "../sessions/index.js";
import { formatFullOutputFooter } from "../sessions/tools/tool-contracts.js";
import {
calculateMaxToolResultCharsWithCap,
resolveAutoLiveToolResultMaxChars,
resolveLiveToolResultMaxChars,
} from "../tool-result-limits.js";
import { formatContextLimitTruncationNotice } from "./context-truncation-notice.js";
import { log } from "./logger.js";
import type { ToolResultPromptProjectionState } from "./session-prompt-state.js";
@@ -40,25 +44,10 @@ import {
type RuntimeTranscriptScope,
} from "./transcript-runtime-state.js";
/**
* Maximum share of the context window a single tool result should occupy.
* This is intentionally conservative a single tool result should not
* consume more than 30% of the context window even without other messages.
*/
const MAX_TOOL_RESULT_CONTEXT_SHARE = 0.3;
/**
* Low-context default cap for a single live tool result text block.
*
* The session runtime already truncates tool results aggressively when serializing old history
* for compaction summaries. For the live request path we still keep a bounded
* request-local ceiling so oversized tool output cannot dominate the next turn.
*/
export const DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS = 16_000;
const LARGE_CONTEXT_MAX_LIVE_TOOL_RESULT_CHARS = 32_000;
const XL_CONTEXT_MAX_LIVE_TOOL_RESULT_CHARS = 64_000;
const LARGE_CONTEXT_TOOL_RESULT_TOKENS = 100_000;
const XL_CONTEXT_TOOL_RESULT_TOKENS = 200_000;
export {
DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS,
resolveLiveToolResultMaxChars,
} from "../tool-result-limits.js";
const PROMPT_TOOL_RESULT_AGGREGATE_CAP_MULTIPLIER = 4;
const AGGREGATE_TOOL_RESULT_CONTEXT_SHARE = 0.5;
@@ -288,45 +277,9 @@ function calculateMaxToolResultChars(contextWindowTokens: number): number {
);
}
export function resolveAutoLiveToolResultMaxChars(contextWindowTokens: number): number {
if (!Number.isFinite(contextWindowTokens)) {
return DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS;
}
const tokens = Math.floor(contextWindowTokens);
if (tokens >= XL_CONTEXT_TOOL_RESULT_TOKENS) {
return XL_CONTEXT_MAX_LIVE_TOOL_RESULT_CHARS;
}
if (tokens >= LARGE_CONTEXT_TOOL_RESULT_TOKENS) {
return LARGE_CONTEXT_MAX_LIVE_TOOL_RESULT_CHARS;
}
return DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS;
}
export function calculateMaxToolResultCharsWithCap(
contextWindowTokens: number,
hardCapChars: number,
): number {
const maxTokens = Math.floor(contextWindowTokens * MAX_TOOL_RESULT_CONTEXT_SHARE);
// Rough conversion: ~4 chars per token on average
const maxChars = maxTokens * 4;
return Math.min(maxChars, Math.max(1, hardCapChars));
}
export function resolveLiveToolResultMaxChars(params: {
contextWindowTokens: number;
cfg?: OpenClawConfig;
agentId?: string | null;
}): number {
const configuredCap = resolveAgentContextLimits(params.cfg, params.agentId)?.toolResultMaxChars;
const cap = configuredCap ?? resolveAutoLiveToolResultMaxChars(params.contextWindowTokens);
return calculateMaxToolResultCharsWithCap(params.contextWindowTokens, cap);
}
export function resolveLiveToolResultAggregateMaxChars(params: {
contextWindowTokens: number;
perResultMaxChars?: number;
cfg?: OpenClawConfig;
agentId?: string | null;
}): number {
const perResultMaxChars = Math.max(
1,
@@ -334,8 +287,6 @@ export function resolveLiveToolResultAggregateMaxChars(params: {
params.perResultMaxChars ??
resolveLiveToolResultMaxChars({
contextWindowTokens: params.contextWindowTokens,
cfg: params.cfg,
agentId: params.agentId,
}),
),
);
@@ -206,8 +206,6 @@ export function guardSessionManager(
typeof opts?.contextWindowTokens === "number"
? resolveLiveToolResultMaxChars({
contextWindowTokens: opts.contextWindowTokens,
cfg: opts.config,
agentId: opts.agentId,
})
: undefined,
suppressNextUserMessagePersistence: opts?.suppressNextUserMessagePersistence,
+39
View File
@@ -0,0 +1,39 @@
/** Automatic live tool-result caps derived from the effective model context. */
const MAX_TOOL_RESULT_CONTEXT_SHARE = 0.3;
export const DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS = 16_000;
const LARGE_CONTEXT_MAX_LIVE_TOOL_RESULT_CHARS = 32_000;
const XL_CONTEXT_MAX_LIVE_TOOL_RESULT_CHARS = 64_000;
const LARGE_CONTEXT_TOOL_RESULT_TOKENS = 100_000;
const XL_CONTEXT_TOOL_RESULT_TOKENS = 200_000;
export function resolveAutoLiveToolResultMaxChars(contextWindowTokens: number): number {
if (!Number.isFinite(contextWindowTokens)) {
return DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS;
}
const tokens = Math.floor(contextWindowTokens);
if (tokens >= XL_CONTEXT_TOOL_RESULT_TOKENS) {
return XL_CONTEXT_MAX_LIVE_TOOL_RESULT_CHARS;
}
if (tokens >= LARGE_CONTEXT_TOOL_RESULT_TOKENS) {
return LARGE_CONTEXT_MAX_LIVE_TOOL_RESULT_CHARS;
}
return DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS;
}
export function calculateMaxToolResultCharsWithCap(
contextWindowTokens: number,
hardCapChars: number,
): number {
const maxTokens = Math.floor(contextWindowTokens * MAX_TOOL_RESULT_CONTEXT_SHARE);
const maxChars = maxTokens * 4;
return Math.min(maxChars, Math.max(1, hardCapChars));
}
export function resolveLiveToolResultMaxChars(params: { contextWindowTokens: number }): number {
return calculateMaxToolResultCharsWithCap(
params.contextWindowTokens,
resolveAutoLiveToolResultMaxChars(params.contextWindowTokens),
);
}
+1 -2
View File
@@ -348,8 +348,7 @@ export async function getReplyFromConfig(
};
});
const typing = resolverTiming.measureSync("reply.create_typing_controller", () => {
const configuredTypingSeconds =
agentEntry?.typingIntervalSeconds ?? agentCfg?.typingIntervalSeconds;
const configuredTypingSeconds = agentCfg?.typingIntervalSeconds;
const typingIntervalSeconds =
typeof configuredTypingSeconds === "number" ? configuredTypingSeconds : 6;
const controller = createTypingController({
@@ -135,8 +135,8 @@ describe("legacy config migration end to end", () => {
reload: { mode: "hybrid" },
nodes: { allowSkills: false, commands: { allow: ["camera.snap"], deny: ["system.run"] } },
},
ui: { prefs: { chatMessageMaxWidth: "82%" } },
cron: { failureAlert: { channel: "telegram", to: "123" } },
messages: { inbound: { byChannel: { whatsapp: 0 } } },
channels: {
defaults: { heartbeatVisibility: { showOk: true } },
slack: { postAs: "user" },
@@ -0,0 +1,217 @@
// Config-tranche migrations move legacy aliases before canonical validation.
import { ensureRecord, getRecord } from "../../../config/legacy.shared.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/account-id.js";
function deletePath(owner: unknown, path: readonly string[], index = 0): boolean {
const record = getRecord(owner);
const key = path[index];
if (!record || !key) {
return false;
}
if (index === path.length - 1) {
if (!Object.hasOwn(record, key)) {
return false;
}
delete record[key];
return true;
}
const child = getRecord(record[key]);
if (!child || !deletePath(child, path, index + 1)) {
return false;
}
if (Object.keys(child).length === 0) {
delete record[key];
}
return true;
}
function visitAgentEntries(
raw: Record<string, unknown>,
visitor: (entry: Record<string, unknown>, path: string) => void,
): void {
const agents = getRecord(raw.agents);
const entries = getRecord(agents?.entries);
if (entries) {
for (const [agentId, value] of Object.entries(entries)) {
const entry = getRecord(value);
if (entry) {
visitor(entry, `agents.entries.${agentId}`);
}
}
}
if (Array.isArray(agents?.list)) {
agents.list.forEach((value, index) => {
const entry = getRecord(value);
if (entry) {
visitor(entry, `agents.list[${index}]`);
}
});
}
}
function stripRetiredPresentationPrefs(raw: Record<string, unknown>, changes: string[]): void {
const prefs = getRecord(getRecord(raw.ui)?.prefs);
if (!prefs) {
return;
}
const removed = ["chatMessageMaxWidth", "textScale", "sidebarLiveActivity"].filter((key) => {
if (!Object.hasOwn(prefs, key)) {
return false;
}
delete prefs[key];
return true;
});
if (removed.length > 0) {
changes.push(
`Removed browser-local ui.prefs keys: ${removed.map((key) => `ui.prefs.${key}`).join(", ")}.`,
);
}
const ui = getRecord(raw.ui);
if (Object.keys(prefs).length === 0) {
delete ui?.prefs;
}
if (ui && Object.keys(ui).length === 0) {
delete raw.ui;
}
}
function stripRetiredAgentConfig(raw: Record<string, unknown>, changes: string[]): void {
const agents = getRecord(raw.agents);
const defaults = getRecord(agents?.defaults);
let removedContextLimits = false;
const stripContextLimits = (owner: Record<string, unknown>) => {
for (const key of ["memoryGetDefaultLines", "toolResultMaxChars"]) {
removedContextLimits = deletePath(owner, ["contextLimits", key]) || removedContextLimits;
}
};
if (defaults) {
stripContextLimits(defaults);
}
let removedTypingOverride = false;
visitAgentEntries(raw, (entry) => {
if (Object.hasOwn(entry, "typingIntervalSeconds")) {
delete entry.typingIntervalSeconds;
removedTypingOverride = true;
}
stripContextLimits(entry);
});
if (removedTypingOverride) {
changes.push(
"Removed per-agent typingIntervalSeconds overrides; agents.defaults.typingIntervalSeconds now applies to every agent.",
);
}
if (removedContextLimits) {
changes.push(
"Removed contextLimits.memoryGetDefaultLines/toolResultMaxChars overrides; canonical memory and context-window caps now apply.",
);
}
}
type LegacyWhatsAppDebounce = { path: string; value?: number; accountId?: string };
type LegacyWhatsAppDebounceWithValue = LegacyWhatsAppDebounce & { value: number };
function readLegacyDebounce(
path: string,
owner: Record<string, unknown>,
accountId?: string,
): LegacyWhatsAppDebounce | null {
if (!Object.hasOwn(owner, "debounceMs")) {
return null;
}
const raw = owner.debounceMs;
delete owner.debounceMs;
return {
path,
...(accountId ? { accountId } : {}),
...(typeof raw === "number" && Number.isInteger(raw) && raw >= 0 ? { value: raw } : {}),
};
}
function migrateWhatsAppDebounce(raw: Record<string, unknown>, changes: string[]): void {
const whatsapp = getRecord(getRecord(raw.channels)?.whatsapp);
if (!whatsapp) {
return;
}
const sources: LegacyWhatsAppDebounce[] = [];
const rootSource = readLegacyDebounce("channels.whatsapp.debounceMs", whatsapp);
if (rootSource) {
sources.push(rootSource);
}
const accounts = getRecord(whatsapp.accounts);
if (accounts) {
for (const accountId of Object.keys(accounts).toSorted()) {
const account = getRecord(accounts[accountId]);
if (!account) {
continue;
}
const source = readLegacyDebounce(
`channels.whatsapp.accounts.${accountId}.debounceMs`,
account,
normalizeAccountId(accountId),
);
if (source) {
sources.push(source);
}
}
}
if (sources.length === 0) {
return;
}
const validSources = sources.filter(
(source): source is LegacyWhatsAppDebounceWithValue => source.value !== undefined,
);
if (validSources.length === 0) {
changes.push(
`Removed invalid WhatsApp debounce values: ${sources.map((source) => source.path).join(", ")}.`,
);
return;
}
const inbound = ensureRecord(ensureRecord(raw, "messages"), "inbound");
const byChannel = ensureRecord(inbound, "byChannel");
if (byChannel.whatsapp !== undefined) {
changes.push(
`Removed ${sources.map((source) => source.path).join(", ")} (messages.inbound.byChannel.whatsapp already set).`,
);
return;
}
const configuredDefaultAccount = normalizeAccountId(
typeof whatsapp.defaultAccount === "string" ? whatsapp.defaultAccount : undefined,
);
const selected =
validSources.find((source) => source.accountId === configuredDefaultAccount) ??
validSources.find((source) => source.accountId === DEFAULT_ACCOUNT_ID) ??
validSources.find((source) => source.path === "channels.whatsapp.debounceMs") ??
validSources[0];
if (!selected) {
return;
}
byChannel.whatsapp = selected.value;
const distinctValues = new Set(validSources.map((source) => source.value));
if (distinctValues.size === 1 && validSources.length === sources.length) {
changes.push(
`Moved ${sources.map((source) => source.path).join(", ")} → messages.inbound.byChannel.whatsapp.`,
);
} else {
changes.push(
`Collapsed conflicting WhatsApp debounce values into messages.inbound.byChannel.whatsapp using ${selected.path} (${selected.value} ms); account-specific debounce is no longer supported.`,
);
}
}
export function migrateConfigTranche(raw: Record<string, unknown>, changes: string[]): void {
stripRetiredPresentationPrefs(raw, changes);
if (deletePath(raw, ["skills", "load", "watchDebounceMs"])) {
changes.push("Removed skills.load.watchDebounceMs; the watcher now uses the 250 ms default.");
}
stripRetiredAgentConfig(raw, changes);
migrateWhatsAppDebounce(raw, changes);
}
export function hasConfigTrancheLegacyKeys(root: Record<string, unknown>): boolean {
const changes: string[] = [];
migrateConfigTranche(structuredClone(root), changes);
return changes.length > 0;
}
@@ -276,6 +276,107 @@ describe("retired runtime config migrations", () => {
);
});
it("migrates the config tranche while preserving canonical settings", () => {
const result = applyAll({
ui: {
prefs: {
chatMessageMaxWidth: "82%",
textScale: 125,
sidebarLiveActivity: false,
showAdvancedSettings: true,
},
},
skills: { load: { watch: true, watchDebounceMs: 500 } },
agents: {
defaults: {
typingIntervalSeconds: 6,
contextLimits: {
memoryGetMaxChars: 12_000,
memoryGetDefaultLines: 180,
toolResultMaxChars: 24_000,
},
},
entries: {
writer: {
typingMode: "message",
typingIntervalSeconds: 8,
contextLimits: { toolResultMaxChars: 8_000 },
},
},
list: [
{
id: "legacy",
typingIntervalSeconds: 10,
contextLimits: { memoryGetDefaultLines: 80 },
},
],
},
channels: {
whatsapp: {
defaultAccount: "Work",
debounceMs: 2_000,
accounts: {
default: { debounceMs: 3_000 },
work: { debounceMs: 4_000 },
},
},
},
});
expect(result.raw).toMatchObject({
ui: { prefs: { showAdvancedSettings: true } },
skills: { load: { watch: true } },
agents: {
defaults: {
typingIntervalSeconds: 6,
contextLimits: { memoryGetMaxChars: 12_000 },
},
entries: { writer: { typingMode: "message" } },
list: [{ id: "legacy" }],
},
messages: { inbound: { byChannel: { whatsapp: 4_000 } } },
});
expect(result.raw).not.toHaveProperty("channels.whatsapp.debounceMs");
expect(result.raw).not.toHaveProperty("channels.whatsapp.accounts.default.debounceMs");
expect(result.raw).not.toHaveProperty("channels.whatsapp.accounts.work.debounceMs");
expect(result.changes).toContain(
"Collapsed conflicting WhatsApp debounce values into messages.inbound.byChannel.whatsapp using channels.whatsapp.accounts.work.debounceMs (4000 ms); account-specific debounce is no longer supported.",
);
});
it("keeps an existing canonical WhatsApp debounce value", () => {
const result = applyAll({
messages: { inbound: { byChannel: { whatsapp: 900 } } },
channels: {
whatsapp: {
debounceMs: 2_000,
accounts: { work: { debounceMs: 4_000 } },
},
},
});
expect(result.raw).toHaveProperty("messages.inbound.byChannel.whatsapp", 900);
expect(result.raw).not.toHaveProperty("channels.whatsapp.debounceMs");
expect(result.raw).not.toHaveProperty("channels.whatsapp.accounts.work.debounceMs");
});
it("preserves accounts.default debounce inheritance for a named default account", () => {
const result = applyAll({
channels: {
whatsapp: {
defaultAccount: "work",
debounceMs: 2_000,
accounts: {
default: { debounceMs: 3_000 },
work: { name: "Work" },
},
},
},
});
expect(result.raw).toHaveProperty("messages.inbound.byChannel.whatsapp", 3_000);
});
it("moves aliases and strips dead keys", () => {
const result = applyAll({
tui: { footer: { showRemoteHost: true } },
@@ -7,6 +7,10 @@ import {
type LegacyConfigMigrationSpec,
type LegacyConfigRule,
} from "../../../config/legacy.shared.js";
import {
hasConfigTrancheLegacyKeys,
migrateConfigTranche,
} from "./legacy-config-migrations.runtime.config-tranche.js";
import {
consolidateMediaCapabilityConfig,
hasDiscordRealtimeVoice,
@@ -425,14 +429,8 @@ function migrateFinalLayoutKills(raw: Record<string, unknown>, changes: string[]
}
const controlUi = getRecord(gateway?.controlUi);
if (controlUi && Object.hasOwn(controlUi, "chatMessageMaxWidth")) {
const prefs = ensureRecord(ensureRecord(raw, "ui"), "prefs");
if (prefs.chatMessageMaxWidth === undefined) {
prefs.chatMessageMaxWidth = controlUi.chatMessageMaxWidth;
changes.push("Moved gateway.controlUi.chatMessageMaxWidth → ui.prefs.chatMessageMaxWidth.");
} else {
changes.push("Removed gateway.controlUi.chatMessageMaxWidth (ui.prefs value already set).");
}
delete controlUi.chatMessageMaxWidth;
changes.push("Removed gateway.controlUi.chatMessageMaxWidth; chat width is now browser-local.");
}
}
@@ -488,6 +486,18 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_RETIRED: LegacyConfigMigrationSpec
consolidateMediaCapabilityConfig(raw, changes);
},
}),
defineLegacyConfigMigration({
id: "runtime.config-tranche",
describe: "Migrate retired config-tranche options",
legacyRules: [
rule(
[],
"Presentation-only preferences and duplicate tuning options moved to canonical defaults.",
(_value, root) => hasConfigTrancheLegacyKeys(root),
),
],
apply: migrateConfigTranche,
}),
defineLegacyConfigMigration({
id: "runtime.tuning-knobs-purge",
describe: "Remove retired runtime tuning knobs",
@@ -618,7 +618,6 @@ exports[`config tier coverage > keeps the curated common leaf set reviewable 1`]
"ui.prefs.chatShowToolCalls",
"ui.prefs.locale",
"ui.prefs.showAdvancedSettings",
"ui.prefs.textScale",
"ui.prefs.theme",
"ui.prefs.themeMode",
"update.auto.enabled",
File diff suppressed because one or more lines are too long
-46
View File
@@ -567,52 +567,6 @@ describe("gateway.controlUi.sessionObserver", () => {
});
});
describe("ui.prefs.chatMessageMaxWidth", () => {
it("accepts constrained CSS width values", () => {
for (const value of ["960px", "82%", "min(1280px, 82%)", "calc(100% - 2rem)"]) {
const result = OpenClawSchema.safeParse({
ui: {
prefs: {
chatMessageMaxWidth: value,
},
},
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.ui?.prefs?.chatMessageMaxWidth).toBe(value);
}
}
});
it("normalizes whitespace around the width value", () => {
const result = OpenClawSchema.safeParse({
ui: {
prefs: {
chatMessageMaxWidth: " min(1280px, 82%) ",
},
},
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.ui?.prefs?.chatMessageMaxWidth).toBe("min(1280px, 82%)");
}
});
it("rejects arbitrary CSS injection", () => {
for (const value of ["url(https://example.com/x)", "960px; color: red", "var(--x)"]) {
const result = OpenClawSchema.safeParse({
ui: {
prefs: {
chatMessageMaxWidth: value,
},
},
});
expect(result.success).toBe(false);
}
});
});
describe("plugins.entries.*.hooks", () => {
it.each([true, false])("accepts allowConversationAccess=%s", (allowConversationAccess) => {
const result = OpenClawSchema.safeParse({
@@ -146,28 +146,12 @@ describe("config schema regressions", () => {
expect(res.ok).toBe(false);
});
it("accepts 1M-character tool result caps for long-context agents", () => {
const res = validateConfigObject({
agents: {
defaults: {
contextLimits: {
toolResultMaxChars: 1_000_000,
},
},
},
});
expect(res.ok).toBe(true);
});
it("accepts agents.defaults and agents.entries contextLimits overrides", () => {
const res = validateConfigObject({
agents: {
defaults: {
contextLimits: {
memoryGetMaxChars: 20_000,
memoryGetDefaultLines: 180,
toolResultMaxChars: 24_000,
postCompactionMaxChars: 4_000,
},
},
-64
View File
@@ -1,64 +0,0 @@
// Normalizes Control UI CSS config values before runtime use.
const CSS_WIDTH_KEYWORDS = new Set(["none", "min-content", "max-content"]);
const CSS_WIDTH_FUNCTIONS = new Set(["calc", "clamp", "fit-content", "max", "min"]);
const CSS_WIDTH_UNITS = new Set(["ch", "em", "rem", "vh", "vmax", "vmin", "vw", "px"]);
const CSS_WIDTH_ALLOWED_CHARS = /^[0-9A-Za-z.%+\-*/(),\s]+$/;
const CSS_WIDTH_IDENTIFIER_RE = /[A-Za-z][A-Za-z0-9-]*/g;
const CSS_WIDTH_SIMPLE_RE = /^(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|ch|vw|vh|vmin|vmax|%)$/i;
const CSS_WIDTH_MAX_LENGTH = 96;
function hasBalancedParentheses(value: string): boolean {
let depth = 0;
for (const char of value) {
if (char === "(") {
depth++;
} else if (char === ")") {
depth--;
if (depth < 0) {
return false;
}
}
}
return depth === 0;
}
function hasAllowedIdentifiers(value: string): boolean {
for (const match of value.matchAll(CSS_WIDTH_IDENTIFIER_RE)) {
const identifier = match[0].toLowerCase();
// Keep the config parser to width-only CSS tokens; reject arbitrary identifiers/properties.
if (
!CSS_WIDTH_FUNCTIONS.has(identifier) &&
!CSS_WIDTH_KEYWORDS.has(identifier) &&
!CSS_WIDTH_UNITS.has(identifier)
) {
return false;
}
}
return true;
}
/** Normalizes operator-provided Control UI chat max-width CSS values before validation. */
export function normalizeControlUiChatMessageMaxWidth(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
/** Validates the constrained CSS width grammar accepted by `ui.prefs.chatMessageMaxWidth`. */
export function isValidControlUiChatMessageMaxWidth(value: string): boolean {
const normalized = normalizeControlUiChatMessageMaxWidth(value);
if (normalized.length === 0 || normalized.length > CSS_WIDTH_MAX_LENGTH) {
return false;
}
if (CSS_WIDTH_KEYWORDS.has(normalized.toLowerCase())) {
return true;
}
if (CSS_WIDTH_SIMPLE_RE.test(normalized)) {
return true;
}
if (!CSS_WIDTH_ALLOWED_CHARS.test(normalized)) {
return false;
}
if (!hasBalancedParentheses(normalized) || !hasAllowedIdentifiers(normalized)) {
return false;
}
return /^(?:calc|clamp|fit-content|max|min)\(.+\)$/i.test(normalized);
}
+11
View File
@@ -82,6 +82,9 @@ describe("dead config keys", () => {
"gateway.nodes.allowCommands",
"gateway.nodes.denyCommands",
"gateway.controlUi.chatMessageMaxWidth",
"ui.prefs.chatMessageMaxWidth",
"ui.prefs.textScale",
"ui.prefs.sidebarLiveActivity",
"cron.failureDestination",
"channels.defaults.heartbeat",
"tools.loopDetection.historySize",
@@ -117,6 +120,12 @@ describe("dead config keys", () => {
"agents.defaults.runRetries",
"agents.entries.test.memory.search.chunking",
"agents.entries.test.runRetries",
"agents.entries.test.typingIntervalSeconds",
"agents.defaults.contextLimits.memoryGetDefaultLines",
"agents.defaults.contextLimits.toolResultMaxChars",
"agents.entries.test.contextLimits.memoryGetDefaultLines",
"agents.entries.test.contextLimits.toolResultMaxChars",
"skills.load.watchDebounceMs",
"gateway.handshakeTimeoutMs",
"gateway.channelHealthCheckMinutes",
"gateway.channelStaleEventThresholdMinutes",
@@ -162,6 +171,8 @@ describe("dead config keys", () => {
"channels.telegram.errorCooldownMs",
"channels.telegram.accounts.work.timeoutSeconds",
"channels.telegram.accounts.work.retry",
"channels.whatsapp.debounceMs",
"channels.whatsapp.accounts.work.debounceMs",
"channels.telegram.groups.-100.errorCooldownMs",
"channels.telegram.groups.-100.topics.1.errorCooldownMs",
"channels.discord.gatewayInfoTimeoutMs",
-2
View File
@@ -187,8 +187,6 @@ describe("base config schema", () => {
expect(codexUserLocation?.properties?.region?.type).toBe("string");
expect(codexUserLocation?.properties?.city?.type).toBe("string");
expect(codexUserLocation?.properties?.timezone?.type).toBe("string");
expect(schemaAt(BASE_SCHEMA, ["ui", "prefs", "chatMessageMaxWidth"])?.type).toBe("string");
});
it("does not publish metadata-only composition branches", () => {
+1 -3
View File
@@ -180,11 +180,9 @@ export const AGENT_FIELD_HELP: Record<string, string> = {
"agents.defaults.typingMode":
'Controls when typing starts for agents: "never", "instant", "thinking", or "message". Per-agent typingMode overrides this default.',
"agents.defaults.typingIntervalSeconds":
"Controls typing-indicator keepalive cadence in seconds. Per-agent typingIntervalSeconds overrides this default.",
"Controls typing-indicator keepalive cadence in seconds for every agent. Increase it to reduce update frequency across all typing-capable channels.",
"agents.entries.*.typingMode":
"Overrides the default typing start policy for one agent without changing other agents.",
"agents.entries.*.typingIntervalSeconds":
"Overrides the default typing-indicator keepalive cadence for one agent. Use this when one agent needs a different refresh interval from the shared default.",
commands:
"Controls chat command surfaces, owner gating, and elevated command access behavior across providers. Keep defaults unless you need stricter operator controls or broader command availability.",
"commands.native":
-8
View File
@@ -273,10 +273,6 @@ export const CORE_FIELD_HELP: Record<string, string> = {
"Focused per-agent-context budget defaults for selected high-volume excerpts and injected prompt blocks. Use this to tune bounded read/injection sizes without reopening any unbounded call paths.",
"agents.defaults.contextLimits.memoryGetMaxChars":
"Default max characters returned by memory_get before truncation metadata and continuation notice are added. Increase to approximate older larger excerpts, but keep it bounded.",
"agents.defaults.contextLimits.memoryGetDefaultLines":
"Default memory_get line window used when requests omit lines. This controls how many source lines are selected before the max-char cap is applied.",
"agents.defaults.contextLimits.toolResultMaxChars":
"Advanced ceiling for a single live tool result before truncation. Leave unset to use the model-context auto cap; explicit values affect both persisted live tool-result writes and overflow-recovery truncation heuristics.",
"agents.defaults.contextLimits.postCompactionMaxChars":
"Default max characters retained from AGENTS.md during post-compaction context refresh injection. Lower this to make compaction recovery cheaper, or raise it for agents that depend on longer startup guidance.",
"agents.entries":
@@ -289,10 +285,6 @@ export const CORE_FIELD_HELP: Record<string, string> = {
"Optional per-agent overrides for the focused context budget knobs. Omitted fields inherit agents.defaults.contextLimits.",
"agents.entries.*.contextLimits.memoryGetMaxChars":
"Per-agent override for the default memory_get max character budget.",
"agents.entries.*.contextLimits.memoryGetDefaultLines":
"Per-agent override for the default memory_get line window when lines is omitted.",
"agents.entries.*.contextLimits.toolResultMaxChars":
"Per-agent advanced ceiling for the live tool-result max character budget. Omit to inherit defaults or the model-context auto cap.",
"agents.entries.*.contextLimits.postCompactionMaxChars":
"Per-agent override for the post-compaction AGENTS.md excerpt budget.",
"agents.entries.*.thinkingDefault":
@@ -110,7 +110,6 @@ export const TARGET_KEYS = [
"agents.defaults.typingMode",
"agents.defaults.typingIntervalSeconds",
"agents.entries.*.typingMode",
"agents.entries.*.typingIntervalSeconds",
"session.mainKey",
"session.sendPolicy",
"session.sendPolicy.default",
@@ -498,7 +497,6 @@ export const FINAL_BACKLOG_TARGET_KEYS = [
"skills.load.allowSymlinkTargets",
"skills.load.extraDirs",
"skills.load.watch",
"skills.load.watchDebounceMs",
"skills.workshop.allowSymlinkTargetWrites",
"ui.assistant.avatar",
"ui.assistant.name",
-4
View File
@@ -188,8 +188,6 @@ export const RUNTIME_FIELD_HELP: Record<string, string> = {
'Iframe sandbox policy for hosted Control UI embeds. "strict" disables scripts, "scripts" allows interactive embeds while keeping origin isolation (default), and "trusted" adds `allow-same-origin` for same-site documents that intentionally need stronger privileges.',
"gateway.controlUi.allowExternalEmbedUrls":
"DANGEROUS toggle that allows hosted embeds to load absolute external http(s) URLs. Keep this off unless your Control UI intentionally embeds trusted third-party pages; hosted /__openclaw__/canvas and /__openclaw__/a2ui documents do not need it.",
"ui.prefs.chatMessageMaxWidth":
'Optional CSS max-width for the centered Control UI chat transcript, for example "960px", "82%", or "min(1280px, 82%)". Values are validated against a constrained width grammar before reaching the browser.',
"gateway.controlUi.allowedOrigins":
'Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting ["*"] means allow any browser origin and should be avoided outside tightly controlled local testing.',
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback":
@@ -448,8 +446,6 @@ export const RUNTIME_FIELD_HELP: Record<string, string> = {
"Trusted real target roots that skill symlinks may resolve into when they sit outside their configured source root. Keep this narrow, such as a sibling repo skills directory.",
"skills.load.watch":
"Enable filesystem watching for skill-definition changes so updates can be applied without full process restart. Keep enabled in development workflows and disable in immutable production images.",
"skills.load.watchDebounceMs":
"Debounce window in milliseconds for coalescing rapid skill file changes before reload logic runs. Increase to reduce reload churn on frequent writes, or lower for faster edit feedback.",
"skills.workshop.allowSymlinkTargetWrites":
"Allows Skill Workshop apply to write through symlinked workspace skill paths whose real target is already trusted by skills.load.allowSymlinkTargets. Keep disabled unless operators intentionally want generated proposal applies to mutate those shared skill roots.",
approvals:
-7
View File
@@ -83,16 +83,12 @@ export const FIELD_LABELS: Record<string, string> = {
"agents.defaults": "Agent Defaults",
"agents.defaults.contextLimits": "Default Context Limits",
"agents.defaults.contextLimits.memoryGetMaxChars": "Default memory_get Max Chars",
"agents.defaults.contextLimits.memoryGetDefaultLines": "Default memory_get Line Window",
"agents.defaults.contextLimits.toolResultMaxChars": "Default Tool Result Max Chars",
"agents.defaults.contextLimits.postCompactionMaxChars": "Default Post-compaction Max Chars",
"agents.entries": "Agent List",
"agents.entries.*.skillsLimits": "Agent Skills Limits",
"agents.entries.*.skillsLimits.maxSkillsPromptChars": "Agent Skills Prompt Max Chars",
"agents.entries.*.contextLimits": "Agent Context Limits",
"agents.entries.*.contextLimits.memoryGetMaxChars": "Agent memory_get Max Chars",
"agents.entries.*.contextLimits.memoryGetDefaultLines": "Agent memory_get Line Window",
"agents.entries.*.contextLimits.toolResultMaxChars": "Agent Tool Result Max Chars",
"agents.entries.*.contextLimits.postCompactionMaxChars": "Agent Post-compaction Max Chars",
"agents.entries.*.models": "Agent Model Overrides",
"agents.entries.*.modelPolicy": "Agent Model Policy",
@@ -343,7 +339,6 @@ export const FIELD_LABELS: Record<string, string> = {
"gateway.controlUi.root": "Control UI Assets Root",
"gateway.controlUi.embedSandbox": "Control UI Embed Sandbox Mode",
"gateway.controlUi.allowExternalEmbedUrls": "Allow External Control UI Embed URLs",
"ui.prefs.chatMessageMaxWidth": "Control UI Chat Message Max Width",
"gateway.controlUi.allowedOrigins": "Control UI Allowed Origins",
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback":
"Dangerously Allow Host-Header Origin Fallback",
@@ -416,7 +411,6 @@ export const FIELD_LABELS: Record<string, string> = {
"skills.load.extraDirs": "Extra Skill Directories",
"skills.load.allowSymlinkTargets": "Allowed Skill Symlink Targets",
"skills.load.watch": "Watch Skills",
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
"skills.workshop.allowSymlinkTargetWrites": "Allow Skill Workshop Symlink Writes",
"agents.defaults.skills": "Skills",
"agents.defaults.subagents.delegationMode": "Sub-agent Delegation Mode",
@@ -608,7 +602,6 @@ export const FIELD_LABELS: Record<string, string> = {
"agents.defaults.typingMode": "Typing Mode",
"agents.defaults.typingIntervalSeconds": "Typing Interval (Seconds)",
"agents.entries.*.typingMode": "Agent Typing Mode",
"agents.entries.*.typingIntervalSeconds": "Agent Typing Interval (Seconds)",
"agents.entries.*.sandbox.browser.network": "Agent Sandbox Browser Network",
"agents.entries.*.sandbox.browser.cdpSourceRange": "Agent Sandbox Browser CDP Source Range",
"agents.entries.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin":
-1
View File
@@ -48,7 +48,6 @@ const TAG_OVERRIDES: Record<string, ConfigTag[]> = {
"gateway.push.apns.relay.baseUrl": ["network", "advanced"],
"gateway.controlUi.embedSandbox": ["security", "access", "advanced"],
"gateway.controlUi.allowExternalEmbedUrls": ["security", "access", "network", "advanced"],
"ui.prefs.chatMessageMaxWidth": ["advanced"],
"gateway.controlUi.toolTitles": ["advanced"],
"gateway.controlUi.sessionObserver": ["advanced"],
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback": [
+2 -3
View File
@@ -169,13 +169,12 @@ tts.providers.* tts.providers.*.apiKey
ui.assistant.avatar ui.assistant.name ui.prefs.chatFollowUpMode
ui.prefs.chatPersistCommentary ui.prefs.chatSendShortcut ui.prefs.chatShowThinking
ui.prefs.chatShowToolCalls ui.prefs.locale ui.prefs.showAdvancedSettings
ui.prefs.textScale ui.prefs.theme ui.prefs.themeMode update.auto.enabled update.channel
ui.prefs.theme ui.prefs.themeMode update.auto.enabled update.channel
wizard.accessMode wizard.appRecommendations
`
.trim()
.split(/\s+/);
const NUMERIC_COMMON_EXCEPTIONS = new Set(["ui.prefs.textScale"]);
const ADVANCED_TUNING_PATHS = new Set(["agents.defaults.heartbeat.every"]);
const CHANNEL_KERNEL_TIER_PREFIXES = ["channels.defaults", "channels.modelByChannel"] as const;
@@ -240,7 +239,7 @@ function isNumericSchema(schema: JsonSchemaObject): boolean {
}
function isNumericCommonException(path: string): boolean {
return NUMERIC_COMMON_EXCEPTIONS.has(path) || splitPath(path).at(-1) === "port";
return splitPath(path).at(-1) === "port";
}
function resolveTier(params: { inheritedTier: boolean; ownTier: boolean | undefined }): boolean {
-4
View File
@@ -100,10 +100,6 @@ export type AgentStartupContextConfig = {
export type AgentContextLimitsConfig = {
/** Default max chars returned by memory_get before truncation metadata/notice (default: 12000). */
memoryGetMaxChars?: number;
/** Default line window for memory_get when lines is omitted (default: 120). */
memoryGetDefaultLines?: number;
/** Advanced max chars for a single live tool result; unset uses model-context auto cap. */
toolResultMaxChars?: number;
/** Max chars retained from post-compaction AGENTS.md context injection (default: 1800). */
postCompactionMaxChars?: number;
};
-2
View File
@@ -130,8 +130,6 @@ export type AgentConfig = {
humanDelay?: HumanDelayConfig;
/** Optional per-agent typing start policy. */
typingMode?: AgentDefaultsConfig["typingMode"];
/** Optional per-agent typing keepalive cadence. */
typingIntervalSeconds?: AgentDefaultsConfig["typingIntervalSeconds"];
/** Optional per-agent TTS overrides, deep-merged over top-level tts. */
/** Per-agent TTS overrides. prefsPath remains scoped because agents may use distinct preference stores. */
tts?: TtsConfig & { prefsPath?: string };
-6
View File
@@ -160,8 +160,6 @@ export type OpenClawConfig = {
theme?: "claw" | "knot" | "dash" | "custom";
/** Light/dark preference. */
themeMode?: "light" | "dark" | "system";
/** Text scale percentage stop. */
textScale?: 90 | 100 | 110 | 125 | 140;
/** BCP 47 UI locale, e.g. "en" or "pt-BR". */
locale?: string;
/** Show model thinking output in chat. */
@@ -176,10 +174,6 @@ export type OpenClawConfig = {
chatFollowUpMode?: "steer" | "queue";
/** Ordered page and pinned-session entries shown in the Control UI sidebar. */
sidebarEntries?: string[];
/** Maximum chat message width CSS value. */
chatMessageMaxWidth?: string;
/** Show live agent activity beneath running Control UI sidebar sessions. */
sidebarLiveActivity?: boolean;
/** Expand advanced settings in schema-driven Control UI forms. */
showAdvancedSettings?: boolean;
};
-2
View File
@@ -30,8 +30,6 @@ export type SkillsLoadConfig = {
allowSymlinkTargets?: string[];
/** Watch skill folders for changes and refresh the skills snapshot. */
watch?: boolean;
/** Debounce for the skills watcher (ms). */
watchDebounceMs?: number;
};
/** Skill installation preferences and upload policy. */
-2
View File
@@ -53,8 +53,6 @@ type WhatsAppSharedConfig = CommonChannelMessagingConfig<string[], string> &
groups?: Record<string, WhatsAppGroupConfig>;
/** Per-direct-chat prompt overrides keyed by user ID or `*` wildcard. */
direct?: Record<string, WhatsAppDirectConfig>;
/** Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable). */
debounceMs?: number;
};
type WhatsAppSpecificConfig = {
@@ -392,8 +392,6 @@ describe("agent defaults schema", () => {
const defaults = AgentDefaultsSchema.parse({
contextLimits: {
memoryGetMaxChars: 20_000,
memoryGetDefaultLines: 200,
toolResultMaxChars: 24_000,
postCompactionMaxChars: 4_000,
},
})!;
@@ -408,8 +406,6 @@ describe("agent defaults schema", () => {
});
expect(defaults.contextLimits?.memoryGetMaxChars).toBe(20_000);
expect(defaults.contextLimits?.memoryGetDefaultLines).toBe(200);
expect(defaults.contextLimits?.toolResultMaxChars).toBe(24_000);
expect(agent.skillsLimits?.maxSkillsPromptChars).toBe(30_000);
expect(agent.contextLimits?.memoryGetMaxChars).toBe(18_000);
});
-3
View File
@@ -279,8 +279,6 @@ const SandboxPruneSchema = z
export const AgentContextLimitsSchema = z
.object({
memoryGetMaxChars: z.number().int().min(1).max(250_000).optional(),
memoryGetDefaultLines: z.number().int().min(1).max(5_000).optional(),
toolResultMaxChars: z.number().int().min(1).max(1_000_000).optional(),
postCompactionMaxChars: z.number().int().min(1).max(50_000).optional(),
})
.strict()
@@ -940,7 +938,6 @@ export const AgentEntrySchema = z
.optional(),
humanDelay: HumanDelaySchema.optional(),
typingMode: TypingModeSchema.optional(),
typingIntervalSeconds: z.number().int().positive().optional(),
tts: AgentTtsConfigSchema,
skillsLimits: AgentSkillsLimitsSchema,
contextLimits: AgentContextLimitsSchema,
@@ -49,9 +49,6 @@ function buildWhatsAppCommonShape(params: { useDefaults: boolean }) {
...buildChannelReactionShape({
reactionLevels: ["off", "ack", "minimal", "extensive"],
}),
debounceMs: params.useDefaults
? z.number().int().nonnegative().optional().default(0)
: z.number().int().nonnegative().optional(),
pluginHooks: WhatsAppPluginHooksSchema,
};
}
-17
View File
@@ -1,10 +1,6 @@
import { normalizeStringifiedOptionalString } from "@openclaw/normalization-core/string-coerce";
import { z } from "zod";
import { parseDurationMs } from "../cli/parse-duration.js";
import {
isValidControlUiChatMessageMaxWidth,
normalizeControlUiChatMessageMaxWidth,
} from "./control-ui-css.js";
import { SilentReplyPolicyConfigSchema } from "./zod-schema.agent-defaults.js";
import { ToolsSchema } from "./zod-schema.agent-runtime.js";
import { AgentsSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js";
@@ -222,9 +218,6 @@ export const OpenClawSchemaShape = {
themeMode: z
.union([z.literal("light"), z.literal("dark"), z.literal("system")])
.optional(),
textScale: z
.union([z.literal(90), z.literal(100), z.literal(110), z.literal(125), z.literal(140)])
.optional(),
locale: z.string().max(20).optional(),
chatShowThinking: z.boolean().optional(),
chatShowToolCalls: z.boolean().optional(),
@@ -232,15 +225,6 @@ export const OpenClawSchemaShape = {
chatSendShortcut: z.union([z.literal("enter"), z.literal("modifier-enter")]).optional(),
chatFollowUpMode: z.union([z.literal("steer"), z.literal("queue")]).optional(),
sidebarEntries: z.array(z.string()).optional(),
chatMessageMaxWidth: z
.string()
.transform((value) => normalizeControlUiChatMessageMaxWidth(value))
.refine((value) => isValidControlUiChatMessageMaxWidth(value), {
message:
"Expected a CSS width value such as 960px, 82%, min(1280px, 82%), or calc(100% - 2rem)",
})
.optional(),
sidebarLiveActivity: z.boolean().optional(),
showAdvancedSettings: z.boolean().optional(),
})
.optional(),
@@ -418,7 +402,6 @@ export const OpenClawSchemaShape = {
extraDirs: z.array(z.string()).optional(),
allowSymlinkTargets: z.array(z.string()).optional(),
watch: z.boolean().optional(),
watchDebounceMs: z.number().int().min(0).optional(),
})
.optional(),
install: z
@@ -9,7 +9,6 @@ import { LEGACY_SECRETREF_ENV_MARKER_PREFIX } from "../config/types.secrets.js";
import { migrateLegacySecretRefEnvMarkers } from "../secrets/legacy-secretref-env-marker.js";
import { readConfigMachineState } from "../state/config-machine-state.js";
import { CORE_HEALTH_CHECKS } from "./doctor-core-checks.js";
import "./doctor-tool-result-cap-advice.js";
import { resolveDoctorContributionHealthChecks } from "./doctor-health-contributions.js";
import {
createDoctorHealthContribution,
@@ -1865,7 +1864,6 @@ describe("doctor health contributions", () => {
expect(contributionIds).toContain("core/doctor/device-pairing");
expect(contributionIds).toContain("core/doctor/channel-plugin-blockers");
expect(contributionIds).toContain("core/doctor/channel-preview-warnings");
expect(contributionIds).toContain("core/doctor/tool-result-cap");
expect(contributionIds).toContain("core/doctor/systemd-linger");
expect(contributionChecks.map((check) => check.id)).toEqual(contributionIds);
});
@@ -1935,60 +1933,6 @@ describe("doctor health contributions", () => {
expect(mocks.readSystemdUserLingerStatus).not.toHaveBeenCalled();
});
it("keeps tool result cap opt-in for default lint selection", async () => {
const contributionChecks = await resolveDoctorContributionHealthChecks();
const toolResultCapCheck = contributionChecks.find(
(check) => check.id === "core/doctor/tool-result-cap",
);
expect(toolResultCapCheck).toMatchObject({ defaultEnabled: false });
expect(toolResultCapCheck).toBeDefined();
const ctx = {
cfg: {
agents: {
defaults: { contextLimits: { toolResultMaxChars: 16_000 } },
},
},
mode: "lint",
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
} as const;
const detect = vi.fn(async () => [
{
checkId: "core/doctor/tool-result-cap",
severity: "warning" as const,
message: "Configured tool result cap overrides the model-window default.",
path: "agents.defaults.contextLimits.toolResultMaxChars",
},
]);
// This case owns lint selection; the real cap detector is exercised below.
const checks = [{ ...toolResultCapCheck!, detect }];
await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({
checksRun: 0,
checksSkipped: 1,
});
expect(detect).not.toHaveBeenCalled();
await expect(
runDoctorLintChecks(ctx, { checks, includeAllChecks: true }),
).resolves.toMatchObject({
checksRun: 1,
checksSkipped: 0,
findings: [
expect.objectContaining({
checkId: "core/doctor/tool-result-cap",
path: "agents.defaults.contextLimits.toolResultMaxChars",
}),
],
});
await expect(
runDoctorLintChecks(ctx, { checks, onlyIds: ["core/doctor/tool-result-cap"] }),
).resolves.toMatchObject({
checksRun: 1,
checksSkipped: 0,
});
expect(detect).toHaveBeenCalledTimes(2);
});
it("keeps stale plugin-runtime symlinks opt-in for structured lint selection", async () => {
const contributionChecks = await resolveDoctorContributionHealthChecks();
const check = contributionChecks.find(
@@ -2036,56 +1980,6 @@ describe("doctor health contributions", () => {
expect(mocks.collectStalePluginRuntimeSymlinkHealthFindings).toHaveBeenCalledTimes(1);
});
it("reports agent findings for inherited default tool result caps", async () => {
const contributionChecks = await resolveDoctorContributionHealthChecks();
const toolResultCapCheck = contributionChecks.find(
(check) => check.id === "core/doctor/tool-result-cap",
);
expect(toolResultCapCheck).toBeDefined();
mocks.resolveAgentContextLimits.mockImplementation(
(cfg: { agents?: { defaults?: { contextLimits?: unknown } } }) =>
cfg.agents?.defaults?.contextLimits ?? {},
);
mocks.resolveDefaultModelForAgent.mockImplementation((...args: unknown[]) => {
const params = args[0] as { agentId?: string };
return params.agentId === "writer"
? { provider: "openai", model: "gpt-5.5" }
: { provider: "local", model: "tiny" };
});
mocks.findModelCatalogEntry.mockImplementation((...args: unknown[]) => {
const params = args[1] as { modelId?: string };
return params.modelId === "gpt-5.5" ? { contextTokens: 200_000 } : { contextTokens: 8_000 };
});
const ctx = {
cfg: {
agents: {
defaults: { contextLimits: { toolResultMaxChars: 16_000 } },
list: [{ id: "writer" }],
},
},
mode: "lint" as const,
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
};
await expect(
runDoctorLintChecks(ctx, {
checks: [toolResultCapCheck!],
onlyIds: ["core/doctor/tool-result-cap"],
}),
).resolves.toMatchObject({
checksRun: 1,
findings: expect.arrayContaining([
expect.objectContaining({
checkId: "core/doctor/tool-result-cap",
path: "agents.defaults.contextLimits.toolResultMaxChars",
target: "agents.list.writer",
}),
]),
});
});
it("keeps legacy plugin dependency lint opt-in and read-only", async () => {
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
const tempDir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "openclaw-legacy-plugin-deps-lint-"));
-174
View File
@@ -86,7 +86,6 @@ const loadDoctorCoreChecksModule = async () => await import("./doctor-core-check
const loadDoctorStateIntegrityModule = async () =>
await import("../commands/doctor-state-integrity.js");
const loadHealthCheckRegistryModule = async () => await import("./health-check-registry.js");
const loadCatalogLookupModule = async () => await import("../agents/model-catalog.js");
const loadPreparedModelCatalogModule = async () =>
await import("../agents/prepared-model-catalog.js");
const loadModelSelectionModule = async () => await import("../agents/model-selection.js");
@@ -842,167 +841,6 @@ async function runHooksModelHealth(ctx: DoctorHealthFlowContext): Promise<void>
}
}
type ToolResultCapTarget = {
agentId?: string;
configuredCap?: number;
path?: string;
scopeLabel: string;
target?: string;
};
async function collectToolResultCapFindings(
cfg: OpenClawConfig,
): Promise<readonly HealthFinding[]> {
const { resolveAgentContextLimits } = await loadAgentScopeModule();
const { normalizeAgentId } = await import("../routing/session-key.js");
const targets: ToolResultCapTarget[] = [];
const defaultsConfiguredCap = cfg.agents?.defaults?.contextLimits?.toolResultMaxChars;
if (defaultsConfiguredCap !== undefined) {
targets.push({
configuredCap: defaultsConfiguredCap,
path: "agents.defaults.contextLimits.toolResultMaxChars",
scopeLabel: "defaults",
target: "agents.defaults",
});
}
for (const entry of cfg.agents?.list ?? []) {
const normalizedAgentId = normalizeAgentId(entry.id);
if (
!normalizedAgentId ||
(defaultsConfiguredCap === undefined && entry.contextLimits?.toolResultMaxChars === undefined)
) {
continue;
}
targets.push({
agentId: normalizedAgentId,
configuredCap: resolveAgentContextLimits(cfg, normalizedAgentId)?.toolResultMaxChars,
path:
entry.contextLimits?.toolResultMaxChars === undefined
? "agents.defaults.contextLimits.toolResultMaxChars"
: `agents.list.${normalizedAgentId}.contextLimits.toolResultMaxChars`,
scopeLabel: `agent "${normalizedAgentId}"`,
target: `agents.list.${normalizedAgentId}`,
});
}
if (targets.length === 0) {
return [];
}
const { collectToolResultCapDoctorIssues, toolResultCapDoctorIssueToHealthFinding } =
await import("./doctor-tool-result-cap-advice.js");
return collectToolResultCapTargetAdvice({
cfg,
readOnlyCatalog: true,
targets,
}).then((entries) =>
entries.flatMap((entry) =>
collectToolResultCapDoctorIssues(entry).map(toolResultCapDoctorIssueToHealthFinding),
),
);
}
async function collectToolResultCapTargetAdvice(params: {
cfg: OpenClawConfig;
readOnlyCatalog?: boolean;
targets: readonly ToolResultCapTarget[];
}): Promise<
Array<{
contextWindowTokens: number;
modelKey: string;
configuredCap?: number;
deep?: boolean;
path?: string;
scopeLabel?: string;
target?: string;
}>
> {
const { DEFAULT_CONTEXT_TOKENS } = await loadAgentDefaultsModule();
const { findModelCatalogEntry } = await loadCatalogLookupModule();
const { loadPreparedModelCatalog } = await loadPreparedModelCatalogModule();
const { resolveContextWindowInfo } = await import("../agents/context-window-guard.js");
const { resolveDefaultModelForAgent, modelKey } = await loadModelSelectionModule();
const catalog = await loadPreparedModelCatalog({
config: params.cfg,
...(params.readOnlyCatalog ? { readOnly: true } : {}),
});
return params.targets.map((target) => {
const modelRef = resolveDefaultModelForAgent({
cfg: params.cfg,
agentId: target.agentId,
});
const entry = findModelCatalogEntry(catalog, {
provider: modelRef.provider,
modelId: modelRef.model,
});
const contextWindow = resolveContextWindowInfo({
cfg: params.cfg,
provider: modelRef.provider,
modelId: modelRef.model,
modelContextTokens: entry?.contextTokens,
modelContextWindow: entry?.contextWindow,
defaultTokens: DEFAULT_CONTEXT_TOKENS,
});
return {
contextWindowTokens: contextWindow.tokens,
modelKey: modelKey(modelRef.provider, modelRef.model),
configuredCap: target.configuredCap,
path: target.path,
scopeLabel: target.scopeLabel,
target: target.target,
};
});
}
async function runToolResultCapHealth(ctx: DoctorHealthFlowContext): Promise<void> {
const { resolveAgentContextLimits } = await loadAgentScopeModule();
const { normalizeAgentId } = await import("../routing/session-key.js");
const targets: ToolResultCapTarget[] = [];
const defaultsConfiguredCap = ctx.cfg.agents?.defaults?.contextLimits?.toolResultMaxChars;
if (ctx.options.deep === true || defaultsConfiguredCap !== undefined) {
targets.push({
configuredCap: defaultsConfiguredCap,
scopeLabel: "defaults",
});
}
for (const entry of ctx.cfg.agents?.list ?? []) {
const normalizedAgentId = normalizeAgentId(entry.id);
if (
!normalizedAgentId ||
(ctx.options.deep !== true &&
defaultsConfiguredCap === undefined &&
entry.contextLimits?.toolResultMaxChars === undefined)
) {
continue;
}
targets.push({
agentId: normalizedAgentId,
configuredCap: resolveAgentContextLimits(ctx.cfg, normalizedAgentId)?.toolResultMaxChars,
scopeLabel: `agent "${normalizedAgentId}"`,
});
}
if (targets.length === 0) {
return;
}
const { buildToolResultCapDoctorAdvice } = await import("./doctor-tool-result-cap-advice.js");
const { note } = await loadNoteModule();
const entries = await collectToolResultCapTargetAdvice({
cfg: ctx.cfg,
targets,
});
const lines = entries.flatMap((entry) =>
buildToolResultCapDoctorAdvice({
...entry,
deep: ctx.options.deep === true,
}),
);
if (lines.length > 0) {
note(lines.join("\n"), "Tool result cap");
}
}
async function runSystemdLingerHealth(ctx: DoctorHealthFlowContext): Promise<void> {
if (
ctx.options.nonInteractive === true ||
@@ -2095,18 +1933,6 @@ function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
healthCheckIds: ["core/doctor/hooks-model"],
run: runHooksModelHealth,
}),
createDoctorHealthContribution({
id: "doctor:tool-result-cap",
label: "Tool result cap",
healthChecks: {
id: "core/doctor/tool-result-cap",
description:
"Detect explicit toolResultMaxChars settings that fight model-window defaults.",
defaultEnabled: false,
detect: async (ctx) => collectToolResultCapFindings(ctx.cfg),
},
run: runToolResultCapHealth,
}),
createDoctorHealthContribution({
id: "doctor:provider-catalog-projection",
label: "Provider catalog projection",
@@ -1,80 +0,0 @@
// Tool result cap advice tests cover doctor guidance for capped tool output.
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it } from "vitest";
import {
buildToolResultCapDoctorAdvice,
collectToolResultCapDoctorIssues,
toolResultCapDoctorIssueToHealthFinding,
} from "./doctor-tool-result-cap-advice.js";
describe("buildToolResultCapDoctorAdvice", () => {
it("stays quiet for unset config outside deep doctor output", () => {
expect(
buildToolResultCapDoctorAdvice({
contextWindowTokens: 200_000,
modelKey: "openai/gpt-5.5",
}),
).toEqual([]);
});
it("shows the effective auto cap in deep doctor output", () => {
expect(
buildToolResultCapDoctorAdvice({
contextWindowTokens: 200_000,
modelKey: "openai/gpt-5.5",
deep: true,
}),
).toEqual([
'- primary model "openai/gpt-5.5" context window 200,000 tokens; live tool-result cap 64,000 chars (auto)',
]);
});
it("warns when an explicit cap keeps a large model on the old 16K ceiling", () => {
expect(
buildToolResultCapDoctorAdvice({
contextWindowTokens: 200_000,
modelKey: "openai/gpt-5.5",
configuredCap: 16_000,
scopeLabel: 'agent "writer"',
}),
).toEqual([
'- agent "writer": configured toolResultMaxChars is 16,000 chars; unset it to use the 64,000 char auto cap for "openai/gpt-5.5".',
]);
});
it("warns when an explicit cap is above the runtime context-share ceiling", () => {
expect(
buildToolResultCapDoctorAdvice({
contextWindowTokens: 8_000,
modelKey: "local/tiny",
configuredCap: 20_000,
}),
).toEqual([
"- configured toolResultMaxChars is 20,000 chars, but this model can use at most 9,600 chars per live tool result; lower it or unset it.",
]);
});
it("maps cap advice issues to structured health findings", () => {
const [issue] = collectToolResultCapDoctorIssues({
contextWindowTokens: 200_000,
modelKey: "openai/gpt-5.5",
configuredCap: 16_000,
path: "agents.writer.contextLimits.toolResultMaxChars",
scopeLabel: 'agent "writer"',
target: "agents.writer",
});
expect(
toolResultCapDoctorIssueToHealthFinding(expectDefined(issue, "issue test invariant")),
).toEqual({
checkId: "core/doctor/tool-result-cap",
severity: "warning",
message:
'agent "writer": configured toolResultMaxChars is 16,000 chars; unset it to use the 64,000 char auto cap for "openai/gpt-5.5".',
path: "agents.writer.contextLimits.toolResultMaxChars",
target: "agents.writer",
requirement: "configured-below-auto-cap",
fixHint: "Lower or unset agents.writer.contextLimits.toolResultMaxChars.",
});
});
});
-162
View File
@@ -1,162 +0,0 @@
// Tool result cap advice helpers format doctor guidance for capped outputs.
import {
calculateMaxToolResultCharsWithCap,
resolveAutoLiveToolResultMaxChars,
} from "../agents/embedded-agent-runner/tool-result-truncation.js";
import type { HealthFinding } from "./health-checks.js";
const TOOL_RESULT_CAP_CHECK_ID = "core/doctor/tool-result-cap";
type ToolResultCapDoctorIssue = {
kind: "configured-above-runtime-ceiling" | "configured-below-auto-cap";
contextWindowTokens: number;
modelKey: string;
configuredCap: number;
runtimeCeiling?: number;
autoEffectiveCap?: number;
path?: string;
scopeLabel?: string;
target?: string;
};
// Doctor advice for explicit live tool-result caps that fight model-window defaults.
type ToolResultCapDoctorAdviceParams = {
contextWindowTokens: number;
modelKey: string;
configuredCap?: number;
deep?: boolean;
path?: string;
scopeLabel?: string;
target?: string;
};
function formatNumber(value: number): string {
return String(Math.max(0, Math.floor(value))).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
function formatIssueMessage(issue: ToolResultCapDoctorIssue): string {
const prefix = issue.scopeLabel ? `${issue.scopeLabel}: ` : "";
if (issue.kind === "configured-above-runtime-ceiling") {
return `${prefix}configured toolResultMaxChars is ${formatNumber(
issue.configuredCap,
)} chars, but this model can use at most ${formatNumber(
issue.runtimeCeiling ?? 0,
)} chars per live tool result; lower it or unset it.`;
}
return `${prefix}configured toolResultMaxChars is ${formatNumber(
issue.configuredCap,
)} chars; unset it to use the ${formatNumber(issue.autoEffectiveCap ?? 0)} char auto cap for "${
issue.modelKey
}".`;
}
export function collectToolResultCapDoctorIssues(
params: ToolResultCapDoctorAdviceParams,
): ToolResultCapDoctorIssue[] {
if (!Number.isFinite(params.contextWindowTokens) || params.contextWindowTokens <= 0) {
return [];
}
const configuredCap =
typeof params.configuredCap === "number" && Number.isFinite(params.configuredCap)
? Math.floor(params.configuredCap)
: undefined;
if (configuredCap === undefined) {
return [];
}
const autoCap = resolveAutoLiveToolResultMaxChars(params.contextWindowTokens);
const runtimeCeiling = calculateMaxToolResultCharsWithCap(
params.contextWindowTokens,
Number.MAX_SAFE_INTEGER,
);
const effectiveCap = calculateMaxToolResultCharsWithCap(
params.contextWindowTokens,
configuredCap,
);
const autoEffectiveCap = calculateMaxToolResultCharsWithCap(params.contextWindowTokens, autoCap);
if (configuredCap > runtimeCeiling) {
return [
{
kind: "configured-above-runtime-ceiling",
contextWindowTokens: params.contextWindowTokens,
modelKey: params.modelKey,
configuredCap,
runtimeCeiling,
path: params.path,
scopeLabel: params.scopeLabel,
target: params.target,
},
];
}
if (effectiveCap < autoEffectiveCap) {
return [
{
kind: "configured-below-auto-cap",
contextWindowTokens: params.contextWindowTokens,
modelKey: params.modelKey,
configuredCap,
autoEffectiveCap,
path: params.path,
scopeLabel: params.scopeLabel,
target: params.target,
},
];
}
return [];
}
export function toolResultCapDoctorIssueToHealthFinding(
issue: ToolResultCapDoctorIssue,
): HealthFinding {
return {
checkId: TOOL_RESULT_CAP_CHECK_ID,
severity: "warning",
message: formatIssueMessage(issue),
...(issue.path ? { path: issue.path } : {}),
...(issue.target ? { target: issue.target } : {}),
requirement: issue.kind,
fixHint: issue.path ? `Lower or unset ${issue.path}.` : "Lower or unset toolResultMaxChars.",
};
}
/** Builds human-readable doctor lines for stale or ineffective toolResultMaxChars settings. */
export function buildToolResultCapDoctorAdvice(params: ToolResultCapDoctorAdviceParams): string[] {
if (!Number.isFinite(params.contextWindowTokens) || params.contextWindowTokens <= 0) {
return [];
}
const autoCap = resolveAutoLiveToolResultMaxChars(params.contextWindowTokens);
const configuredCap =
typeof params.configuredCap === "number" && Number.isFinite(params.configuredCap)
? Math.floor(params.configuredCap)
: undefined;
const configuredSource = configuredCap !== undefined;
const requestedCap = configuredCap ?? autoCap;
const effectiveCap = calculateMaxToolResultCharsWithCap(params.contextWindowTokens, requestedCap);
const lines: string[] = [];
const prefix = params.scopeLabel ? `${params.scopeLabel}: ` : "";
// Deep mode always shows the effective cap, even when no warning is needed.
if (params.deep) {
lines.push(
`- ${prefix}primary model "${params.modelKey}" context window ${formatNumber(
params.contextWindowTokens,
)} tokens; live tool-result cap ${formatNumber(effectiveCap)} chars (${
configuredSource ? "explicit" : "auto"
})`,
);
}
if (configuredCap === undefined) {
return lines;
}
lines.push(
...collectToolResultCapDoctorIssues(params).map((issue) => `- ${formatIssueMessage(issue)}`),
);
return lines;
}
-1
View File
@@ -148,7 +148,6 @@ export type ControlUiBootstrapConfig = {
localMediaPreviewRoots?: string[];
embedSandbox?: ControlUiEmbedSandboxMode;
allowExternalEmbedUrls?: boolean;
chatMessageMaxWidth?: string;
seamColor?: string;
/** Resolved `agents.defaults.timeFormat`; "auto" keeps the browser locale default. */
timeFormat?: "auto" | "12" | "24";
-3
View File
@@ -99,7 +99,6 @@ describe("handleControlUiHttpRequest", () => {
assistantAgentId: string;
devGitBranch?: string;
localMediaPreviewRoots?: string[];
chatMessageMaxWidth?: string;
seamColor?: string;
timeFormat?: "auto" | "12" | "24";
terminalEnabled: boolean;
@@ -1103,7 +1102,6 @@ describe("handleControlUiHttpRequest", () => {
config: {
agents: { defaults: { workspace: tmp } },
ui: {
prefs: { chatMessageMaxWidth: "min(1280px, 82%)" },
seamColor: "#1A2b3C",
assistant: { name: "</script><script>alert(1)//", avatar: "</script>.png" },
},
@@ -1118,7 +1116,6 @@ describe("handleControlUiHttpRequest", () => {
expect(parsed.assistantAvatarStatus).toBe("none");
expect(parsed.assistantAvatarReason).toBe("missing");
expect(parsed.assistantAgentId).toBe("main");
expect(parsed.chatMessageMaxWidth).toBe("min(1280px, 82%)");
expect(parsed.seamColor).toBe("#1A2b3C");
expect(parsed.timeFormat).toBe("auto");
expect(parsed.terminalEnabled).toBe(false);
-1
View File
@@ -989,7 +989,6 @@ export async function handleControlUiHttpRequest(
? "strict"
: "scripts",
allowExternalEmbedUrls: config?.gateway?.controlUi?.allowExternalEmbedUrls === true,
chatMessageMaxWidth: config?.ui?.prefs?.chatMessageMaxWidth,
seamColor: config?.ui?.seamColor,
timeFormat: "auto",
terminalEnabled,
+3 -5
View File
@@ -560,10 +560,8 @@ function isHeartbeatTypingEnabled(params: {
return typingMode !== "never";
}
function resolveHeartbeatTypingIntervalSeconds(cfg: OpenClawConfig, agentId: string) {
const configured =
resolveAgentConfig(cfg, agentId)?.typingIntervalSeconds ??
cfg.agents?.defaults?.typingIntervalSeconds;
function resolveHeartbeatTypingIntervalSeconds(cfg: OpenClawConfig) {
const configured = cfg.agents?.defaults?.typingIntervalSeconds;
return typeof configured === "number" && configured > 0 ? configured : undefined;
}
@@ -1692,7 +1690,7 @@ export async function runHeartbeatOnce(opts: {
const hasChatDelivery = Boolean(
delivery.channel !== "none" && delivery.to && (visibility.showAlerts || visibility.showOk),
);
const heartbeatTypingIntervalSeconds = resolveHeartbeatTypingIntervalSeconds(cfg, agentId);
const heartbeatTypingIntervalSeconds = resolveHeartbeatTypingIntervalSeconds(cfg);
const heartbeatChannelPlugin =
delivery.channel !== "none" ? resolveHeartbeatChannelPlugin(delivery.channel) : undefined;
const heartbeatTyping =
+26 -71
View File
@@ -176,7 +176,7 @@ describe("ensureSkillsWatcher", () => {
});
refreshModule.ensureSkillsWatcher({
workspaceDir,
config: { skills: { load: { watchDebounceMs: 10 } } },
config: { skills: { load: {} } },
});
createdWatchers[0]?.emit(
@@ -187,7 +187,7 @@ describe("ensureSkillsWatcher", () => {
watchedPath: path.join(workspaceDir, "skills", "demo"),
},
);
await vi.advanceTimersByTimeAsync(20);
await vi.advanceTimersByTimeAsync(500);
expect(seen).toEqual([]);
} finally {
@@ -210,7 +210,7 @@ describe("ensureSkillsWatcher", () => {
});
refreshModule.ensureSkillsWatcher({
workspaceDir,
config: { skills: { load: { watchDebounceMs: 10 } } },
config: { skills: { load: {} } },
});
const calls = watchMock.mock.calls as unknown as Array<
@@ -222,7 +222,7 @@ describe("ensureSkillsWatcher", () => {
const changedPath = path.join(workspaceDir, "skills", "group", "demo", "SKILL.md");
createdWatchers[firstIndex]?.emit("all", "change", changedPath);
await vi.advanceTimersByTimeAsync(10);
await vi.advanceTimersByTimeAsync(250);
expect(seen).toEqual([
{
@@ -246,20 +246,20 @@ describe("ensureSkillsWatcher", () => {
refreshModule.ensureSkillsWatcher({
workspaceDir,
config: { skills: { load: { watchDebounceMs: 10 } } },
config: { skills: { load: {} } },
});
createdWatchers[0]?.emit("raw", "rename", "README.md", {
watchedPath: "/tmp/workspace/skills/demo",
});
await vi.advanceTimersByTimeAsync(10);
await vi.advanceTimersByTimeAsync(250);
expect(seen).toEqual([]);
createdWatchers[0]?.emit("raw", "rename", "SKILL.md", {
watchedPath: "/tmp/workspace/skills/demo",
});
await Promise.resolve();
await vi.advanceTimersByTimeAsync(10);
await vi.advanceTimersByTimeAsync(250);
expect(seen).toEqual([
{
@@ -280,13 +280,13 @@ describe("ensureSkillsWatcher", () => {
refreshModule.ensureSkillsWatcher({
workspaceDir,
config: { skills: { load: { watchDebounceMs: 10 } } },
config: { skills: { load: {} } },
});
createdWatchers[0]?.emit("raw", "rename", undefined, {
watchedPath: "/tmp/workspace/skills",
});
await vi.advanceTimersByTimeAsync(10);
await vi.advanceTimersByTimeAsync(250);
expect(seen).toEqual([
{
@@ -312,15 +312,15 @@ describe("ensureSkillsWatcher", () => {
refreshModule.ensureSkillsWatcher({
workspaceDir,
config: { skills: { load: { watchDebounceMs: 10 } } },
config: { skills: { load: {} } },
});
createdWatchers[0]?.emit("raw", "change", "SKILL.md", { watchedPath: skillDir });
await Promise.resolve();
await vi.advanceTimersByTimeAsync(10);
await vi.advanceTimersByTimeAsync(250);
expect(seen).toEqual([]);
await vi.advanceTimersByTimeAsync(10);
await vi.advanceTimersByTimeAsync(250);
expect(seen).toEqual([
{
workspaceDir,
@@ -725,11 +725,11 @@ describe("ensureSkillsWatcher", () => {
});
refreshModule.ensureSkillsWatcher({
workspaceDir: "/tmp/workspace",
config: { skills: { load: { watchDebounceMs: 10 } } },
config: { skills: { load: {} } },
});
createdWatchers[0]?.emit("all", event, "/tmp/workspace/skills/demo/SKILL.md");
await vi.advanceTimersByTimeAsync(10);
await vi.advanceTimersByTimeAsync(250);
expect(seen).toEqual([
{
@@ -797,11 +797,11 @@ describe("ensureSkillsWatcher", () => {
});
refreshModule.ensureSkillsWatcher({
workspaceDir: "/tmp/ws-a",
config: { skills: { load: { extraDirs: ["/tmp/shared"], watchDebounceMs: 10 } } },
config: { skills: { load: { extraDirs: ["/tmp/shared"] } } },
});
refreshModule.ensureSkillsWatcher({
workspaceDir: "/tmp/ws-b",
config: { skills: { load: { extraDirs: ["/tmp/shared"], watchDebounceMs: 10 } } },
config: { skills: { load: { extraDirs: ["/tmp/shared"] } } },
});
const callPaths = (watchMock.mock.calls as unknown as Array<[string]>).map((call) => call[0]);
@@ -809,7 +809,7 @@ describe("ensureSkillsWatcher", () => {
expect(sharedIndex).toBeGreaterThanOrEqual(0);
createdWatchers[sharedIndex]?.emit("all", "change", "/tmp/shared/demo/SKILL.md");
await vi.advanceTimersByTimeAsync(10);
await vi.advanceTimersByTimeAsync(250);
expect(seen).toContainEqual({
workspaceDir: "/tmp/ws-a",
@@ -831,11 +831,11 @@ describe("ensureSkillsWatcher", () => {
});
refreshModule.ensureSkillsWatcher({
workspaceDir: "/tmp/ws-a",
config: { skills: { load: { extraDirs: ["/tmp/shared"], watchDebounceMs: 10 } } },
config: { skills: { load: { extraDirs: ["/tmp/shared"] } } },
});
refreshModule.ensureSkillsWatcher({
workspaceDir: "/tmp/ws-b",
config: { skills: { load: { extraDirs: ["/tmp/shared"], watchDebounceMs: 10 } } },
config: { skills: { load: { extraDirs: ["/tmp/shared"] } } },
});
// ws-a turns watching off: it unsubscribes, but the shared watcher stays
@@ -851,7 +851,7 @@ describe("ensureSkillsWatcher", () => {
expect(sharedIndex).toBeGreaterThanOrEqual(0);
createdWatchers[sharedIndex]?.emit("all", "change", "/tmp/shared/demo/SKILL.md");
await vi.advanceTimersByTimeAsync(10);
await vi.advanceTimersByTimeAsync(250);
expect(seen).toContainEqual({
workspaceDir: "/tmp/ws-b",
@@ -867,7 +867,7 @@ describe("ensureSkillsWatcher", () => {
const workspaceDir = "/tmp/workspace-version-cleanup";
refreshModule.ensureSkillsWatcher({
workspaceDir,
config: { skills: { load: { watchDebounceMs: 10 } } },
config: { skills: { load: {} } },
});
const firstVersion = bumpSkillsSnapshotVersion({
@@ -897,7 +897,7 @@ describe("ensureSkillsWatcher", () => {
const idleWorkspaceDir = "/tmp/workspace-idle";
refreshModule.ensureSkillsWatcher({
workspaceDir: idleWorkspaceDir,
config: { skills: { load: { watchDebounceMs: 10 } } },
config: { skills: { load: {} } },
});
const callPaths = (watchMock.mock.calls as unknown as Array<[string]>).map((call) => call[0]);
const idleSkillsIndex = callPaths.findIndex(
@@ -912,7 +912,7 @@ describe("ensureSkillsWatcher", () => {
vi.advanceTimersByTime(60 * 60_000 + 1_000);
refreshModule.ensureSkillsWatcher({
workspaceDir: "/tmp/workspace-active",
config: { skills: { load: { watchDebounceMs: 10 } } },
config: { skills: { load: {} } },
});
expect(createdWatchers[idleSkillsIndex]?.close).toHaveBeenCalledTimes(1);
@@ -932,7 +932,7 @@ describe("ensureSkillsWatcher", () => {
const activeWorkspaceDir = "/tmp/workspace-active-refresh";
refreshModule.ensureSkillsWatcher({
workspaceDir: activeWorkspaceDir,
config: { skills: { load: { watchDebounceMs: 10 } } },
config: { skills: { load: {} } },
});
const callPaths = (watchMock.mock.calls as unknown as Array<[string]>).map((call) => call[0]);
const activeSkillsIndex = callPaths.findIndex(
@@ -943,59 +943,14 @@ describe("ensureSkillsWatcher", () => {
vi.advanceTimersByTime(30 * 60_000);
refreshModule.ensureSkillsWatcher({
workspaceDir: activeWorkspaceDir,
config: { skills: { load: { watchDebounceMs: 10 } } },
config: { skills: { load: {} } },
});
vi.advanceTimersByTime(31 * 60_000);
refreshModule.ensureSkillsWatcher({
workspaceDir: "/tmp/workspace-other",
config: { skills: { load: { watchDebounceMs: 10 } } },
config: { skills: { load: {} } },
});
expect(createdWatchers[activeSkillsIndex]?.close).not.toHaveBeenCalled();
});
it("rebuilds a shared watcher with last-writer debounce while preserving subscribers", async () => {
vi.useFakeTimers();
const seen: SkillsChangeEvent[] = [];
refreshModule.registerSkillsChangeListener((change) => {
seen.push(change);
});
refreshModule.ensureSkillsWatcher({
workspaceDir: "/tmp/ws-a",
config: { skills: { load: { extraDirs: ["/tmp/shared"], watchDebounceMs: 10 } } },
});
const callPaths1 = (watchMock.mock.calls as unknown as Array<[string]>).map((call) => call[0]);
const firstSharedIndex = callPaths1.findIndex((target) => target === "/tmp/shared");
// ws-b subscribes to the same path with a different debounce: the shared
// watcher is rebuilt once, the previous instance closed, and both
// workspaces remain subscribed.
refreshModule.ensureSkillsWatcher({
workspaceDir: "/tmp/ws-b",
config: { skills: { load: { extraDirs: ["/tmp/shared"], watchDebounceMs: 50 } } },
});
expect(createdWatchers[firstSharedIndex]?.close).toHaveBeenCalledTimes(1);
const callPaths2 = (watchMock.mock.calls as unknown as Array<[string]>).map((call) => call[0]);
const sharedIndices = callPaths2
.map((target, index) => (target === "/tmp/shared" ? index : -1))
.filter((index) => index >= 0);
expect(sharedIndices).toHaveLength(2);
expect(callPaths2.filter((target) => target === "/tmp/shared/skills")).toHaveLength(2);
const liveSharedIndex = sharedIndices[sharedIndices.length - 1] ?? -1;
createdWatchers[liveSharedIndex]?.emit("all", "change", "/tmp/shared/demo/SKILL.md");
await vi.advanceTimersByTimeAsync(50);
expect(seen).toContainEqual({
workspaceDir: "/tmp/ws-a",
reason: "watch",
changedPath: "/tmp/shared/demo/SKILL.md",
});
expect(seen).toContainEqual({
workspaceDir: "/tmp/ws-b",
reason: "watch",
changedPath: "/tmp/shared/demo/SKILL.md",
});
});
});
+17 -36
View File
@@ -23,7 +23,6 @@ export { registerSkillsChangeListener } from "./refresh-state.js";
type SkillsPathWatchState = {
watcher: FSWatcher;
depth: number;
debounceMs: number;
timer?: ReturnType<typeof setTimeout>;
pendingPath?: string;
readonly subscribers: Set<string>;
@@ -52,6 +51,7 @@ const MAX_SYMLINK_WATCH_TARGETS_PER_ROOT = 100;
const MAX_SYMLINK_WATCH_DIRECTORY_SCANS_PER_ROOT = 200;
const MAX_SYMLINK_WATCH_RAW_ENTRIES_PER_ROOT = 2_000;
const RAW_SKILL_FILE_POLL_INTERVAL_MS = 100;
const SKILLS_WATCH_DEBOUNCE_MS = 250;
// One watcher per unique watched directory. Agent workspaces that include the
// same shared skill root (the global skills dir, the home skills dir, or a
// configured extra/plugin dir) subscribe to the same watcher instead of each
@@ -467,11 +467,6 @@ async function waitForStableSkillFile(filePath: string, stabilityMs: number): Pr
}
}
function resolveWatchDebounceMs(config?: OpenClawConfig): number {
const raw = config?.skills?.load?.watchDebounceMs;
return typeof raw === "number" && Number.isFinite(raw) ? Math.max(0, raw) : 250;
}
function resolveSkillsWatcherUsePolling(): boolean {
const envPolling = process.env.CHOKIDAR_USEPOLLING;
if (envPolling === undefined) {
@@ -502,7 +497,7 @@ function sameWatchTargets(a: WatchTarget[], b: WatchTarget[]): boolean {
return true;
}
function createSkillsPathWatcher(target: WatchTarget, debounceMs: number): SkillsPathWatchState {
function createSkillsPathWatcher(target: WatchTarget): SkillsPathWatchState {
const usePolling = resolveSkillsWatcherUsePolling();
const watcher = chokidar.watch(target.path, {
ignoreInitial: true,
@@ -512,7 +507,7 @@ function createSkillsPathWatcher(target: WatchTarget, debounceMs: number): Skill
// so watcher invalidation must observe that whole decision surface.
depth: target.depth,
awaitWriteFinish: {
stabilityThreshold: debounceMs,
stabilityThreshold: SKILLS_WATCH_DEBOUNCE_MS,
pollInterval: 100,
},
ignored: (watchPath, stats) => shouldIgnoreSkillsWatchPath(watchPath, stats, { usePolling }),
@@ -521,7 +516,6 @@ function createSkillsPathWatcher(target: WatchTarget, debounceMs: number): Skill
const state: SkillsPathWatchState = {
watcher,
depth: target.depth,
debounceMs,
subscribers: new Set<string>(),
};
@@ -544,10 +538,10 @@ function createSkillsPathWatcher(target: WatchTarget, debounceMs: number): Skill
changedPath: pendingPath,
});
}
}, debounceMs);
}, SKILLS_WATCH_DEBOUNCE_MS);
};
const scheduleRawSkillFile = (changedPath: string) => {
void waitForStableSkillFile(changedPath, debounceMs)
void waitForStableSkillFile(changedPath, SKILLS_WATCH_DEBOUNCE_MS)
.catch((err: unknown) => {
log.warn(`skills watcher stability check failed (${changedPath}): ${String(err)}`);
})
@@ -586,26 +580,18 @@ function teardownSkillsPathWatcher(state: SkillsPathWatchState): void {
void state.watcher.close().catch(() => {});
}
function subscribeWorkspaceToPath(
workspaceDir: string,
watchTarget: WatchTarget,
debounceMs: number,
): void {
function subscribeWorkspaceToPath(workspaceDir: string, watchTarget: WatchTarget): void {
const existing = pathWatchers.get(watchTarget.key);
if (existing && existing.debounceMs === debounceMs && existing.depth >= watchTarget.depth) {
if (existing && existing.depth >= watchTarget.depth) {
existing.subscribers.add(workspaceDir);
return;
}
if (existing) {
// Debounce changed (config reload): rebuild the shared watcher while
// preserving existing subscribers. Debounce is a gateway-global config
// value, so all workspaces normally request the same value and this branch
// does not fire; if it does, the most recent requested debounce wins for
// every subscriber of the shared path (last-writer-wins).
const next = createSkillsPathWatcher(
{ ...watchTarget, depth: Math.max(existing.depth, watchTarget.depth) },
debounceMs,
);
// A deeper target needs a rebuilt watcher while preserving subscribers.
const next = createSkillsPathWatcher({
...watchTarget,
depth: Math.max(existing.depth, watchTarget.depth),
});
for (const subscriber of existing.subscribers) {
next.subscribers.add(subscriber);
}
@@ -614,7 +600,7 @@ function subscribeWorkspaceToPath(
pathWatchers.set(watchTarget.key, next);
return;
}
const state = createSkillsPathWatcher(watchTarget, debounceMs);
const state = createSkillsPathWatcher(watchTarget);
state.subscribers.add(workspaceDir);
pathWatchers.set(watchTarget.key, state);
}
@@ -666,7 +652,6 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope
}
const now = Date.now();
const watchEnabled = params.config?.skills?.load?.watch !== false;
const debounceMs = resolveWatchDebounceMs(params.config);
const previousTargets = workspaceWatchTargets.get(workspaceDir) ?? [];
if (!watchEnabled) {
@@ -678,14 +663,10 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope
workspaceWatchLastEnsuredAt.set(workspaceDir, now);
const watchTargets = resolveWatchTargets(workspaceDir, params.config);
const targetsUnchanged = sameWatchTargets(previousTargets, watchTargets);
const debounceUnchanged = watchTargets.every(
// undefined for paths not yet watched -> false -> fall through to subscribe.
(watchTarget) => {
const pathWatcher = pathWatchers.get(watchTarget.key);
return pathWatcher?.debounceMs === debounceMs && pathWatcher.depth >= watchTarget.depth;
},
const watcherDepthsCoverTargets = watchTargets.every(
(watchTarget) => (pathWatchers.get(watchTarget.key)?.depth ?? -1) >= watchTarget.depth,
);
if (targetsUnchanged && debounceUnchanged) {
if (targetsUnchanged && watcherDepthsCoverTargets) {
evictIdleWorkspaceWatchStates(now);
return;
}
@@ -698,7 +679,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope
}
}
for (const watchTarget of watchTargets) {
subscribeWorkspaceToPath(workspaceDir, watchTarget, debounceMs);
subscribeWorkspaceToPath(workspaceDir, watchTarget);
}
workspaceWatchTargets.set(workspaceDir, watchTargets);
-6
View File
@@ -43,7 +43,6 @@ type ApplicationConfig = {
localMediaPreviewRoots: string[];
embedSandboxMode: ControlUiEmbedSandboxMode;
allowExternalEmbedUrls: boolean;
chatMessageMaxWidth: string | null;
terminalEnabled: boolean;
pluginFrameGrants: ControlUiPluginFrameGrantAck[];
};
@@ -80,7 +79,6 @@ const DEFAULT_APPLICATION_CONFIG: ApplicationConfig = {
localMediaPreviewRoots: [],
embedSandboxMode: "strict",
allowExternalEmbedUrls: false,
chatMessageMaxWidth: null,
terminalEnabled: readDocumentTerminalEnabled() ?? false,
pluginFrameGrants: [],
};
@@ -157,10 +155,6 @@ function normalizeApplicationConfig(parsed: ControlUiBootstrapConfig): Applicati
? "strict"
: "scripts",
allowExternalEmbedUrls: parsed.allowExternalEmbedUrls === true,
chatMessageMaxWidth:
typeof parsed.chatMessageMaxWidth === "string" && parsed.chatMessageMaxWidth.trim()
? parsed.chatMessageMaxWidth
: null,
terminalEnabled: parsed.terminalEnabled === true,
pluginFrameGrants: Array.isArray(parsed.pluginFrameGrants)
? parsed.pluginFrameGrants.filter(
+22 -20
View File
@@ -31,11 +31,12 @@ describe("server pref extraction", () => {
configWithPrefs({
theme: "knot",
themeMode: "dark",
textScale: 125,
locale: "de",
chatShowThinking: false,
chatSendShortcut: "modifier-enter",
textScale: 125,
sidebarLiveActivity: false,
chatMessageMaxWidth: "82%",
showAdvancedSettings: true,
sidebarEntries: ["route:usage", "session:agent:main:test", "route:usage", 7],
bogus: true,
@@ -46,11 +47,9 @@ describe("server pref extraction", () => {
expect(onApplied).toHaveBeenCalledWith({
theme: "knot",
themeMode: "dark",
textScale: 125,
locale: "de",
chatShowThinking: false,
chatSendShortcut: "modifier-enter",
sidebarLiveActivity: false,
showAdvancedSettings: true,
sidebarEntries: ["route:usage", "session:agent:main:test"],
});
@@ -59,7 +58,7 @@ describe("server pref extraction", () => {
it("ignores invalid values and configs without prefs", () => {
const onApplied = vi.fn();
expect(
applyServerUiPrefs(configWithPrefs({ theme: "neon", textScale: 97, locale: "xx-YY" }), {
applyServerUiPrefs(configWithPrefs({ theme: "neon", locale: "xx-YY" }), {
onApplied,
}),
).toBe(false);
@@ -74,12 +73,11 @@ describe("server pref extraction", () => {
describe("applyServerUiPrefs", () => {
it("applies a server delta to the local mirror once", () => {
const onApplied = vi.fn();
const config = configWithPrefs({ themeMode: "dark", textScale: 110 });
const config = configWithPrefs({ themeMode: "dark" });
expect(applyServerUiPrefs(config, { onApplied })).toBe(true);
expect(loadSettings().themeMode).toBe("dark");
expect(loadSettings().textScale).toBe(110);
expect(onApplied).toHaveBeenCalledWith({ themeMode: "dark", textScale: 110 });
expect(onApplied).toHaveBeenCalledWith({ themeMode: "dark" });
// The same server value never re-applies, so a later local edit sticks.
patchSettings({ themeMode: "light" });
@@ -111,15 +109,15 @@ describe("applyServerUiPrefs", () => {
it("applies only the fields the server actually changed", () => {
const onApplied = vi.fn();
applyServerUiPrefs(configWithPrefs({ themeMode: "dark", textScale: 100 }), { onApplied });
applyServerUiPrefs(configWithPrefs({ themeMode: "dark", locale: "de" }), { onApplied });
// Unpushable local edit on one field...
patchSettings({ themeMode: "light" });
// ...survives a server change to a *different* field.
expect(
applyServerUiPrefs(configWithPrefs({ themeMode: "dark", textScale: 125 }), { onApplied }),
applyServerUiPrefs(configWithPrefs({ themeMode: "dark", locale: "fr" }), { onApplied }),
).toBe(true);
expect(loadSettings().textScale).toBe(125);
expect(loadSettings().locale).toBe("fr");
expect(loadSettings().themeMode).toBe("light");
});
@@ -169,12 +167,16 @@ describe("changedServerUiPrefs", () => {
).toBeNull();
});
it("syncs the live sidebar activity preference", () => {
it("does not sync browser-local presentation preferences", () => {
const previous = loadSettings();
expect(previous.sidebarLiveActivity).toBe(true);
expect(changedServerUiPrefs(previous, { ...previous, sidebarLiveActivity: false })).toEqual({
sidebarLiveActivity: false,
});
expect(
changedServerUiPrefs(previous, {
...previous,
textScale: 125,
sidebarLiveActivity: false,
chatMessageMaxWidth: "82%",
}),
).toBeNull();
});
it("syncs the advanced settings visibility preference", () => {
@@ -314,7 +316,7 @@ describe("pushServerUiPrefs", () => {
const client = { request } as unknown as Parameters<typeof pushServerUiPrefs>[0];
pushServerUiPrefs(client, { themeMode: "dark" });
pushServerUiPrefs(client, { textScale: 125 });
pushServerUiPrefs(client, { locale: "de" });
pushServerUiPrefs(client, { themeMode: "light" });
await vi.waitFor(() => {
@@ -323,7 +325,7 @@ describe("pushServerUiPrefs", () => {
// The first patch carries the first delta; the rest coalesce into one.
expect(patched[0]).toBe(JSON.stringify({ ui: { prefs: { themeMode: "dark" } } }));
expect(patched[1]).toBe(
JSON.stringify({ ui: { prefs: { textScale: 125, themeMode: "light" } } }),
JSON.stringify({ ui: { prefs: { locale: "de", themeMode: "light" } } }),
);
});
@@ -349,7 +351,7 @@ describe("pushServerUiPrefs", () => {
const clientB = { request: requestB } as unknown as Parameters<typeof pushServerUiPrefs>[0];
pushServerUiPrefs(clientA, { themeMode: "dark" });
pushServerUiPrefs(clientB, { textScale: 125 });
pushServerUiPrefs(clientB, { locale: "de" });
resolveFirstGet?.({ hash: "a-1" });
await vi.waitFor(() => {
@@ -359,7 +361,7 @@ describe("pushServerUiPrefs", () => {
expect(requestA.mock.calls.filter(([method]) => method === "config.patch").length).toBe(0);
expect(requestB).toHaveBeenCalledWith("config.patch", {
baseHash: "b-1",
raw: JSON.stringify({ ui: { prefs: { textScale: 125 } } }),
raw: JSON.stringify({ ui: { prefs: { locale: "de" } } }),
note: "control-ui prefs sync",
});
});
@@ -378,7 +380,7 @@ describe("pushServerUiPrefs", () => {
});
const client = { request } as unknown as Parameters<typeof pushServerUiPrefs>[0];
pushServerUiPrefs(client, { textScale: 125 });
pushServerUiPrefs(client, { locale: "de" });
await vi.waitFor(() => {
expect(patchCalls).toBe(2);
});
-12
View File
@@ -13,12 +13,9 @@ import {
loadSettings,
normalizeChatFollowUpModeOverride,
normalizeChatSendShortcut,
normalizeTextScale,
patchSettings,
TEXT_SCALE_STOPS,
type ChatFollowUpMode,
type ChatSendShortcut,
type TextScaleStop,
type UiSettings,
} from "./settings.ts";
import type { ThemeMode, ThemeName } from "./theme.ts";
@@ -61,11 +58,6 @@ const SYNCED_PREFS = {
extract: (value) => (THEME_MODES.has(value as ThemeMode) ? (value as ThemeMode) : undefined),
local: (settings) => settings.themeMode,
}),
textScale: prefSpec<TextScaleStop>({
extract: (value) =>
TEXT_SCALE_STOPS.includes(value as TextScaleStop) ? normalizeTextScale(value) : undefined,
local: (settings) => normalizeTextScale(settings.textScale),
}),
locale: prefSpec<string>({
extract: (value) => (typeof value === "string" && isSupportedLocale(value) ? value : undefined),
local: (settings) => settings.locale,
@@ -100,10 +92,6 @@ const SYNCED_PREFS = {
extract: (value) => normalizeSidebarEntries(value) ?? undefined,
local: (settings) => settings.sidebarEntries,
}),
sidebarLiveActivity: prefSpec<boolean>({
extract: (value) => (typeof value === "boolean" ? value : undefined),
local: (settings) => settings.sidebarLiveActivity !== false,
}),
showAdvancedSettings: prefSpec<boolean>({
extract: (value) => (typeof value === "boolean" ? value : undefined),
local: (settings) => settings.showAdvancedSettings === true,
+21
View File
@@ -5,6 +5,7 @@ import { createStorageMock } from "../test-helpers/storage.ts";
import {
loadLocalUserIdentity,
loadSettings,
normalizeChatMessageMaxWidth,
persistSessionToken,
resolvePageGatewaySettings,
saveSettings,
@@ -628,6 +629,26 @@ describe("loadSettings default gateway URL derivation", () => {
);
});
it("normalizes and persists browser-local chat message width", () => {
setTestLocation({ protocol: "https:", host: "gateway.example:8443", pathname: "/" });
const gwUrl = expectedGatewayUrl("");
const scopedKey = `openclaw.control.settings.v1:${gwUrl}`;
expect(normalizeChatMessageMaxWidth(" min(1280px, 82%) ")).toBe("min(1280px, 82%)");
expect(normalizeChatMessageMaxWidth("960px; color: red")).toBeUndefined();
saveSettings({ ...loadSettings(), chatMessageMaxWidth: " min(1280px, 82%) " });
expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}").chatMessageMaxWidth).toBe(
"min(1280px, 82%)",
);
expect(loadSettings().chatMessageMaxWidth).toBe("min(1280px, 82%)");
saveSettings({ ...loadSettings(), chatMessageMaxWidth: undefined });
expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}")).not.toHaveProperty(
"chatMessageMaxWidth",
);
});
it("persists only a normalized realtime Talk microphone id", () => {
setTestLocation({
protocol: "https:",
+66
View File
@@ -51,6 +51,67 @@ import { normalizeLocalUserIdentity, type LocalUserIdentity } from "./user-ident
export const TEXT_SCALE_STOPS = [90, 100, 110, 125, 140] as const;
export type TextScaleStop = (typeof TEXT_SCALE_STOPS)[number];
const CSS_WIDTH_KEYWORDS = new Set(["none", "min-content", "max-content"]);
const CSS_WIDTH_FUNCTIONS = new Set(["calc", "clamp", "fit-content", "max", "min"]);
const CSS_WIDTH_UNITS = new Set(["ch", "em", "rem", "vh", "vmax", "vmin", "vw", "px"]);
const CSS_WIDTH_ALLOWED_CHARS = /^[0-9A-Za-z.%+\-*/(),\s]+$/;
const CSS_WIDTH_IDENTIFIER_RE = /[A-Za-z][A-Za-z0-9-]*/g;
const CSS_WIDTH_SIMPLE_RE = /^(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|ch|vw|vh|vmin|vmax|%)$/i;
const CSS_WIDTH_MAX_LENGTH = 96;
function hasBalancedParentheses(value: string): boolean {
let depth = 0;
for (const char of value) {
if (char === "(") {
depth++;
} else if (char === ")") {
depth--;
if (depth < 0) {
return false;
}
}
}
return depth === 0;
}
function hasAllowedWidthIdentifiers(value: string): boolean {
for (const match of value.matchAll(CSS_WIDTH_IDENTIFIER_RE)) {
const identifier = match[0].toLowerCase();
if (
!CSS_WIDTH_FUNCTIONS.has(identifier) &&
!CSS_WIDTH_KEYWORDS.has(identifier) &&
!CSS_WIDTH_UNITS.has(identifier)
) {
return false;
}
}
return true;
}
export function normalizeChatMessageMaxWidth(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().replace(/\s+/g, " ");
if (normalized.length === 0) {
return undefined;
}
if (normalized.length > CSS_WIDTH_MAX_LENGTH) {
return undefined;
}
if (CSS_WIDTH_KEYWORDS.has(normalized.toLowerCase()) || CSS_WIDTH_SIMPLE_RE.test(normalized)) {
return normalized;
}
if (
!CSS_WIDTH_ALLOWED_CHARS.test(normalized) ||
!hasBalancedParentheses(normalized) ||
!hasAllowedWidthIdentifiers(normalized)
) {
return undefined;
}
return /^(?:calc|clamp|fit-content|max|min)\(.+\)$/i.test(normalized) ? normalized : undefined;
}
const CHAT_SEND_SHORTCUTS = ["enter", "modifier-enter"] as const;
export type ChatSendShortcut = (typeof CHAT_SEND_SHORTCUTS)[number];
@@ -126,6 +187,7 @@ export type UiSettings = {
navWidth: number; // Sidebar width when expanded (240400px)
sidebarEntries: string[]; // Ordered routes, Workboard boards, and pinned sessions below Home
sidebarLiveActivity?: boolean; // Latest activity under running sidebar sessions (default true)
chatMessageMaxWidth?: string; // Browser-local centered chat transcript max width
showAdvancedSettings?: boolean; // Expand advanced schema settings (default false)
pinnedAgentIds?: string[]; // Agents surfaced first in the agent-chip quick switcher
textScale?: TextScaleStop; // Browser-local text scale percentage
@@ -462,6 +524,7 @@ export function loadSettings(): UiSettings {
typeof parsed.sidebarLiveActivity === "boolean"
? parsed.sidebarLiveActivity
: defaults.sidebarLiveActivity,
chatMessageMaxWidth: normalizeChatMessageMaxWidth(parsed.chatMessageMaxWidth),
showAdvancedSettings:
typeof parsed.showAdvancedSettings === "boolean"
? parsed.showAdvancedSettings
@@ -589,6 +652,9 @@ function persistSettings(next: UiSettings, options: { selectGateway?: boolean }
navWidth: next.navWidth,
sidebarEntries: next.sidebarEntries,
...(next.sidebarLiveActivity === false ? { sidebarLiveActivity: false } : {}),
...(normalizeChatMessageMaxWidth(next.chatMessageMaxWidth)
? { chatMessageMaxWidth: normalizeChatMessageMaxWidth(next.chatMessageMaxWidth) }
: {}),
...(next.showAdvancedSettings === true ? { showAdvancedSettings: true } : {}),
// Empty pin list is the default; only real pins persist.
...(next.pinnedAgentIds && next.pinnedAgentIds.length > 0
@@ -649,7 +649,7 @@ describe("config form renderer", () => {
type: "array",
items: { type: "string" },
},
chatMessageMaxWidth: {
displayWidth: {
type: "string",
},
},
@@ -667,7 +667,7 @@ describe("config form renderer", () => {
lastTouchedAt: "2026-05-05T00:00:00.000Z",
setupCommand: "apt-get update",
allowedDomains: ["example.com"],
chatMessageMaxWidth: "960px",
displayWidth: "960px",
},
onPatch: vi.fn(),
}),
+7
View File
@@ -596,6 +596,13 @@
"path": "ui/src/pages/config/mcp.ts",
"text": "openclaw mcp status --verbose"
},
{
"count": 1,
"kind": "html-attribute",
"name": "placeholder",
"path": "ui/src/pages/config/view.ts",
"text": "48rem"
},
{
"count": 1,
"kind": "html-attribute",
+5
View File
@@ -1366,6 +1366,11 @@ export const en: TranslationMap = {
chatPrefs: {
title: "Chat",
hint: "Browser-local chat preferences.",
messageWidth: "Message width",
messageWidthHint:
"Optional CSS width for the centered transcript, such as 960px, 82%, or min(1280px, 82%).",
messageWidthInvalid:
"Enter a CSS width such as 960px, 82%, min(1280px, 82%), or calc(100% - 2rem).",
},
sidebarPrefs: {
title: "Sidebar",
-1
View File
@@ -80,7 +80,6 @@ function createInitializationContext(): ApplicationContext {
localMediaPreviewRoots: [],
embedSandboxMode: "strict",
allowExternalEmbedUrls: false,
chatMessageMaxWidth: null,
terminalEnabled: false,
},
},
+2 -4
View File
@@ -2764,15 +2764,13 @@ class ChatPane extends OpenClawLightDomElement {
!rootsChanged &&
state.terminalAvailable === previousTerminalAvailable &&
state.embedSandboxMode === config.embedSandboxMode &&
state.allowExternalEmbedUrls === config.allowExternalEmbedUrls &&
state.chatMessageMaxWidth === config.chatMessageMaxWidth
state.allowExternalEmbedUrls === config.allowExternalEmbedUrls
) {
return;
}
state.localMediaPreviewRoots = config.localMediaPreviewRoots;
state.embedSandboxMode = config.embedSandboxMode;
state.allowExternalEmbedUrls = config.allowExternalEmbedUrls;
state.chatMessageMaxWidth = config.chatMessageMaxWidth;
state.requestUpdate?.();
}
@@ -3947,7 +3945,7 @@ class ChatPane extends OpenClawLightDomElement {
localMediaPreviewRoots: state.localMediaPreviewRoots,
embedSandboxMode: state.embedSandboxMode,
allowExternalEmbedUrls: state.allowExternalEmbedUrls,
chatMessageMaxWidth: state.chatMessageMaxWidth,
chatMessageMaxWidth: state.settings.chatMessageMaxWidth,
assistantAttachmentAuthToken: resolveAssistantAttachmentAuthToken(state as never),
basePath: state.basePath,
gatewayUrl: state.settings.gatewayUrl,
-1
View File
@@ -611,7 +611,6 @@ describe("image lightbox lifecycle", () => {
current: {
allowExternalEmbedUrls: false,
assistantIdentity: { name: "Assistant" },
chatMessageMaxWidth: null,
embedSandboxMode: "scripts",
localMediaPreviewRoots: [],
},
-2
View File
@@ -215,7 +215,6 @@ export type ChatPageHost = ChatHost &
localMediaPreviewRoots: string[];
embedSandboxMode: EmbedSandboxMode;
allowExternalEmbedUrls: boolean;
chatMessageMaxWidth: string | null;
chatToolMessages: Record<string, unknown>[];
chatAttachments: ChatAttachment[];
chatQueue: ChatQueueItem[];
@@ -1271,7 +1270,6 @@ export function createPageState(
localMediaPreviewRoots: appConfig.localMediaPreviewRoots,
embedSandboxMode: appConfig.embedSandboxMode,
allowExternalEmbedUrls: appConfig.allowExternalEmbedUrls,
chatMessageMaxWidth: appConfig.chatMessageMaxWidth,
client: null,
connected: false,
connectionEpoch: 0,
+4
View File
@@ -69,6 +69,7 @@ type ConfigSelection = { activeSection: string | null; activeSubsection: string
type ConfigPageSetting =
| "textScale"
| "sidebarLiveActivity"
| "chatMessageMaxWidth"
| "showAdvancedSettings"
| "chatSendShortcut"
| "chatFollowUpMode"
@@ -713,6 +714,7 @@ export class ConfigPage extends OpenClawLightDomElement {
customTheme: next.customTheme,
textScale: next.textScale,
sidebarLiveActivity: next.sidebarLiveActivity,
chatMessageMaxWidth: next.chatMessageMaxWidth,
showAdvancedSettings: next.showAdvancedSettings,
chatSendShortcut: next.chatSendShortcut,
chatFollowUpMode: next.chatFollowUpMode,
@@ -940,6 +942,8 @@ export class ConfigPage extends OpenClawLightDomElement {
setTextScale: (value) => this.setSetting("textScale", normalizeTextScale(value)),
sidebarLiveActivity: this.settings.sidebarLiveActivity !== false,
setSidebarLiveActivity: (enabled) => this.setSetting("sidebarLiveActivity", enabled),
chatMessageMaxWidth: this.settings.chatMessageMaxWidth,
setChatMessageMaxWidth: (value) => this.setSetting("chatMessageMaxWidth", value),
showAdvancedSettings: this.settings.showAdvancedSettings === true,
setShowAdvancedSettings: (enabled) => this.setSetting("showAdvancedSettings", enabled),
forceAdvancedSection: this.routeData?.advanced ? this.routeData.section : null,
+22
View File
@@ -69,6 +69,8 @@ describe("config view", () => {
setTextScale: vi.fn(),
sidebarLiveActivity: true,
setSidebarLiveActivity: vi.fn(),
chatMessageMaxWidth: undefined,
setChatMessageMaxWidth: vi.fn(),
showAdvancedSettings: false,
setShowAdvancedSettings: vi.fn(),
chatSendShortcut: "enter" as const,
@@ -1633,6 +1635,26 @@ describe("config view", () => {
expect(setSidebarLiveActivity).toHaveBeenCalledWith(false);
});
it("validates and changes the browser-local chat width", () => {
const setChatMessageMaxWidth = vi.fn();
const { container } = renderConfigView({
activeSection: "__appearance__",
includeSections: ["__appearance__"],
setChatMessageMaxWidth,
});
const input = container.querySelector<HTMLInputElement>("[data-settings-chat-message-width]");
expect(input).not.toBeNull();
input!.value = " min(1280px, 82%) ";
input!.dispatchEvent(new Event("change", { bubbles: true }));
expect(setChatMessageMaxWidth).toHaveBeenCalledWith("min(1280px, 82%)");
input!.value = "960px; color: red";
input!.dispatchEvent(new Event("change", { bubbles: true }));
expect(input!.validationMessage).not.toBe("");
expect(setChatMessageMaxWidth).toHaveBeenCalledTimes(1);
});
it("marks browser follow-up overrides and resets them to the server", () => {
const setChatFollowUpMode = vi.fn();
const { container } = renderConfigView({
+41 -3
View File
@@ -11,6 +11,7 @@ import {
normalizeChatFollowUpMode,
normalizeChatSendShortcut,
normalizeCatalogOpenTarget,
normalizeChatMessageMaxWidth,
TEXT_SCALE_STOPS,
type ChatFollowUpMode,
type ChatSendShortcut,
@@ -186,6 +187,8 @@ export type ConfigProps = {
setTextScale: (value: number) => void;
sidebarLiveActivity: boolean;
setSidebarLiveActivity: (enabled: boolean) => void;
chatMessageMaxWidth?: string;
setChatMessageMaxWidth: (value: string | undefined) => void;
showAdvancedSettings: boolean;
setShowAdvancedSettings: (enabled: boolean) => void;
forceAdvancedSection?: string | null;
@@ -1067,6 +1070,43 @@ function renderChatPreferencesSection(props: ConfigProps) {
${t("configView.chatPrefs.hint")} ${t("configView.syncedHint")}
</p>
<div class="settings-group">
${renderSettingsRow({
title: t("configView.chatPrefs.messageWidth"),
description: t("configView.chatPrefs.messageWidthHint"),
control: html`
<input
class="settings-input"
data-settings-chat-message-width
type="text"
spellcheck="false"
placeholder="48rem"
.value=${props.chatMessageMaxWidth ?? ""}
@change=${(event: Event) => {
const input = event.currentTarget as HTMLInputElement;
const normalized = normalizeChatMessageMaxWidth(input.value);
if (input.value.trim() && !normalized) {
input.setCustomValidity(t("configView.chatPrefs.messageWidthInvalid"));
input.reportValidity();
return;
}
input.setCustomValidity("");
input.value = normalized ?? "";
props.setChatMessageMaxWidth(normalized);
}}
/>
${props.chatMessageMaxWidth
? html`
<button
type="button"
class="btn btn--sm"
@click=${() => props.setChatMessageMaxWidth(undefined)}
>
${t("common.reset")}
</button>
`
: nothing}
`,
})}
${renderSettingsSelectRow({
title: t("chat.sendShortcut"),
value: props.chatSendShortcut,
@@ -1226,9 +1266,7 @@ function renderSidebarPreferencesSection(props: ConfigProps) {
<div class="settings-section__header">
<h2 class="settings-section__heading">${t("configView.sidebarPrefs.title")}</h2>
</div>
<p class="settings-section__desc">
${t("configView.sidebarPrefs.hint")} ${t("configView.syncedHint")}
</p>
<p class="settings-section__desc">${t("configView.sidebarPrefs.hint")}</p>
<div class="settings-group">
${renderSettingsToggleRow({
title: t("configView.sidebarPrefs.liveActivity"),
-1
View File
@@ -98,7 +98,6 @@ function externalPluginConfig(
localMediaPreviewRoots: [],
embedSandboxMode: "scripts",
allowExternalEmbedUrls: false,
chatMessageMaxWidth: null,
terminalEnabled: false,
pluginFrameGrants,
};