mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-02 22:50:20 +00:00
322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
import type { CancelNotification, PromptRequest, PromptResponse } from "@agentclientprotocol/sdk";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type { GatewayClient } from "../gateway/client.js";
|
|
import type { EventFrame } from "../gateway/protocol/index.js";
|
|
import { createInMemorySessionStore } from "./session.js";
|
|
import { AcpGatewayAgent } from "./translator.js";
|
|
import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js";
|
|
|
|
type Harness = {
|
|
agent: AcpGatewayAgent;
|
|
requestSpy: ReturnType<typeof vi.fn>;
|
|
sessionUpdateSpy: ReturnType<typeof vi.fn>;
|
|
sessionStore: ReturnType<typeof createInMemorySessionStore>;
|
|
sentRunIds: string[];
|
|
};
|
|
|
|
function createPromptRequest(sessionId: string): PromptRequest {
|
|
return {
|
|
sessionId,
|
|
prompt: [{ type: "text", text: "hello" }],
|
|
_meta: {},
|
|
} as unknown as PromptRequest;
|
|
}
|
|
|
|
function createChatEvent(payload: Record<string, unknown>): EventFrame {
|
|
return {
|
|
type: "event",
|
|
event: "chat",
|
|
payload,
|
|
} as EventFrame;
|
|
}
|
|
|
|
function createToolEvent(payload: Record<string, unknown>): EventFrame {
|
|
return {
|
|
type: "event",
|
|
event: "agent",
|
|
payload,
|
|
} as EventFrame;
|
|
}
|
|
|
|
function createHarness(sessions: Array<{ sessionId: string; sessionKey: string }>): Harness {
|
|
const sentRunIds: string[] = [];
|
|
const requestSpy = 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 {};
|
|
});
|
|
const connection = createAcpConnection();
|
|
const sessionStore = createInMemorySessionStore();
|
|
for (const session of sessions) {
|
|
sessionStore.createSession({
|
|
sessionId: session.sessionId,
|
|
sessionKey: session.sessionKey,
|
|
cwd: "/tmp",
|
|
});
|
|
}
|
|
|
|
const agent = new AcpGatewayAgent(
|
|
connection,
|
|
createAcpGateway(requestSpy as unknown as GatewayClient["request"]),
|
|
{ sessionStore },
|
|
);
|
|
|
|
return {
|
|
agent,
|
|
requestSpy,
|
|
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
sessionUpdateSpy: connection.sessionUpdate as unknown as ReturnType<typeof vi.fn>,
|
|
sessionStore,
|
|
sentRunIds,
|
|
};
|
|
}
|
|
|
|
async function startPendingPrompt(
|
|
harness: Harness,
|
|
sessionId: string,
|
|
): Promise<{ promptPromise: Promise<PromptResponse>; runId: string }> {
|
|
const before = harness.sentRunIds.length;
|
|
const promptPromise = harness.agent.prompt(createPromptRequest(sessionId));
|
|
await vi.waitFor(() => {
|
|
expect(harness.sentRunIds.length).toBe(before + 1);
|
|
});
|
|
return {
|
|
promptPromise,
|
|
runId: harness.sentRunIds[before],
|
|
};
|
|
}
|
|
|
|
async function cancelAndExpectAbortForPendingRun(
|
|
harness: Harness,
|
|
sessionId: string,
|
|
sessionKey: string,
|
|
pending: { promptPromise: Promise<PromptResponse>; runId: string },
|
|
) {
|
|
await harness.agent.cancel({ sessionId } as CancelNotification);
|
|
|
|
expect(harness.requestSpy).toHaveBeenCalledWith("chat.abort", {
|
|
sessionKey,
|
|
runId: pending.runId,
|
|
});
|
|
await expect(pending.promptPromise).resolves.toEqual({ stopReason: "cancelled" });
|
|
}
|
|
|
|
async function deliverFinalChatEventAndExpectEndTurn(
|
|
harness: Harness,
|
|
sessionKey: string,
|
|
pending: { promptPromise: Promise<PromptResponse>; runId: string },
|
|
seq: number,
|
|
) {
|
|
await harness.agent.handleGatewayEvent(
|
|
createChatEvent({
|
|
runId: pending.runId,
|
|
sessionKey,
|
|
seq,
|
|
state: "final",
|
|
}),
|
|
);
|
|
await expect(pending.promptPromise).resolves.toEqual({ stopReason: "end_turn" });
|
|
}
|
|
|
|
describe("acp translator cancel and run scoping", () => {
|
|
it("cancel passes active runId to chat.abort", async () => {
|
|
const sessionKey = "agent:main:shared";
|
|
const harness = createHarness([{ sessionId: "session-1", sessionKey }]);
|
|
const pending = await startPendingPrompt(harness, "session-1");
|
|
|
|
await cancelAndExpectAbortForPendingRun(harness, "session-1", sessionKey, pending);
|
|
});
|
|
|
|
it("cancel uses pending runId when there is no active run", async () => {
|
|
const sessionKey = "agent:main:shared";
|
|
const harness = createHarness([{ sessionId: "session-1", sessionKey }]);
|
|
const pending = await startPendingPrompt(harness, "session-1");
|
|
harness.sessionStore.clearActiveRun("session-1");
|
|
|
|
await cancelAndExpectAbortForPendingRun(harness, "session-1", sessionKey, pending);
|
|
});
|
|
|
|
it("cancel skips chat.abort when there is no active run and no pending prompt", async () => {
|
|
const sessionKey = "agent:main:shared";
|
|
const harness = createHarness([{ sessionId: "session-1", sessionKey }]);
|
|
|
|
await harness.agent.cancel({ sessionId: "session-1" } as CancelNotification);
|
|
|
|
const abortCalls = harness.requestSpy.mock.calls.filter(([method]) => method === "chat.abort");
|
|
expect(abortCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("cancel from a session without active run does not abort another session sharing the same key", async () => {
|
|
const sessionKey = "agent:main:shared";
|
|
const harness = createHarness([
|
|
{ sessionId: "session-1", sessionKey },
|
|
{ sessionId: "session-2", sessionKey },
|
|
]);
|
|
const pending2 = await startPendingPrompt(harness, "session-2");
|
|
|
|
await harness.agent.cancel({ sessionId: "session-1" } as CancelNotification);
|
|
|
|
const abortCalls = harness.requestSpy.mock.calls.filter(([method]) => method === "chat.abort");
|
|
expect(abortCalls).toHaveLength(0);
|
|
expect(harness.sessionStore.getSession("session-2")?.activeRunId).toBe(pending2.runId);
|
|
|
|
await deliverFinalChatEventAndExpectEndTurn(harness, sessionKey, pending2, 1);
|
|
});
|
|
|
|
it("drops chat events when runId does not match the active prompt", async () => {
|
|
const sessionKey = "agent:main:shared";
|
|
const harness = createHarness([{ sessionId: "session-1", sessionKey }]);
|
|
const pending = await startPendingPrompt(harness, "session-1");
|
|
|
|
await harness.agent.handleGatewayEvent(
|
|
createChatEvent({
|
|
runId: "run-other",
|
|
sessionKey,
|
|
seq: 1,
|
|
state: "final",
|
|
}),
|
|
);
|
|
expect(harness.sessionStore.getSession("session-1")?.activeRunId).toBe(pending.runId);
|
|
|
|
await harness.agent.handleGatewayEvent(
|
|
createChatEvent({
|
|
runId: pending.runId,
|
|
sessionKey,
|
|
seq: 2,
|
|
state: "final",
|
|
}),
|
|
);
|
|
await expect(pending.promptPromise).resolves.toEqual({ stopReason: "end_turn" });
|
|
});
|
|
|
|
it("projects gateway thinking blocks into hidden ACP thought chunks", async () => {
|
|
const sessionKey = "agent:main:shared";
|
|
const harness = createHarness([{ sessionId: "session-1", sessionKey }]);
|
|
const pending = await startPendingPrompt(harness, "session-1");
|
|
harness.sessionUpdateSpy.mockClear();
|
|
|
|
await harness.agent.handleGatewayEvent(
|
|
createChatEvent({
|
|
runId: pending.runId,
|
|
sessionKey,
|
|
seq: 1,
|
|
state: "delta",
|
|
message: {
|
|
content: [
|
|
{ type: "thinking", thinking: "Internal loop about NO_REPLY" },
|
|
{ type: "text", text: "Final visible reply" },
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(harness.sessionUpdateSpy).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({
|
|
sessionId: "session-1",
|
|
update: expect.objectContaining({
|
|
sessionUpdate: "agent_thought_chunk",
|
|
content: { type: "text", text: "Internal loop about NO_REPLY" },
|
|
}),
|
|
}),
|
|
);
|
|
expect(harness.sessionUpdateSpy).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({
|
|
sessionId: "session-1",
|
|
update: expect.objectContaining({
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { type: "text", text: "Final visible reply" },
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("drops tool events when runId does not match the active prompt", async () => {
|
|
const sessionKey = "agent:main:shared";
|
|
const harness = createHarness([{ sessionId: "session-1", sessionKey }]);
|
|
const pending = await startPendingPrompt(harness, "session-1");
|
|
harness.sessionUpdateSpy.mockClear();
|
|
|
|
await harness.agent.handleGatewayEvent(
|
|
createToolEvent({
|
|
runId: "run-other",
|
|
sessionKey,
|
|
stream: "tool",
|
|
data: {
|
|
phase: "start",
|
|
name: "read_file",
|
|
toolCallId: "tool-1",
|
|
args: { path: "README.md" },
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(harness.sessionUpdateSpy).not.toHaveBeenCalled();
|
|
|
|
await harness.agent.handleGatewayEvent(
|
|
createChatEvent({
|
|
runId: pending.runId,
|
|
sessionKey,
|
|
seq: 1,
|
|
state: "final",
|
|
}),
|
|
);
|
|
await expect(pending.promptPromise).resolves.toEqual({ stopReason: "end_turn" });
|
|
});
|
|
|
|
it("routes events to the pending prompt that matches runId when session keys are shared", async () => {
|
|
const sessionKey = "agent:main:shared";
|
|
const harness = createHarness([
|
|
{ sessionId: "session-1", sessionKey },
|
|
{ sessionId: "session-2", sessionKey },
|
|
]);
|
|
const pending1 = await startPendingPrompt(harness, "session-1");
|
|
const pending2 = await startPendingPrompt(harness, "session-2");
|
|
harness.sessionUpdateSpy.mockClear();
|
|
|
|
await harness.agent.handleGatewayEvent(
|
|
createToolEvent({
|
|
runId: pending2.runId,
|
|
sessionKey,
|
|
stream: "tool",
|
|
data: {
|
|
phase: "start",
|
|
name: "read_file",
|
|
toolCallId: "tool-2",
|
|
args: { path: "notes.txt" },
|
|
},
|
|
}),
|
|
);
|
|
expect(harness.sessionUpdateSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionId: "session-2",
|
|
update: expect.objectContaining({
|
|
sessionUpdate: "tool_call",
|
|
toolCallId: "tool-2",
|
|
status: "in_progress",
|
|
}),
|
|
}),
|
|
);
|
|
expect(harness.sessionUpdateSpy).toHaveBeenCalledTimes(1);
|
|
|
|
await deliverFinalChatEventAndExpectEndTurn(harness, sessionKey, pending2, 1);
|
|
expect(harness.sessionStore.getSession("session-1")?.activeRunId).toBe(pending1.runId);
|
|
|
|
await harness.agent.handleGatewayEvent(
|
|
createChatEvent({
|
|
runId: pending1.runId,
|
|
sessionKey,
|
|
seq: 2,
|
|
state: "final",
|
|
}),
|
|
);
|
|
await expect(pending1.promptPromise).resolves.toEqual({ stopReason: "end_turn" });
|
|
});
|
|
});
|