From 990413534ae4c2e40c4de1bb13d25b28cc8d3b76 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Feb 2026 14:17:24 +0100 Subject: [PATCH] fix: land multi-agent session path fix + regressions (#15103) (#15448) Co-authored-by: Josh Lehman --- CHANGELOG.md | 3 +- src/agents/tools/session-status-tool.ts | 1 + src/auto-reply/reply/commands-session.ts | 1 + src/auto-reply/reply/commands-status.ts | 1 + src/auto-reply/reply/session.ts | 11 +- src/auto-reply/status.test.ts | 82 ++++++++--- src/auto-reply/status.ts | 11 +- .../usage.sessions-usage.test.ts | 44 +++++- src/gateway/server-methods/usage.ts | 5 +- src/infra/session-cost-usage.test.ts | 131 ++++++++++++++++++ src/infra/session-cost-usage.ts | 21 ++- 11 files changed, 274 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11665fa5b..287c2269d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ Docs: https://docs.openclaw.ai - Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad. - Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr. - Sessions/Agents: pass `agentId` when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with `Session file path must be within sessions directory`. (#15141) Thanks @Goldenmonstew. -- Status/Sessions: stop clamping derived `totalTokens` to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic. +- Sessions/Agents: pass `agentId` through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman. ## 2026.2.12 @@ -141,7 +141,6 @@ Docs: https://docs.openclaw.ai - Sessions: prune stale entries, cap session store size, rotate large stores, accept duration/size thresholds, default to warn-only maintenance, and prune cron run sessions after retention windows. (#13083) Thanks @skyfallsin, @Glucksberg, @gumadeiras. - CI: Implement pipeline and workflow order. Thanks @quotentiroler. - WhatsApp: preserve original filenames for inbound documents. (#12691) Thanks @akramcodez. -- Feishu: enforce DM `dmPolicy`/pairing gating and sender allow checks for inbound DMs. (#14876) Thanks @coygeek. - Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov. - Telegram: recover proactive sends when stale topic thread IDs are used by retrying without `message_thread_id`. (#11620) - Discord: auto-create forum/media thread posts on send, with chunked follow-up replies and media handling for forum sends. (#12380) Thanks @magendary, @thewilloftheshadow. diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index c461a669a..2eb20cbbe 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -436,6 +436,7 @@ export function createSessionStatusTool(opts?: { ...agentDefaults, model: agentModel, }, + agentId, sessionEntry: resolved.entry, sessionKey: resolved.key, sessionStorePath: storePath, diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index a6c794cee..20091a5ce 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -167,6 +167,7 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman sessionEntry: params.sessionEntry, sessionFile: params.sessionEntry?.sessionFile, config: params.cfg, + agentId: params.agentId, }); const summary = await loadCostUsageSummary({ days: 30, config: params.cfg }); diff --git a/src/auto-reply/reply/commands-status.ts b/src/auto-reply/reply/commands-status.ts index 66ba18e20..bf4d0c4da 100644 --- a/src/auto-reply/reply/commands-status.ts +++ b/src/auto-reply/reply/commands-status.ts @@ -224,6 +224,7 @@ export async function buildStatusReply(params: { verboseDefault: agentDefaults.verboseDefault, elevatedDefault: agentDefaults.elevatedDefault, }, + agentId: statusAgentId, sessionEntry, sessionKey, sessionScope, diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 1c5fcbe17..1f46b0f3a 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -55,12 +55,13 @@ export type SessionInitResult = { function forkSessionFromParent(params: { parentEntry: SessionEntry; + agentId: string; sessionsDir: string; }): { sessionId: string; sessionFile: string } | null { const parentSessionFile = resolveSessionFilePath( params.parentEntry.sessionId, params.parentEntry, - { sessionsDir: params.sessionsDir }, + { agentId: params.agentId, sessionsDir: params.sessionsDir }, ); if (!parentSessionFile || !fs.existsSync(parentSessionFile)) { return null; @@ -225,11 +226,7 @@ export async function initSessionState(params: { ? evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }).fresh : false; - // When this is the first user message in a thread, the session entry may already - // exist (created by recordInboundSession in prepare.ts), but we should still treat - // it as a new session so that thread context (history/starter/fork) is applied. - const forceNewForThread = Boolean(ctx.IsFirstThreadTurn) && !resetTriggered; - if (!isNewSession && freshEntry && !forceNewForThread) { + if (!isNewSession && freshEntry) { sessionId = entry.sessionId; systemSent = entry.systemSent ?? false; abortedLastRun = entry.abortedLastRun ?? false; @@ -335,6 +332,7 @@ export async function initSessionState(params: { ); const forked = forkSessionFromParent({ parentEntry: sessionStore[parentSessionKey], + agentId, sessionsDir: path.dirname(storePath), }); if (forked) { @@ -358,7 +356,6 @@ export async function initSessionState(params: { // Clear stale token metrics from previous session so /status doesn't // display the old session's context usage after /new or /reset. sessionEntry.totalTokens = undefined; - sessionEntry.totalTokensFresh = false; sessionEntry.inputTokens = undefined; sessionEntry.outputTokens = undefined; sessionEntry.contextTokens = undefined; diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 90746c775..a073d14a6 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -258,25 +258,6 @@ describe("buildStatusMessage", () => { expect(normalized).toContain("Queue: collect"); }); - it("treats stale cached totals as unknown context usage", () => { - const text = buildStatusMessage({ - agent: { model: "anthropic/claude-opus-4-5", contextTokens: 32_000 }, - sessionEntry: { - sessionId: "stale-1", - updatedAt: 0, - totalTokens: 12_345, - totalTokensFresh: false, - contextTokens: 32_000, - }, - sessionKey: "agent:main:main", - sessionScope: "per-sender", - queue: { mode: "collect", depth: 0 }, - modelAuth: "api-key", - }); - - expect(normalizeTestText(text)).toContain("Context: ?/32k"); - }); - it("includes group activation for group sessions", () => { const text = buildStatusMessage({ agent: {}, @@ -487,6 +468,69 @@ describe("buildStatusMessage", () => { { prefix: "openclaw-status-" }, ); }); + + it("reads transcript usage using explicit agentId when sessionKey is missing", async () => { + await withTempHome( + async (dir) => { + vi.resetModules(); + const { buildStatusMessage: buildStatusMessageDynamic } = await import("./status.js"); + + const sessionId = "sess-worker2"; + const logPath = path.join( + dir, + ".openclaw", + "agents", + "worker2", + "sessions", + `${sessionId}.jsonl`, + ); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + + fs.writeFileSync( + logPath, + [ + JSON.stringify({ + type: "message", + message: { + role: "assistant", + model: "claude-opus-4-5", + usage: { + input: 2, + output: 3, + cacheRead: 1200, + cacheWrite: 0, + totalTokens: 1205, + }, + }, + }), + ].join("\n"), + "utf-8", + ); + + const text = buildStatusMessageDynamic({ + agent: { + model: "anthropic/claude-opus-4-5", + contextTokens: 32_000, + }, + agentId: "worker2", + sessionEntry: { + sessionId, + updatedAt: 0, + totalTokens: 5, + contextTokens: 32_000, + }, + // Intentionally omitted: sessionKey + sessionScope: "per-sender", + queue: { mode: "collect", depth: 0 }, + includeTranscriptUsage: true, + modelAuth: "api-key", + }); + + expect(normalizeTestText(text)).toContain("Context: 1.2k/32k"); + }, + { prefix: "openclaw-status-" }, + ); + }); }); describe("buildCommandsMessage", () => { diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index ab266f5ae..7b147053a 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -12,7 +12,6 @@ import { resolveSandboxRuntimeStatus } from "../agents/sandbox.js"; import { derivePromptTokens, normalizeUsage, type UsageLike } from "../agents/usage.js"; import { resolveMainSessionKey, - resolveFreshSessionTotalTokens, resolveSessionFilePath, resolveSessionFilePathOptions, type SessionEntry, @@ -59,6 +58,7 @@ type QueueStatus = { type StatusArgs = { config?: OpenClawConfig; agent: AgentConfig; + agentId?: string; sessionEntry?: SessionEntry; sessionKey?: string; sessionScope?: SessionScope; @@ -169,6 +169,7 @@ const formatQueueDetails = (queue?: QueueStatus) => { const readUsageFromSessionLog = ( sessionId?: string, sessionEntry?: SessionEntry, + agentId?: string, sessionKey?: string, storePath?: string, ): @@ -186,11 +187,12 @@ const readUsageFromSessionLog = ( } let logPath: string; try { - const agentId = sessionKey ? resolveAgentIdFromSessionKey(sessionKey) : undefined; + const resolvedAgentId = + agentId ?? (sessionKey ? resolveAgentIdFromSessionKey(sessionKey) : undefined); logPath = resolveSessionFilePath( sessionId, sessionEntry, - resolveSessionFilePathOptions({ agentId, storePath }), + resolveSessionFilePathOptions({ agentId: resolvedAgentId, storePath }), ); } catch { return undefined; @@ -344,7 +346,7 @@ export function buildStatusMessage(args: StatusArgs): string { let inputTokens = entry?.inputTokens; let outputTokens = entry?.outputTokens; - let totalTokens = resolveFreshSessionTotalTokens(entry); + let totalTokens = entry?.totalTokens ?? (entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0); // Prefer prompt-size tokens from the session transcript when it looks larger // (cached prompt tokens are often missing from agent meta/store). @@ -352,6 +354,7 @@ export function buildStatusMessage(args: StatusArgs): string { const logUsage = readUsageFromSessionLog( entry?.sessionId, entry, + args.agentId, args.sessionKey, args.sessionStorePath, ); diff --git a/src/gateway/server-methods/usage.sessions-usage.test.ts b/src/gateway/server-methods/usage.sessions-usage.test.ts index 6f5c62ab7..efdeb9a16 100644 --- a/src/gateway/server-methods/usage.sessions-usage.test.ts +++ b/src/gateway/server-methods/usage.sessions-usage.test.ts @@ -64,10 +64,20 @@ vi.mock("../../infra/session-cost-usage.js", async () => { cacheWriteCost: 0, missingCostEntries: 0, })), + loadSessionUsageTimeSeries: vi.fn(async () => ({ + sessionId: "s-opus", + points: [], + })), + loadSessionLogs: vi.fn(async () => []), }; }); -import { discoverAllSessions } from "../../infra/session-cost-usage.js"; +import { + discoverAllSessions, + loadSessionCostSummary, + loadSessionLogs, + loadSessionUsageTimeSeries, +} from "../../infra/session-cost-usage.js"; import { loadCombinedSessionStoreForGateway } from "../session-utils.js"; import { usageHandlers } from "./usage.js"; @@ -148,6 +158,10 @@ describe("sessions.usage", () => { const result = respond.mock.calls[0]?.[1] as unknown as { sessions: Array<{ key: string }> }; expect(result.sessions).toHaveLength(1); expect(result.sessions[0]?.key).toBe(storeKey); + expect(vi.mocked(loadSessionCostSummary)).toHaveBeenCalled(); + expect( + vi.mocked(loadSessionCostSummary).mock.calls.some((call) => call[0]?.agentId === "opus"), + ).toBe(true); } finally { if (previousStateDir === undefined) { delete process.env.OPENCLAW_STATE_DIR; @@ -176,4 +190,32 @@ describe("sessions.usage", () => { const error = respond.mock.calls[0]?.[2] as { message?: string } | undefined; expect(error?.message).toContain("Invalid session reference"); }); + + it("passes parsed agentId into sessions.usage.timeseries", async () => { + const respond = vi.fn(); + + await usageHandlers["sessions.usage.timeseries"]({ + respond, + params: { + key: "agent:opus:s-opus", + }, + } as unknown as Parameters<(typeof usageHandlers)["sessions.usage.timeseries"]>[0]); + + expect(vi.mocked(loadSessionUsageTimeSeries)).toHaveBeenCalled(); + expect(vi.mocked(loadSessionUsageTimeSeries).mock.calls[0]?.[0]?.agentId).toBe("opus"); + }); + + it("passes parsed agentId into sessions.usage.logs", async () => { + const respond = vi.fn(); + + await usageHandlers["sessions.usage.logs"]({ + respond, + params: { + key: "agent:opus:s-opus", + }, + } as unknown as Parameters<(typeof usageHandlers)["sessions.usage.logs"]>[0]); + + expect(vi.mocked(loadSessionLogs)).toHaveBeenCalled(); + expect(vi.mocked(loadSessionLogs).mock.calls[0]?.[0]?.agentId).toBe("opus"); + }); }); diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index 90e576549..dab574c05 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -496,11 +496,13 @@ export const usageHandlers: GatewayRequestHandlers = { }; for (const merged of limitedEntries) { + const agentId = parseAgentSessionKey(merged.key)?.agentId; const usage = await loadSessionCostSummary({ sessionId: merged.sessionId, sessionEntry: merged.storeEntry, sessionFile: merged.sessionFile, config, + agentId, startMs, endMs, }); @@ -519,7 +521,6 @@ export const usageHandlers: GatewayRequestHandlers = { aggregateTotals.missingCostEntries += usage.missingCostEntries; } - const agentId = parseAgentSessionKey(merged.key)?.agentId; const channel = merged.storeEntry?.channel ?? merged.storeEntry?.origin?.provider; const chatType = merged.storeEntry?.chatType ?? merged.storeEntry?.origin?.chatType; @@ -796,6 +797,7 @@ export const usageHandlers: GatewayRequestHandlers = { sessionEntry: entry, sessionFile, config, + agentId, maxPoints: 200, }); @@ -849,6 +851,7 @@ export const usageHandlers: GatewayRequestHandlers = { sessionEntry: entry, sessionFile, config, + agentId, limit, }); diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index 7ff330e84..e8427448b 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -7,6 +7,8 @@ import { discoverAllSessions, loadCostUsageSummary, loadSessionCostSummary, + loadSessionLogs, + loadSessionUsageTimeSeries, } from "./session-cost-usage.js"; describe("session cost usage", () => { @@ -240,4 +242,133 @@ describe("session cost usage", () => { } } }); + + it("resolves non-main absolute sessionFile using explicit agentId for cost summary", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cost-agent-")); + const workerSessionsDir = path.join(root, "agents", "worker1", "sessions"); + await fs.mkdir(workerSessionsDir, { recursive: true }); + const workerSessionFile = path.join(workerSessionsDir, "sess-worker-1.jsonl"); + const now = new Date("2026-02-12T10:00:00.000Z"); + + await fs.writeFile( + workerSessionFile, + JSON.stringify({ + type: "message", + timestamp: now.toISOString(), + message: { + role: "assistant", + provider: "openai", + model: "gpt-5.2", + usage: { + input: 7, + output: 11, + totalTokens: 18, + cost: { total: 0.01 }, + }, + }, + }), + "utf-8", + ); + + const originalState = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = root; + try { + const summary = await loadSessionCostSummary({ + sessionId: "sess-worker-1", + sessionEntry: { sessionFile: workerSessionFile } as { sessionFile: string }, + agentId: "worker1", + }); + expect(summary?.totalTokens).toBe(18); + expect(summary?.totalCost).toBeCloseTo(0.01, 5); + } finally { + if (originalState === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = originalState; + } + } + }); + + it("resolves non-main absolute sessionFile using explicit agentId for timeseries", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-timeseries-agent-")); + const workerSessionsDir = path.join(root, "agents", "worker2", "sessions"); + await fs.mkdir(workerSessionsDir, { recursive: true }); + const workerSessionFile = path.join(workerSessionsDir, "sess-worker-2.jsonl"); + + await fs.writeFile( + workerSessionFile, + [ + JSON.stringify({ + type: "message", + timestamp: "2026-02-12T10:00:00.000Z", + message: { + role: "assistant", + provider: "openai", + model: "gpt-5.2", + usage: { input: 5, output: 3, totalTokens: 8, cost: { total: 0.001 } }, + }, + }), + ].join("\n"), + "utf-8", + ); + + const originalState = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = root; + try { + const timeseries = await loadSessionUsageTimeSeries({ + sessionId: "sess-worker-2", + sessionEntry: { sessionFile: workerSessionFile } as { sessionFile: string }, + agentId: "worker2", + }); + expect(timeseries?.points.length).toBe(1); + expect(timeseries?.points[0]?.totalTokens).toBe(8); + } finally { + if (originalState === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = originalState; + } + } + }); + + it("resolves non-main absolute sessionFile using explicit agentId for logs", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-logs-agent-")); + const workerSessionsDir = path.join(root, "agents", "worker3", "sessions"); + await fs.mkdir(workerSessionsDir, { recursive: true }); + const workerSessionFile = path.join(workerSessionsDir, "sess-worker-3.jsonl"); + + await fs.writeFile( + workerSessionFile, + [ + JSON.stringify({ + type: "message", + timestamp: "2026-02-12T10:00:00.000Z", + message: { + role: "user", + content: "hello worker", + }, + }), + ].join("\n"), + "utf-8", + ); + + const originalState = process.env.OPENCLAW_STATE_DIR; + process.env.OPENCLAW_STATE_DIR = root; + try { + const logs = await loadSessionLogs({ + sessionId: "sess-worker-3", + sessionEntry: { sessionFile: workerSessionFile } as { sessionFile: string }, + agentId: "worker3", + }); + expect(logs).toHaveLength(1); + expect(logs?.[0]?.content).toContain("hello worker"); + expect(logs?.[0]?.role).toBe("user"); + } finally { + if (originalState === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = originalState; + } + } + }); }); diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index 30f4304e1..6b09a518d 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -561,12 +561,17 @@ export async function loadSessionCostSummary(params: { sessionEntry?: SessionEntry; sessionFile?: string; config?: OpenClawConfig; + agentId?: string; startMs?: number; endMs?: number; }): Promise { const sessionFile = params.sessionFile ?? - (params.sessionId ? resolveSessionFilePath(params.sessionId, params.sessionEntry) : undefined); + (params.sessionId + ? resolveSessionFilePath(params.sessionId, params.sessionEntry, { + agentId: params.agentId, + }) + : undefined); if (!sessionFile || !fs.existsSync(sessionFile)) { return null; } @@ -851,11 +856,16 @@ export async function loadSessionUsageTimeSeries(params: { sessionEntry?: SessionEntry; sessionFile?: string; config?: OpenClawConfig; + agentId?: string; maxPoints?: number; }): Promise { const sessionFile = params.sessionFile ?? - (params.sessionId ? resolveSessionFilePath(params.sessionId, params.sessionEntry) : undefined); + (params.sessionId + ? resolveSessionFilePath(params.sessionId, params.sessionEntry, { + agentId: params.agentId, + }) + : undefined); if (!sessionFile || !fs.existsSync(sessionFile)) { return null; } @@ -931,11 +941,16 @@ export async function loadSessionLogs(params: { sessionEntry?: SessionEntry; sessionFile?: string; config?: OpenClawConfig; + agentId?: string; limit?: number; }): Promise { const sessionFile = params.sessionFile ?? - (params.sessionId ? resolveSessionFilePath(params.sessionId, params.sessionEntry) : undefined); + (params.sessionId + ? resolveSessionFilePath(params.sessionId, params.sessionEntry, { + agentId: params.agentId, + }) + : undefined); if (!sessionFile || !fs.existsSync(sessionFile)) { return null; }