diff --git a/CHANGELOG.md b/CHANGELOG.md index a9668b1075d..d3a0cf1de98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Exec/Allowlist: allow multiline heredoc bodies (`<<`, `<<-`) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr. - 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. ## 2026.2.12 diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 2eded36e96e..c461a669aa0 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -438,6 +438,7 @@ export function createSessionStatusTool(opts?: { }, sessionEntry: resolved.entry, sessionKey: resolved.key, + sessionStorePath: storePath, groupActivation, modelAuth: resolveModelAuthLabel({ provider: providerForCard, diff --git a/src/auto-reply/reply.raw-body.test.ts b/src/auto-reply/reply.raw-body.test.ts index abeda4a447f..38c8b30e218 100644 --- a/src/auto-reply/reply.raw-body.test.ts +++ b/src/auto-reply/reply.raw-body.test.ts @@ -1,8 +1,10 @@ +import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { saveSessionStore } from "../config/sessions.js"; import { getReplyFromConfig } from "./reply.js"; vi.mock("../agents/pi-embedded.js", () => ({ @@ -41,7 +43,7 @@ describe("RawBody directive parsing", () => { }); afterEach(() => { - vi.restoreAllMocks(); + vi.clearAllMocks(); }); it("/model, /think, /verbose directives detected from RawBody even when Body has structural wrapper", async () => { @@ -238,4 +240,58 @@ describe("RawBody directive parsing", () => { expect(prompt).not.toContain("/think:high"); }); }); + + it("reuses non-default agent session files without throwing path validation errors", async () => { + await withTempHome(async (home) => { + const agentId = "worker1"; + const sessionId = "sess-worker-1"; + const sessionKey = `agent:${agentId}:telegram:12345`; + const sessionsDir = path.join(home, ".openclaw", "agents", agentId, "sessions"); + const sessionFile = path.join(sessionsDir, `${sessionId}.jsonl`); + const storePath = path.join(sessionsDir, "sessions.json"); + await fs.mkdir(sessionsDir, { recursive: true }); + await fs.writeFile(sessionFile, "", "utf-8"); + await saveSessionStore(storePath, { + [sessionKey]: { + sessionId, + sessionFile, + updatedAt: Date.now(), + }, + }); + + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId, provider: "anthropic", model: "claude-opus-4-5" }, + }, + }); + + const res = await getReplyFromConfig( + { + Body: "hello", + From: "telegram:12345", + To: "telegram:12345", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + CommandAuthorized: true, + }, + {}, + { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "openclaw"), + }, + }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toBe("ok"); + expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + expect(vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.sessionFile).toBe(sessionFile); + }); + }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.e2e.test.ts index 47fcc99194d..c1d4b1a6ada 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.e2e.test.ts @@ -150,6 +150,40 @@ describe("trigger handling", () => { expect(store[sessionKey]?.compactionCount).toBe(1); }); }); + it("runs /compact for non-default agents without transcript path validation failures", async () => { + await withTempHome(async (home) => { + vi.mocked(compactEmbeddedPiSession).mockClear(); + vi.mocked(compactEmbeddedPiSession).mockResolvedValue({ + ok: true, + compacted: true, + result: { + summary: "summary", + firstKeptEntryId: "x", + tokensBefore: 12000, + }, + }); + + const res = await getReplyFromConfig( + { + Body: "/compact", + From: "+1004", + To: "+2000", + SessionKey: "agent:worker1:telegram:12345", + CommandAuthorized: true, + }, + {}, + makeCfg(home), + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text?.startsWith("⚙️ Compacted")).toBe(true); + expect(compactEmbeddedPiSession).toHaveBeenCalledOnce(); + expect(vi.mocked(compactEmbeddedPiSession).mock.calls[0]?.[0]?.sessionFile).toContain( + join("agents", "worker1", "sessions"), + ); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); it("ignores think directives that only appear in the context wrapper", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts index 3df9a9bf011..232c2e7b3b2 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -6,7 +6,7 @@ import { isEmbeddedPiRunActive, waitForEmbeddedPiRunEnd, } from "../../agents/pi-embedded.js"; -import { resolveSessionFilePath } from "../../config/sessions.js"; +import { resolveSessionFilePath, resolveSessionFilePathOptions } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { formatContextUsageShort, formatTokenCount } from "../status.js"; @@ -79,7 +79,14 @@ export const handleCompactCommand: CommandHandler = async (params) => { groupChannel: params.sessionEntry.groupChannel, groupSpace: params.sessionEntry.space, spawnedBy: params.sessionEntry.spawnedBy, - sessionFile: resolveSessionFilePath(sessionId, params.sessionEntry), + sessionFile: resolveSessionFilePath( + sessionId, + params.sessionEntry, + resolveSessionFilePathOptions({ + agentId: params.agentId, + storePath: params.storePath, + }), + ), workspaceDir: params.workspaceDir, config: params.cfg, skillsSnapshot: params.sessionEntry.skillsSnapshot, diff --git a/src/auto-reply/reply/commands-status.ts b/src/auto-reply/reply/commands-status.ts index 1695ba627f9..66ba18e20c8 100644 --- a/src/auto-reply/reply/commands-status.ts +++ b/src/auto-reply/reply/commands-status.ts @@ -106,6 +106,7 @@ export async function buildStatusReply(params: { sessionEntry?: SessionEntry; sessionKey: string; sessionScope?: SessionScope; + storePath?: string; provider: string; model: string; contextTokens: number; @@ -124,6 +125,7 @@ export async function buildStatusReply(params: { sessionEntry, sessionKey, sessionScope, + storePath, provider, model, contextTokens, @@ -225,6 +227,7 @@ export async function buildStatusReply(params: { sessionEntry, sessionKey, sessionScope, + sessionStorePath: storePath, groupActivation, resolvedThink: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), resolvedVerbose: resolvedVerboseLevel, diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 781ce23dd7c..27a25766be8 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -17,6 +17,7 @@ import { import { resolveGroupSessionKey, resolveSessionFilePath, + resolveSessionFilePathOptions, type SessionEntry, updateSessionStore, } from "../../config/sessions.js"; @@ -316,7 +317,11 @@ export async function runPreparedReply( } } const sessionIdFinal = sessionId ?? crypto.randomUUID(); - const sessionFile = resolveSessionFilePath(sessionIdFinal, sessionEntry); + const sessionFile = resolveSessionFilePath( + sessionIdFinal, + sessionEntry, + resolveSessionFilePathOptions({ agentId, storePath }), + ); const queueBodyBase = baseBodyForPrompt; const queuedBody = mediaNote ? [mediaNote, mediaReplyHint, queueBodyBase].filter(Boolean).join("\n").trim() diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index ac1fc8082d3..5298c90e883 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -406,6 +406,68 @@ describe("buildStatusMessage", () => { { prefix: "openclaw-status-" }, ); }); + + it("reads transcript usage for non-default agents", async () => { + await withTempHome( + async (dir) => { + vi.resetModules(); + const { buildStatusMessage: buildStatusMessageDynamic } = await import("./status.js"); + + const sessionId = "sess-worker1"; + const logPath = path.join( + dir, + ".openclaw", + "agents", + "worker1", + "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: 1, + output: 2, + cacheRead: 1000, + cacheWrite: 0, + totalTokens: 1003, + }, + }, + }), + ].join("\n"), + "utf-8", + ); + + const text = buildStatusMessageDynamic({ + agent: { + model: "anthropic/claude-opus-4-5", + contextTokens: 32_000, + }, + sessionEntry: { + sessionId, + updatedAt: 0, + totalTokens: 3, + contextTokens: 32_000, + }, + sessionKey: "agent:worker1:telegram:12345", + sessionScope: "per-sender", + queue: { mode: "collect", depth: 0 }, + includeTranscriptUsage: true, + modelAuth: "api-key", + }); + + expect(normalizeTestText(text)).toContain("Context: 1.0k/32k"); + }, + { prefix: "openclaw-status-" }, + ); + }); }); describe("buildCommandsMessage", () => { diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 4ff2771e1d1..858411e5e2f 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -13,12 +13,14 @@ import { derivePromptTokens, normalizeUsage, type UsageLike } from "../agents/us import { resolveMainSessionKey, resolveSessionFilePath, + resolveSessionFilePathOptions, type SessionEntry, type SessionScope, } from "../config/sessions.js"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import { resolveCommitHash } from "../infra/git-commit.js"; import { listPluginCommands } from "../plugins/commands.js"; +import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { getTtsMaxLength, getTtsProvider, @@ -59,6 +61,7 @@ type StatusArgs = { sessionEntry?: SessionEntry; sessionKey?: string; sessionScope?: SessionScope; + sessionStorePath?: string; groupActivation?: "mention" | "always"; resolvedThink?: ThinkLevel; resolvedVerbose?: VerboseLevel; @@ -165,6 +168,8 @@ const formatQueueDetails = (queue?: QueueStatus) => { const readUsageFromSessionLog = ( sessionId?: string, sessionEntry?: SessionEntry, + sessionKey?: string, + storePath?: string, ): | { input: number; @@ -178,7 +183,17 @@ const readUsageFromSessionLog = ( if (!sessionId) { return undefined; } - const logPath = resolveSessionFilePath(sessionId, sessionEntry); + let logPath: string; + try { + const agentId = sessionKey ? resolveAgentIdFromSessionKey(sessionKey) : undefined; + logPath = resolveSessionFilePath( + sessionId, + sessionEntry, + resolveSessionFilePathOptions({ agentId, storePath }), + ); + } catch { + return undefined; + } if (!fs.existsSync(logPath)) { return undefined; } @@ -333,7 +348,12 @@ export function buildStatusMessage(args: StatusArgs): string { // Prefer prompt-size tokens from the session transcript when it looks larger // (cached prompt tokens are often missing from agent meta/store). if (args.includeTranscriptUsage) { - const logUsage = readUsageFromSessionLog(entry?.sessionId, entry); + const logUsage = readUsageFromSessionLog( + entry?.sessionId, + entry, + args.sessionKey, + args.sessionStorePath, + ); if (logUsage) { const candidate = logUsage.promptTokens || logUsage.total; if (!totalTokens || totalTokens === 0 || candidate > totalTokens) { diff --git a/src/config/sessions/paths.test.ts b/src/config/sessions/paths.test.ts index cdea98b2e78..3ca4cdb9b25 100644 --- a/src/config/sessions/paths.test.ts +++ b/src/config/sessions/paths.test.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveSessionFilePath, + resolveSessionFilePathOptions, resolveSessionTranscriptPath, resolveSessionTranscriptPathInDir, resolveStorePath, @@ -75,4 +76,19 @@ describe("session path safety", () => { const resolved = resolveSessionTranscriptPath("sess-1", "main"); expect(resolved.endsWith(path.join("agents", "main", "sessions", "sess-1.jsonl"))).toBe(true); }); + + it("prefers storePath when resolving session file options", () => { + const opts = resolveSessionFilePathOptions({ + storePath: "/tmp/custom/agent-store/sessions.json", + agentId: "ops", + }); + expect(opts).toEqual({ + sessionsDir: path.resolve("/tmp/custom/agent-store"), + }); + }); + + it("falls back to agentId when storePath is absent", () => { + const opts = resolveSessionFilePathOptions({ agentId: "ops" }); + expect(opts).toEqual({ agentId: "ops" }); + }); }); diff --git a/src/config/sessions/paths.ts b/src/config/sessions/paths.ts index 9801f9a6bb9..f123390a55b 100644 --- a/src/config/sessions/paths.ts +++ b/src/config/sessions/paths.ts @@ -33,6 +33,26 @@ export function resolveDefaultSessionStorePath(agentId?: string): string { return path.join(resolveAgentSessionsDir(agentId), "sessions.json"); } +export type SessionFilePathOptions = { + agentId?: string; + sessionsDir?: string; +}; + +export function resolveSessionFilePathOptions(params: { + agentId?: string; + storePath?: string; +}): SessionFilePathOptions | undefined { + const storePath = params.storePath?.trim(); + if (storePath) { + return { sessionsDir: path.dirname(path.resolve(storePath)) }; + } + const agentId = params.agentId?.trim(); + if (agentId) { + return { agentId }; + } + return undefined; +} + export const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i; export function validateSessionId(sessionId: string): string { @@ -43,7 +63,7 @@ export function validateSessionId(sessionId: string): string { return trimmed; } -function resolveSessionsDir(opts?: { agentId?: string; sessionsDir?: string }): string { +function resolveSessionsDir(opts?: SessionFilePathOptions): string { const sessionsDir = opts?.sessionsDir?.trim(); if (sessionsDir) { return path.resolve(sessionsDir); @@ -95,7 +115,7 @@ export function resolveSessionTranscriptPath( export function resolveSessionFilePath( sessionId: string, entry?: { sessionFile?: string }, - opts?: { agentId?: string; sessionsDir?: string }, + opts?: SessionFilePathOptions, ): string { const sessionsDir = resolveSessionsDir(opts); const candidate = entry?.sessionFile?.trim(); diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index fefa103b1ed..90e57654979 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -1,5 +1,4 @@ import fs from "node:fs"; -import path from "node:path"; import type { SessionEntry, SessionSystemPromptReport } from "../../config/sessions/types.js"; import type { CostUsageSummary, @@ -13,7 +12,10 @@ import type { } from "../../infra/session-cost-usage.js"; import type { GatewayRequestHandlers } from "./types.js"; import { loadConfig } from "../../config/config.js"; -import { resolveSessionFilePath } from "../../config/sessions/paths.js"; +import { + resolveSessionFilePath, + resolveSessionFilePathOptions, +} from "../../config/sessions/paths.js"; import { loadProviderUsageSummary } from "../../infra/provider-usage.js"; import { loadCostUsageSummary, @@ -334,10 +336,10 @@ export const usageHandlers: GatewayRequestHandlers = { // Resolve the session file path let sessionFile: string; try { - const pathOpts = - storePath && storePath !== "(multiple)" - ? { sessionsDir: path.dirname(storePath) } - : { agentId: agentIdFromKey }; + const pathOpts = resolveSessionFilePathOptions({ + storePath: storePath !== "(multiple)" ? storePath : undefined, + agentId: agentIdFromKey, + }); sessionFile = resolveSessionFilePath(sessionId, storeEntry, pathOpts); } catch { respond( @@ -778,7 +780,7 @@ export const usageHandlers: GatewayRequestHandlers = { const sessionId = entry?.sessionId ?? rawSessionId; let sessionFile: string; try { - const pathOpts = storePath ? { sessionsDir: path.dirname(storePath) } : { agentId }; + const pathOpts = resolveSessionFilePathOptions({ storePath, agentId }); sessionFile = resolveSessionFilePath(sessionId, entry, pathOpts); } catch { respond( @@ -830,7 +832,7 @@ export const usageHandlers: GatewayRequestHandlers = { const sessionId = entry?.sessionId ?? rawSessionId; let sessionFile: string; try { - const pathOpts = storePath ? { sessionsDir: path.dirname(storePath) } : { agentId }; + const pathOpts = resolveSessionFilePathOptions({ storePath, agentId }); sessionFile = resolveSessionFilePath(sessionId, entry, pathOpts); } catch { respond( diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index a5eeaf3207b..687ea5dbf28 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -524,6 +524,84 @@ describe("runHeartbeatOnce", () => { } }); + it("reuses non-default agent sessionFile from templated stores", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-")); + const storeTemplate = path.join(tmpDir, "agents", "{agentId}", "sessions", "sessions.json"); + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + const agentId = "ops"; + try { + const cfg: OpenClawConfig = { + agents: { + defaults: { + heartbeat: { every: "30m", prompt: "Default prompt" }, + }, + list: [ + { id: "main", default: true }, + { + id: agentId, + heartbeat: { every: "5m", target: "whatsapp", prompt: "Ops check" }, + }, + ], + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storeTemplate }, + }; + const sessionKey = resolveAgentMainSessionKey({ cfg, agentId }); + const storePath = resolveStorePath(storeTemplate, { agentId }); + const sessionsDir = path.dirname(storePath); + const sessionId = "sid-ops"; + const sessionFile = path.join(sessionsDir, `${sessionId}.jsonl`); + + await fs.mkdir(sessionsDir, { recursive: true }); + await fs.writeFile(sessionFile, "", "utf-8"); + await fs.writeFile( + storePath, + JSON.stringify( + { + [sessionKey]: { + sessionId, + sessionFile, + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "+1555", + }, + }, + null, + 2, + ), + ); + + replySpy.mockResolvedValue([{ text: "Final alert" }]); + const sendWhatsApp = vi.fn().mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); + const result = await runHeartbeatOnce({ + cfg, + agentId, + deps: { + sendWhatsApp, + getQueueSize: () => 0, + nowMs: () => 0, + webAuthExists: async () => true, + hasActiveWebListener: () => true, + }, + }); + + expect(result.status).toBe("ran"); + expect(sendWhatsApp).toHaveBeenCalledTimes(1); + expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object)); + expect(replySpy).toHaveBeenCalledWith( + expect.objectContaining({ SessionKey: sessionKey }), + { isHeartbeat: true }, + cfg, + ); + } finally { + replySpy.mockRestore(); + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + it("runs heartbeats in the explicit session key when configured", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-")); const storePath = path.join(tmpDir, "sessions.json");