openclaw/src/agents/openclaw-tools.session-stat...

412 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
const loadSessionStoreMock = vi.fn();
const updateSessionStoreMock = vi.fn();
const callGatewayMock = vi.fn();
const loadCombinedSessionStoreForGatewayMock = vi.fn();
const createMockConfig = () => ({
session: { mainKey: "main", scope: "per-sender" },
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
models: {},
},
},
tools: {
agentToAgent: { enabled: false },
},
});
let mockConfig: Record<string, unknown> = createMockConfig();
vi.mock("../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/sessions.js")>();
return {
...actual,
loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath),
updateSessionStore: async (
storePath: string,
mutator: (store: Record<string, unknown>) => Promise<void> | void,
) => {
const store = loadSessionStoreMock(storePath) as Record<string, unknown>;
await mutator(store);
updateSessionStoreMock(storePath, store);
return store;
},
resolveStorePath: (_store: string | undefined, opts?: { agentId?: string }) =>
opts?.agentId === "support" ? "/tmp/support/sessions.json" : "/tmp/main/sessions.json",
};
});
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("../gateway/session-utils.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../gateway/session-utils.js")>();
return {
...actual,
loadCombinedSessionStoreForGateway: (cfg: unknown) =>
loadCombinedSessionStoreForGatewayMock(cfg),
};
});
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => mockConfig,
};
});
vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: async () => [
{
provider: "anthropic",
id: "claude-opus-4-5",
name: "Opus",
contextWindow: 200000,
},
{
provider: "anthropic",
id: "claude-sonnet-4-5",
name: "Sonnet",
contextWindow: 200000,
},
],
}));
vi.mock("../agents/auth-profiles.js", () => ({
ensureAuthProfileStore: () => ({ profiles: {} }),
resolveAuthProfileDisplayLabel: () => undefined,
resolveAuthProfileOrder: () => [],
}));
vi.mock("../agents/model-auth.js", () => ({
resolveEnvApiKey: () => null,
resolveUsableCustomProviderApiKey: () => null,
resolveModelAuthMode: () => "api-key",
}));
vi.mock("../infra/provider-usage.js", () => ({
resolveUsageProviderId: () => undefined,
loadProviderUsageSummary: async () => ({
updatedAt: Date.now(),
providers: [],
}),
formatUsageSummaryLine: () => null,
}));
import "./test-helpers/fast-core-tools.js";
import { createOpenClawTools } from "./openclaw-tools.js";
function resetSessionStore(store: Record<string, unknown>) {
loadSessionStoreMock.mockClear();
updateSessionStoreMock.mockClear();
callGatewayMock.mockClear();
loadCombinedSessionStoreForGatewayMock.mockClear();
loadSessionStoreMock.mockReturnValue(store);
loadCombinedSessionStoreForGatewayMock.mockReturnValue({
storePath: "(multiple)",
store,
});
callGatewayMock.mockResolvedValue({});
mockConfig = createMockConfig();
}
function installSandboxedSessionStatusConfig() {
mockConfig = {
session: { mainKey: "main", scope: "per-sender" },
tools: {
sessions: { visibility: "all" },
agentToAgent: { enabled: true, allow: ["*"] },
},
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
models: {},
sandbox: { sessionToolsVisibility: "spawned" },
},
},
};
}
function mockSpawnedSessionList(
resolveSessions: (spawnedBy: string | undefined) => Array<Record<string, unknown>>,
) {
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: Record<string, unknown> };
if (request.method === "sessions.list") {
return { sessions: resolveSessions(request.params?.spawnedBy as string | undefined) };
}
return {};
});
}
function expectSpawnedSessionLookupCalls(spawnedBy: string) {
const expectedCall = {
method: "sessions.list",
params: {
includeGlobal: false,
includeUnknown: false,
limit: 500,
spawnedBy,
},
};
expect(callGatewayMock).toHaveBeenCalledTimes(2);
expect(callGatewayMock).toHaveBeenNthCalledWith(1, expectedCall);
expect(callGatewayMock).toHaveBeenNthCalledWith(2, expectedCall);
}
function getSessionStatusTool(agentSessionKey = "main", options?: { sandboxed?: boolean }) {
const tool = createOpenClawTools({
agentSessionKey,
sandboxed: options?.sandboxed,
}).find((candidate) => candidate.name === "session_status");
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing session_status tool");
}
return tool;
}
describe("session_status tool", () => {
it("returns a status card for the current session", async () => {
resetSessionStore({
main: {
sessionId: "s1",
updatedAt: 10,
},
});
const tool = getSessionStatusTool();
const result = await tool.execute("call1", {});
const details = result.details as { ok?: boolean; statusText?: string };
expect(details.ok).toBe(true);
expect(details.statusText).toContain("OpenClaw");
expect(details.statusText).toContain("🧠 Model:");
expect(details.statusText).not.toContain("OAuth/token status");
});
it("errors for unknown session keys", async () => {
resetSessionStore({
main: { sessionId: "s1", updatedAt: 10 },
});
const tool = getSessionStatusTool();
await expect(tool.execute("call2", { sessionKey: "nope" })).rejects.toThrow(
"Unknown sessionId",
);
expect(updateSessionStoreMock).not.toHaveBeenCalled();
});
it("resolves sessionId inputs", async () => {
const sessionId = "sess-main";
resetSessionStore({
"agent:main:main": {
sessionId,
updatedAt: 10,
},
});
const tool = getSessionStatusTool();
const result = await tool.execute("call3", { sessionKey: sessionId });
const details = result.details as { ok?: boolean; sessionKey?: string };
expect(details.ok).toBe(true);
expect(details.sessionKey).toBe("agent:main:main");
});
it("resolves duplicate sessionId inputs deterministically", async () => {
resetSessionStore({
"agent:main:main": {
sessionId: "current",
updatedAt: 10,
},
"agent:main:other": {
sessionId: "run-dup",
updatedAt: 999,
},
"agent:main:acp:run-dup": {
sessionId: "run-dup",
updatedAt: 100,
},
});
const tool = getSessionStatusTool();
const result = await tool.execute("call-dup", { sessionKey: "run-dup" });
const details = result.details as { ok?: boolean; sessionKey?: string };
expect(details.ok).toBe(true);
expect(details.sessionKey).toBe("agent:main:acp:run-dup");
});
it("uses non-standard session keys without sessionId resolution", async () => {
resetSessionStore({
"temp:slug-generator": {
sessionId: "sess-temp",
updatedAt: 10,
},
});
const tool = getSessionStatusTool();
const result = await tool.execute("call4", { sessionKey: "temp:slug-generator" });
const details = result.details as { ok?: boolean; sessionKey?: string };
expect(details.ok).toBe(true);
expect(details.sessionKey).toBe("temp:slug-generator");
});
it("blocks cross-agent session_status without agent-to-agent access", async () => {
resetSessionStore({
"agent:other:main": {
sessionId: "s2",
updatedAt: 10,
},
});
const tool = getSessionStatusTool("agent:main:main");
await expect(tool.execute("call5", { sessionKey: "agent:other:main" })).rejects.toThrow(
"Agent-to-agent status is disabled",
);
});
it("blocks sandboxed child session_status access outside its tree before store lookup", async () => {
resetSessionStore({
"agent:main:subagent:child": {
sessionId: "s-child",
updatedAt: 20,
},
"agent:main:main": {
sessionId: "s-parent",
updatedAt: 10,
},
});
installSandboxedSessionStatusConfig();
mockSpawnedSessionList(() => []);
const tool = getSessionStatusTool("agent:main:subagent:child", {
sandboxed: true,
});
const expectedError = "Session status visibility is restricted to the current session tree";
await expect(
tool.execute("call6", {
sessionKey: "agent:main:main",
model: "anthropic/claude-sonnet-4-5",
}),
).rejects.toThrow(expectedError);
await expect(
tool.execute("call7", {
sessionKey: "agent:main:subagent:missing",
}),
).rejects.toThrow(expectedError);
expect(loadSessionStoreMock).not.toHaveBeenCalled();
expect(updateSessionStoreMock).not.toHaveBeenCalled();
expectSpawnedSessionLookupCalls("agent:main:subagent:child");
});
it("keeps legacy main requester keys for sandboxed session tree checks", async () => {
resetSessionStore({
"agent:main:main": {
sessionId: "s-main",
updatedAt: 10,
},
"agent:main:subagent:child": {
sessionId: "s-child",
updatedAt: 20,
},
});
installSandboxedSessionStatusConfig();
mockSpawnedSessionList((spawnedBy) =>
spawnedBy === "main" ? [{ key: "agent:main:subagent:child" }] : [],
);
const tool = getSessionStatusTool("main", {
sandboxed: true,
});
const mainResult = await tool.execute("call8", {});
const mainDetails = mainResult.details as { ok?: boolean; sessionKey?: string };
expect(mainDetails.ok).toBe(true);
expect(mainDetails.sessionKey).toBe("agent:main:main");
const childResult = await tool.execute("call9", {
sessionKey: "agent:main:subagent:child",
});
const childDetails = childResult.details as { ok?: boolean; sessionKey?: string };
expect(childDetails.ok).toBe(true);
expect(childDetails.sessionKey).toBe("agent:main:subagent:child");
expectSpawnedSessionLookupCalls("main");
});
it("scopes bare session keys to the requester agent", async () => {
loadSessionStoreMock.mockClear();
updateSessionStoreMock.mockClear();
const stores = new Map<string, Record<string, unknown>>([
[
"/tmp/main/sessions.json",
{
"agent:main:main": { sessionId: "s-main", updatedAt: 10 },
},
],
[
"/tmp/support/sessions.json",
{
main: { sessionId: "s-support", updatedAt: 20 },
},
],
]);
loadSessionStoreMock.mockImplementation((storePath: string) => {
return stores.get(storePath) ?? {};
});
updateSessionStoreMock.mockImplementation(
(_storePath: string, store: Record<string, unknown>) => {
// Keep map in sync for resolveSessionEntry fallbacks if needed.
if (_storePath) {
stores.set(_storePath, store);
}
},
);
const tool = getSessionStatusTool("agent:support:main");
const result = await tool.execute("call6", { sessionKey: "main" });
const details = result.details as { ok?: boolean; sessionKey?: string };
expect(details.ok).toBe(true);
expect(details.sessionKey).toBe("main");
});
it("resets per-session model override via model=default", async () => {
resetSessionStore({
main: {
sessionId: "s1",
updatedAt: 10,
providerOverride: "anthropic",
modelOverride: "claude-sonnet-4-5",
authProfileOverride: "p1",
},
});
const tool = getSessionStatusTool();
await tool.execute("call3", { model: "default" });
expect(updateSessionStoreMock).toHaveBeenCalled();
const [, savedStore] = updateSessionStoreMock.mock.calls.at(-1) as [
string,
Record<string, unknown>,
];
const saved = savedStore.main as Record<string, unknown>;
expect(saved.providerOverride).toBeUndefined();
expect(saved.modelOverride).toBeUndefined();
expect(saved.authProfileOverride).toBeUndefined();
});
});