mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
fix(usage): show saved conversation names (#152690)
Reuse the canonical Gateway display-name resolver for overview, selected-session, and historical-family usage rows. Generated titles now appear while explicit renames retain precedence and unnamed sessions retain the key fallback. Verified original title-omission regression, 52 focused tests, 34 scoped checks, and clean independent review.
This commit is contained in:
parent
caf70e29b6
commit
b9e18567a6
3 changed files with 112 additions and 26 deletions
|
|
@ -88,6 +88,7 @@ Control UI capabilities grouped by area, each with the Gateway RPC methods behin
|
|||
</Accordion>
|
||||
<Accordion title="Usage">
|
||||
- Session-derived token and estimated-cost analysis stays separate from provider billing.
|
||||
- Session rows and selected details show saved conversation names, including generated titles. Explicit renames take precedence; unnamed sessions show their keys.
|
||||
- The Sessions card counts the rows currently shown: up to 50 in **All**, or matching sessions selected on this Usage page in **Recently viewed**. The total is the loaded session count for the agent scope; the separate selected-session comparison does not increase the shown count.
|
||||
- Filter sessions with the provider, model, channel, or tool menus, or type case-insensitive `key:value` terms. Values within one category match as alternatives. Toggling a menu option preserves the other filters and their quoted text.
|
||||
- Selecting days narrows token and cost totals to those days within the active session filters. Daily charts and exports retain that session scope. Provider/model/tool queries select matching sessions, including all usage within each matched session; hour filters select sessions active in those hours.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { resolveExistingUsageSessionFile } from "../../infra/session-cost-usage.
|
|||
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { resolvePreferredSessionKeyForSessionIdMatches } from "../../sessions/session-id-resolution.js";
|
||||
import { resolveStoredSessionKeyForAgentStore } from "../session-store-key.js";
|
||||
import { resolveGatewaySessionDisplayName } from "../session-utils-display.js";
|
||||
import {
|
||||
loadCombinedSessionStoreForGatewayCore,
|
||||
loadGatewaySessionEntryReadOnly,
|
||||
|
|
@ -311,7 +312,7 @@ export async function selectUsageSessions(params: {
|
|||
agentId: agentIdFromKey,
|
||||
sessionId,
|
||||
sessionFile,
|
||||
label: storeEntry?.label,
|
||||
label: resolveGatewaySessionDisplayName(resolvedStoreKey, storeEntry),
|
||||
updatedAt,
|
||||
storeEntry,
|
||||
},
|
||||
|
|
@ -370,7 +371,7 @@ export async function selectUsageSessions(params: {
|
|||
agentId: discovered.agentId,
|
||||
sessionId: entry.sessionId,
|
||||
sessionFile,
|
||||
label: entry.label,
|
||||
label: resolveGatewaySessionDisplayName(key, entry),
|
||||
updatedAt: entry.updatedAt ?? discovered.mtime,
|
||||
storeEntry: entry,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -28,6 +28,102 @@ import { createDirectChatContext } from "../server-chat.agent-events.test-helper
|
|||
import type { RespondFn } from "./types.js";
|
||||
import { usageHandlers } from "./usage.js";
|
||||
|
||||
function usageMessage(tokens: number, timestamp: number): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Recorded usage" }],
|
||||
api: "openai-responses",
|
||||
provider: "fixture",
|
||||
model: "usage-model",
|
||||
stopReason: "stop",
|
||||
timestamp,
|
||||
usage: {
|
||||
input: tokens,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: tokens,
|
||||
cost: { input: 0.01, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.01 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "generated title",
|
||||
displayName: "Usage worktree",
|
||||
label: undefined,
|
||||
expected: "Usage worktree",
|
||||
},
|
||||
{
|
||||
name: "explicit rename",
|
||||
displayName: "Generated title",
|
||||
label: "My renamed chat",
|
||||
expected: "My renamed chat",
|
||||
},
|
||||
{ name: "unnamed session", displayName: undefined, label: undefined, expected: undefined },
|
||||
])(
|
||||
"projects the $name through overview and selected usage",
|
||||
async ({ displayName, label, expected }) => {
|
||||
const state = await createOpenClawTestState({ label: "usage-session-title" });
|
||||
try {
|
||||
await state.writeConfig({
|
||||
agents: { ownership: "explicit", entries: { main: {} } },
|
||||
plugins: { enabled: false },
|
||||
});
|
||||
const config = getRuntimeConfig();
|
||||
const key = "agent:main:dashboard:usage-title";
|
||||
const sessionId = "usage-title-instance";
|
||||
const timestamp = Date.now();
|
||||
const scope = {
|
||||
agentId: "main",
|
||||
sessionKey: key,
|
||||
sessionId,
|
||||
storePath: path.join(state.sessionsDir(), "sessions.json"),
|
||||
};
|
||||
await upsertSessionEntryCore(scope, { sessionId, updatedAt: timestamp, displayName, label });
|
||||
await persistSessionTranscriptTurn(scope, {
|
||||
cwd: state.workspaceDir,
|
||||
updateMode: "none",
|
||||
messages: [{ message: usageMessage(17, timestamp), now: timestamp }],
|
||||
});
|
||||
// Wait for the real accounting projection before testing its presentation metadata.
|
||||
await loadSessionCostSummary({ agentId: "main", sessionId, sessionTarget: scope, config });
|
||||
|
||||
for (const specificKey of [undefined, key]) {
|
||||
const respond = vi.fn();
|
||||
await expectDefined(
|
||||
usageHandlers["sessions.usage"],
|
||||
"usage handler",
|
||||
)({
|
||||
params: {
|
||||
...(specificKey ? { key: specificKey } : {}),
|
||||
range: "all",
|
||||
groupBy: "instance",
|
||||
},
|
||||
context: { getRuntimeConfig: () => config },
|
||||
respond,
|
||||
} as unknown as Parameters<(typeof usageHandlers)["sessions.usage"]>[0]);
|
||||
expect(respond).toHaveBeenCalledOnce();
|
||||
const [ok, payload] = expectDefined(respond.mock.calls[0], "usage response");
|
||||
expect(ok).toBe(true);
|
||||
const result = payload as SessionsUsageResult;
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
key,
|
||||
sessionId,
|
||||
agentId: "main",
|
||||
label: expected,
|
||||
usage: { totalTokens: 17, totalCost: 0.01 },
|
||||
});
|
||||
expect(result.totals).toMatchObject({ totalTokens: 17, totalCost: 0.01 });
|
||||
}
|
||||
} finally {
|
||||
await state.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("hydrates context metadata only for emitted usage rows while aggregating every match", async () => {
|
||||
const state = await createOpenClawTestState({ label: "usage-page-metadata" });
|
||||
try {
|
||||
|
|
@ -288,7 +384,8 @@ it.each([
|
|||
await upsertSessionEntryCore(scope, {
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
label: `${agentId} chat`,
|
||||
displayName: `${agentId} generated chat`,
|
||||
...(agentId === "main" ? {} : { label: `${agentId} chat` }),
|
||||
});
|
||||
}
|
||||
await persistSessionTranscriptTurn(scope, {
|
||||
|
|
@ -320,10 +417,14 @@ it.each([
|
|||
expect(ok).toBe(true);
|
||||
const result = payload as SessionsUsageResult;
|
||||
expect(result.sessions).toHaveLength(2);
|
||||
expect(result.sessions.map(({ key, agentId }) => ({ key, agentId }))).toEqual(
|
||||
expect(result.sessions.map(({ key, agentId, label }) => ({ key, agentId, label }))).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ key: mainKey, agentId: "main" },
|
||||
{ key: opusKey ?? `agent:${owner}:${sessionId}`, agentId: owner },
|
||||
{ key: mainKey, agentId: "main", label: "main generated chat" },
|
||||
{
|
||||
key: opusKey ?? `agent:${owner}:${sessionId}`,
|
||||
agentId: owner,
|
||||
label: opusKey ? `${owner} chat` : undefined,
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (opusKey) {
|
||||
|
|
@ -422,25 +523,8 @@ it.each([
|
|||
});
|
||||
const mainScope = scopeFor("main", mainKey);
|
||||
const opusScope = scopeFor("opus", opusKey);
|
||||
const usageMessage = (tokens: number): AssistantMessage => ({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Recorded usage" }],
|
||||
api: "openai-responses",
|
||||
provider: "fixture",
|
||||
model: "usage-model",
|
||||
stopReason: "stop",
|
||||
timestamp,
|
||||
usage: {
|
||||
input: tokens,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: tokens,
|
||||
cost: { input: 0.01, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.01 },
|
||||
},
|
||||
});
|
||||
const writeArtifact = async (tokens: number) => {
|
||||
archiveManager.appendMessage(usageMessage(tokens));
|
||||
archiveManager.appendMessage(usageMessage(tokens, timestamp));
|
||||
const content = [archiveManager.getHeader(), ...archiveManager.getEntries()]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n");
|
||||
|
|
@ -464,7 +548,7 @@ it.each([
|
|||
] as const) {
|
||||
await upsertSessionEntryCore(scope, {
|
||||
sessionId,
|
||||
label: `${scope.agentId} chat`,
|
||||
displayName: `${scope.agentId} chat`,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
if (artifact && scope.agentId === "main" && sessionId === firstId) {
|
||||
|
|
@ -476,7 +560,7 @@ it.each([
|
|||
{
|
||||
cwd: state.workspaceDir,
|
||||
updateMode: "none",
|
||||
messages: [{ message: usageMessage(tokens), now: timestamp }],
|
||||
messages: [{ message: usageMessage(tokens, timestamp), now: timestamp }],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue