Auto-reply: fix non-default agent session transcript path resolution (#15154)

* Auto-reply: fix non-default agent transcript path resolution

* Auto-reply: harden non-default agent transcript lookups

* Auto-reply: harden session path resolution across agent stores
This commit is contained in:
Gustavo Madeira Santana
2026-02-12 23:23:12 -05:00
committed by GitHub
parent 79a38858ae
commit ac41176532
13 changed files with 321 additions and 16 deletions

View File

@@ -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

View File

@@ -438,6 +438,7 @@ export function createSessionStatusTool(opts?: {
},
sessionEntry: resolved.entry,
sessionKey: resolved.key,
sessionStorePath: storePath,
groupActivation,
modelAuth: resolveModelAuthLabel({
provider: providerForCard,

View File

@@ -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);
});
});
});

View File

@@ -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({

View File

@@ -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,

View File

@@ -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,

View File

@@ -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()

View File

@@ -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", () => {

View File

@@ -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) {

View File

@@ -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" });
});
});

View File

@@ -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();

View File

@@ -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(

View File

@@ -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");