mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-12 01:10:42 +00:00
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
import type {
|
|
CloseSessionRequest,
|
|
InitializeRequest,
|
|
ListSessionsRequest,
|
|
PromptRequest,
|
|
PromptResponse,
|
|
ResumeSessionRequest,
|
|
} from "@agentclientprotocol/sdk";
|
|
import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type { GatewayClient } from "../gateway/client.js";
|
|
import type { GatewaySessionRow } from "../gateway/session-utils.js";
|
|
import { createInMemorySessionStore } from "./session.js";
|
|
import { AcpGatewayAgent } from "./translator.js";
|
|
import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js";
|
|
|
|
vi.mock("./commands.js", () => ({
|
|
getAvailableCommands: () => [],
|
|
}));
|
|
|
|
function createInitializeRequest(): InitializeRequest {
|
|
return {
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
clientCapabilities: {
|
|
fs: { readTextFile: false, writeTextFile: false },
|
|
terminal: false,
|
|
},
|
|
} as InitializeRequest;
|
|
}
|
|
|
|
function createListSessionsRequest(params: {
|
|
cwd?: string;
|
|
cursor?: string | null;
|
|
limit?: number;
|
|
}): ListSessionsRequest {
|
|
const request: ListSessionsRequest = {
|
|
_meta: {},
|
|
};
|
|
if (params.cwd) {
|
|
request.cwd = params.cwd;
|
|
}
|
|
if (params.cursor !== undefined) {
|
|
request.cursor = params.cursor;
|
|
}
|
|
if (params.limit !== undefined) {
|
|
request._meta = { limit: params.limit };
|
|
}
|
|
return request;
|
|
}
|
|
|
|
function createResumeSessionRequest(
|
|
sessionId: string,
|
|
cwd = "/tmp/openclaw",
|
|
): ResumeSessionRequest {
|
|
return {
|
|
sessionId,
|
|
cwd,
|
|
mcpServers: [],
|
|
_meta: {},
|
|
} as ResumeSessionRequest;
|
|
}
|
|
|
|
function createCloseSessionRequest(sessionId: string): CloseSessionRequest {
|
|
return {
|
|
sessionId,
|
|
_meta: {},
|
|
} as CloseSessionRequest;
|
|
}
|
|
|
|
function createPromptRequest(sessionId: string): PromptRequest {
|
|
return {
|
|
sessionId,
|
|
prompt: [{ type: "text", text: "hello" }],
|
|
_meta: {},
|
|
} as PromptRequest;
|
|
}
|
|
|
|
function createGatewaySessions(rows: GatewaySessionRow[]) {
|
|
return {
|
|
ts: Date.now(),
|
|
path: "/tmp/sessions.json",
|
|
count: rows.length,
|
|
totalCount: rows.length,
|
|
limitApplied: rows.length,
|
|
hasMore: false,
|
|
defaults: {
|
|
modelProvider: null,
|
|
model: null,
|
|
contextTokens: null,
|
|
},
|
|
sessions: rows,
|
|
};
|
|
}
|
|
|
|
function createSessionRow(params: {
|
|
key: string;
|
|
cwd?: string;
|
|
title?: string;
|
|
updatedAt?: number;
|
|
}): GatewaySessionRow {
|
|
return {
|
|
key: params.key,
|
|
kind: "direct",
|
|
spawnedWorkspaceDir: params.cwd,
|
|
derivedTitle: params.title,
|
|
updatedAt: params.updatedAt ?? 1_710_000_000_000,
|
|
thinkingLevel: "adaptive",
|
|
modelProvider: "openai",
|
|
model: "gpt-5.4",
|
|
};
|
|
}
|
|
|
|
async function startPendingPrompt(params: {
|
|
agent: AcpGatewayAgent;
|
|
sentRunIds: string[];
|
|
sessionId: string;
|
|
}): Promise<{ promptPromise: Promise<PromptResponse>; runId: string }> {
|
|
const before = params.sentRunIds.length;
|
|
const promptPromise = params.agent.prompt(createPromptRequest(params.sessionId));
|
|
await vi.waitFor(() => {
|
|
expect(params.sentRunIds.length).toBe(before + 1);
|
|
});
|
|
return {
|
|
promptPromise,
|
|
runId: params.sentRunIds[before],
|
|
};
|
|
}
|
|
|
|
describe("acp translator stable lifecycle handlers", () => {
|
|
it("advertises only session capabilities backed by bridge handlers", async () => {
|
|
const sessionStore = createInMemorySessionStore();
|
|
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(), {
|
|
sessionStore,
|
|
});
|
|
|
|
const result = await agent.initialize(createInitializeRequest());
|
|
const capabilities = result.agentCapabilities;
|
|
if (!capabilities) {
|
|
throw new Error("initialize response did not include agent capabilities");
|
|
}
|
|
|
|
expect(capabilities.loadSession).toBe(true);
|
|
expect(typeof agent.loadSession).toBe("function");
|
|
expect(capabilities.sessionCapabilities?.list).toEqual({});
|
|
expect(typeof agent.listSessions).toBe("function");
|
|
expect(capabilities.sessionCapabilities?.resume).toEqual({});
|
|
expect(typeof agent.resumeSession).toBe("function");
|
|
expect(capabilities.sessionCapabilities?.close).toEqual({});
|
|
expect(typeof agent.closeSession).toBe("function");
|
|
expect(capabilities.sessionCapabilities?.fork).toBeUndefined();
|
|
expect("unstable_listSessions" in agent).toBe(false);
|
|
|
|
sessionStore.clearAllSessionsForTest();
|
|
});
|
|
|
|
it("lists Gateway sessions through the stable handler with opaque cursors and cwd filtering", async () => {
|
|
const allRows = [
|
|
createSessionRow({ key: "agent:main:a1", cwd: "/work/a", title: "A1" }),
|
|
createSessionRow({ key: "agent:main:a2", cwd: "/work/a", title: "A2" }),
|
|
createSessionRow({ key: "agent:main:a3", cwd: "/work/a", title: "A3" }),
|
|
createSessionRow({ key: "agent:main:b1", cwd: "/work/b", title: "B1" }),
|
|
createSessionRow({ key: "agent:main:a4", cwd: "/work/a", title: "A4" }),
|
|
];
|
|
const request = vi.fn(async (method: string, params?: { limit?: number }) => {
|
|
if (method === "sessions.list") {
|
|
const limit = params?.limit ?? allRows.length;
|
|
return {
|
|
...createGatewaySessions(allRows.slice(0, limit)),
|
|
totalCount: allRows.length,
|
|
hasMore: limit < allRows.length,
|
|
};
|
|
}
|
|
return { ok: true };
|
|
}) as GatewayClient["request"];
|
|
const sessionStore = createInMemorySessionStore();
|
|
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
|
|
sessionStore,
|
|
});
|
|
|
|
const first = await agent.listSessions(createListSessionsRequest({ cwd: "/work/a", limit: 2 }));
|
|
const second = await agent.listSessions(
|
|
createListSessionsRequest({ cwd: "/work/a", limit: 2, cursor: first.nextCursor }),
|
|
);
|
|
|
|
expect(first.sessions.map((session) => session.sessionId)).toEqual([
|
|
"agent:main:a1",
|
|
"agent:main:a2",
|
|
]);
|
|
expect(first.sessions.map((session) => session.cwd)).toEqual(["/work/a", "/work/a"]);
|
|
expect(first.nextCursor).toBeTypeOf("string");
|
|
expect(first.nextCursor).not.toBe("");
|
|
expect(second.sessions.map((session) => session.sessionId)).toEqual([
|
|
"agent:main:a3",
|
|
"agent:main:a4",
|
|
]);
|
|
expect(second.sessions.map((session) => session.cwd)).toEqual(["/work/a", "/work/a"]);
|
|
expect(second.nextCursor).toBeNull();
|
|
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {
|
|
limit: 3,
|
|
includeDerivedTitles: true,
|
|
});
|
|
expect(request).toHaveBeenNthCalledWith(2, "sessions.list", {
|
|
limit: 5,
|
|
includeDerivedTitles: true,
|
|
});
|
|
|
|
sessionStore.clearAllSessionsForTest();
|
|
});
|
|
|
|
it("does not include sessions without workspace metadata in cwd-filtered lists", async () => {
|
|
const allRows = [
|
|
createSessionRow({ key: "agent:main:unknown", title: "Unknown workspace" }),
|
|
createSessionRow({ key: "agent:main:a1", cwd: "/work/a", title: "A1" }),
|
|
createSessionRow({ key: "agent:main:b1", cwd: "/work/b", title: "B1" }),
|
|
];
|
|
const request = vi.fn(async (method: string) => {
|
|
if (method === "sessions.list") {
|
|
return createGatewaySessions(allRows);
|
|
}
|
|
return { ok: true };
|
|
}) as GatewayClient["request"];
|
|
const sessionStore = createInMemorySessionStore();
|
|
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
|
|
sessionStore,
|
|
});
|
|
|
|
const result = await agent.listSessions(createListSessionsRequest({ cwd: "/work/a" }));
|
|
|
|
expect(result.sessions.map((session) => session.sessionId)).toEqual(["agent:main:a1"]);
|
|
expect(result.sessions.map((session) => session.cwd)).toEqual(["/work/a"]);
|
|
|
|
sessionStore.clearAllSessionsForTest();
|
|
});
|
|
|
|
it("rejects session/list cursors when the cwd filter changes", async () => {
|
|
const allRows = [
|
|
createSessionRow({ key: "agent:main:a1", cwd: "/work/a", title: "A1" }),
|
|
createSessionRow({ key: "agent:main:a2", cwd: "/work/a", title: "A2" }),
|
|
createSessionRow({ key: "agent:main:b1", cwd: "/work/b", title: "B1" }),
|
|
];
|
|
const request = vi.fn(async (method: string, params?: { limit?: number }) => {
|
|
if (method === "sessions.list") {
|
|
const limit = params?.limit ?? allRows.length;
|
|
return {
|
|
...createGatewaySessions(allRows.slice(0, limit)),
|
|
totalCount: allRows.length,
|
|
hasMore: limit < allRows.length,
|
|
};
|
|
}
|
|
return { ok: true };
|
|
}) as GatewayClient["request"];
|
|
const sessionStore = createInMemorySessionStore();
|
|
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
|
|
sessionStore,
|
|
});
|
|
|
|
const unfiltered = await agent.listSessions(createListSessionsRequest({ limit: 1 }));
|
|
expect(unfiltered.nextCursor).toBeTypeOf("string");
|
|
expect(unfiltered.nextCursor).not.toBe("");
|
|
await expect(
|
|
agent.listSessions(
|
|
createListSessionsRequest({ cwd: "/work/a", cursor: unfiltered.nextCursor }),
|
|
),
|
|
).rejects.toThrow(/cursor does not match the cwd filter/i);
|
|
|
|
const filtered = await agent.listSessions(
|
|
createListSessionsRequest({ cwd: "/work/a", limit: 1 }),
|
|
);
|
|
expect(filtered.nextCursor).toBeTypeOf("string");
|
|
expect(filtered.nextCursor).not.toBe("");
|
|
await expect(
|
|
agent.listSessions(createListSessionsRequest({ cursor: filtered.nextCursor })),
|
|
).rejects.toThrow(/cursor does not match the cwd filter/i);
|
|
|
|
sessionStore.clearAllSessionsForTest();
|
|
});
|
|
|
|
it("rejects relative cwd filters for session/list", async () => {
|
|
const sessionStore = createInMemorySessionStore();
|
|
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(), {
|
|
sessionStore,
|
|
});
|
|
|
|
await expect(
|
|
agent.listSessions(createListSessionsRequest({ cwd: "relative/path" })),
|
|
).rejects.toThrow(/requires an absolute cwd/i);
|
|
|
|
sessionStore.clearAllSessionsForTest();
|
|
});
|
|
|
|
it("resumes an existing Gateway session without replaying transcript history", async () => {
|
|
const connection = createAcpConnection();
|
|
const sessionUpdate = connection.__sessionUpdateMock;
|
|
const request = vi.fn(async (method: string) => {
|
|
if (method === "sessions.list") {
|
|
return createGatewaySessions([
|
|
createSessionRow({
|
|
key: "agent:main:work",
|
|
cwd: "/tmp/openclaw",
|
|
title: "Work session",
|
|
}),
|
|
]);
|
|
}
|
|
if (method === "sessions.get") {
|
|
throw new Error("resume must not load transcript history");
|
|
}
|
|
return { ok: true };
|
|
}) as GatewayClient["request"];
|
|
const sessionStore = createInMemorySessionStore();
|
|
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
|
|
sessionStore,
|
|
});
|
|
|
|
const result = await agent.resumeSession(createResumeSessionRequest("agent:main:work"));
|
|
|
|
expect(result.modes?.currentModeId).toBe("adaptive");
|
|
expect(result.configOptions).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: "thought_level",
|
|
currentValue: "adaptive",
|
|
}),
|
|
]),
|
|
);
|
|
expect(sessionStore.getSession("agent:main:work")?.sessionKey).toBe("agent:main:work");
|
|
expect(request).not.toHaveBeenCalledWith("sessions.get", expect.anything());
|
|
expect(sessionUpdate).toHaveBeenCalledWith({
|
|
sessionId: "agent:main:work",
|
|
update: {
|
|
sessionUpdate: "session_info_update",
|
|
title: "Work session",
|
|
updatedAt: "2024-03-09T16:00:00.000Z",
|
|
},
|
|
});
|
|
|
|
sessionStore.clearAllSessionsForTest();
|
|
});
|
|
|
|
it("rejects resume for a missing Gateway session without creating bridge state", async () => {
|
|
const request = vi.fn(async (method: string) => {
|
|
if (method === "sessions.list") {
|
|
return createGatewaySessions([]);
|
|
}
|
|
return { ok: true };
|
|
}) as GatewayClient["request"];
|
|
const sessionStore = createInMemorySessionStore();
|
|
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
|
|
sessionStore,
|
|
});
|
|
|
|
await expect(
|
|
agent.resumeSession(createResumeSessionRequest("missing-session")),
|
|
).rejects.toThrow(/Session missing-session not found/i);
|
|
|
|
expect(sessionStore.hasSession("missing-session")).toBe(false);
|
|
sessionStore.clearAllSessionsForTest();
|
|
});
|
|
|
|
it("closes sessions by aborting active work, resolving pending prompts, and deleting bridge state", async () => {
|
|
const sentRunIds: string[] = [];
|
|
const request = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
|
if (method === "chat.send") {
|
|
const runId = params?.idempotencyKey;
|
|
if (typeof runId === "string") {
|
|
sentRunIds.push(runId);
|
|
}
|
|
return new Promise<never>(() => {});
|
|
}
|
|
return { ok: true };
|
|
}) as GatewayClient["request"];
|
|
const sessionStore = createInMemorySessionStore();
|
|
sessionStore.createSession({
|
|
sessionId: "session-1",
|
|
sessionKey: "agent:main:work",
|
|
cwd: "/tmp/openclaw",
|
|
});
|
|
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(request), {
|
|
sessionStore,
|
|
});
|
|
const pending = await startPendingPrompt({ agent, sentRunIds, sessionId: "session-1" });
|
|
|
|
await expect(agent.closeSession(createCloseSessionRequest("session-1"))).resolves.toEqual({});
|
|
|
|
expect(request).toHaveBeenCalledWith("chat.abort", {
|
|
sessionKey: "agent:main:work",
|
|
runId: pending.runId,
|
|
});
|
|
await expect(pending.promptPromise).resolves.toEqual({ stopReason: "cancelled" });
|
|
expect(sessionStore.hasSession("session-1")).toBe(false);
|
|
});
|
|
|
|
it("rejects close for missing sessions", async () => {
|
|
const sessionStore = createInMemorySessionStore();
|
|
const agent = new AcpGatewayAgent(createAcpConnection(), createAcpGateway(), {
|
|
sessionStore,
|
|
});
|
|
|
|
await expect(agent.closeSession(createCloseSessionRequest("missing-session"))).rejects.toThrow(
|
|
/Session missing-session not found/i,
|
|
);
|
|
|
|
sessionStore.clearAllSessionsForTest();
|
|
});
|
|
});
|