mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-29 11:02:12 +03:00
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:
committed by
GitHub
parent
79a38858ae
commit
ac41176532
@@ -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.
|
- 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.
|
- 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.
|
- 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
|
## 2026.2.12
|
||||||
|
|
||||||
|
|||||||
@@ -438,6 +438,7 @@ export function createSessionStatusTool(opts?: {
|
|||||||
},
|
},
|
||||||
sessionEntry: resolved.entry,
|
sessionEntry: resolved.entry,
|
||||||
sessionKey: resolved.key,
|
sessionKey: resolved.key,
|
||||||
|
sessionStorePath: storePath,
|
||||||
groupActivation,
|
groupActivation,
|
||||||
modelAuth: resolveModelAuthLabel({
|
modelAuth: resolveModelAuthLabel({
|
||||||
provider: providerForCard,
|
provider: providerForCard,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||||
|
import { saveSessionStore } from "../config/sessions.js";
|
||||||
import { getReplyFromConfig } from "./reply.js";
|
import { getReplyFromConfig } from "./reply.js";
|
||||||
|
|
||||||
vi.mock("../agents/pi-embedded.js", () => ({
|
vi.mock("../agents/pi-embedded.js", () => ({
|
||||||
@@ -41,7 +43,7 @@ describe("RawBody directive parsing", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("/model, /think, /verbose directives detected from RawBody even when Body has structural wrapper", async () => {
|
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");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+34
@@ -150,6 +150,40 @@ describe("trigger handling", () => {
|
|||||||
expect(store[sessionKey]?.compactionCount).toBe(1);
|
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 () => {
|
it("ignores think directives that only appear in the context wrapper", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
isEmbeddedPiRunActive,
|
isEmbeddedPiRunActive,
|
||||||
waitForEmbeddedPiRunEnd,
|
waitForEmbeddedPiRunEnd,
|
||||||
} from "../../agents/pi-embedded.js";
|
} from "../../agents/pi-embedded.js";
|
||||||
import { resolveSessionFilePath } from "../../config/sessions.js";
|
import { resolveSessionFilePath, resolveSessionFilePathOptions } from "../../config/sessions.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||||
import { formatContextUsageShort, formatTokenCount } from "../status.js";
|
import { formatContextUsageShort, formatTokenCount } from "../status.js";
|
||||||
@@ -79,7 +79,14 @@ export const handleCompactCommand: CommandHandler = async (params) => {
|
|||||||
groupChannel: params.sessionEntry.groupChannel,
|
groupChannel: params.sessionEntry.groupChannel,
|
||||||
groupSpace: params.sessionEntry.space,
|
groupSpace: params.sessionEntry.space,
|
||||||
spawnedBy: params.sessionEntry.spawnedBy,
|
spawnedBy: params.sessionEntry.spawnedBy,
|
||||||
sessionFile: resolveSessionFilePath(sessionId, params.sessionEntry),
|
sessionFile: resolveSessionFilePath(
|
||||||
|
sessionId,
|
||||||
|
params.sessionEntry,
|
||||||
|
resolveSessionFilePathOptions({
|
||||||
|
agentId: params.agentId,
|
||||||
|
storePath: params.storePath,
|
||||||
|
}),
|
||||||
|
),
|
||||||
workspaceDir: params.workspaceDir,
|
workspaceDir: params.workspaceDir,
|
||||||
config: params.cfg,
|
config: params.cfg,
|
||||||
skillsSnapshot: params.sessionEntry.skillsSnapshot,
|
skillsSnapshot: params.sessionEntry.skillsSnapshot,
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export async function buildStatusReply(params: {
|
|||||||
sessionEntry?: SessionEntry;
|
sessionEntry?: SessionEntry;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
sessionScope?: SessionScope;
|
sessionScope?: SessionScope;
|
||||||
|
storePath?: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
model: string;
|
model: string;
|
||||||
contextTokens: number;
|
contextTokens: number;
|
||||||
@@ -124,6 +125,7 @@ export async function buildStatusReply(params: {
|
|||||||
sessionEntry,
|
sessionEntry,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
sessionScope,
|
sessionScope,
|
||||||
|
storePath,
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
contextTokens,
|
contextTokens,
|
||||||
@@ -225,6 +227,7 @@ export async function buildStatusReply(params: {
|
|||||||
sessionEntry,
|
sessionEntry,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
sessionScope,
|
sessionScope,
|
||||||
|
sessionStorePath: storePath,
|
||||||
groupActivation,
|
groupActivation,
|
||||||
resolvedThink: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
|
resolvedThink: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
|
||||||
resolvedVerbose: resolvedVerboseLevel,
|
resolvedVerbose: resolvedVerboseLevel,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
resolveGroupSessionKey,
|
resolveGroupSessionKey,
|
||||||
resolveSessionFilePath,
|
resolveSessionFilePath,
|
||||||
|
resolveSessionFilePathOptions,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
updateSessionStore,
|
updateSessionStore,
|
||||||
} from "../../config/sessions.js";
|
} from "../../config/sessions.js";
|
||||||
@@ -316,7 +317,11 @@ export async function runPreparedReply(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const sessionIdFinal = sessionId ?? crypto.randomUUID();
|
const sessionIdFinal = sessionId ?? crypto.randomUUID();
|
||||||
const sessionFile = resolveSessionFilePath(sessionIdFinal, sessionEntry);
|
const sessionFile = resolveSessionFilePath(
|
||||||
|
sessionIdFinal,
|
||||||
|
sessionEntry,
|
||||||
|
resolveSessionFilePathOptions({ agentId, storePath }),
|
||||||
|
);
|
||||||
const queueBodyBase = baseBodyForPrompt;
|
const queueBodyBase = baseBodyForPrompt;
|
||||||
const queuedBody = mediaNote
|
const queuedBody = mediaNote
|
||||||
? [mediaNote, mediaReplyHint, queueBodyBase].filter(Boolean).join("\n").trim()
|
? [mediaNote, mediaReplyHint, queueBodyBase].filter(Boolean).join("\n").trim()
|
||||||
|
|||||||
@@ -406,6 +406,68 @@ describe("buildStatusMessage", () => {
|
|||||||
{ prefix: "openclaw-status-" },
|
{ 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", () => {
|
describe("buildCommandsMessage", () => {
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import { derivePromptTokens, normalizeUsage, type UsageLike } from "../agents/us
|
|||||||
import {
|
import {
|
||||||
resolveMainSessionKey,
|
resolveMainSessionKey,
|
||||||
resolveSessionFilePath,
|
resolveSessionFilePath,
|
||||||
|
resolveSessionFilePathOptions,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
type SessionScope,
|
type SessionScope,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
|
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
|
||||||
import { resolveCommitHash } from "../infra/git-commit.js";
|
import { resolveCommitHash } from "../infra/git-commit.js";
|
||||||
import { listPluginCommands } from "../plugins/commands.js";
|
import { listPluginCommands } from "../plugins/commands.js";
|
||||||
|
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||||
import {
|
import {
|
||||||
getTtsMaxLength,
|
getTtsMaxLength,
|
||||||
getTtsProvider,
|
getTtsProvider,
|
||||||
@@ -59,6 +61,7 @@ type StatusArgs = {
|
|||||||
sessionEntry?: SessionEntry;
|
sessionEntry?: SessionEntry;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
sessionScope?: SessionScope;
|
sessionScope?: SessionScope;
|
||||||
|
sessionStorePath?: string;
|
||||||
groupActivation?: "mention" | "always";
|
groupActivation?: "mention" | "always";
|
||||||
resolvedThink?: ThinkLevel;
|
resolvedThink?: ThinkLevel;
|
||||||
resolvedVerbose?: VerboseLevel;
|
resolvedVerbose?: VerboseLevel;
|
||||||
@@ -165,6 +168,8 @@ const formatQueueDetails = (queue?: QueueStatus) => {
|
|||||||
const readUsageFromSessionLog = (
|
const readUsageFromSessionLog = (
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
sessionEntry?: SessionEntry,
|
sessionEntry?: SessionEntry,
|
||||||
|
sessionKey?: string,
|
||||||
|
storePath?: string,
|
||||||
):
|
):
|
||||||
| {
|
| {
|
||||||
input: number;
|
input: number;
|
||||||
@@ -178,7 +183,17 @@ const readUsageFromSessionLog = (
|
|||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return undefined;
|
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)) {
|
if (!fs.existsSync(logPath)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -333,7 +348,12 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
// Prefer prompt-size tokens from the session transcript when it looks larger
|
// Prefer prompt-size tokens from the session transcript when it looks larger
|
||||||
// (cached prompt tokens are often missing from agent meta/store).
|
// (cached prompt tokens are often missing from agent meta/store).
|
||||||
if (args.includeTranscriptUsage) {
|
if (args.includeTranscriptUsage) {
|
||||||
const logUsage = readUsageFromSessionLog(entry?.sessionId, entry);
|
const logUsage = readUsageFromSessionLog(
|
||||||
|
entry?.sessionId,
|
||||||
|
entry,
|
||||||
|
args.sessionKey,
|
||||||
|
args.sessionStorePath,
|
||||||
|
);
|
||||||
if (logUsage) {
|
if (logUsage) {
|
||||||
const candidate = logUsage.promptTokens || logUsage.total;
|
const candidate = logUsage.promptTokens || logUsage.total;
|
||||||
if (!totalTokens || totalTokens === 0 || candidate > totalTokens) {
|
if (!totalTokens || totalTokens === 0 || candidate > totalTokens) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import path from "node:path";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
resolveSessionFilePath,
|
resolveSessionFilePath,
|
||||||
|
resolveSessionFilePathOptions,
|
||||||
resolveSessionTranscriptPath,
|
resolveSessionTranscriptPath,
|
||||||
resolveSessionTranscriptPathInDir,
|
resolveSessionTranscriptPathInDir,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
@@ -75,4 +76,19 @@ describe("session path safety", () => {
|
|||||||
const resolved = resolveSessionTranscriptPath("sess-1", "main");
|
const resolved = resolveSessionTranscriptPath("sess-1", "main");
|
||||||
expect(resolved.endsWith(path.join("agents", "main", "sessions", "sess-1.jsonl"))).toBe(true);
|
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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,26 @@ export function resolveDefaultSessionStorePath(agentId?: string): string {
|
|||||||
return path.join(resolveAgentSessionsDir(agentId), "sessions.json");
|
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 const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
|
||||||
|
|
||||||
export function validateSessionId(sessionId: string): string {
|
export function validateSessionId(sessionId: string): string {
|
||||||
@@ -43,7 +63,7 @@ export function validateSessionId(sessionId: string): string {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSessionsDir(opts?: { agentId?: string; sessionsDir?: string }): string {
|
function resolveSessionsDir(opts?: SessionFilePathOptions): string {
|
||||||
const sessionsDir = opts?.sessionsDir?.trim();
|
const sessionsDir = opts?.sessionsDir?.trim();
|
||||||
if (sessionsDir) {
|
if (sessionsDir) {
|
||||||
return path.resolve(sessionsDir);
|
return path.resolve(sessionsDir);
|
||||||
@@ -95,7 +115,7 @@ export function resolveSessionTranscriptPath(
|
|||||||
export function resolveSessionFilePath(
|
export function resolveSessionFilePath(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
entry?: { sessionFile?: string },
|
entry?: { sessionFile?: string },
|
||||||
opts?: { agentId?: string; sessionsDir?: string },
|
opts?: SessionFilePathOptions,
|
||||||
): string {
|
): string {
|
||||||
const sessionsDir = resolveSessionsDir(opts);
|
const sessionsDir = resolveSessionsDir(opts);
|
||||||
const candidate = entry?.sessionFile?.trim();
|
const candidate = entry?.sessionFile?.trim();
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
|
||||||
import type { SessionEntry, SessionSystemPromptReport } from "../../config/sessions/types.js";
|
import type { SessionEntry, SessionSystemPromptReport } from "../../config/sessions/types.js";
|
||||||
import type {
|
import type {
|
||||||
CostUsageSummary,
|
CostUsageSummary,
|
||||||
@@ -13,7 +12,10 @@ import type {
|
|||||||
} from "../../infra/session-cost-usage.js";
|
} from "../../infra/session-cost-usage.js";
|
||||||
import type { GatewayRequestHandlers } from "./types.js";
|
import type { GatewayRequestHandlers } from "./types.js";
|
||||||
import { loadConfig } from "../../config/config.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 { loadProviderUsageSummary } from "../../infra/provider-usage.js";
|
||||||
import {
|
import {
|
||||||
loadCostUsageSummary,
|
loadCostUsageSummary,
|
||||||
@@ -334,10 +336,10 @@ export const usageHandlers: GatewayRequestHandlers = {
|
|||||||
// Resolve the session file path
|
// Resolve the session file path
|
||||||
let sessionFile: string;
|
let sessionFile: string;
|
||||||
try {
|
try {
|
||||||
const pathOpts =
|
const pathOpts = resolveSessionFilePathOptions({
|
||||||
storePath && storePath !== "(multiple)"
|
storePath: storePath !== "(multiple)" ? storePath : undefined,
|
||||||
? { sessionsDir: path.dirname(storePath) }
|
agentId: agentIdFromKey,
|
||||||
: { agentId: agentIdFromKey };
|
});
|
||||||
sessionFile = resolveSessionFilePath(sessionId, storeEntry, pathOpts);
|
sessionFile = resolveSessionFilePath(sessionId, storeEntry, pathOpts);
|
||||||
} catch {
|
} catch {
|
||||||
respond(
|
respond(
|
||||||
@@ -778,7 +780,7 @@ export const usageHandlers: GatewayRequestHandlers = {
|
|||||||
const sessionId = entry?.sessionId ?? rawSessionId;
|
const sessionId = entry?.sessionId ?? rawSessionId;
|
||||||
let sessionFile: string;
|
let sessionFile: string;
|
||||||
try {
|
try {
|
||||||
const pathOpts = storePath ? { sessionsDir: path.dirname(storePath) } : { agentId };
|
const pathOpts = resolveSessionFilePathOptions({ storePath, agentId });
|
||||||
sessionFile = resolveSessionFilePath(sessionId, entry, pathOpts);
|
sessionFile = resolveSessionFilePath(sessionId, entry, pathOpts);
|
||||||
} catch {
|
} catch {
|
||||||
respond(
|
respond(
|
||||||
@@ -830,7 +832,7 @@ export const usageHandlers: GatewayRequestHandlers = {
|
|||||||
const sessionId = entry?.sessionId ?? rawSessionId;
|
const sessionId = entry?.sessionId ?? rawSessionId;
|
||||||
let sessionFile: string;
|
let sessionFile: string;
|
||||||
try {
|
try {
|
||||||
const pathOpts = storePath ? { sessionsDir: path.dirname(storePath) } : { agentId };
|
const pathOpts = resolveSessionFilePathOptions({ storePath, agentId });
|
||||||
sessionFile = resolveSessionFilePath(sessionId, entry, pathOpts);
|
sessionFile = resolveSessionFilePath(sessionId, entry, pathOpts);
|
||||||
} catch {
|
} catch {
|
||||||
respond(
|
respond(
|
||||||
|
|||||||
@@ -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 () => {
|
it("runs heartbeats in the explicit session key when configured", async () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
|
||||||
const storePath = path.join(tmpDir, "sessions.json");
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
|||||||
Reference in New Issue
Block a user