mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 21:40:44 +00:00
377 lines
12 KiB
TypeScript
377 lines
12 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import {
|
|
assert,
|
|
ClaudeChannelNotificationSchema,
|
|
ClaudePermissionNotificationSchema,
|
|
connectGateway,
|
|
connectMcpClient,
|
|
extractTextFromGatewayPayload,
|
|
type ClaudeChannelNotification,
|
|
type GatewayRpcClient,
|
|
maybeApprovePendingBridgePairing,
|
|
waitFor,
|
|
} from "./mcp-channels-harness.ts";
|
|
|
|
function summarizeSessionRows(rows: Array<Record<string, unknown>> | undefined) {
|
|
return (rows ?? []).map((entry) => ({
|
|
key: entry.key,
|
|
channel: entry.channel,
|
|
deliveryContext: entry.deliveryContext,
|
|
lastChannel: entry.lastChannel,
|
|
lastTo: entry.lastTo,
|
|
lastAccountId: entry.lastAccountId,
|
|
lastThreadId: entry.lastThreadId,
|
|
}));
|
|
}
|
|
|
|
function formatUnknownError(error: unknown): string {
|
|
if (error instanceof Error) {
|
|
return error.message;
|
|
}
|
|
if (error === undefined || error === null) {
|
|
return "";
|
|
}
|
|
if (typeof error === "string") {
|
|
return error;
|
|
}
|
|
if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") {
|
|
return `${error}`;
|
|
}
|
|
if (typeof error === "symbol") {
|
|
return error.description ?? "symbol";
|
|
}
|
|
try {
|
|
return JSON.stringify(error) ?? "";
|
|
} catch {
|
|
return Object.prototype.toString.call(error);
|
|
}
|
|
}
|
|
|
|
async function waitForGatewaySeededConversation(gateway: GatewayRpcClient) {
|
|
let lastList: { sessions?: Array<Record<string, unknown>> } | undefined;
|
|
let lastError: unknown;
|
|
try {
|
|
return await waitFor(
|
|
"seeded conversation in gateway sessions.list",
|
|
async () => {
|
|
try {
|
|
lastList = await gateway.request<{ sessions?: Array<Record<string, unknown>> }>(
|
|
"sessions.list",
|
|
{ limit: 50, includeDerivedTitles: false, includeLastMessage: false },
|
|
);
|
|
lastError = undefined;
|
|
} catch (error) {
|
|
lastError = error;
|
|
return undefined;
|
|
}
|
|
return lastList.sessions?.find((entry) => entry.key === "agent:main:main");
|
|
},
|
|
180_000,
|
|
);
|
|
} catch (error) {
|
|
throw new Error(
|
|
`gateway sessions.list did not include seeded conversation: ${JSON.stringify(
|
|
{
|
|
count: lastList?.sessions?.length ?? 0,
|
|
sessions: summarizeSessionRows(lastList?.sessions),
|
|
lastError: formatUnknownError(lastError),
|
|
},
|
|
null,
|
|
2,
|
|
)}`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const gatewayUrl = process.env.GW_URL?.trim();
|
|
const gatewayToken = process.env.GW_TOKEN?.trim();
|
|
assert(gatewayUrl, "missing GW_URL");
|
|
assert(gatewayToken, "missing GW_TOKEN");
|
|
|
|
const gateway = await connectGateway({ url: gatewayUrl, token: gatewayToken });
|
|
let mcpHandle: Awaited<ReturnType<typeof connectMcpClient>> | undefined;
|
|
|
|
try {
|
|
const gatewayConversation = await waitForGatewaySeededConversation(gateway);
|
|
assert(
|
|
(gatewayConversation.deliveryContext as { channel?: unknown } | undefined)?.channel ===
|
|
"imessage",
|
|
"expected seeded gateway deliveryContext channel",
|
|
);
|
|
assert(
|
|
(gatewayConversation.deliveryContext as { to?: unknown } | undefined)?.to === "+15551234567",
|
|
"expected seeded gateway deliveryContext target",
|
|
);
|
|
|
|
mcpHandle = await connectMcpClient({
|
|
gatewayUrl,
|
|
gatewayToken,
|
|
});
|
|
let mcp = mcpHandle.client;
|
|
|
|
if (await maybeApprovePendingBridgePairing(gateway)) {
|
|
await Promise.allSettled([mcp.close(), mcpHandle.transport.close()]);
|
|
mcpHandle = await connectMcpClient({
|
|
gatewayUrl,
|
|
gatewayToken,
|
|
});
|
|
mcp = mcpHandle.client;
|
|
}
|
|
const callTool = <T>(params: Parameters<typeof mcp.callTool>[0]) =>
|
|
mcp.callTool(params, undefined, { timeout: 240_000 }) as Promise<T>;
|
|
|
|
let lastMcpConversationList: unknown;
|
|
const conversation = await waitFor(
|
|
"seeded conversation in conversations_list",
|
|
async () => {
|
|
const listed = await callTool<{
|
|
structuredContent?: { conversations?: Array<Record<string, unknown>> };
|
|
}>({
|
|
name: "conversations_list",
|
|
arguments: {
|
|
includeDerivedTitles: false,
|
|
includeLastMessage: false,
|
|
},
|
|
});
|
|
lastMcpConversationList = listed;
|
|
return listed.structuredContent?.conversations?.find(
|
|
(entry) => entry.sessionKey === "agent:main:main",
|
|
);
|
|
},
|
|
240_000,
|
|
).catch((error) => {
|
|
throw new Error(
|
|
`timeout waiting for seeded MCP conversation: ${JSON.stringify(
|
|
lastMcpConversationList,
|
|
null,
|
|
2,
|
|
)}`,
|
|
{ cause: error },
|
|
);
|
|
});
|
|
assert(conversation.channel === "imessage", "expected seeded channel");
|
|
assert(conversation.to === "+15551234567", "expected seeded target");
|
|
|
|
const fetched = await callTool<{
|
|
structuredContent?: { conversation?: Record<string, unknown> };
|
|
isError?: boolean;
|
|
}>({
|
|
name: "conversation_get",
|
|
arguments: { session_key: "agent:main:main" },
|
|
});
|
|
assert(!fetched.isError, "conversation_get should succeed");
|
|
assert(
|
|
fetched.structuredContent?.conversation?.sessionKey === "agent:main:main",
|
|
"conversation_get returned wrong session",
|
|
);
|
|
|
|
let lastHistory: unknown;
|
|
const messages = await waitFor(
|
|
"seeded transcript messages",
|
|
async () => {
|
|
const history = await callTool<{
|
|
structuredContent?: { messages?: Array<Record<string, unknown>> };
|
|
}>({
|
|
name: "messages_read",
|
|
arguments: { session_key: "agent:main:main", limit: 10 },
|
|
});
|
|
lastHistory = history;
|
|
const currentMessages = history.structuredContent?.messages ?? [];
|
|
return currentMessages.length >= 2 ? currentMessages : undefined;
|
|
},
|
|
240_000,
|
|
).catch((error) => {
|
|
throw new Error(
|
|
`timeout waiting for seeded transcript messages: ${JSON.stringify(lastHistory, null, 2)}`,
|
|
{ cause: error },
|
|
);
|
|
});
|
|
await waitFor(
|
|
"seeded attachment message",
|
|
() =>
|
|
messages.find((entry) => {
|
|
const raw = entry.__openclaw;
|
|
return (
|
|
raw && typeof raw === "object" && (raw as { id?: unknown }).id === "msg-attachment"
|
|
);
|
|
}),
|
|
240_000,
|
|
);
|
|
|
|
const attachments = await callTool<{
|
|
structuredContent?: { attachments?: Array<Record<string, unknown>> };
|
|
isError?: boolean;
|
|
}>({
|
|
name: "attachments_fetch",
|
|
arguments: { session_key: "agent:main:main", message_id: "msg-attachment" },
|
|
});
|
|
assert(!attachments.isError, "attachments_fetch should succeed");
|
|
assert(
|
|
(attachments.structuredContent?.attachments?.length ?? 0) === 1,
|
|
"expected one seeded attachment",
|
|
);
|
|
|
|
const waited = (await Promise.all([
|
|
callTool<{
|
|
structuredContent?: { event?: Record<string, unknown> };
|
|
}>({
|
|
name: "events_wait",
|
|
arguments: {
|
|
session_key: "agent:main:main",
|
|
after_cursor: 0,
|
|
timeout_ms: 10_000,
|
|
},
|
|
}),
|
|
gateway.request("chat.inject", {
|
|
sessionKey: "agent:main:main",
|
|
message: "assistant live event",
|
|
}),
|
|
]).then(([result]) => result)) as {
|
|
structuredContent?: { event?: Record<string, unknown> };
|
|
};
|
|
const assistantEvent = waited.structuredContent?.event;
|
|
assert(assistantEvent, "expected events_wait result");
|
|
assert(assistantEvent.type === "message", "expected message event");
|
|
assert(assistantEvent.role === "assistant", "expected assistant event role");
|
|
assert(assistantEvent.text === "assistant live event", "expected assistant event text");
|
|
const assistantCursor = typeof assistantEvent.cursor === "number" ? assistantEvent.cursor : 0;
|
|
|
|
const polled = await callTool<{
|
|
structuredContent?: { events?: Array<Record<string, unknown>> };
|
|
}>({
|
|
name: "events_poll",
|
|
arguments: { session_key: "agent:main:main", after_cursor: 0, limit: 10 },
|
|
});
|
|
assert(
|
|
(polled.structuredContent?.events ?? []).some(
|
|
(entry) => entry.text === "assistant live event",
|
|
),
|
|
"expected assistant event in events_poll",
|
|
);
|
|
|
|
const channelMessage = `hello from docker ${randomUUID()}`;
|
|
const userEvent = (await Promise.all([
|
|
callTool<{
|
|
structuredContent?: { event?: Record<string, unknown> };
|
|
}>({
|
|
name: "events_wait",
|
|
arguments: {
|
|
session_key: "agent:main:main",
|
|
after_cursor: assistantCursor,
|
|
timeout_ms: 10_000,
|
|
},
|
|
}),
|
|
gateway.request("chat.send", {
|
|
sessionKey: "agent:main:main",
|
|
message: channelMessage,
|
|
idempotencyKey: randomUUID(),
|
|
}),
|
|
]).then(([result]) => result)) as {
|
|
structuredContent?: { event?: Record<string, unknown> };
|
|
};
|
|
const rawGatewayUserMessage = await waitFor("raw gateway user session.message", () =>
|
|
gateway.events.find(
|
|
(entry) =>
|
|
entry.event === "session.message" &&
|
|
entry.payload.sessionKey === "agent:main:main" &&
|
|
extractTextFromGatewayPayload(entry.payload) === channelMessage,
|
|
),
|
|
);
|
|
if (userEvent.structuredContent?.event?.text !== channelMessage) {
|
|
throw new Error(
|
|
`expected user event after chat.send: ${JSON.stringify(
|
|
{
|
|
userEvent: userEvent.structuredContent?.event ?? null,
|
|
rawGatewayUserMessage: rawGatewayUserMessage ?? null,
|
|
recentGatewayEvents: gateway.events.slice(-10).map((entry) => ({
|
|
event: entry.event,
|
|
sessionKey: entry.payload.sessionKey,
|
|
text: extractTextFromGatewayPayload(entry.payload),
|
|
})),
|
|
},
|
|
null,
|
|
2,
|
|
)}`,
|
|
);
|
|
}
|
|
assert(rawGatewayUserMessage, "expected raw gateway session.message after chat.send");
|
|
|
|
let helpNotification: ClaudeChannelNotification;
|
|
try {
|
|
helpNotification = await waitFor(
|
|
"Claude channel notification",
|
|
() =>
|
|
mcpHandle.rawMessages
|
|
.map((entry) => ClaudeChannelNotificationSchema.safeParse(entry))
|
|
.find(
|
|
(entry) =>
|
|
entry.success &&
|
|
entry.data.params.meta.session_key === "agent:main:main" &&
|
|
entry.data.params.content === channelMessage,
|
|
)?.data.params,
|
|
);
|
|
} catch (error) {
|
|
throw new Error(
|
|
`timeout waiting for Claude channel notification: ${JSON.stringify(
|
|
{
|
|
rawMessages: mcpHandle.rawMessages.slice(-10),
|
|
},
|
|
null,
|
|
2,
|
|
)}`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
assert(helpNotification.content === channelMessage, "expected Claude channel content");
|
|
|
|
await mcp.notification({
|
|
method: "notifications/claude/channel/permission_request",
|
|
params: {
|
|
request_id: "abcde",
|
|
tool_name: "Bash",
|
|
description: "run npm test",
|
|
input_preview: '{"cmd":"npm test"}',
|
|
},
|
|
});
|
|
|
|
await gateway.request("chat.send", {
|
|
sessionKey: "agent:main:main",
|
|
message: "yes abcde",
|
|
idempotencyKey: randomUUID(),
|
|
});
|
|
const permission = await waitFor(
|
|
"Claude permission notification",
|
|
() =>
|
|
mcpHandle.rawMessages
|
|
.map((entry) => ClaudePermissionNotificationSchema.safeParse(entry))
|
|
.find((entry) => entry.success && entry.data.params.request_id === "abcde")?.data.params,
|
|
);
|
|
assert(permission.behavior === "allow", "expected allow permission reply");
|
|
|
|
process.stdout.write(
|
|
JSON.stringify(
|
|
{
|
|
ok: true,
|
|
sessionKey: "agent:main:main",
|
|
rawNotifications: mcpHandle.rawMessages.filter(
|
|
(entry) =>
|
|
ClaudeChannelNotificationSchema.safeParse(entry).success ||
|
|
ClaudePermissionNotificationSchema.safeParse(entry).success,
|
|
).length,
|
|
},
|
|
null,
|
|
2,
|
|
) + "\n",
|
|
);
|
|
} finally {
|
|
await Promise.allSettled([
|
|
...(mcpHandle ? [mcpHandle.client.close(), mcpHandle.transport.close()] : []),
|
|
gateway.close(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
await main();
|