mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-27 03:37:50 +00:00
Route WebChat image attachments through the configured imageModel when the active session model cannot accept images, while keeping one-turn image auth and fallback state ephemeral. Thanks @frankekn.
1342 lines
43 KiB
TypeScript
1342 lines
43 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
|
import type { GetReplyOptions } from "../auto-reply/get-reply-options.types.js";
|
|
import { clearConfigCache } from "../config/config.js";
|
|
import type { AgentModelConfig } from "../config/types.agents-shared.js";
|
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
|
import { __setMaxChatHistoryMessagesBytesForTest } from "./server-constants.js";
|
|
import type { GatewayRequestContext, RespondFn } from "./server-methods/shared-types.js";
|
|
import {
|
|
connectOk,
|
|
createGatewaySuiteHarness,
|
|
dispatchInboundMessageMock,
|
|
getReplyFromConfig,
|
|
installGatewayTestHooks,
|
|
mockGetReplyFromConfigOnce,
|
|
onceMessage,
|
|
rpcReq,
|
|
testState,
|
|
writeSessionStore,
|
|
} from "./test-helpers.js";
|
|
|
|
installGatewayTestHooks({ scope: "suite" });
|
|
const FAST_WAIT_OPTS = { timeout: 250, interval: 2 } as const;
|
|
type GatewayHarness = Awaited<ReturnType<typeof createGatewaySuiteHarness>>;
|
|
type GatewaySocket = Awaited<ReturnType<GatewayHarness["openWs"]>>;
|
|
let harness: GatewayHarness;
|
|
|
|
beforeAll(async () => {
|
|
harness = await createGatewaySuiteHarness();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await harness.close();
|
|
});
|
|
|
|
const sendReq = (
|
|
ws: { send: (payload: string) => void },
|
|
id: string,
|
|
method: string,
|
|
params: unknown,
|
|
) => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "req",
|
|
id,
|
|
method,
|
|
params,
|
|
}),
|
|
);
|
|
};
|
|
|
|
function createDeferred<T>() {
|
|
let resolve: ((value: T | PromiseLike<T>) => void) | undefined;
|
|
let reject: ((reason?: unknown) => void) | undefined;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
if (!resolve || !reject) {
|
|
throw new Error("Expected deferred callbacks to be initialized");
|
|
}
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
async function withGatewayChatHarness(
|
|
run: (ctx: { ws: GatewaySocket; createSessionDir: () => Promise<string> }) => Promise<void>,
|
|
) {
|
|
const tempDirs: string[] = [];
|
|
const ws = await harness.openWs();
|
|
const createSessionDir = async () => {
|
|
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
|
|
tempDirs.push(sessionDir);
|
|
testState.sessionStorePath = path.join(sessionDir, "sessions.json");
|
|
return sessionDir;
|
|
};
|
|
|
|
try {
|
|
await run({ ws, createSessionDir });
|
|
} finally {
|
|
__setMaxChatHistoryMessagesBytesForTest();
|
|
clearConfigCache();
|
|
testState.sessionStorePath = undefined;
|
|
ws.close();
|
|
await Promise.all(
|
|
tempDirs.map((dir) =>
|
|
fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
async function writeMainSessionStore() {
|
|
await writeSessionStore({
|
|
entries: {
|
|
main: { sessionId: "sess-main", updatedAt: Date.now() },
|
|
},
|
|
});
|
|
}
|
|
|
|
async function writeGatewayConfig(config: Record<string, unknown>) {
|
|
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
|
if (!configPath) {
|
|
throw new Error("OPENCLAW_CONFIG_PATH missing in gateway test environment");
|
|
}
|
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
clearConfigCache();
|
|
}
|
|
|
|
async function writeMainSessionTranscript(sessionDir: string, lines: string[]) {
|
|
await fs.writeFile(path.join(sessionDir, "sess-main.jsonl"), `${lines.join("\n")}\n`, "utf-8");
|
|
}
|
|
|
|
async function fetchHistoryMessages(
|
|
ws: GatewaySocket,
|
|
params?: {
|
|
limit?: number;
|
|
maxChars?: number;
|
|
},
|
|
): Promise<unknown[]> {
|
|
const historyRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
|
|
sessionKey: "main",
|
|
limit: params?.limit ?? 1000,
|
|
...(typeof params?.maxChars === "number" ? { maxChars: params.maxChars } : {}),
|
|
});
|
|
expect(historyRes.ok).toBe(true);
|
|
return historyRes.payload?.messages ?? [];
|
|
}
|
|
|
|
type ConfiguredImageModelCase = {
|
|
id: string;
|
|
imageModel: AgentModelConfig;
|
|
expectedFallbacks: string[];
|
|
};
|
|
|
|
const configuredImageModelCases: ConfiguredImageModelCase[] = [
|
|
{
|
|
id: "with-image-fallback",
|
|
imageModel: { primary: "openai/gpt-4o", fallbacks: ["openai/gpt-4o-mini"] },
|
|
expectedFallbacks: ["openai/gpt-4o-mini"],
|
|
},
|
|
{
|
|
id: "without-image-fallback",
|
|
imageModel: { primary: "openai/gpt-4o" },
|
|
expectedFallbacks: [],
|
|
},
|
|
];
|
|
|
|
async function prepareMainHistoryHarness(params: {
|
|
ws: GatewaySocket;
|
|
createSessionDir: () => Promise<string>;
|
|
historyMaxBytes?: number;
|
|
}) {
|
|
if (params.historyMaxBytes !== undefined) {
|
|
__setMaxChatHistoryMessagesBytesForTest(params.historyMaxBytes);
|
|
}
|
|
await connectOk(params.ws);
|
|
const sessionDir = await params.createSessionDir();
|
|
await writeMainSessionStore();
|
|
return sessionDir;
|
|
}
|
|
|
|
describe("gateway server chat", () => {
|
|
test("chat.history does not wait for model catalog discovery to return history", async () => {
|
|
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
|
|
try {
|
|
testState.sessionStorePath = path.join(sessionDir, "sessions.json");
|
|
testState.agentConfig = {
|
|
model: { primary: "test-provider/slow-catalog-model" },
|
|
};
|
|
await writeSessionStore({
|
|
entries: {
|
|
main: {
|
|
sessionId: "sess-main",
|
|
modelProvider: "test-provider",
|
|
model: "slow-catalog-model",
|
|
updatedAt: Date.now(),
|
|
},
|
|
},
|
|
});
|
|
const responses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
|
|
const context = {
|
|
loadGatewayModelCatalog: vi.fn<GatewayRequestContext["loadGatewayModelCatalog"]>(
|
|
async () => {
|
|
throw new Error("model catalog should not load for chat.history");
|
|
},
|
|
),
|
|
logGateway: {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
} as unknown as GatewayRequestContext;
|
|
const { chatHandlers } = await import("./server-methods/chat.js");
|
|
|
|
await chatHandlers["chat.history"]({
|
|
req: {
|
|
type: "req",
|
|
id: "history-no-catalog",
|
|
method: "chat.history",
|
|
params: { sessionKey: "main" },
|
|
},
|
|
params: { sessionKey: "main" },
|
|
client: null,
|
|
isWebchatConnect: () => false,
|
|
respond: ((ok, payload, error) => {
|
|
responses.push({ ok, payload, error });
|
|
}) as RespondFn,
|
|
context,
|
|
});
|
|
|
|
expect(context.loadGatewayModelCatalog).not.toHaveBeenCalled();
|
|
expect(responses).toHaveLength(1);
|
|
expect(responses[0]?.ok).toBe(true);
|
|
const payload = responses[0]?.payload as
|
|
| { sessionKey?: string; sessionId?: string; messages?: unknown }
|
|
| undefined;
|
|
expect(payload?.sessionKey).toBe("main");
|
|
expect(payload?.sessionId).toBe("sess-main");
|
|
expect(Array.isArray(payload?.messages)).toBe(true);
|
|
} finally {
|
|
clearConfigCache();
|
|
testState.agentConfig = undefined;
|
|
testState.sessionStorePath = undefined;
|
|
await fs.rm(sessionDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("chat.send returns in_flight when duplicate attachment send wins parsing race", async () => {
|
|
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
|
|
const dispatchRelease = createDeferred<void>();
|
|
try {
|
|
testState.sessionStorePath = path.join(sessionDir, "sessions.json");
|
|
await writeSessionStore({
|
|
entries: {
|
|
main: {
|
|
sessionId: "sess-main",
|
|
modelProvider: "test-provider",
|
|
model: "vision-model",
|
|
updatedAt: Date.now(),
|
|
},
|
|
},
|
|
});
|
|
|
|
const firstCatalog =
|
|
createDeferred<Awaited<ReturnType<GatewayRequestContext["loadGatewayModelCatalog"]>>>();
|
|
const responses: Array<{ id: string; ok: boolean; payload?: unknown; error?: unknown }> = [];
|
|
const context = {
|
|
loadGatewayModelCatalog: vi
|
|
.fn<GatewayRequestContext["loadGatewayModelCatalog"]>()
|
|
.mockImplementationOnce(() => firstCatalog.promise)
|
|
.mockResolvedValue([
|
|
{
|
|
id: "vision-model",
|
|
name: "Vision Model",
|
|
provider: "test-provider",
|
|
input: ["text", "image"],
|
|
},
|
|
]),
|
|
logGateway: {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
agentRunSeq: new Map<string, number>(),
|
|
chatAbortControllers: new Map(),
|
|
chatAbortedRuns: new Map(),
|
|
chatRunBuffers: new Map(),
|
|
chatDeltaSentAt: new Map(),
|
|
chatDeltaLastBroadcastLen: new Map(),
|
|
chatDeltaLastBroadcastText: new Map(),
|
|
addChatRun: vi.fn(),
|
|
removeChatRun: vi.fn(),
|
|
broadcast: vi.fn(),
|
|
nodeSendToSession: vi.fn(),
|
|
registerToolEventRecipient: vi.fn(),
|
|
dedupe: new Map(),
|
|
} as unknown as GatewayRequestContext;
|
|
dispatchInboundMessageMock.mockImplementation(async () => dispatchRelease.promise);
|
|
|
|
const pngB64 =
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
|
|
const params = {
|
|
sessionKey: "main",
|
|
message: "see image",
|
|
idempotencyKey: "idem-attachment-race",
|
|
attachments: [
|
|
{
|
|
type: "image",
|
|
mimeType: "image/png",
|
|
fileName: "dot.png",
|
|
content: pngB64,
|
|
},
|
|
],
|
|
};
|
|
const { chatHandlers } = await import("./server-methods/chat.js");
|
|
const callSend = (id: string) =>
|
|
chatHandlers["chat.send"]({
|
|
req: { type: "req", id, method: "chat.send", params },
|
|
params,
|
|
client: null,
|
|
isWebchatConnect: () => false,
|
|
respond: ((ok, payload, error) => {
|
|
responses.push({ id, ok, payload, error });
|
|
}) as RespondFn,
|
|
context,
|
|
});
|
|
|
|
const first = Promise.resolve(callSend("first"));
|
|
await vi.waitFor(() => {
|
|
expect(context.loadGatewayModelCatalog).toHaveBeenCalledTimes(1);
|
|
}, FAST_WAIT_OPTS);
|
|
|
|
await callSend("duplicate");
|
|
expect(responses).toEqual([
|
|
{
|
|
id: "duplicate",
|
|
ok: true,
|
|
payload: { runId: "idem-attachment-race", status: "started" },
|
|
error: undefined,
|
|
},
|
|
]);
|
|
|
|
firstCatalog.resolve([
|
|
{
|
|
id: "vision-model",
|
|
name: "Vision Model",
|
|
provider: "test-provider",
|
|
input: ["text", "image"],
|
|
},
|
|
]);
|
|
await first;
|
|
|
|
expect(responses).toEqual([
|
|
{
|
|
id: "duplicate",
|
|
ok: true,
|
|
payload: { runId: "idem-attachment-race", status: "started" },
|
|
error: undefined,
|
|
},
|
|
{
|
|
id: "first",
|
|
ok: true,
|
|
payload: { runId: "idem-attachment-race", status: "in_flight" },
|
|
error: undefined,
|
|
},
|
|
]);
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
|
expect(context.addChatRun).toHaveBeenCalledTimes(1);
|
|
dispatchRelease.resolve();
|
|
await vi.waitFor(() => {
|
|
expect(context.removeChatRun).toHaveBeenCalledTimes(1);
|
|
}, FAST_WAIT_OPTS);
|
|
} finally {
|
|
dispatchRelease.resolve();
|
|
dispatchInboundMessageMock.mockReset();
|
|
testState.sessionStorePath = undefined;
|
|
clearConfigCache();
|
|
await fs.rm(sessionDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test.each(configuredImageModelCases)(
|
|
"chat.send inlines image attachments through configured imageModel with allowlist present: $id",
|
|
async ({ id, imageModel, expectedFallbacks }) => {
|
|
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
|
|
try {
|
|
testState.sessionStorePath = path.join(sessionDir, "sessions.json");
|
|
testState.agentConfig = {
|
|
model: {
|
|
primary: "anthropic/claude-opus-4-6",
|
|
fallbacks: ["anthropic/claude-haiku-4-6"],
|
|
},
|
|
imageModel,
|
|
models: {
|
|
"anthropic/claude-opus-4-6": {},
|
|
},
|
|
};
|
|
await writeSessionStore({
|
|
entries: {
|
|
main: {
|
|
sessionId: "sess-main",
|
|
modelProvider: "anthropic",
|
|
model: "claude-opus-4-6",
|
|
updatedAt: Date.now(),
|
|
},
|
|
},
|
|
});
|
|
|
|
const context = {
|
|
loadGatewayModelCatalog: vi.fn<GatewayRequestContext["loadGatewayModelCatalog"]>(
|
|
async () => [
|
|
{
|
|
id: "claude-opus-4-6",
|
|
name: "Claude Opus 4.6",
|
|
provider: "anthropic",
|
|
input: ["text"],
|
|
},
|
|
{
|
|
id: "gpt-4o",
|
|
name: "GPT-4o",
|
|
provider: "openai",
|
|
input: ["text", "image"],
|
|
},
|
|
{
|
|
id: "gpt-4o-mini",
|
|
name: "GPT-4o mini",
|
|
provider: "openai",
|
|
input: ["text", "image"],
|
|
},
|
|
{
|
|
id: "claude-haiku-4-6",
|
|
name: "Claude Haiku 4.6",
|
|
provider: "anthropic",
|
|
input: ["text"],
|
|
},
|
|
],
|
|
),
|
|
logGateway: {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
agentRunSeq: new Map<string, number>(),
|
|
chatAbortControllers: new Map(),
|
|
chatAbortedRuns: new Map(),
|
|
chatRunBuffers: new Map(),
|
|
chatDeltaSentAt: new Map(),
|
|
chatDeltaLastBroadcastLen: new Map(),
|
|
chatDeltaLastBroadcastText: new Map(),
|
|
addChatRun: vi.fn(),
|
|
removeChatRun: vi.fn(),
|
|
broadcast: vi.fn(),
|
|
nodeSendToSession: vi.fn(),
|
|
registerToolEventRecipient: vi.fn(),
|
|
dedupe: new Map(),
|
|
} as unknown as GatewayRequestContext;
|
|
const pngB64 =
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
|
|
let captured: { ctx?: Record<string, unknown>; replyOptions?: GetReplyOptions } | undefined;
|
|
dispatchInboundMessageMock.mockImplementationOnce(async (...args: unknown[]) => {
|
|
const [params] = args as [
|
|
{
|
|
ctx: Record<string, unknown>;
|
|
replyOptions?: GetReplyOptions;
|
|
},
|
|
];
|
|
captured = {
|
|
ctx: params.ctx,
|
|
replyOptions: params.replyOptions,
|
|
};
|
|
});
|
|
|
|
const { chatHandlers } = await import("./server-methods/chat.js");
|
|
const responses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
|
|
await chatHandlers["chat.send"]({
|
|
req: {
|
|
type: "req",
|
|
id: `configured-image-model-${id}`,
|
|
method: "chat.send",
|
|
params: {
|
|
sessionKey: "main",
|
|
message: "see image",
|
|
idempotencyKey: `idem-configured-image-model-${id}`,
|
|
attachments: [
|
|
{
|
|
type: "image",
|
|
mimeType: "image/png",
|
|
fileName: "dot.png",
|
|
content: pngB64,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
params: {
|
|
sessionKey: "main",
|
|
message: "see image",
|
|
idempotencyKey: `idem-configured-image-model-${id}`,
|
|
attachments: [
|
|
{
|
|
type: "image",
|
|
mimeType: "image/png",
|
|
fileName: "dot.png",
|
|
content: pngB64,
|
|
},
|
|
],
|
|
},
|
|
client: null,
|
|
isWebchatConnect: () => false,
|
|
respond: ((ok, payload, error) => {
|
|
responses.push({ ok, payload, error });
|
|
}) as RespondFn,
|
|
context,
|
|
});
|
|
|
|
expect(responses[0]?.ok).toBe(true);
|
|
await vi.waitFor(() => expect(captured).toBeDefined(), FAST_WAIT_OPTS);
|
|
expect(captured?.replyOptions?.images).toEqual([
|
|
expect.objectContaining({ type: "image", mimeType: "image/png" }),
|
|
]);
|
|
expect(captured?.replyOptions?.modelOverride).toBe("openai/gpt-4o");
|
|
expect(captured?.replyOptions?.modelOverrideFallbacks).toEqual(expectedFallbacks);
|
|
expect(captured?.ctx?.MediaPaths).toBeUndefined();
|
|
} finally {
|
|
dispatchInboundMessageMock.mockReset();
|
|
testState.agentConfig = undefined;
|
|
testState.sessionStorePath = undefined;
|
|
clearConfigCache();
|
|
await fs.rm(sessionDir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
test("chat.send reuses an active internal run for duplicate WebChat text sends", async () => {
|
|
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
|
|
const dispatchRelease = createDeferred<void>();
|
|
try {
|
|
testState.sessionStorePath = path.join(sessionDir, "sessions.json");
|
|
await writeSessionStore({
|
|
entries: {
|
|
main: {
|
|
sessionId: "sess-main",
|
|
updatedAt: Date.now(),
|
|
},
|
|
},
|
|
});
|
|
|
|
const responses: Array<{ id: string; ok: boolean; payload?: unknown; error?: unknown }> = [];
|
|
const context = {
|
|
loadGatewayModelCatalog: vi.fn<GatewayRequestContext["loadGatewayModelCatalog"]>(),
|
|
logGateway: {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
agentRunSeq: new Map<string, number>(),
|
|
chatAbortControllers: new Map(),
|
|
chatAbortedRuns: new Map(),
|
|
chatRunBuffers: new Map(),
|
|
chatDeltaSentAt: new Map(),
|
|
chatDeltaLastBroadcastLen: new Map(),
|
|
chatDeltaLastBroadcastText: new Map(),
|
|
addChatRun: vi.fn(),
|
|
removeChatRun: vi.fn(),
|
|
broadcast: vi.fn(),
|
|
nodeSendToSession: vi.fn(),
|
|
registerToolEventRecipient: vi.fn(),
|
|
dedupe: new Map(),
|
|
} as unknown as GatewayRequestContext;
|
|
dispatchInboundMessageMock.mockImplementation(async () => dispatchRelease.promise);
|
|
|
|
const { chatHandlers } = await import("./server-methods/chat.js");
|
|
const callSend = (id: string, idempotencyKey: string) =>
|
|
chatHandlers["chat.send"]({
|
|
req: {
|
|
type: "req",
|
|
id,
|
|
method: "chat.send",
|
|
params: {
|
|
sessionKey: "main",
|
|
message: "?",
|
|
idempotencyKey,
|
|
},
|
|
},
|
|
params: {
|
|
sessionKey: "main",
|
|
message: "?",
|
|
idempotencyKey,
|
|
},
|
|
client: {
|
|
connect: {
|
|
client: {
|
|
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
|
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
|
},
|
|
scopes: ["operator.write"],
|
|
},
|
|
} as never,
|
|
isWebchatConnect: () => true,
|
|
respond: ((ok, payload, error) => {
|
|
responses.push({ id, ok, payload, error });
|
|
}) as RespondFn,
|
|
context,
|
|
});
|
|
|
|
const first = Promise.resolve(callSend("first", "idem-active-a"));
|
|
await vi.waitFor(() => {
|
|
expect(responses).toEqual([
|
|
{
|
|
id: "first",
|
|
ok: true,
|
|
payload: { runId: "idem-active-a", status: "started" },
|
|
error: undefined,
|
|
},
|
|
]);
|
|
}, FAST_WAIT_OPTS);
|
|
|
|
await callSend("duplicate", "idem-active-b");
|
|
|
|
expect(responses).toEqual([
|
|
{
|
|
id: "first",
|
|
ok: true,
|
|
payload: { runId: "idem-active-a", status: "started" },
|
|
error: undefined,
|
|
},
|
|
{
|
|
id: "duplicate",
|
|
ok: true,
|
|
payload: { runId: "idem-active-a", status: "in_flight" },
|
|
error: undefined,
|
|
},
|
|
]);
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
|
|
expect(context.addChatRun).toHaveBeenCalledTimes(1);
|
|
|
|
dispatchRelease.resolve();
|
|
await first;
|
|
await vi.waitFor(() => {
|
|
expect(context.removeChatRun).toHaveBeenCalledTimes(1);
|
|
}, FAST_WAIT_OPTS);
|
|
} finally {
|
|
dispatchRelease.resolve();
|
|
dispatchInboundMessageMock.mockReset();
|
|
testState.sessionStorePath = undefined;
|
|
clearConfigCache();
|
|
await fs.rm(sessionDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("chat.send starts the next WebChat turn after the prior internal run finishes", async () => {
|
|
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
|
|
try {
|
|
testState.sessionStorePath = path.join(sessionDir, "sessions.json");
|
|
await writeSessionStore({
|
|
entries: {
|
|
main: {
|
|
sessionId: "sess-main",
|
|
updatedAt: Date.now(),
|
|
},
|
|
},
|
|
});
|
|
|
|
const responses: Array<{ id: string; ok: boolean; payload?: unknown; error?: unknown }> = [];
|
|
const context = {
|
|
loadGatewayModelCatalog: vi.fn<GatewayRequestContext["loadGatewayModelCatalog"]>(),
|
|
logGateway: {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
},
|
|
agentRunSeq: new Map<string, number>(),
|
|
chatAbortControllers: new Map(),
|
|
chatAbortedRuns: new Map(),
|
|
chatRunBuffers: new Map(),
|
|
chatDeltaSentAt: new Map(),
|
|
chatDeltaLastBroadcastLen: new Map(),
|
|
chatDeltaLastBroadcastText: new Map(),
|
|
addChatRun: vi.fn(),
|
|
removeChatRun: vi.fn(),
|
|
broadcast: vi.fn(),
|
|
nodeSendToSession: vi.fn(),
|
|
registerToolEventRecipient: vi.fn(),
|
|
dedupe: new Map(),
|
|
} as unknown as GatewayRequestContext;
|
|
dispatchInboundMessageMock.mockResolvedValue(undefined);
|
|
|
|
const { chatHandlers } = await import("./server-methods/chat.js");
|
|
const callSend = (id: string, message: string, idempotencyKey: string) =>
|
|
chatHandlers["chat.send"]({
|
|
req: {
|
|
type: "req",
|
|
id,
|
|
method: "chat.send",
|
|
params: {
|
|
sessionKey: "main",
|
|
message,
|
|
idempotencyKey,
|
|
},
|
|
},
|
|
params: {
|
|
sessionKey: "main",
|
|
message,
|
|
idempotencyKey,
|
|
},
|
|
client: {
|
|
connect: {
|
|
client: {
|
|
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
|
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
|
},
|
|
scopes: ["operator.write"],
|
|
},
|
|
} as never,
|
|
isWebchatConnect: () => true,
|
|
respond: ((ok, payload, error) => {
|
|
responses.push({ id, ok, payload, error });
|
|
}) as RespondFn,
|
|
context,
|
|
});
|
|
|
|
await callSend("first", "first message", "idem-sequential-a");
|
|
await vi.waitFor(() => {
|
|
expect(context.removeChatRun).toHaveBeenCalledTimes(1);
|
|
}, FAST_WAIT_OPTS);
|
|
|
|
await callSend("second", "second message", "idem-sequential-b");
|
|
await vi.waitFor(() => {
|
|
expect(context.removeChatRun).toHaveBeenCalledTimes(2);
|
|
}, FAST_WAIT_OPTS);
|
|
|
|
expect(responses).toEqual([
|
|
{
|
|
id: "first",
|
|
ok: true,
|
|
payload: { runId: "idem-sequential-a", status: "started" },
|
|
error: undefined,
|
|
},
|
|
{
|
|
id: "second",
|
|
ok: true,
|
|
payload: { runId: "idem-sequential-b", status: "started" },
|
|
error: undefined,
|
|
},
|
|
]);
|
|
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(2);
|
|
expect(context.addChatRun).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
dispatchInboundMessageMock.mockReset();
|
|
testState.sessionStorePath = undefined;
|
|
clearConfigCache();
|
|
await fs.rm(sessionDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("chat.history backfills claude-cli sessions from Claude project files", async () => {
|
|
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
|
await connectOk(ws);
|
|
const sessionDir = await createSessionDir();
|
|
const originalHome = process.env.HOME;
|
|
const homeDir = path.join(sessionDir, "home");
|
|
const cliSessionId = "5b8b202c-f6bb-4046-9475-d2f15fd07530";
|
|
const claudeProjectsDir = path.join(homeDir, ".claude", "projects", "workspace");
|
|
await fs.mkdir(claudeProjectsDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(claudeProjectsDir, `${cliSessionId}.jsonl`),
|
|
[
|
|
JSON.stringify({
|
|
type: "queue-operation",
|
|
operation: "enqueue",
|
|
timestamp: "2026-03-26T16:29:54.722Z",
|
|
sessionId: cliSessionId,
|
|
content: "[Thu 2026-03-26 16:29 GMT] hi",
|
|
}),
|
|
JSON.stringify({
|
|
type: "user",
|
|
uuid: "user-1",
|
|
timestamp: "2026-03-26T16:29:54.800Z",
|
|
message: {
|
|
role: "user",
|
|
content:
|
|
'Sender (untrusted metadata):\n```json\n{"label":"openclaw-control-ui"}\n```\n\n[Thu 2026-03-26 16:29 GMT] hi',
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
type: "assistant",
|
|
uuid: "assistant-1",
|
|
timestamp: "2026-03-26T16:29:55.500Z",
|
|
message: {
|
|
role: "assistant",
|
|
model: "claude-sonnet-4-6",
|
|
content: [{ type: "text", text: "hello from Claude" }],
|
|
},
|
|
}),
|
|
].join("\n"),
|
|
"utf-8",
|
|
);
|
|
process.env.HOME = homeDir;
|
|
try {
|
|
await writeSessionStore({
|
|
entries: {
|
|
main: {
|
|
sessionId: "sess-main",
|
|
updatedAt: Date.now(),
|
|
modelProvider: "claude-cli",
|
|
model: "claude-sonnet-4-6",
|
|
cliSessionBindings: {
|
|
"claude-cli": {
|
|
sessionId: cliSessionId,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const messages = await fetchHistoryMessages(ws);
|
|
expect(messages).toHaveLength(2);
|
|
const userMessage = messages[0] as { role?: string; content?: string };
|
|
expect(userMessage.role).toBe("user");
|
|
expect(userMessage.content).toBe("hi");
|
|
const assistantMessage = messages[1] as { role?: string; provider?: string };
|
|
expect(assistantMessage.role).toBe("assistant");
|
|
expect(assistantMessage.provider).toBe("claude-cli");
|
|
} finally {
|
|
if (originalHome === undefined) {
|
|
delete process.env.HOME;
|
|
} else {
|
|
process.env.HOME = originalHome;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
test("smoke: caps history payload and preserves routing metadata", async () => {
|
|
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
|
const historyMaxBytes = 64 * 1024;
|
|
const sessionDir = await prepareMainHistoryHarness({
|
|
ws,
|
|
createSessionDir,
|
|
historyMaxBytes,
|
|
});
|
|
|
|
const bigText = "x".repeat(2_000);
|
|
const historyLines: string[] = [];
|
|
for (let i = 0; i < 45; i += 1) {
|
|
historyLines.push(
|
|
JSON.stringify({
|
|
message: {
|
|
role: "user",
|
|
content: [{ type: "text", text: `${i}:${bigText}` }],
|
|
timestamp: Date.now() + i,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
await writeMainSessionTranscript(sessionDir, historyLines);
|
|
const messages = await fetchHistoryMessages(ws);
|
|
const bytes = Buffer.byteLength(JSON.stringify(messages), "utf8");
|
|
expect(bytes).toBeLessThanOrEqual(historyMaxBytes);
|
|
expect(messages.length).toBeLessThan(45);
|
|
|
|
await writeSessionStore({
|
|
entries: {
|
|
main: {
|
|
sessionId: "sess-main",
|
|
updatedAt: Date.now(),
|
|
lastChannel: "whatsapp",
|
|
lastTo: "+1555",
|
|
},
|
|
},
|
|
});
|
|
|
|
const sendRes = await rpcReq(ws, "chat.send", {
|
|
sessionKey: "main",
|
|
message: "hello",
|
|
idempotencyKey: "idem-route",
|
|
});
|
|
expect(sendRes.ok).toBe(true);
|
|
|
|
const sessionStorePath = testState.sessionStorePath;
|
|
if (!sessionStorePath) {
|
|
throw new Error("expected session store path");
|
|
}
|
|
const stored = JSON.parse(await fs.readFile(sessionStorePath, "utf-8")) as Record<
|
|
string,
|
|
{ lastChannel?: string; lastTo?: string } | undefined
|
|
>;
|
|
expect(stored["agent:main:main"]?.lastChannel).toBe("whatsapp");
|
|
expect(stored["agent:main:main"]?.lastTo).toBe("+1555");
|
|
});
|
|
});
|
|
|
|
test("chat.send does not force-disable block streaming", async () => {
|
|
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
|
const spy = getReplyFromConfig;
|
|
await connectOk(ws);
|
|
|
|
await createSessionDir();
|
|
await writeMainSessionStore();
|
|
testState.agentConfig = { blockStreamingDefault: "on" };
|
|
try {
|
|
let capturedOpts: GetReplyOptions | undefined;
|
|
mockGetReplyFromConfigOnce(async (_ctx, opts) => {
|
|
capturedOpts = opts;
|
|
return undefined;
|
|
});
|
|
|
|
const sendRes = await rpcReq(ws, "chat.send", {
|
|
sessionKey: "main",
|
|
message: "hello",
|
|
idempotencyKey: "idem-block-streaming",
|
|
});
|
|
expect(sendRes.ok).toBe(true);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(spy.mock.calls.length).toBeGreaterThan(0);
|
|
}, FAST_WAIT_OPTS);
|
|
|
|
expect(capturedOpts?.disableBlockStreaming).toBeUndefined();
|
|
} finally {
|
|
testState.agentConfig = undefined;
|
|
}
|
|
});
|
|
});
|
|
|
|
test("chat.history hard-caps single oversized nested payloads", async () => {
|
|
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
|
const historyMaxBytes = 64 * 1024;
|
|
const sessionDir = await prepareMainHistoryHarness({
|
|
ws,
|
|
createSessionDir,
|
|
historyMaxBytes,
|
|
});
|
|
|
|
const hugeNestedText = "n".repeat(120_000);
|
|
const oversizedLine = JSON.stringify({
|
|
message: {
|
|
role: "assistant",
|
|
timestamp: Date.now(),
|
|
content: [
|
|
{
|
|
type: "tool_result",
|
|
toolUseId: "tool-1",
|
|
output: {
|
|
nested: {
|
|
payload: hugeNestedText,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
await writeMainSessionTranscript(sessionDir, [oversizedLine]);
|
|
const messages = await fetchHistoryMessages(ws);
|
|
expect(messages.length).toBe(1);
|
|
|
|
const serialized = JSON.stringify(messages);
|
|
const bytes = Buffer.byteLength(serialized, "utf8");
|
|
expect(bytes).toBeLessThanOrEqual(historyMaxBytes);
|
|
expect(serialized).toContain("[chat.history omitted: message too large]");
|
|
expect(serialized.includes(hugeNestedText.slice(0, 256))).toBe(false);
|
|
});
|
|
});
|
|
|
|
test("chat.history keeps recent small messages when latest message is oversized", async () => {
|
|
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
|
const historyMaxBytes = 64 * 1024;
|
|
const sessionDir = await prepareMainHistoryHarness({
|
|
ws,
|
|
createSessionDir,
|
|
historyMaxBytes,
|
|
});
|
|
|
|
const baseText = "s".repeat(1_200);
|
|
const lines: string[] = [];
|
|
for (let i = 0; i < 30; i += 1) {
|
|
lines.push(
|
|
JSON.stringify({
|
|
message: {
|
|
role: "user",
|
|
timestamp: Date.now() + i,
|
|
content: [{ type: "text", text: `small-${i}:${baseText}` }],
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
const hugeNestedText = "z".repeat(120_000);
|
|
lines.push(
|
|
JSON.stringify({
|
|
message: {
|
|
role: "assistant",
|
|
timestamp: Date.now() + 1_000,
|
|
content: [
|
|
{
|
|
type: "tool_result",
|
|
toolUseId: "tool-1",
|
|
output: {
|
|
nested: {
|
|
payload: hugeNestedText,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
await writeMainSessionTranscript(sessionDir, lines);
|
|
const messages = await fetchHistoryMessages(ws);
|
|
const serialized = JSON.stringify(messages);
|
|
const bytes = Buffer.byteLength(serialized, "utf8");
|
|
|
|
expect(bytes).toBeLessThanOrEqual(historyMaxBytes);
|
|
expect(messages.length).toBeGreaterThan(1);
|
|
expect(serialized).toContain("small-29:");
|
|
expect(serialized).toContain("[chat.history omitted: message too large]");
|
|
expect(serialized.includes(hugeNestedText.slice(0, 256))).toBe(false);
|
|
});
|
|
});
|
|
|
|
test("chat.history preserves usage and cost metadata for assistant messages", async () => {
|
|
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
|
await connectOk(ws);
|
|
|
|
const sessionDir = await createSessionDir();
|
|
await writeMainSessionStore();
|
|
|
|
await writeMainSessionTranscript(sessionDir, [
|
|
JSON.stringify({
|
|
message: {
|
|
role: "assistant",
|
|
timestamp: Date.now(),
|
|
content: [{ type: "text", text: "hello" }],
|
|
usage: { input: 12, output: 5, totalTokens: 17 },
|
|
cost: { total: 0.0123 },
|
|
details: { debug: true },
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const messages = await fetchHistoryMessages(ws);
|
|
expect(messages).toHaveLength(1);
|
|
const message = messages[0] as {
|
|
role?: string;
|
|
usage?: { input?: number; output?: number; totalTokens?: number };
|
|
cost?: { total?: number };
|
|
};
|
|
expect(message.role).toBe("assistant");
|
|
expect(message.usage?.input).toBe(12);
|
|
expect(message.usage?.output).toBe(5);
|
|
expect(message.usage?.totalTokens).toBe(17);
|
|
expect(message.cost?.total).toBe(0.0123);
|
|
expect(messages[0]).not.toHaveProperty("details");
|
|
});
|
|
});
|
|
|
|
test("chat.history strips inline directives from displayed message text", async () => {
|
|
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
|
await connectOk(ws);
|
|
|
|
const sessionDir = await createSessionDir();
|
|
await writeMainSessionStore();
|
|
|
|
const lines = [
|
|
JSON.stringify({
|
|
message: {
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "text", text: "Hello [[reply_to_current]] world [[audio_as_voice]]" },
|
|
],
|
|
timestamp: Date.now(),
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
message: {
|
|
role: "assistant",
|
|
content: "A [[reply_to:abc-123]] B",
|
|
timestamp: Date.now() + 1,
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
message: {
|
|
role: "assistant",
|
|
text: "[[ reply_to : 456 ]] C",
|
|
timestamp: Date.now() + 2,
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
message: {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: " keep padded " }],
|
|
timestamp: Date.now() + 3,
|
|
},
|
|
}),
|
|
];
|
|
await writeMainSessionTranscript(sessionDir, lines);
|
|
const messages = await fetchHistoryMessages(ws);
|
|
expect(messages.length).toBe(4);
|
|
|
|
const serialized = JSON.stringify(messages);
|
|
expect(serialized.includes("[[reply_to")).toBe(false);
|
|
expect(serialized.includes("[[audio_as_voice]]")).toBe(false);
|
|
|
|
const first = messages[0] as { content?: Array<{ text?: string }> };
|
|
const second = messages[1] as { content?: string };
|
|
const third = messages[2] as { text?: string };
|
|
const fourth = messages[3] as { content?: Array<{ text?: string }> };
|
|
|
|
expect(first.content?.[0]?.text?.replace(/\s+/g, " ").trim()).toBe("Hello world");
|
|
expect(second.content?.replace(/\s+/g, " ").trim()).toBe("A B");
|
|
expect(third.text?.replace(/\s+/g, " ").trim()).toBe("C");
|
|
expect(fourth.content?.[0]?.text).toBe(" keep padded ");
|
|
});
|
|
});
|
|
|
|
test("chat.history keeps visible assistant progress text from mixed tool-use transcript messages", async () => {
|
|
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
|
const sessionDir = await prepareMainHistoryHarness({ ws, createSessionDir });
|
|
await writeMainSessionTranscript(sessionDir, [
|
|
JSON.stringify({
|
|
message: {
|
|
role: "user",
|
|
content: [{ type: "text", text: "fix it" }],
|
|
timestamp: 1,
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
message: {
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "thinking", thinking: "private reasoning" },
|
|
{
|
|
type: "text",
|
|
text: "I will clean that up now.",
|
|
textSignature: JSON.stringify({
|
|
v: 1,
|
|
id: "msg-progress",
|
|
phase: "commentary",
|
|
}),
|
|
},
|
|
{
|
|
type: "toolCall",
|
|
id: "call-read",
|
|
name: "read",
|
|
arguments: { path: "AGENTS.md" },
|
|
},
|
|
],
|
|
timestamp: 2,
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
message: {
|
|
role: "toolResult",
|
|
toolCallId: "call-read",
|
|
toolName: "read",
|
|
content: [{ type: "text", text: "file contents" }],
|
|
timestamp: 3,
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const messages = await fetchHistoryMessages(ws);
|
|
const assistantMessage = messages[1] as {
|
|
role?: string;
|
|
content?: Array<{ type?: string; text?: string }>;
|
|
timestamp?: number;
|
|
};
|
|
expect(assistantMessage.role).toBe("assistant");
|
|
expect(assistantMessage.content).toEqual([
|
|
{ type: "text", text: "I will clean that up now." },
|
|
]);
|
|
expect(assistantMessage.timestamp).toBe(2);
|
|
});
|
|
});
|
|
|
|
test("chat.history applies gateway.webchat.chatHistoryMaxChars from config", async () => {
|
|
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
|
await writeGatewayConfig({
|
|
gateway: {
|
|
webchat: {
|
|
chatHistoryMaxChars: 5,
|
|
},
|
|
},
|
|
});
|
|
const sessionDir = await prepareMainHistoryHarness({ ws, createSessionDir });
|
|
await writeMainSessionTranscript(sessionDir, [
|
|
JSON.stringify({
|
|
message: {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "abcdefghij" }],
|
|
timestamp: Date.now(),
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const messages = await fetchHistoryMessages(ws);
|
|
expect(JSON.stringify(messages)).toContain("abcde\\n...(truncated)...");
|
|
});
|
|
});
|
|
|
|
test("chat.history prefers RPC maxChars over config", async () => {
|
|
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
|
await writeGatewayConfig({
|
|
gateway: {
|
|
webchat: {
|
|
chatHistoryMaxChars: 3,
|
|
},
|
|
},
|
|
});
|
|
const sessionDir = await prepareMainHistoryHarness({ ws, createSessionDir });
|
|
await writeMainSessionTranscript(sessionDir, [
|
|
JSON.stringify({
|
|
message: {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "abcdefghij" }],
|
|
timestamp: Date.now(),
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const messages = await fetchHistoryMessages(ws, { maxChars: 7 });
|
|
const serialized = JSON.stringify(messages);
|
|
expect(serialized).toContain("abcdefg\\n...(truncated)...");
|
|
expect(serialized).not.toContain("abc\\n...(truncated)...");
|
|
});
|
|
});
|
|
|
|
test("chat.history rejects invalid RPC maxChars values", async () => {
|
|
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
|
await prepareMainHistoryHarness({ ws, createSessionDir });
|
|
|
|
const zeroRes = await rpcReq(ws, "chat.history", {
|
|
sessionKey: "main",
|
|
maxChars: 0,
|
|
});
|
|
expect(zeroRes.ok).toBe(false);
|
|
expect((zeroRes.error as { message?: string } | undefined)?.message ?? "").toMatch(
|
|
/invalid chat\.history params/i,
|
|
);
|
|
|
|
const tooLargeRes = await rpcReq(ws, "chat.history", {
|
|
sessionKey: "main",
|
|
maxChars: 500_001,
|
|
});
|
|
expect(tooLargeRes.ok).toBe(false);
|
|
expect((tooLargeRes.error as { message?: string } | undefined)?.message ?? "").toMatch(
|
|
/invalid chat\.history params/i,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("chat.history still drops assistant NO_REPLY entries before truncation", async () => {
|
|
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
|
const sessionDir = await prepareMainHistoryHarness({ ws, createSessionDir });
|
|
await writeMainSessionTranscript(sessionDir, [
|
|
JSON.stringify({
|
|
message: {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "NO_REPLY" }],
|
|
timestamp: Date.now(),
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const messages = await fetchHistoryMessages(ws, { maxChars: 3 });
|
|
expect(messages).toStrictEqual([]);
|
|
});
|
|
});
|
|
|
|
test("smoke: supports abort and idempotent completion", async () => {
|
|
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
|
const spy = getReplyFromConfig;
|
|
let aborted = false;
|
|
await connectOk(ws);
|
|
|
|
await createSessionDir();
|
|
await writeMainSessionStore();
|
|
|
|
mockGetReplyFromConfigOnce(async (_ctx, opts) => {
|
|
opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-1");
|
|
const signal = opts?.abortSignal;
|
|
await new Promise<void>((resolve) => {
|
|
if (!signal || signal.aborted) {
|
|
aborted = Boolean(signal?.aborted);
|
|
resolve();
|
|
return;
|
|
}
|
|
signal.addEventListener(
|
|
"abort",
|
|
() => {
|
|
aborted = true;
|
|
resolve();
|
|
},
|
|
{ once: true },
|
|
);
|
|
});
|
|
return undefined;
|
|
});
|
|
|
|
const sendResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-abort-1", 2_000);
|
|
sendReq(ws, "send-abort-1", "chat.send", {
|
|
sessionKey: "main",
|
|
message: "hello",
|
|
idempotencyKey: "idem-abort-1",
|
|
timeoutMs: 30_000,
|
|
});
|
|
|
|
const sendRes = await sendResP;
|
|
expect(sendRes.ok).toBe(true);
|
|
await vi.waitFor(() => {
|
|
expect(spy.mock.calls.length).toBeGreaterThan(0);
|
|
}, FAST_WAIT_OPTS);
|
|
|
|
const inFlight = await rpcReq<{ status?: string }>(ws, "chat.send", {
|
|
sessionKey: "main",
|
|
message: "hello",
|
|
idempotencyKey: "idem-abort-1",
|
|
});
|
|
expect(inFlight.ok).toBe(true);
|
|
expect(["started", "in_flight", "ok"]).toContain(inFlight.payload?.status ?? "");
|
|
|
|
const abortRes = await rpcReq<{ aborted?: boolean }>(ws, "chat.abort", {
|
|
sessionKey: "main",
|
|
runId: "idem-abort-1",
|
|
});
|
|
expect(abortRes.ok).toBe(true);
|
|
expect(abortRes.payload?.aborted).toBe(true);
|
|
await vi.waitFor(() => {
|
|
expect(aborted).toBe(true);
|
|
}, FAST_WAIT_OPTS);
|
|
|
|
spy.mockClear();
|
|
spy.mockResolvedValueOnce(undefined);
|
|
|
|
const completeRes = await rpcReq<{ status?: string }>(ws, "chat.send", {
|
|
sessionKey: "main",
|
|
message: "hello",
|
|
idempotencyKey: "idem-complete-1",
|
|
});
|
|
expect(completeRes.ok).toBe(true);
|
|
|
|
await vi.waitFor(async () => {
|
|
const again = await rpcReq<{ status?: string }>(ws, "chat.send", {
|
|
sessionKey: "main",
|
|
message: "hello",
|
|
idempotencyKey: "idem-complete-1",
|
|
});
|
|
expect(again.ok).toBe(true);
|
|
expect(again.payload?.status).toBe("ok");
|
|
}, FAST_WAIT_OPTS);
|
|
});
|
|
});
|
|
});
|