From fe881fd92cf26ecc146c4ae86457635029ef5487 Mon Sep 17 00:00:00 2001 From: Joe Eftekhari Date: Mon, 2 Mar 2026 09:46:41 -1000 Subject: [PATCH 1/5] refactor: extract readSessionMessagesWithStatus for transcript file detection --- src/gateway/session-utils.fs.ts | 21 +++++++++++++++++---- src/gateway/session-utils.ts | 2 ++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 3712c8c827207..a6a8f1ac8bdd1 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -71,16 +71,21 @@ function setCachedSessionTitleFields(cacheKey: string, stat: fs.Stats, value: Se } } -export function readSessionMessages( +export type ReadSessionMessagesResult = { + messages: unknown[]; + fileFound: boolean; +}; + +export function readSessionMessagesWithStatus( sessionId: string, storePath: string | undefined, sessionFile?: string, -): unknown[] { +): ReadSessionMessagesResult { const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile); const filePath = candidates.find((p) => fs.existsSync(p)); if (!filePath) { - return []; + return { messages: [], fileFound: false }; } const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/); @@ -115,7 +120,15 @@ export function readSessionMessages( // ignore bad lines } } - return messages; + return { messages, fileFound: true }; +} + +export function readSessionMessages( + sessionId: string, + storePath: string | undefined, + sessionFile?: string, +): unknown[] { + return readSessionMessagesWithStatus(sessionId, storePath, sessionFile).messages; } export function resolveSessionTranscriptCandidates( diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index fa4c514388b0c..cac5867b0c7e3 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -55,8 +55,10 @@ export { readSessionTitleFieldsFromTranscript, readSessionPreviewItemsFromTranscript, readSessionMessages, + readSessionMessagesWithStatus, resolveSessionTranscriptCandidates, } from "./session-utils.fs.js"; +export type { ReadSessionMessagesResult } from "./session-utils.fs.js"; export type { GatewayAgentRow, GatewaySessionRow, From a4f43197244f70304701653a7e8aa87952c11957 Mon Sep 17 00:00:00 2001 From: Joe Eftekhari Date: Mon, 2 Mar 2026 09:46:49 -1000 Subject: [PATCH 2/5] feat: add historyStatus to chat.history gateway response --- src/gateway/server-methods/chat.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 62fa18e20e9f1..4651606f48664 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -40,7 +40,7 @@ import { getMaxChatHistoryMessagesBytes } from "../server-constants.js"; import { capArrayByJsonBytes, loadSessionEntry, - readSessionMessages, + readSessionMessagesWithStatus, resolveSessionModelRef, } from "../session-utils.js"; import { formatForLog } from "../ws-log.js"; @@ -541,8 +541,26 @@ export const chatHandlers: GatewayRequestHandlers = { }; const { cfg, storePath, entry } = loadSessionEntry(sessionKey); const sessionId = entry?.sessionId; - const rawMessages = - sessionId && storePath ? readSessionMessages(sessionId, storePath, entry?.sessionFile) : []; + const result = + sessionId && storePath + ? readSessionMessagesWithStatus(sessionId, storePath, entry?.sessionFile) + : null; + const rawMessages = result?.messages ?? []; + + // Classify transcript availability for diagnostics. + let historyStatus: "ok" | "not_found" | "not_persisted" | "empty"; + if (!entry) { + historyStatus = "not_found"; + } else if (!sessionId || !storePath) { + historyStatus = "not_persisted"; + } else if (!result || !result.fileFound) { + historyStatus = "not_persisted"; + } else if (rawMessages.length === 0) { + historyStatus = "empty"; + } else { + historyStatus = "ok"; + } + const hardMax = 1000; const defaultLimit = 200; const requested = typeof limit === "number" ? limit : defaultLimit; @@ -584,6 +602,7 @@ export const chatHandlers: GatewayRequestHandlers = { messages: bounded.messages, thinkingLevel, verboseLevel, + historyStatus, }); }, "chat.abort": ({ params, respond, context }) => { From 08bf7e83b20ed7b69ac56ad28251592b8dc519d2 Mon Sep 17 00:00:00 2001 From: Joe Eftekhari Date: Mon, 2 Mar 2026 09:46:57 -1000 Subject: [PATCH 3/5] test: add coverage for session history status detection --- .../server.chat.gateway-server-chat-b.test.ts | 90 ++++++++++++++++++- src/gateway/session-utils.fs.test.ts | 46 ++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 2e76e1a5de145..af6765135bbc6 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -72,10 +72,15 @@ async function writeMainSessionTranscript(sessionDir: string, lines: string[]) { await fs.writeFile(path.join(sessionDir, "sess-main.jsonl"), `${lines.join("\n")}\n`, "utf-8"); } +type HistoryPayload = { + messages?: unknown[]; + historyStatus?: string; +}; + async function fetchHistoryMessages( ws: Awaited>["ws"], ): Promise { - const historyRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", { + const historyRes = await rpcReq(ws, "chat.history", { sessionKey: "main", limit: 1000, }); @@ -83,6 +88,18 @@ async function fetchHistoryMessages( return historyRes.payload?.messages ?? []; } +async function fetchHistoryPayload( + ws: Awaited>["ws"], + sessionKey = "main", +): Promise { + const historyRes = await rpcReq(ws, "chat.history", { + sessionKey, + limit: 1000, + }); + expect(historyRes.ok).toBe(true); + return historyRes.payload ?? {}; +} + describe("gateway server chat", () => { test("smoke: caps history payload and preserves routing metadata", async () => { await withGatewayChatHarness(async ({ ws, createSessionDir }) => { @@ -332,6 +349,77 @@ describe("gateway server chat", () => { }); }); + test("chat.history returns historyStatus: not_found when session key has no store entry", async () => { + await withGatewayChatHarness(async ({ ws, createSessionDir }) => { + await connectOk(ws); + await createSessionDir(); + // No session store written — entry doesn't exist + const payload = await fetchHistoryPayload(ws, "nonexistent-key"); + expect(payload.historyStatus).toBe("not_found"); + expect(payload.messages).toEqual([]); + }); + }); + + test("chat.history returns historyStatus: not_persisted when store entry has no sessionId", async () => { + await withGatewayChatHarness(async ({ ws, createSessionDir }) => { + await connectOk(ws); + await createSessionDir(); + // Write store entry without sessionId + await writeSessionStore({ + entries: { + main: { updatedAt: Date.now() } as Record, + }, + }); + const payload = await fetchHistoryPayload(ws); + expect(payload.historyStatus).toBe("not_persisted"); + expect(payload.messages).toEqual([]); + }); + }); + + test("chat.history returns historyStatus: not_persisted when transcript file is missing", async () => { + await withGatewayChatHarness(async ({ ws, createSessionDir }) => { + await connectOk(ws); + await createSessionDir(); + // Store entry has sessionId but no transcript file on disk + await writeMainSessionStore(); + const payload = await fetchHistoryPayload(ws); + expect(payload.historyStatus).toBe("not_persisted"); + expect(payload.messages).toEqual([]); + }); + }); + + test("chat.history returns historyStatus: empty when transcript file exists but has no messages", async () => { + await withGatewayChatHarness(async ({ ws, createSessionDir }) => { + await connectOk(ws); + const sessionDir = await createSessionDir(); + await writeMainSessionStore(); + // Write an empty transcript (header only, no messages) + await writeMainSessionTranscript(sessionDir, [ + JSON.stringify({ type: "session", version: 1, id: "sess-main" }), + ]); + const payload = await fetchHistoryPayload(ws); + expect(payload.historyStatus).toBe("empty"); + expect(payload.messages).toEqual([]); + }); + }); + + test("chat.history returns historyStatus: ok when transcript has messages", async () => { + await withGatewayChatHarness(async ({ ws, createSessionDir }) => { + await connectOk(ws); + const sessionDir = await createSessionDir(); + await writeMainSessionStore(); + await writeMainSessionTranscript(sessionDir, [ + JSON.stringify({ message: { role: "user", content: "Hello", timestamp: Date.now() } }), + JSON.stringify({ + message: { role: "assistant", content: "Hi", timestamp: Date.now() + 1 }, + }), + ]); + const payload = await fetchHistoryPayload(ws); + expect(payload.historyStatus).toBe("ok"); + expect(payload.messages!.length).toBe(2); + }); + }); + test("smoke: supports abort and idempotent completion", async () => { await withGatewayChatHarness(async ({ ws, createSessionDir }) => { const spy = getReplyFromConfig; diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 09ab7e2cda276..2acc6f7f70786 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -8,6 +8,7 @@ import { readFirstUserMessageFromTranscript, readLastMessagePreviewFromTranscript, readSessionMessages, + readSessionMessagesWithStatus, readSessionTitleFieldsFromTranscript, readSessionPreviewItemsFromTranscript, resolveSessionTranscriptCandidates, @@ -463,6 +464,51 @@ describe("readSessionTitleFieldsFromTranscript cache", () => { }); }); +describe("readSessionMessagesWithStatus", () => { + let tmpDir: string; + let storePath: string; + + registerTempSessionStore("openclaw-session-fs-status-test-", (nextTmpDir, nextStorePath) => { + tmpDir = nextTmpDir; + storePath = nextStorePath; + }); + + test("returns fileFound: false when no transcript file exists", () => { + const result = readSessionMessagesWithStatus("nonexistent-session", storePath); + expect(result).toEqual({ messages: [], fileFound: false }); + }); + + test("returns fileFound: true with empty messages for empty transcript file", () => { + const sessionId = "test-status-empty"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + fs.writeFileSync(transcriptPath, "", "utf-8"); + + const result = readSessionMessagesWithStatus(sessionId, storePath); + expect(result.fileFound).toBe(true); + expect(result.messages).toEqual([]); + }); + + test("returns fileFound: true with messages for transcript with content", () => { + const sessionId = "test-status-content"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ message: { role: "user", content: "Hello" } }), + JSON.stringify({ message: { role: "assistant", content: "World" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readSessionMessagesWithStatus(sessionId, storePath); + expect(result.fileFound).toBe(true); + expect(result.messages).toHaveLength(2); + }); + + test("readSessionMessages still returns [] for missing file (regression)", () => { + const result = readSessionMessages("nonexistent-session-compat", storePath); + expect(result).toEqual([]); + }); +}); + describe("readSessionMessages", () => { let tmpDir: string; let storePath: string; From a8a94a71c2e5ea48da8a55a4d10b55c3db269741 Mon Sep 17 00:00:00 2001 From: seslly <161339320+seslly@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:25:27 -0800 Subject: [PATCH 4/5] finish and clean up --- docs/concepts/session-tool.md | 13 ++++++++ ...openclaw-tools.sessions-visibility.test.ts | 31 +++++++++++++++++++ src/agents/openclaw-tools.sessions.test.ts | 23 +++++++++++++- src/agents/tools/sessions-access.ts | 10 ++++-- src/agents/tools/sessions-history-tool.ts | 8 ++++- src/agents/tools/sessions-resolution.ts | 13 +++++--- 6 files changed, 89 insertions(+), 9 deletions(-) diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md index 90b48a7db5355..8595d68ea4067 100644 --- a/docs/concepts/session-tool.md +++ b/docs/concepts/session-tool.md @@ -75,6 +75,19 @@ Behavior: - Returns messages array in the raw transcript format. - When given a `sessionId`, OpenClaw resolves it to the corresponding session key (missing ids error). +Result status and observability: + +- On success the tool returns `sessionKey`, `messages`, and `historyStatus` (from the gateway). Use `historyStatus` to interpret empty `messages`: + - `ok`: transcript has messages (may still be truncated/capped by the tool). + - `empty`: transcript file exists but has no messages yet. + - `not_persisted`: session entry exists but no transcript file or sessionId (e.g. ACP session not yet persisted). + - `not_found`: no store entry for that session key (e.g. wrong key or store not yet updated). +- When the tool cannot return history, it returns a top-level `status` and `error`: + - `forbidden`: visibility or agent-to-agent policy denies access (e.g. `tools.sessions.visibility` or `tools.agentToAgent`). + - `not_found`: session resolution failed (invalid sessionId or key not in store). + +For ACP thread-bound sessions: if channel thread output exists but `sessions_history` returns `messages: []` and `historyStatus: "empty"` or `not_persisted`, the transcript may not be written yet or may be on a different path; check gateway logs and session store for that session key. + ## sessions_send Send a message into another session. diff --git a/src/agents/openclaw-tools.sessions-visibility.test.ts b/src/agents/openclaw-tools.sessions-visibility.test.ts index 193eaa1195f8e..4bd2b625a5a55 100644 --- a/src/agents/openclaw-tools.sessions-visibility.test.ts +++ b/src/agents/openclaw-tools.sessions-visibility.test.ts @@ -115,4 +115,35 @@ describe("sessions tools visibility", () => { }); expect(denied.details).toMatchObject({ status: "forbidden" }); }); + + it("uses canonical main key for tree visibility when agentSessionKey is not set", async () => { + mockConfig = { + session: { mainKey: "main", scope: "per-sender" }, + tools: { agentToAgent: { enabled: false } }, + }; + const acpChildKey = "agent:main:acp:thread-123"; + mockGatewayWithHistory((req) => { + if (req.method === "sessions.list" && req.params?.spawnedBy === "agent:main:main") { + return { sessions: [{ key: acpChildKey }] }; + } + if (req.method === "sessions.resolve") { + const key = typeof req.params?.key === "string" ? String(req.params?.key) : ""; + return { key: key || acpChildKey }; + } + return undefined; + }); + + const tools = createOpenClawTools({ agentSessionKey: undefined }); + const tool = tools.find((candidate) => candidate.name === "sessions_history"); + expect(tool).toBeDefined(); + if (!tool) { + throw new Error("missing sessions_history tool"); + } + + const result = await tool.execute("call5", { sessionKey: acpChildKey }); + expect(result.details).toMatchObject({ + sessionKey: acpChildKey, + messages: expect.any(Array), + }); + }); }); diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index 9b07fafc4dac8..e6eb99c50505c 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -246,6 +246,27 @@ describe("sessions tools", () => { expect(withToolsDetails.messages).toHaveLength(2); }); + it("sessions_history surfaces historyStatus from chat.history for observability", async () => { + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string }; + if (request.method === "chat.history") { + return { messages: [], historyStatus: "empty" as const }; + } + return {}; + }); + + const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_history"); + expect(tool).toBeDefined(); + if (!tool) { + throw new Error("missing sessions_history tool"); + } + + const result = await tool.execute("call4a", { sessionKey: "main" }); + const details = result.details as { messages?: unknown[]; historyStatus?: string }; + expect(details.messages).toEqual([]); + expect(details.historyStatus).toBe("empty"); + }); + it("sessions_history caps oversized payloads and strips heavy fields", async () => { const oversized = Array.from({ length: 80 }, (_, idx) => ({ role: "assistant", @@ -504,7 +525,7 @@ describe("sessions tools", () => { const result = await tool.execute("call6", { sessionKey: sessionId }); const details = result.details as { status?: string; error?: string }; - expect(details.status).toBe("error"); + expect(details.status).toBe("not_found"); expect(details.error).toMatch(/Session not found|No session found/); }); diff --git a/src/agents/tools/sessions-access.ts b/src/agents/tools/sessions-access.ts index 6574c2296cf9b..05488f7f79170 100644 --- a/src/agents/tools/sessions-access.ts +++ b/src/agents/tools/sessions-access.ts @@ -1,5 +1,9 @@ import type { OpenClawConfig } from "../../config/config.js"; -import { isSubagentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { + buildAgentMainSessionKey, + isSubagentSessionKey, + resolveAgentIdFromSessionKey, +} from "../../routing/session-key.js"; import { listSpawnedSessionKeys, resolveInternalSessionKey, @@ -71,7 +75,9 @@ export function resolveSandboxedSessionToolContext(params: { mainKey, }) : undefined; - const effectiveRequesterKey = requesterInternalKey ?? alias; + // Use canonical main key when no agent session key: store uses it for spawnedBy, so tree visibility matches. + const effectiveRequesterKey = + requesterInternalKey ?? buildAgentMainSessionKey({ agentId: mainKey, mainKey }); const restrictToSpawned = params.sandboxed === true && visibility === "spawned" && diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index 18d9576f0b2d6..ef702fc84f9c6 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -239,10 +239,15 @@ export function createSessionsHistoryTool(opts?: { ? Math.max(1, Math.floor(params.limit)) : undefined; const includeTools = Boolean(params.includeTools); - const result = await callGateway<{ messages: Array }>({ + type ChatHistoryResult = { + messages?: Array; + historyStatus?: "ok" | "not_found" | "not_persisted" | "empty"; + }; + const result = await callGateway({ method: "chat.history", params: { sessionKey: resolvedKey, limit }, }); + const historyStatus = result?.historyStatus ?? "ok"; const rawMessages = Array.isArray(result?.messages) ? result.messages : []; const selectedMessages = includeTools ? rawMessages : stripToolMessages(rawMessages); const sanitizedMessages = selectedMessages.map((message) => sanitizeHistoryMessage(message)); @@ -261,6 +266,7 @@ export function createSessionsHistoryTool(opts?: { return jsonResult({ sessionKey: displayKey, messages: hardened.items, + historyStatus, truncated: droppedMessages || contentTruncated || hardened.hardCapped, droppedMessages: droppedMessages || hardened.hardCapped, contentTruncated, diff --git a/src/agents/tools/sessions-resolution.ts b/src/agents/tools/sessions-resolution.ts index f350adb1830c1..063a925775c8b 100644 --- a/src/agents/tools/sessions-resolution.ts +++ b/src/agents/tools/sessions-resolution.ts @@ -157,7 +157,7 @@ export type SessionReferenceResolution = displayKey: string; resolvedViaSessionId: boolean; } - | { ok: false; status: "error" | "forbidden"; error: string }; + | { ok: false; status: "error" | "not_found" | "forbidden"; error: string }; async function resolveSessionKeyFromSessionId(params: { sessionId: string; @@ -202,12 +202,15 @@ async function resolveSessionKeyFromSessionId(params: { }; } const message = err instanceof Error ? err.message : String(err); + const notFoundMessage = + `Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`; + const isNotFound = + message.toLowerCase().includes("not found") || + message.toLowerCase().includes("no session found"); return { ok: false, - status: "error", - error: - message || - `Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`, + status: isNotFound ? "not_found" : "error", + error: message || notFoundMessage, }; } } From 10204369b5ea21298743bef4aa6a343185e6b3f5 Mon Sep 17 00:00:00 2001 From: Joe Eftekhari Date: Mon, 2 Mar 2026 21:34:48 -1000 Subject: [PATCH 5/5] style: format sessions-resolution --- src/agents/tools/sessions-resolution.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/agents/tools/sessions-resolution.ts b/src/agents/tools/sessions-resolution.ts index 063a925775c8b..bd79a53b244d7 100644 --- a/src/agents/tools/sessions-resolution.ts +++ b/src/agents/tools/sessions-resolution.ts @@ -202,8 +202,7 @@ async function resolveSessionKeyFromSessionId(params: { }; } const message = err instanceof Error ? err.message : String(err); - const notFoundMessage = - `Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`; + const notFoundMessage = `Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`; const isNotFound = message.toLowerCase().includes("not found") || message.toLowerCase().includes("no session found");