From b9e18567a6ab5f2f2a102c2ea7e8a19286ff3313 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 19 Sep 2026 02:38:55 -0700 Subject: [PATCH] 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. --- docs/web/control-ui/feature-reference.md | 1 + .../server-methods/usage-session-selection.ts | 5 +- ...sage-owner-attribution.integration.test.ts | 132 ++++++++++++++---- 3 files changed, 112 insertions(+), 26 deletions(-) diff --git a/docs/web/control-ui/feature-reference.md b/docs/web/control-ui/feature-reference.md index 744c69c9f12d..e5a593359942 100644 --- a/docs/web/control-ui/feature-reference.md +++ b/docs/web/control-ui/feature-reference.md @@ -88,6 +88,7 @@ Control UI capabilities grouped by area, each with the Gateway RPC methods behin - 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. diff --git a/src/gateway/server-methods/usage-session-selection.ts b/src/gateway/server-methods/usage-session-selection.ts index a7166fcce13e..bd1987837a5c 100644 --- a/src/gateway/server-methods/usage-session-selection.ts +++ b/src/gateway/server-methods/usage-session-selection.ts @@ -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, }, diff --git a/src/gateway/server-methods/usage.sessions-usage-owner-attribution.integration.test.ts b/src/gateway/server-methods/usage.sessions-usage-owner-attribution.integration.test.ts index 70b20964950a..3342ae60cdc7 100644 --- a/src/gateway/server-methods/usage.sessions-usage-owner-attribution.integration.test.ts +++ b/src/gateway/server-methods/usage.sessions-usage-owner-attribution.integration.test.ts @@ -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 }], }, ); }