mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
1603781bb0
commit
bb657eec93
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"core": 2308,
|
||||
"channel": 3632,
|
||||
"core": 2299,
|
||||
"channel": 3630,
|
||||
"plugin": 3556
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-70
@@ -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";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+3
-15
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 (240–400px)
|
||||
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(),
|
||||
}),
|
||||
|
||||
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -80,7 +80,6 @@ function createInitializationContext(): ApplicationContext {
|
||||
localMediaPreviewRoots: [],
|
||||
embedSandboxMode: "strict",
|
||||
allowExternalEmbedUrls: false,
|
||||
chatMessageMaxWidth: null,
|
||||
terminalEnabled: false,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -611,7 +611,6 @@ describe("image lightbox lifecycle", () => {
|
||||
current: {
|
||||
allowExternalEmbedUrls: false,
|
||||
assistantIdentity: { name: "Assistant" },
|
||||
chatMessageMaxWidth: null,
|
||||
embedSandboxMode: "scripts",
|
||||
localMediaPreviewRoots: [],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -98,7 +98,6 @@ function externalPluginConfig(
|
||||
localMediaPreviewRoots: [],
|
||||
embedSandboxMode: "scripts",
|
||||
allowExternalEmbedUrls: false,
|
||||
chatMessageMaxWidth: null,
|
||||
terminalEnabled: false,
|
||||
pluginFrameGrants,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user