diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 409e7dc43d8..33ae6ca49c6 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -1166,6 +1166,89 @@ describe("listSessionsFromStore search", () => { } }); + test("listSessionsFromStoreAsync uses subagent run model for child session transcript fallback", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-utils-async-subagent-")); + const storePath = path.join(tmpDir, "sessions.json"); + const now = Date.now(); + const cfg = { + gateway: { + sessionsList: { + fallbackConcurrency: 4, + }, + }, + session: { mainKey: "main" }, + agents: { + list: [{ id: "main", default: true }], + defaults: { + models: { + "anthropic/claude-sonnet-4-6": { params: { context1m: true } }, + }, + }, + }, + } as unknown as OpenClawConfig; + fs.writeFileSync( + path.join(tmpDir, "sess-child.jsonl"), + [ + JSON.stringify({ type: "session", version: 1, id: "sess-child" }), + JSON.stringify({ + message: { + role: "assistant", + usage: { + input: 2_000, + output: 500, + cacheRead: 1_200, + cost: { total: 0.007725 }, + }, + }, + }), + ].join("\n"), + "utf-8", + ); + + addSubagentRunForTests({ + runId: "run-child-async", + childSessionKey: "agent:main:subagent:child-async", + controllerSessionKey: "agent:main:main", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "child task", + cleanup: "keep", + createdAt: now - 5_000, + startedAt: now - 4_000, + model: "anthropic/claude-sonnet-4-6", + }); + + try { + const result = await listSessionsFromStoreAsync({ + cfg, + storePath, + store: { + "agent:main:subagent:child-async": { + sessionId: "sess-child", + updatedAt: now, + spawnedBy: "agent:main:main", + totalTokens: 0, + totalTokensFresh: false, + } as SessionEntry, + }, + opts: {}, + }); + + expect(result.sessions[0]).toMatchObject({ + key: "agent:main:subagent:child-async", + status: "running", + modelProvider: "anthropic", + model: "claude-sonnet-4-6", + totalTokens: 3_200, + totalTokensFresh: true, + contextTokens: 1_048_576, + }); + expect(result.sessions[0]?.estimatedCostUsd).toBeCloseTo(0.007725, 8); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + test("listSessionsFromStoreAsync hydrates transcript fallbacks when concurrency is enabled", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-utils-async-")); const storePath = path.join(tmpDir, "sessions.json"); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 099d28767fd..25c50119c79 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -49,13 +49,13 @@ import { import { normalizeSessionDeliveryFields } from "../utils/delivery-context.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; import { estimateUsageCost, resolveModelCostConfig } from "../utils/usage-format.js"; +import type { SessionsListParams } from "./protocol/index.js"; import { readLatestSessionUsageFromTranscript, readLatestSessionUsageFromTranscriptAsync, readSessionTitleFieldsFromTranscript, type SessionTranscriptUsageSnapshot, } from "./session-utils.fs.js"; -import type { SessionsListParams } from "./protocol/index.js"; import type { GatewayAgentRow, GatewaySessionRow, @@ -424,7 +424,13 @@ function resolveSessionEntryModelIdentity(params: { const sessionAgentId = normalizeAgentId( parseAgentSessionKey(params.key)?.agentId ?? resolveDefaultAgentId(params.cfg), ); - const resolved = resolveSessionModelIdentityRef(params.cfg, params.entry, sessionAgentId); + const subagentRun = getSubagentRunByChildSessionKey(params.key); + const resolved = resolveSessionModelIdentityRef( + params.cfg, + params.entry, + sessionAgentId, + subagentRun?.model, + ); return { provider: resolved.provider, model: resolved.model ?? DEFAULT_MODEL, @@ -1422,19 +1428,18 @@ export function listSessionsFromStore(params: { const { cfg, storePath, store, opts } = params; const normalizedOpts = normalizeSessionsListOptions(opts); - let sessions = filterSessionListEntries(store, normalizedOpts) - .map(([key, entry]) => - buildGatewaySessionRow({ - cfg, - storePath, - store, - key, - entry, - now: normalizedOpts.now, - includeDerivedTitles: normalizedOpts.includeDerivedTitles, - includeLastMessage: normalizedOpts.includeLastMessage, - }), - ); + let sessions = filterSessionListEntries(store, normalizedOpts).map(([key, entry]) => + buildGatewaySessionRow({ + cfg, + storePath, + store, + key, + entry, + now: normalizedOpts.now, + includeDerivedTitles: normalizedOpts.includeDerivedTitles, + includeLastMessage: normalizedOpts.includeLastMessage, + }), + ); sessions = finalizeSessionListRows(sessions, normalizedOpts); return { @@ -1460,36 +1465,35 @@ export async function listSessionsFromStoreAsync(params: { const normalizedOpts = normalizeSessionsListOptions(params.opts); const filteredEntries = filterSessionListEntries(params.store, normalizedOpts); const transcriptUsageByKey = new Map(); - const tasks = filteredEntries - .filter(([key, entry]) => { - const resolvedModel = resolveSessionEntryModelIdentity({ - cfg: params.cfg, - key, - entry, - }); - return shouldResolveTranscriptUsageFallback({ - cfg: params.cfg, - entry, - fallbackProvider: resolvedModel.provider, - fallbackModel: resolvedModel.model, - }); - }) - .map(([key, entry]) => async () => { - const resolvedModel = resolveSessionEntryModelIdentity({ - cfg: params.cfg, - key, - entry, - }); - const usage = await resolveTranscriptUsageFallbackAsync({ - cfg: params.cfg, - key, - entry, - storePath: params.storePath, - fallbackProvider: resolvedModel.provider, - fallbackModel: resolvedModel.model, - }); - return { key, usage }; + const entriesNeedingFallback = filteredEntries.flatMap(([key, entry]) => { + const resolvedModel = resolveSessionEntryModelIdentity({ + cfg: params.cfg, + key, + entry, }); + if ( + !shouldResolveTranscriptUsageFallback({ + cfg: params.cfg, + entry, + fallbackProvider: resolvedModel.provider, + fallbackModel: resolvedModel.model, + }) + ) { + return []; + } + return [{ key, entry, resolvedModel }]; + }); + const tasks = entriesNeedingFallback.map(({ key, entry, resolvedModel }) => async () => { + const usage = await resolveTranscriptUsageFallbackAsync({ + cfg: params.cfg, + key, + entry, + storePath: params.storePath, + fallbackProvider: resolvedModel.provider, + fallbackModel: resolvedModel.model, + }); + return { key, usage }; + }); if (tasks.length > 0) { const { results } = await runTasksWithConcurrency({