Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/concepts/session-tool.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions src/agents/openclaw-tools.sessions-visibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
});
});
23 changes: 22 additions & 1 deletion src/agents/openclaw-tools.sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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/);
});

Expand Down
10 changes: 8 additions & 2 deletions src/agents/tools/sessions-access.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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" &&
Expand Down
8 changes: 7 additions & 1 deletion src/agents/tools/sessions-history-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> }>({
type ChatHistoryResult = {
messages?: Array<unknown>;
historyStatus?: "ok" | "not_found" | "not_persisted" | "empty";
};
const result = await callGateway<ChatHistoryResult>({
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));
Expand All @@ -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,
Expand Down
12 changes: 7 additions & 5 deletions src/agents/tools/sessions-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -202,12 +202,14 @@ 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,
};
}
}
Expand Down
25 changes: 22 additions & 3 deletions src/gateway/server-methods/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -584,6 +602,7 @@ export const chatHandlers: GatewayRequestHandlers = {
messages: bounded.messages,
thinkingLevel,
verboseLevel,
historyStatus,
});
},
"chat.abort": ({ params, respond, context }) => {
Expand Down
90 changes: 89 additions & 1 deletion src/gateway/server.chat.gateway-server-chat-b.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,34 @@ 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<ReturnType<typeof startServerWithClient>>["ws"],
): Promise<unknown[]> {
const historyRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
const historyRes = await rpcReq<HistoryPayload>(ws, "chat.history", {
sessionKey: "main",
limit: 1000,
});
expect(historyRes.ok).toBe(true);
return historyRes.payload?.messages ?? [];
}

async function fetchHistoryPayload(
ws: Awaited<ReturnType<typeof startServerWithClient>>["ws"],
sessionKey = "main",
): Promise<HistoryPayload> {
const historyRes = await rpcReq<HistoryPayload>(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 }) => {
Expand Down Expand Up @@ -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<string, unknown>,
},
});
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;
Expand Down
46 changes: 46 additions & 0 deletions src/gateway/session-utils.fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
readFirstUserMessageFromTranscript,
readLastMessagePreviewFromTranscript,
readSessionMessages,
readSessionMessagesWithStatus,
readSessionTitleFieldsFromTranscript,
readSessionPreviewItemsFromTranscript,
resolveSessionTranscriptCandidates,
Expand Down Expand Up @@ -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;
Expand Down
Loading