Skip to content

Commit dbb7f3a

Browse files
feat(messages): config-level default for the persistent /usage footer
Adds `messages.responseUsage` (precedence session -> channel -> config default -> off) so the persistent /usage footer can default-on, with three distinct states: explicit on (tokens/full), explicit off (persisted), and unset (inherit the configured default). Unifies effective-value resolution behind a single channel-aware resolver `resolveEffectiveResponseUsage` used by reply rendering, the no-arg /usage toggle, the ACP control, and the gateway session-row builder; the row builder's `effectiveResponseUsage` is carried through sessions.changed events, chat snapshots, and the UI row so live consumers never go stale. `/usage reset` (aliases inherit/clear/default) clears the override to inherit; only explicit off persists; a full session reset preserves the preference. ACP "Usage detail" gains an "inherit" option for unset sessions. Docs/help/completions updated; "on" documented as a legacy alias; config-doc baseline regenerated.
1 parent a2725b6 commit dbb7f3a

37 files changed

Lines changed: 761 additions & 49 deletions
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
9246475f5771612a5fd12de38b153783c4a4cbb8b2682a5c40115916661c90f2 config-baseline.json
2-
6349131baaa1828f2a071f42e4d7b17c8966c59b6588c8a4c1a32ea5ea4dcd5e config-baseline.core.json
1+
f5a5855ddd7aa8c23a732f257eceaa20fd163b1d5f342c909f4aef15aa8643cf config-baseline.json
2+
b8dffdb1a328aaf728a0707ab04d21c65f1a225a2360042e10832aa608699716 config-baseline.core.json
33
671979e86e4c4f59415d0a20879e838f9bbd883b3d29eeb02cb5131db8d187fe config-baseline.channel.json
44
94529978588d6e3776a86780b22cf9ff46a6f9957f2f178d3829403fad451ca7 config-baseline.plugin.json

docs/concepts/usage-tracking.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,68 @@ title: "Usage tracking"
3030
- CLI: `openclaw channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
3131
- macOS menu bar: "Usage" section under Context (only if available).
3232

33+
## Default usage footer mode
34+
35+
`/usage off|tokens|full` sets the footer for a session and is remembered for that
36+
session. `messages.responseUsage` seeds that mode for sessions that have not
37+
chosen one, so the footer can be on by default without typing `/usage` each time.
38+
39+
Set one mode for every channel, or a per-channel map with a `default` fallback:
40+
41+
```jsonc
42+
{
43+
"messages": {
44+
"responseUsage": "tokens",
45+
// or: { "default": "off", "discord": "full" }
46+
},
47+
}
48+
```
49+
50+
### Three distinct session states
51+
52+
A session's `responseUsage` field has three representable states, each with
53+
different semantics:
54+
55+
| State | Stored value | Effective mode |
56+
| ------------------- | ------------------------------- | --------------------------------------------------------------------- |
57+
| **Unset / inherit** | `undefined` (absent) | Falls through to `messages.responseUsage` config default, then `off`. |
58+
| **Explicit off** | `"off"` (stored) | Always off — a non-off config default cannot re-enable the footer. |
59+
| **Explicit on** | `"tokens"` or `"full"` (stored) | That mode, regardless of config default. |
60+
61+
### Precedence
62+
63+
Effective mode = session override → channel config entry → `default``off`.
64+
65+
An explicit `/usage off` is **persisted** as the literal value `"off"` in the
66+
session, not the same as "unset." This means a non-off `messages.responseUsage`
67+
default cannot turn the footer back on once the user has explicitly disabled it.
68+
69+
### Resetting vs. turning off
70+
71+
- `/usage off` — forces the footer off and persists that choice. A configured
72+
non-off default cannot override this.
73+
- `/usage reset` (aliases: `inherit`, `clear`, `default`) — clears the session
74+
override. The session then **inherits** the effective config default
75+
(`messages.responseUsage`). If no default is configured, the footer is off
76+
(unchanged from before). Use this to "go back to default" without explicitly
77+
turning the footer on.
78+
- A full session reset (`/reset` or `/new`) or a session rollover **preserves**
79+
the explicit usage-mode preference so the user's display choice survives
80+
session rollovers. Only `/usage reset` (and its aliases) actually clears the
81+
override.
82+
83+
### Toggle behavior
84+
85+
`/usage` with no arguments cycles: off → tokens → full → off. The starting point
86+
for the cycle is the **effective** current mode (session override falling through
87+
to the config default when unset), so the cycle is always consistent with what
88+
the user sees in the footer.
89+
90+
### Config
91+
92+
With no config the prior behavior holds (footer off until `/usage`). Use
93+
`/usage reset` to clear a session override and re-inherit the configured default.
94+
3395
## Custom `/usage full` footer
3496

3597
`/usage full` shows a built-in compact footer with model, reasoning, fast/slow,

docs/reference/token-use.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ Use these in chat:
7676
configured for the active model.
7777
- `/usage off|tokens|full` → appends a **per-response usage footer** to every reply.
7878
- Persists per session (stored as `responseUsage`).
79+
- `/usage reset` (aliases: `inherit`, `clear`, `default`) — clears the session
80+
override so the session re-inherits the configured default.
7981
- `/usage full` shows estimated cost only when OpenClaw has usage metadata and
8082
local pricing for the active model. Otherwise it shows tokens only.
8183
- `/usage cost` → shows a local cost summary from OpenClaw session logs.

docs/tools/slash-commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ plugins.
240240
| `/tasks` | List active/recent background tasks for the current session |
241241
| `/context [list\|detail\|map\|json]` | Explain how context is assembled |
242242
| `/whoami` | Show your sender id. Alias: `/id` |
243-
| `/usage off\|tokens\|full\|cost` | Control the per-response usage footer or print a local cost summary |
243+
| `/usage off\|tokens\|full\|reset\|cost` | Control the per-response usage footer (`reset`/`inherit`/`clear`/`default` clears the session override to re-inherit the configured default) or print a local cost summary |
244244
</Accordion>
245245

246246
<Accordion title="Skills, allowlists, approvals">

docs/web/tui.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ Session controls:
126126
- `/verbose <on|full|off>`
127127
- `/trace <on|off>`
128128
- `/reasoning <on|off|stream>`
129-
- `/usage <off|tokens|full>`
129+
- `/usage <off|tokens|full|reset>` (`reset`/`inherit`/`clear`/`default` clears the session override)
130130
- `/goal [status] | /goal start <objective> | /goal pause|resume|complete|block|clear`
131131
- `/elevated <on|off|ask|full>` (alias: `/elev`)
132132
- `/activation <mention|always>`

src/acp/commands.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const BASE_AVAILABLE_COMMANDS: AvailableCommand[] = [
1616
{ name: "subagents", description: "List or manage sub-agents." },
1717
{ name: "config", description: "Read or write config (owner-only)." },
1818
{ name: "debug", description: "Set runtime-only overrides (owner-only)." },
19-
{ name: "usage", description: "Toggle usage footer (off|tokens|full)." },
19+
{ name: "usage", description: "Toggle usage footer (off|tokens|full|reset). 'reset'/'inherit'/'clear'/'default' clears the session override to re-inherit the configured default." },
2020
{ name: "stop", description: "Stop the current run." },
2121
{ name: "restart", description: "Restart the gateway (if enabled)." },
2222
{ name: "activation", description: "Set group activation (mention|always)." },

src/acp/translator.presentation.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,9 +221,11 @@ export function buildSessionPresentation(params: {
221221
id: ACP_RESPONSE_USAGE_CONFIG_ID,
222222
name: "Usage detail",
223223
description:
224-
"Controls how much usage information OpenClaw attaches to responses for the session.",
225-
currentValue: normalizeOptionalString(row.responseUsage) || "off",
226-
values: ["off", "tokens", "full"],
224+
"Controls how much usage information OpenClaw attaches to responses for the session. 'inherit' follows the configured default; 'off' explicitly disables it for this session.",
225+
// Unset must read as "inherit" (follows the configured default), NOT "off" —
226+
// an unset session inheriting a non-off default would otherwise misreport as off.
227+
currentValue: normalizeOptionalString(row.responseUsage) || "inherit",
228+
values: ["inherit", "off", "tokens", "full"],
227229
}),
228230
buildSelectConfigOption({
229231
id: ACP_ELEVATED_LEVEL_CONFIG_ID,

src/acp/translator.session-config.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,4 +358,106 @@ describe("acp setSessionConfigOption bridge behavior", () => {
358358

359359
sessionStore.clearAllSessionsForTest();
360360
});
361+
362+
it('maps response_usage "inherit" selection to sessions.patch with responseUsage: null', async () => {
363+
const sessionStore = createInMemorySessionStore();
364+
const connection = createAcpConnection();
365+
const request = vi.fn(async (method: string, _params?: unknown) => {
366+
if (method === "sessions.list") {
367+
return {
368+
ts: Date.now(),
369+
path: "/tmp/sessions.json",
370+
count: 1,
371+
defaults: { modelProvider: null, model: null, contextTokens: null },
372+
sessions: [
373+
{
374+
key: "usage-inherit-session",
375+
kind: "direct",
376+
updatedAt: Date.now(),
377+
thinkingLevel: "minimal",
378+
modelProvider: "openai",
379+
model: "gpt-5.4",
380+
responseUsage: "tokens",
381+
},
382+
],
383+
};
384+
}
385+
if (method === "sessions.patch") {
386+
expect(requireRecord(_params, "sessions.patch params")).toMatchObject({
387+
key: "usage-inherit-session",
388+
responseUsage: null,
389+
});
390+
}
391+
return { ok: true };
392+
}) as GatewayClient["request"];
393+
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
394+
sessionStore,
395+
});
396+
397+
await agent.loadSession(createLoadSessionRequest("usage-inherit-session"));
398+
399+
const result = await agent.setSessionConfigOption(
400+
createSetSessionConfigOptionRequest("usage-inherit-session", "response_usage", "inherit"),
401+
);
402+
403+
// After selecting "inherit", the ACP config option should report "inherit" (unset).
404+
expectConfigOption(result.configOptions, "response_usage", { currentValue: "inherit" });
405+
expect(
406+
(request as unknown as MockCallSource).mock.calls.some(
407+
([method]) => method === "sessions.patch",
408+
),
409+
).toBe(true);
410+
411+
sessionStore.clearAllSessionsForTest();
412+
});
413+
414+
it('maps response_usage "off" selection to sessions.patch with responseUsage: "off"', async () => {
415+
const sessionStore = createInMemorySessionStore();
416+
const connection = createAcpConnection();
417+
const request = vi.fn(async (method: string, _params?: unknown) => {
418+
if (method === "sessions.list") {
419+
return {
420+
ts: Date.now(),
421+
path: "/tmp/sessions.json",
422+
count: 1,
423+
defaults: { modelProvider: null, model: null, contextTokens: null },
424+
sessions: [
425+
{
426+
key: "usage-off-session",
427+
kind: "direct",
428+
updatedAt: Date.now(),
429+
thinkingLevel: "minimal",
430+
modelProvider: "openai",
431+
model: "gpt-5.4",
432+
},
433+
],
434+
};
435+
}
436+
if (method === "sessions.patch") {
437+
expect(requireRecord(_params, "sessions.patch params")).toMatchObject({
438+
key: "usage-off-session",
439+
responseUsage: "off",
440+
});
441+
}
442+
return { ok: true };
443+
}) as GatewayClient["request"];
444+
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
445+
sessionStore,
446+
});
447+
448+
await agent.loadSession(createLoadSessionRequest("usage-off-session"));
449+
450+
const result = await agent.setSessionConfigOption(
451+
createSetSessionConfigOptionRequest("usage-off-session", "response_usage", "off"),
452+
);
453+
454+
expectConfigOption(result.configOptions, "response_usage", { currentValue: "off" });
455+
expect(
456+
(request as unknown as MockCallSource).mock.calls.some(
457+
([method]) => method === "sessions.patch",
458+
),
459+
).toBe(true);
460+
461+
sessionStore.clearAllSessionsForTest();
462+
});
361463
});

src/acp/translator.session-setup.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ describe("acp session UX bridge behavior", () => {
9898
});
9999
expectConfigOption(result.configOptions, "verbose_level", { currentValue: "off" });
100100
expectConfigOption(result.configOptions, "reasoning_level", { currentValue: "off" });
101-
expectConfigOption(result.configOptions, "response_usage", { currentValue: "off" });
101+
// Unset session inherits the configured default → control reads "inherit", not "off".
102+
expectConfigOption(result.configOptions, "response_usage", { currentValue: "inherit" });
102103
expectConfigOption(result.configOptions, "elevated_level", { currentValue: "off" });
103104

104105
sessionStore.clearAllSessionsForTest();

src/acp/translator.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1592,7 +1592,10 @@ export class AcpGatewayAgent implements Agent {
15921592
value: string | boolean,
15931593
): {
15941594
overrides: Partial<GatewaySessionPresentationRow>;
1595-
patch?: Record<string, string | boolean>;
1595+
// null lets a control clear a session override (e.g. responseUsage "inherit" →
1596+
// delete → follow the configured default); the gateway sessions.patch handler
1597+
// treats null as "clear/inherit".
1598+
patch?: Record<string, string | boolean | null>;
15961599
} {
15971600
if (typeof value !== "string") {
15981601
throw new Error(
@@ -1630,11 +1633,15 @@ export class AcpGatewayAgent implements Agent {
16301633
patch: { reasoningLevel: value },
16311634
overrides: { reasoningLevel: value },
16321635
};
1633-
case ACP_RESPONSE_USAGE_CONFIG_ID:
1636+
case ACP_RESPONSE_USAGE_CONFIG_ID: {
1637+
// "inherit" clears the session override so the session follows the
1638+
// configured default (distinct from an explicit "off"): null → delete.
1639+
const next = value === "inherit" ? null : value;
16341640
return {
1635-
patch: { responseUsage: value },
1636-
overrides: { responseUsage: value as GatewaySessionPresentationRow["responseUsage"] },
1641+
patch: { responseUsage: next },
1642+
overrides: { responseUsage: next as GatewaySessionPresentationRow["responseUsage"] },
16371643
};
1644+
}
16381645
case ACP_ELEVATED_LEVEL_CONFIG_ID:
16391646
return {
16401647
patch: { elevatedLevel: value },

0 commit comments

Comments
 (0)