Files
openclaw/src/gateway/server.chat.gateway-server-chat.test.ts
T
Josh Lehman 0a8e3604ba refactor: flip sessions and transcripts to sqlite storage (#98236)
* refactor(sessions): migrate runtime storage to sqlite

* test(sessions): fix sqlite CI regressions

* test(sessions): align remaining sqlite fixtures

* fix(codex): require sqlite trajectory recorder

* test(sessions): align orphan recovery sqlite fixture

* test(sessions): align sqlite rebase fixtures

* fix(sessions): finish current-main integration of the sqlite flip

Resolve the whole-store SDK removal across its owner boundary: drop the
loadSessionStore re-export and the registry whole-store wrappers, wire
hasTrackedActiveSessionRun into gateway chat, complete the
preserveLockedHarnessIds cleanup contract, flip the codex thread-history
import to storePath targets, and port remaining main-side tests from
file-store helpers to session accessor reads.

* chore: drop committed pebbles log, revert plugin-inspector bump, refresh generated docs

Remove the 1.8k-line .pebbles/events.jsonl work log from the branch, restore
the plugin-inspector advisory lane to main's pinned 0.3.10 so the supply-chain
bump gets its own review, and regenerate docs_map, the plugin SDK API baseline,
and the export-surface ratchet for the merged tree.

* feat(sessions): keep archived transcripts by default with zstd cold storage

Codex-style retention: deleting or resetting a session archives its
transcript as a zstd-compressed JSONL artifact (plain when the runtime
lacks node:zlib zstd) and keeps it until the disk budget evicts oldest
first. resetArchiveRetention now governs both deleted and reset archives
and defaults to keep; maxDiskBytes defaults to 2gb so retention stays
bounded, with archives evicted before live sessions. The cron reaper
follows the same knob instead of deleting archives on its own timer.

* fix(state): converge agent DB migration lineages and bound database growth

Merge coherence: run both structure-gated legacy memory-schema repairs
(flip-lineage drop, main-lineage identity rebuild) before the flip
migration so pre-flip v1/v2 and pre-merge flip v1/v4 databases all
converge, and hoist foreign_keys=OFF outside the schema transaction
where the pragma was silently ignored and the v1 sessions rebuild
cascade-deleted session_entries.

Growth guards: fresh agent DBs enable auto_vacuum=INCREMENTAL, WAL
maintenance releases freed pages in bounded passes (never a blocking
full VACUUM), and doctor reports state/agent DB bloat from freelist
stats.

* fix(codex): resolve the store path for thread-history import via the SDK

The supervision catalog passed the legacy sessionFile locator to the
storePath-targeted transcript mirror; resolve the agent store path with
the session-store SDK helper instead of a runtime-object seam so test
fakes and headless callers need no extra surface. Drop the obsolete
missing-session-id preprocessing case: sessions rows are NOT NULL on
session_id and upsert repairs id-less patches at write time.

* fix(sessions): fail safe on malformed disk-budget config and doctor stat errors

A malformed explicit maxDiskBytes disables the budget instead of
falling back to the destructive 2gb default the user never chose, and
the doctor bloat check skips databases whose paths stat-fail instead of
aborting doctor.

* fix(sessions): complete sqlite conflict translations

* test(sqlite): align hardening checks with maintenance

* test(sessions): inspect compressed transcript archives

* fix(tests): await session seeds and drop unused helpers flagged by CI lint

The five unawaited writeSessionStoreSeed calls raced their SQLite seeds
against the assertions, failing compact shards; the bloat probe drops a
useless initializer and the merged tests drop now-unused helpers.

* test(sessions): type legacy proof events directly

* test(sessions): align hardening contracts

* perf(sessions): read usage transcript sizes from SQL aggregates

Usage/cost scans walked every session and materialized every transcript
event just to re-stringify it for a byte estimate — the #86718 stall
class reborn on the DB. readTranscriptStatsSync sums stored JSON bytes
in SQLite without loading a single row.

* fix(sessions): re-root foreign-root transcript paths onto the current sessions dir

Restored backups, moved OPENCLAW_STATE_DIR, and rehearsal copies carry
absolute sessionFile paths from the old root; the containment fallback
kept those foreign paths, so migration read (and would archive) files in
the original root and reported local copies missing. Re-root the
canonical agents/<id>/sessions suffix onto the current dir when the file
exists there; genuine cross-root layouts still fall through unchanged.

* test(agents): seed harness admission through sqlite

* fix(sqlite): close agent db on pragma setup failure

* fix(doctor): compact and retrofit incremental auto-vacuum after session import

The migration is the sanctioned offline window: post-import compact
reclaims import churn and applies auto_vacuum=INCREMENTAL to databases
created before the fresh-DB pragma existed, so runtime maintenance can
release pages in bounded passes on every install.

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-11 14:50:37 -07:00

2287 lines
71 KiB
TypeScript

// Gateway server chat tests cover WebSocket chat flow, history construction,
// NO_REPLY handling, agent events, and connected control-UI delivery.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { loadSessionEntry } from "../config/sessions/session-accessor.js";
import { replaceSqliteTranscriptEvents } from "../config/sessions/session-accessor.sqlite.js";
import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js";
import {
getActiveGatewayRootWorkCount,
isGatewaySubordinateWorkAdmissionClosed,
markGatewayRestartDraining,
resetGatewayWorkAdmission,
tryBeginGatewaySuspendAdmission,
} from "../process/gateway-work-admission.js";
import { extractFirstTextBlock } from "../shared/chat-message-content.js";
import { createDeferred } from "../test-utils/deferred.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import * as sessionLifecycleState from "./session-lifecycle-state.js";
import {
connectOk,
dispatchInboundMessageMock,
installGatewayTestHooks,
mockGetReplyFromConfigOnce,
onceMessage,
rpcReq,
testState,
trackConnectChallengeNonce,
withGatewayServer,
writeSessionStore,
} from "./test-helpers.js";
import { agentCommand } from "./test-helpers.runtime-state.js";
import { installConnectedControlUiServerSuite } from "./test-with-server.js";
installGatewayTestHooks({ scope: "suite" });
const CHAT_RESPONSE_TIMEOUT_MS = 10_000;
let ws: WebSocket;
let port: number;
installConnectedControlUiServerSuite((started) => {
ws = started.ws;
port = started.port;
});
describe("gateway server chat", () => {
beforeEach(() => {
dispatchInboundMessageMock.mockReset();
});
const removeTempDir = async (dir: string): Promise<void> => {
await fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
};
const buildNoReplyHistoryFixture = (includeMixedAssistant = false) => [
{
role: "user",
content: [{ type: "text", text: "hello" }],
timestamp: 1,
},
{
role: "assistant",
content: [{ type: "text", text: "NO_REPLY" }],
timestamp: 2,
},
{
role: "assistant",
content: [{ type: "text", text: "real reply" }],
timestamp: 3,
},
{
role: "assistant",
text: "real text field reply",
content: "NO_REPLY",
timestamp: 4,
},
{
role: "user",
content: [{ type: "text", text: "NO_REPLY" }],
timestamp: 5,
},
...(includeMixedAssistant
? [
{
role: "assistant",
content: [
{ type: "text", text: "NO_REPLY" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } },
],
timestamp: 6,
},
]
: []),
];
const loadChatHistoryWithMessages = async (
messages: Array<Record<string, unknown>>,
): Promise<unknown[]> => {
return withMainSessionStore(async () => {
const lines = messages.map((message) => JSON.stringify({ message }));
await replaceMainTranscriptLines(lines);
const res = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
});
expect(res.ok).toBe(true);
return res.payload?.messages ?? [];
});
};
const replaceMainTranscriptLines = async (lines: string[]): Promise<void> => {
const storePath = testState.sessionStorePath;
if (!storePath) {
throw new Error("session store path was not initialized");
}
const events = lines.map((line, index) => ({
...(JSON.parse(line) as Record<string, unknown>),
id: `message-${index}`,
type: "message",
}));
await replaceSqliteTranscriptEvents(
{ agentId: "main", sessionId: "sess-main", sessionKey: "main", storePath },
events,
);
};
const withMainSessionStore = async <T>(
run: (dir: string) => Promise<T>,
options?: { archivedAt?: number; sessionId?: string },
): Promise<T> => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
try {
const sessionId = options?.sessionId ?? "sess-main";
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId,
sessionFile: path.join(dir, `${sessionId}.jsonl`),
updatedAt: Date.now(),
...(options?.archivedAt !== undefined ? { archivedAt: options.archivedAt } : {}),
},
},
});
return await run(dir);
} finally {
testState.sessionStorePath = undefined;
await removeTempDir(dir);
}
};
const collectHistoryTextValues = (historyMessages: unknown[]) =>
historyMessages
.map((message) => {
if (message && typeof message === "object") {
const entry = message as { text?: unknown };
if (typeof entry.text === "string") {
return entry.text;
}
}
return extractFirstTextBlock(message);
})
.filter((value): value is string => typeof value === "string");
const expectRecordFields = (value: unknown, expected: Record<string, unknown>) => {
if (!value || typeof value !== "object") {
throw new Error("Expected record");
}
const actual = value as Record<string, unknown>;
for (const [key, expectedValue] of Object.entries(expected)) {
expect(actual[key]).toEqual(expectedValue);
}
return actual;
};
const expectStringRunId = (payload: unknown) => {
const actual = expectRecordFields(payload, {});
expect(typeof actual.runId).toBe("string");
return actual.runId as string;
};
const expectAgentWaitTimeout = (res: Awaited<ReturnType<typeof rpcReq>>, error?: string) => {
expect(res.ok).toBe(true);
expect(res.payload?.status).toBe("timeout");
if (error !== undefined) {
expect(res.payload?.error).toBe(error);
expect(res.payload?.pendingError).toBe(true);
}
};
const expectAgentWaitStartedAt = (res: Awaited<ReturnType<typeof rpcReq>>, startedAt: number) => {
expect(res.ok).toBe(true);
expect(res.payload?.status).toBe("ok");
expect(res.payload?.startedAt).toBe(startedAt);
};
const sendChatAndExpectStarted = async (runId: string, message = "/context list") => {
const res = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message,
idempotencyKey: runId,
});
expect(res.ok).toBe(true);
expect(res.payload?.status).toBe("started");
return res;
};
test("chat.send rejects archived sessions before dispatch", async () => {
await withMainSessionStore(
async () => {
dispatchInboundMessageMock.mockClear();
const res = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "blocked while archived",
idempotencyKey: "proof-chat-archived-session",
});
expect(res.ok).toBe(false);
expect(res.error).toMatchObject({
code: "INVALID_REQUEST",
message: 'Session "agent:main:main" is archived. Restore it before starting new work.',
});
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
},
{ archivedAt: Date.now() },
);
});
test("keeps started chat dispatch on its retained request root", async () => {
await withMainSessionStore(async () => {
let subordinateAdmissionClosed: boolean | undefined;
dispatchInboundMessageMock.mockImplementationOnce(async (...args: unknown[]) => {
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
const suspension = tryBeginGatewaySuspendAdmission(() => {});
expect(suspension).not.toBeNull();
try {
subordinateAdmissionClosed = isGatewaySubordinateWorkAdmissionClosed();
} finally {
suspension?.rollback();
}
const [params] = args as [
{
dispatcher: {
sendFinalReply: (payload: { text: string }) => boolean;
markComplete: () => void;
waitForIdle: () => Promise<void>;
getQueuedCounts: () => { final: number; block: number; tool: number };
};
},
];
params.dispatcher.sendFinalReply({ text: "detached root stayed live" });
params.dispatcher.markComplete();
await params.dispatcher.waitForIdle();
return {
queuedFinal: true,
counts: params.dispatcher.getQueuedCounts(),
};
});
const finalPromise = onceMessage(
ws,
(message) =>
message.type === "event" &&
message.event === "chat" &&
message.payload?.state === "final" &&
message.payload?.runId === "idem-chat-detached-root",
8_000,
);
const res = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "prove detached root transfer",
idempotencyKey: "idem-chat-detached-root",
});
expect(res.ok).toBe(true);
expect(res.payload?.status).toBe("started");
await vi.waitFor(() => {
expect(subordinateAdmissionClosed).toBe(false);
});
await finalPromise;
await vi.waitFor(() => {
expect(getActiveGatewayRootWorkCount()).toBe(0);
});
});
});
const waitForAgentRunOk = async (runId: string, timeoutMs = 1_000) => {
const res = await rpcReq(ws, "agent.wait", {
runId,
timeoutMs,
});
expect(res.ok).toBe(true);
expect(res.payload?.status).toBe("ok");
return res;
};
const abortChatRun = async (runId: string) => {
const res = await rpcReq(ws, "chat.abort", {
sessionKey: "main",
runId,
});
expect(res.ok).toBe(true);
return res;
};
const mockBlockedChatReply = () => {
let releaseBlockedReply: (() => void) | undefined;
const blockedReply = new Promise<void>((resolve) => {
releaseBlockedReply = resolve;
});
mockGetReplyFromConfigOnce(async (_ctx, opts) => {
await new Promise<void>((resolve) => {
let settled = false;
const finish = () => {
if (settled) {
return;
}
settled = true;
resolve();
};
void blockedReply.then(finish);
if (opts?.abortSignal?.aborted) {
finish();
return;
}
opts?.abortSignal?.addEventListener("abort", finish, { once: true });
});
return undefined;
});
return () => {
releaseBlockedReply?.();
};
};
test("sessions.send accepts dashboard messages for existing sessions", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-send-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
try {
await writeSessionStore({
entries: {
"agent:main:dashboard:test-send": {
sessionId: "sess-dashboard-send",
updatedAt: Date.now(),
},
},
});
const res = await rpcReq(ws, "sessions.send", {
key: "agent:main:dashboard:test-send",
message: "hello from dashboard",
idempotencyKey: "idem-sessions-send-1",
});
expect(res.ok).toBe(true);
expect(res.payload?.runId).toBe("idem-sessions-send-1");
expect(res.payload?.messageSeq).toBe(1);
} finally {
testState.sessionStorePath = undefined;
await removeTempDir(dir);
}
});
test("sessions.send creates a configured agent main session before sending", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-send-agent-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
testState.agentsConfig = {
list: [{ id: "main", default: true }, { id: "orion" }],
};
try {
await writeSessionStore({ entries: {} });
const res = await rpcReq(ws, "sessions.send", {
key: "agent:orion:main",
message: "hello orion",
idempotencyKey: "idem-sessions-send-orion",
});
expect(res.ok).toBe(true);
expect(res.payload?.runId).toBe("idem-sessions-send-orion");
expect(
loadSessionEntry({
sessionKey: "agent:orion:main",
storePath: testState.sessionStorePath,
})?.sessionId,
).toBeTypeOf("string");
} finally {
testState.agentsConfig = undefined;
testState.sessionStorePath = undefined;
await removeTempDir(dir);
}
});
test("sessions.steer accepts dashboard follow-up messages for existing sessions", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-steer-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
try {
await writeSessionStore({
entries: {
"agent:main:dashboard:test-steer": {
sessionId: "sess-dashboard-steer",
updatedAt: Date.now(),
},
},
});
const res = await rpcReq(ws, "sessions.steer", {
key: "agent:main:dashboard:test-steer",
message: "follow-up from dashboard",
idempotencyKey: "idem-sessions-steer-1",
});
expect(res.ok).toBe(true);
expect(res.payload?.runId).toBe("idem-sessions-steer-1");
expect(res.payload?.messageSeq).toBe(1);
} finally {
testState.sessionStorePath = undefined;
await removeTempDir(dir);
}
});
test("sessions.abort stops active dashboard runs", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-abort-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
try {
await writeSessionStore({
entries: {
"agent:main:dashboard:test-abort": {
sessionId: "sess-dashboard-abort",
updatedAt: Date.now(),
},
},
});
const sendRes = await rpcReq(ws, "sessions.send", {
key: "agent:main:dashboard:test-abort",
message: "hello",
idempotencyKey: "idem-sessions-abort-1",
timeoutMs: 30_000,
});
expect(sendRes.ok).toBe(true);
const cancelledEventP = onceMessage(
ws,
(o) => {
const data =
o.payload?.data && typeof o.payload.data === "object"
? (o.payload.data as Record<string, unknown>)
: {};
return (
o.type === "event" &&
o.event === "agent" &&
o.payload?.runId === "idem-sessions-abort-1" &&
o.payload?.stream === "lifecycle" &&
data.phase === "end" &&
data.stopReason === "rpc"
);
},
8000,
);
void cancelledEventP.catch(() => undefined);
const abortRes = await rpcReq(ws, "sessions.abort", {
key: "agent:main:dashboard:test-abort",
runId: "idem-sessions-abort-1",
});
expect(abortRes.ok).toBe(true);
expect(["aborted", "no-active-run"]).toContain(abortRes.payload?.status);
if (abortRes.payload?.status === "aborted") {
expect(abortRes.payload?.abortedRunId).toBe("idem-sessions-abort-1");
const cancelledEvent = await cancelledEventP;
expectRecordFields(cancelledEvent.payload?.data, {
phase: "end",
status: "cancelled",
aborted: true,
stopReason: "rpc",
});
const waitRes = await rpcReq(ws, "agent.wait", {
runId: "idem-sessions-abort-1",
timeoutMs: 0,
});
expect(waitRes.ok).toBe(true);
expectRecordFields(waitRes.payload, {
runId: "idem-sessions-abort-1",
status: "error",
stopReason: "rpc",
});
} else {
expect(abortRes.payload?.abortedRunId).toBeNull();
}
} finally {
testState.sessionStorePath = undefined;
await removeTempDir(dir);
}
});
test("sessions.abort resolves active runs by runId without a caller session key", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-abort-runid-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
try {
await writeSessionStore({
entries: {
"agent:main:dashboard:test-abort-runid": {
sessionId: "sess-dashboard-abort-runid",
updatedAt: Date.now(),
},
},
});
const sendRes = await rpcReq(ws, "sessions.send", {
key: "agent:main:dashboard:test-abort-runid",
message: "hello",
idempotencyKey: "idem-sessions-abort-runid-1",
timeoutMs: 30_000,
});
expect(sendRes.ok).toBe(true);
const abortRes = await rpcReq(ws, "sessions.abort", {
runId: "idem-sessions-abort-runid-1",
});
expect(abortRes.ok).toBe(true);
expect(["aborted", "no-active-run"]).toContain(abortRes.payload?.status);
if (abortRes.payload?.status === "aborted") {
expect(abortRes.payload?.abortedRunId).toBe("idem-sessions-abort-runid-1");
}
} finally {
testState.sessionStorePath = undefined;
await removeTempDir(dir);
}
});
test("sanitizes inbound chat.send message text and rejects null bytes", async () => {
const nullByteRes = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "hello\u0000world",
idempotencyKey: "idem-null-byte-1",
});
expect(nullByteRes.ok).toBe(false);
expect((nullByteRes.error as { message?: string } | undefined)?.message ?? "").toMatch(
/null bytes/i,
);
const sanitizedRes = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "Cafe\u0301\u0007\tline",
idempotencyKey: "idem-sanitized-1",
});
expect(sanitizedRes.ok).toBe(true);
});
test("handles chat send and history flows", async () => {
const tempDirs: string[] = [];
let webchatWs: WebSocket | undefined;
try {
webchatWs = new WebSocket(`ws://127.0.0.1:${port}`, {
headers: { origin: `http://127.0.0.1:${port}` },
});
trackConnectChallengeNonce(webchatWs);
await new Promise<void>((resolve) => {
webchatWs?.once("open", resolve);
});
await connectOk(webchatWs, {
client: {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: "dev",
platform: "web",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
const webchatRes = await rpcReq(webchatWs, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-webchat-1",
});
expect(webchatRes.ok).toBe(true);
webchatWs.close();
webchatWs = undefined;
testState.agentConfig = { timeoutSeconds: 123 };
const timeoutRes = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-timeout-1",
});
expect(timeoutRes.ok).toBe(true);
expect(timeoutRes.payload?.runId).toBe("idem-timeout-1");
testState.agentConfig = undefined;
const sessionRes = await rpcReq(ws, "chat.send", {
sessionKey: "agent:main:subagent:abc",
message: "hello",
idempotencyKey: "idem-session-key-1",
});
expect(sessionRes.ok).toBe(true);
expect(sessionRes.payload?.runId).toBe("idem-session-key-1");
const sendPolicyDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
tempDirs.push(sendPolicyDir);
testState.sessionStorePath = path.join(sendPolicyDir, "sessions.json");
testState.sessionConfig = {
sendPolicy: {
default: "allow",
rules: [
{
action: "deny",
match: { channel: "discord", chatType: "group" },
},
],
},
};
await writeSessionStore({
entries: {
"discord:group:dev": {
sessionId: "sess-discord",
updatedAt: Date.now(),
chatType: "group",
channel: "discord",
},
},
});
const blockedRes = await rpcReq(ws, "chat.send", {
sessionKey: "discord:group:dev",
message: "hello",
idempotencyKey: "idem-1",
});
expect(blockedRes.ok).toBe(false);
expect((blockedRes.error as { message?: string } | undefined)?.message ?? "").toMatch(
/send blocked/i,
);
testState.sessionStorePath = undefined;
testState.sessionConfig = undefined;
const agentBlockedDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
tempDirs.push(agentBlockedDir);
testState.sessionStorePath = path.join(agentBlockedDir, "sessions.json");
testState.sessionConfig = {
sendPolicy: {
default: "allow",
rules: [{ action: "deny", match: { keyPrefix: "cron:" } }],
},
};
await writeSessionStore({
entries: {
"cron:job-1": {
sessionId: "sess-cron",
updatedAt: Date.now(),
},
},
});
vi.mocked(agentCommand).mockClear();
const agentAllowedRes = await rpcReq(ws, "agent", {
sessionKey: "cron:job-1",
message: "hi",
idempotencyKey: "idem-2",
});
expect(agentAllowedRes.ok).toBe(true);
expect(agentAllowedRes.payload?.status).toBe("accepted");
expect(agentAllowedRes.payload?.runId).toBe("idem-2");
await vi.waitFor(() => expect(agentCommand).toHaveBeenCalled());
testState.sessionStorePath = undefined;
testState.sessionConfig = undefined;
const pngB64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
const reqId = "chat-img";
ws.send(
JSON.stringify({
type: "req",
id: reqId,
method: "chat.send",
params: {
sessionKey: "main",
message: "see image",
idempotencyKey: "idem-img",
attachments: [
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: pngB64,
},
},
],
},
}),
);
const imgRes = await onceMessage(
ws,
(o) => o.type === "res" && o.id === reqId,
CHAT_RESPONSE_TIMEOUT_MS,
);
expect(imgRes.ok).toBe(true);
expectStringRunId(imgRes.payload);
const reqIdOnly = "chat-img-only";
ws.send(
JSON.stringify({
type: "req",
id: reqIdOnly,
method: "chat.send",
params: {
sessionKey: "main",
message: "",
idempotencyKey: "idem-img-only",
attachments: [
{
type: "image",
mimeType: "image/png",
fileName: "dot.png",
content: `data:image/png;base64,${pngB64}`,
},
],
},
}),
);
const imgOnlyRes = await onceMessage(
ws,
(o) => o.type === "res" && o.id === reqIdOnly,
CHAT_RESPONSE_TIMEOUT_MS,
);
expect(imgOnlyRes.ok).toBe(true);
expectStringRunId(imgOnlyRes.payload);
const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
tempDirs.push(historyDir);
testState.sessionStorePath = path.join(historyDir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
const lines: string[] = [];
for (let i = 0; i < 201; i += 1) {
lines.push(
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: `m${i}` }],
timestamp: Date.now() + i,
},
}),
);
}
await replaceMainTranscriptLines(lines);
const defaultRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
});
expect(defaultRes.ok).toBe(true);
const defaultMsgs = defaultRes.payload?.messages ?? [];
expect(defaultMsgs.length).toBe(200);
expect(extractFirstTextBlock(defaultMsgs[0])).toBe("m1");
} finally {
testState.agentConfig = undefined;
testState.sessionStorePath = undefined;
testState.sessionConfig = undefined;
if (webchatWs) {
webchatWs.close();
}
await Promise.all(tempDirs.map((dir) => removeTempDir(dir)));
}
});
test("chat.send accepts the backing session id returned by chat.history", async () => {
await withMainSessionStore(async () => {
const historyRes = await rpcReq<{ sessionId?: string }>(ws, "chat.history", {
sessionKey: "main",
});
expect(historyRes.ok).toBe(true);
const sessionId = historyRes.payload?.sessionId;
expect(sessionId).toBe("sess-main");
const runId = "idem-chat-send-history-session-id";
const sendRes = await rpcReq(ws, "chat.send", {
sessionKey: "main",
sessionId,
message: "/context list",
idempotencyKey: runId,
});
expect(sendRes.ok).toBe(true);
expect(sendRes.payload?.status).toBe("started");
await waitForAgentRunOk(runId);
});
});
test("marks a running webchat session failed when restart drain overlaps dispatch rejection", async () => {
await withMainSessionStore(async (dir) => {
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
sessionFile: path.join(dir, "sess-main.jsonl"),
updatedAt: 1_000,
status: "running",
startedAt: 900,
},
},
});
const subscribeRes = await rpcReq(ws, "sessions.subscribe", {});
expect(subscribeRes.ok).toBe(true);
const rejectDispatch = createDeferred();
const releasePersistence = createDeferred();
let dispatchStarted = false;
let persistenceEntered = false;
const persistLifecycleEvent = sessionLifecycleState.persistGatewaySessionLifecycleEvent;
const persistSpy = vi
.spyOn(sessionLifecycleState, "persistGatewaySessionLifecycleEvent")
.mockImplementation(async (params) => {
persistenceEntered = true;
await releasePersistence.promise;
await persistLifecycleEvent(params);
});
const sessionChanged = await (async () => {
try {
dispatchInboundMessageMock.mockImplementationOnce(async () => {
dispatchStarted = true;
await rejectDispatch.promise;
throw new Error("provider rejected request");
});
const errorPromise = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "error" &&
o.payload?.runId === "idem-dispatch-error-1",
8_000,
);
const sessionChangedPromise = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "sessions.changed" &&
o.payload?.reason === "chat.dispatch-error" &&
o.payload?.sessionKey === "agent:main:main",
8_000,
);
const res = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "run: pwd",
idempotencyKey: "idem-dispatch-error-1",
});
expect(res.ok).toBe(true);
await vi.waitFor(() => {
expect(dispatchStarted).toBe(true);
});
markGatewayRestartDraining();
rejectDispatch.resolve();
await errorPromise;
await vi.waitFor(() => {
expect(persistenceEntered).toBe(true);
});
expect(getActiveGatewayRootWorkCount()).toBe(1);
releasePersistence.resolve();
const changed = await sessionChangedPromise;
await vi.waitFor(() => {
expect(getActiveGatewayRootWorkCount()).toBe(0);
});
return changed;
} finally {
rejectDispatch.resolve();
releasePersistence.resolve();
persistSpy.mockRestore();
resetGatewayWorkAdmission();
}
})();
expectRecordFields(sessionChanged.payload, {
sessionId: "sess-main",
status: "failed",
hasActiveRun: false,
});
const sessionsRes = await rpcReq<{ sessions?: unknown[] }>(ws, "sessions.list", {});
expect(sessionsRes.ok).toBe(true);
const session = sessionsRes.payload?.sessions?.find(
(row): row is Record<string, unknown> =>
Boolean(row) &&
typeof row === "object" &&
(row as { key?: unknown }).key === "agent:main:main",
);
const actualSession = expectRecordFields(session, {
status: "failed",
hasActiveRun: false,
});
expect(typeof actualSession.startedAt).toBe("number");
expect(typeof actualSession.endedAt).toBe("number");
expect(typeof actualSession.runtimeMs).toBe("number");
});
});
test("chat.history hides assistant NO_REPLY-only entries", async () => {
const historyMessages = await loadChatHistoryWithMessages(buildNoReplyHistoryFixture());
const textValues = collectHistoryTextValues(historyMessages);
// The NO_REPLY assistant message (content block) should be dropped.
// The assistant with text="real text field reply" + content="NO_REPLY" stays
// because entry.text takes precedence over entry.content for the silent check.
// The user message with NO_REPLY text is preserved (only assistant filtered).
expect(textValues).toEqual(["hello", "real reply", "real text field reply", "NO_REPLY"]);
});
test("chat.history hides assistant control replies in Responses output blocks", async () => {
const historyMessages = await loadChatHistoryWithMessages([
{
role: "assistant",
content: [{ type: "output_text", text: "NO_REPLY" }],
timestamp: 1,
},
{
role: "assistant",
content: [{ type: "output_text", text: "visible response" }],
timestamp: 2,
},
{
role: "assistant",
content: [{ type: "input_text", text: "NO_REPLY" }],
timestamp: 3,
},
{
role: "assistant",
content: [{ type: "input_text", text: "visible assistant input" }],
timestamp: 4,
},
]);
expect(collectHistoryTextValues(historyMessages)).toEqual([
"visible response",
"visible assistant input",
]);
});
test("chat.history mirrors current-session message tool sends before NO_REPLY", async () => {
const replyText = "Here, love. Eva, not Evo.";
const historyMessages = await loadChatHistoryWithMessages([
{
role: "user",
content: [{ type: "text", text: "Evo, you there?" }],
timestamp: 1,
},
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call-message-1",
name: "message",
arguments: {
action: "send",
message: replyText,
},
},
],
timestamp: 2,
},
{
role: "toolResult",
toolName: "message",
toolCallId: "call-message-1",
content: { ok: true, messageId: "24268", chatId: "8455538490" },
timestamp: 3,
},
{
role: "assistant",
content: [{ type: "text", text: "NO_REPLY" }],
timestamp: 4,
},
]);
expect(collectHistoryTextValues(historyMessages)).toEqual(["Evo, you there?", replyText]);
expect(
historyMessages.some((message) => {
if (!message || typeof message !== "object") {
return false;
}
const entry = message as { role?: unknown; openclawMessageToolMirror?: unknown };
return entry.role === "assistant" && Boolean(entry.openclawMessageToolMirror);
}),
).toBe(true);
});
test("chat.history marks message-tool replies held for internal source delivery", async () => {
const replyText = "Forward this source reply.";
const historyMessages = await loadChatHistoryWithMessages([
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call-message-internal-source",
name: "message",
arguments: {
action: "send",
message: replyText,
},
},
],
timestamp: 1,
},
{
role: "toolResult",
toolName: "message",
toolCallId: "call-message-internal-source",
content: [{ type: "text", text: "Sent visible reply via internal-ui." }],
details: {
status: "ok",
deliveryStatus: "sent",
sourceReplySink: "internal-ui",
},
timestamp: 2,
},
{
role: "assistant",
content: [{ type: "text", text: "NO_REPLY" }],
timestamp: 3,
},
]);
const visibleAssistantMessages = historyMessages.filter((message) => {
if (!message || typeof message !== "object") {
return false;
}
const entry = message as { role?: unknown };
return entry.role === "assistant" && extractFirstTextBlock(message) !== undefined;
});
expect(visibleAssistantMessages).toEqual([
expect.objectContaining({
role: "assistant",
content: [{ type: "text", text: replyText }],
openclawMessageToolMirror: {
toolName: "message",
toolCallId: "call-message-internal-source",
sourceReplySink: "internal-ui",
sourceMessageSeq: 1,
},
}),
]);
});
test("chat.history hides raw delivery-mirror rows but keeps message-tool mirrors", async () => {
const replyText = "One visible send.";
const historyMessages = await loadChatHistoryWithMessages([
{
role: "user",
content: [{ type: "text", text: "send once" }],
timestamp: 1,
},
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call-message-transcript-only",
name: "message",
arguments: {
action: "send",
message: replyText,
},
},
],
timestamp: 2,
},
{
role: "toolResult",
toolName: "message",
toolCallId: "call-message-transcript-only",
content: { ok: true, messageId: "24271", chatId: "current-run" },
timestamp: 3,
},
{
role: "assistant",
provider: "openclaw",
model: "delivery-mirror",
content: [{ type: "text", text: replyText }],
timestamp: 4,
},
{
role: "assistant",
content: [{ type: "text", text: "NO_REPLY" }],
timestamp: 5,
},
]);
expect(collectHistoryTextValues(historyMessages)).toEqual(["send once", replyText]);
expect(
historyMessages.some(
(message) =>
Boolean(message) &&
typeof message === "object" &&
Boolean((message as { openclawMessageToolMirror?: unknown }).openclawMessageToolMirror),
),
).toBe(true);
expect(historyMessages).not.toContainEqual(
expect.objectContaining({ provider: "openclaw", model: "delivery-mirror" }),
);
});
test("chat.history keeps message-tool mirrors before silent completion rows", async () => {
const replyText = "Visible before completion.";
const historyMessages = await loadChatHistoryWithMessages([
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call-message-before-completion",
name: "message",
arguments: {
action: "send",
message: replyText,
},
},
],
timestamp: 1,
},
{
role: "toolResult",
toolName: "message",
toolCallId: "call-message-before-completion",
content: { ok: true, messageId: "24272", chatId: "current-run" },
timestamp: 2,
},
{
role: "assistant",
provider: "openclaw",
model: "delivery-mirror",
content: [{ type: "text", text: replyText }],
timestamp: 3,
},
]);
expect(collectHistoryTextValues(historyMessages)).toEqual([replyText]);
expect(
historyMessages.some(
(message) =>
Boolean(message) &&
typeof message === "object" &&
Boolean((message as { openclawMessageToolMirror?: unknown }).openclawMessageToolMirror),
),
).toBe(true);
expect(historyMessages).not.toContainEqual(
expect.objectContaining({ provider: "openclaw", model: "delivery-mirror" }),
);
});
test("chat.history hides delivery mirrors that precede successful tool results", async () => {
const replyText = "Visible after result.";
const historyMessages = await loadChatHistoryWithMessages([
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call-message-before-result",
name: "message",
arguments: {
action: "send",
message: replyText,
},
},
],
timestamp: 1,
},
{
role: "assistant",
provider: "openclaw",
model: "delivery-mirror",
content: [{ type: "text", text: replyText }],
timestamp: 2,
},
{
role: "toolResult",
toolName: "message",
toolCallId: "call-message-before-result",
content: { ok: true, messageId: "24273", chatId: "current-run" },
timestamp: 3,
},
]);
expect(collectHistoryTextValues(historyMessages)).toEqual([replyText]);
expect(
historyMessages.some(
(message) =>
Boolean(message) &&
typeof message === "object" &&
Boolean((message as { openclawMessageToolMirror?: unknown }).openclawMessageToolMirror),
),
).toBe(true);
expect(historyMessages).not.toContainEqual(
expect.objectContaining({ provider: "openclaw", model: "delivery-mirror" }),
);
});
test("chat.history preserves other pending message-tool mirrors while deduping one send", async () => {
const firstText = "First visible send.";
const secondText = "Second visible send.";
const historyMessages = await loadChatHistoryWithMessages([
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call-message-first",
name: "message",
arguments: {
action: "send",
message: firstText,
},
},
{
type: "toolCall",
id: "call-message-second",
name: "message",
arguments: {
action: "send",
message: secondText,
},
},
],
timestamp: 1,
},
{
role: "toolResult",
toolName: "message",
toolCallId: "call-message-first",
content: { ok: true, messageId: "24274", chatId: "current-run" },
timestamp: 2,
},
{
role: "assistant",
provider: "openclaw",
model: "delivery-mirror",
content: [{ type: "text", text: firstText }],
timestamp: 3,
},
{
role: "toolResult",
toolName: "message",
toolCallId: "call-message-second",
content: { ok: true, messageId: "24275", chatId: "current-run" },
timestamp: 4,
},
{
role: "assistant",
provider: "openclaw",
model: "delivery-mirror",
content: [{ type: "text", text: secondText }],
timestamp: 5,
},
]);
expect(collectHistoryTextValues(historyMessages)).toEqual([firstText, secondText]);
expect(
historyMessages.filter(
(message) =>
Boolean(message) &&
typeof message === "object" &&
Boolean((message as { openclawMessageToolMirror?: unknown }).openclawMessageToolMirror),
),
).toHaveLength(2);
expect(historyMessages).not.toContainEqual(
expect.objectContaining({ provider: "openclaw", model: "delivery-mirror" }),
);
});
test("chat.history keeps standalone delivery-mirror rows", async () => {
const historyMessages = await loadChatHistoryWithMessages([
{
role: "assistant",
provider: "openclaw",
model: "delivery-mirror",
content: [{ type: "text", text: "standalone delivered reply" }],
timestamp: 1,
},
]);
expect(collectHistoryTextValues(historyMessages)).toEqual(["standalone delivered reply"]);
});
test("chat.history mirrors current-session message tool sends with channel hints", async () => {
const replyText = "Still the current chat.";
const historyMessages = await loadChatHistoryWithMessages([
{
role: "user",
content: [{ type: "text", text: "reply here" }],
timestamp: 1,
},
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call-message-channel-hint",
name: "message",
arguments: {
action: "send",
channel: "telegram",
message: replyText,
},
},
],
timestamp: 2,
},
{
role: "toolResult",
toolName: "message",
toolCallId: "call-message-channel-hint",
content: { ok: true, messageId: "24270", chatId: "current-run" },
timestamp: 3,
},
{
role: "assistant",
content: [{ type: "text", text: "NO_REPLY" }],
timestamp: 4,
},
]);
expect(collectHistoryTextValues(historyMessages)).toEqual(["reply here", replyText]);
expect(
historyMessages.some(
(message) =>
Boolean(message) &&
typeof message === "object" &&
Boolean((message as { openclawMessageToolMirror?: unknown }).openclawMessageToolMirror),
),
).toBe(true);
});
test("chat.history does not mirror explicitly routed message tool sends", async () => {
const historyMessages = await loadChatHistoryWithMessages([
{
role: "user",
content: [{ type: "text", text: "send that elsewhere" }],
timestamp: 1,
},
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call-message-remote",
name: "message",
arguments: {
action: "send",
to: "8455538490",
message: "Remote-only reply",
},
},
],
timestamp: 2,
},
{
role: "toolResult",
toolName: "message",
toolCallId: "call-message-remote",
content: { ok: true, messageId: "24269", chatId: "8455538490" },
timestamp: 3,
},
{
role: "assistant",
content: [{ type: "text", text: "NO_REPLY" }],
timestamp: 4,
},
]);
expect(collectHistoryTextValues(historyMessages)).toEqual(["send that elsewhere"]);
expect(
historyMessages.some(
(message) =>
Boolean(message) &&
typeof message === "object" &&
Boolean((message as { openclawMessageToolMirror?: unknown }).openclawMessageToolMirror),
),
).toBe(false);
});
test("chat.history does not mirror message tool sends from unmatched results", async () => {
const historyMessages = await loadChatHistoryWithMessages([
{
role: "user",
content: [{ type: "text", text: "reply here" }],
timestamp: 1,
},
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call-message-expected",
name: "message",
arguments: {
action: "send",
message: "Should wait for matching result.",
},
},
],
timestamp: 2,
},
{
role: "toolResult",
content: { ok: true, messageId: "wrong-result" },
timestamp: 3,
},
{
role: "assistant",
content: [{ type: "text", text: "NO_REPLY" }],
timestamp: 4,
},
]);
expect(collectHistoryTextValues(historyMessages)).toEqual(["reply here"]);
expect(
historyMessages.some(
(message) =>
Boolean(message) &&
typeof message === "object" &&
Boolean((message as { openclawMessageToolMirror?: unknown }).openclawMessageToolMirror),
),
).toBe(false);
});
test("chat.history does not mirror dry-run message tool sends", async () => {
const historyMessages = await loadChatHistoryWithMessages([
{
role: "user",
content: [{ type: "text", text: "preview that" }],
timestamp: 1,
},
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call-message-dry-run",
name: "message",
arguments: {
action: "send",
dryRun: true,
message: "Preview-only reply",
},
},
],
timestamp: 2,
},
{
role: "toolResult",
toolName: "message",
toolCallId: "call-message-dry-run",
content: {
ok: true,
dryRun: true,
deliveryStatus: "dry_run",
},
timestamp: 3,
},
{
role: "assistant",
content: [{ type: "text", text: "NO_REPLY" }],
timestamp: 4,
},
]);
expect(collectHistoryTextValues(historyMessages)).toEqual(["preview that"]);
expect(
historyMessages.some(
(message) =>
Boolean(message) &&
typeof message === "object" &&
Boolean((message as { openclawMessageToolMirror?: unknown }).openclawMessageToolMirror),
),
).toBe(false);
});
test("chat.history hides commentary-only assistant entries", async () => {
const historyMessages = await loadChatHistoryWithMessages([
{
role: "user",
content: [{ type: "text", text: "hello" }],
timestamp: 1,
},
{
role: "assistant",
phase: "commentary",
content: [{ type: "text", text: "thinking like caveman" }],
timestamp: 2,
},
{
role: "assistant",
content: [{ type: "text", text: "real reply" }],
timestamp: 3,
},
]);
expect(collectHistoryTextValues(historyMessages)).toEqual(["hello", "real reply"]);
});
test("chat.history hides assistant announce/reply skip-only entries", async () => {
const historyMessages = await loadChatHistoryWithMessages([
{
role: "assistant",
content: [{ type: "text", text: "ANNOUNCE_SKIP" }],
timestamp: 1,
},
{
role: "assistant",
content: [{ type: "text", text: "REPLY_SKIP" }],
timestamp: 2,
},
{
role: "assistant",
text: "real text field reply",
content: "ANNOUNCE_SKIP",
timestamp: 3,
},
{
role: "assistant",
content: [{ type: "text", text: "real reply" }],
timestamp: 4,
},
]);
const roleAndText = historyMessages
.map((message) => {
const role =
message &&
typeof message === "object" &&
typeof (message as { role?: unknown }).role === "string"
? (message as { role: string }).role
: "unknown";
const text =
message &&
typeof message === "object" &&
typeof (message as { text?: unknown }).text === "string"
? (message as { text: string }).text
: (extractFirstTextBlock(message) ?? "");
return `${role}:${text}`;
})
.filter((entry) => entry !== "unknown:");
expect(roleAndText).toEqual(["assistant:real text field reply", "assistant:real reply"]);
});
test("routes chat.send slash commands without agent runs", async () => {
await withMainSessionStore(async () => {
const spy = vi.mocked(agentCommand);
const callsBefore = spy.mock.calls.length;
const eventPromise = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "final" &&
o.payload?.runId === "idem-command-1",
8000,
);
const res = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "/context list",
idempotencyKey: "idem-command-1",
});
expect(res.ok).toBe(true);
await eventPromise;
expect(spy.mock.calls.length).toBe(callsBefore);
});
});
test("routes /btw replies through side-result events without transcript injection", async () => {
await withMainSessionStore(async () => {
await replaceMainTranscriptLines([
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: "main thread context" }],
timestamp: Date.now(),
},
}),
]);
dispatchInboundMessageMock.mockImplementationOnce(async (...args: unknown[]) => {
const [params] = args as [
{
dispatcher: {
sendFinalReply: (payload: { text: string; btw: { question: string } }) => boolean;
markComplete: () => void;
waitForIdle: () => Promise<void>;
getQueuedCounts: () => { final: number; block: number; tool: number };
};
},
];
params.dispatcher.sendFinalReply({
text: "323",
btw: { question: "what is 17 * 19?" },
});
params.dispatcher.markComplete();
await params.dispatcher.waitForIdle();
return {
queuedFinal: true,
counts: params.dispatcher.getQueuedCounts(),
};
});
const sideResultPromise = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat.side_result" &&
o.payload?.kind === "btw" &&
o.payload?.runId === "idem-btw-1",
8000,
);
const finalPromise = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "final" &&
o.payload?.runId === "idem-btw-1",
8000,
);
const res = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "/btw what is 17 * 19?",
idempotencyKey: "idem-btw-1",
});
expect(res.ok).toBe(true);
await vi.waitFor(() => {
expect(dispatchInboundMessageMock).toHaveBeenCalled();
});
const sideResult = await sideResultPromise;
const finalEvent = await finalPromise;
expectRecordFields(sideResult.payload, {
kind: "btw",
runId: "idem-btw-1",
sessionKey: "agent:main:main",
question: "what is 17 * 19?",
text: "323",
});
expectRecordFields(finalEvent.payload, {
runId: "idem-btw-1",
sessionKey: "agent:main:main",
state: "final",
});
const historyRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
});
expect(historyRes.ok).toBe(true);
const historyTexts = collectHistoryTextValues(historyRes.payload?.messages ?? []);
expect(historyTexts).toEqual(["main thread context"]);
});
});
test("routes block-streamed /btw replies through side-result events", async () => {
await withMainSessionStore(async () => {
await replaceMainTranscriptLines([
JSON.stringify({
message: {
role: "assistant",
content: [{ type: "text", text: "existing context" }],
timestamp: Date.now(),
},
}),
]);
dispatchInboundMessageMock.mockImplementationOnce(async (...args: unknown[]) => {
const [params] = args as [
{
dispatcher: {
sendBlockReply: (payload: { text: string; btw: { question: string } }) => boolean;
markComplete: () => void;
waitForIdle: () => Promise<void>;
getQueuedCounts: () => { final: number; block: number; tool: number };
};
},
];
params.dispatcher.sendBlockReply({
text: "first chunk",
btw: { question: "what changed?" },
});
params.dispatcher.sendBlockReply({
text: "second chunk",
btw: { question: "what changed?" },
});
params.dispatcher.markComplete();
await params.dispatcher.waitForIdle();
return {
queuedFinal: false,
counts: params.dispatcher.getQueuedCounts(),
};
});
const sideResultPromise = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat.side_result" &&
o.payload?.kind === "btw" &&
o.payload?.runId === "idem-btw-block-1",
8000,
);
const res = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "/btw what changed?",
idempotencyKey: "idem-btw-block-1",
});
expect(res.ok).toBe(true);
await vi.waitFor(() => {
expect(dispatchInboundMessageMock).toHaveBeenCalled();
});
const sideResult = await sideResultPromise;
expectRecordFields(sideResult.payload, {
kind: "btw",
runId: "idem-btw-block-1",
question: "what changed?",
text: "first chunk\n\nsecond chunk",
});
});
});
test("chat.history persists assistant image data URLs as managed image blocks", async () => {
await withMainSessionStore(
async (dir) => {
const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
setTestEnvValue("OPENCLAW_STATE_DIR", dir);
const pngB64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=";
dispatchInboundMessageMock.mockImplementationOnce(async (...args: unknown[]) => {
const [params] = args as [
{
dispatcher: {
sendFinalReply: (payload: { text?: string; mediaUrls?: string[] }) => boolean;
markComplete: () => void;
waitForIdle: () => Promise<void>;
getQueuedCounts: () => { final: number; block: number; tool: number };
};
},
];
params.dispatcher.sendFinalReply({
text: "Image reply",
mediaUrls: [`data:image/png;base64,${pngB64}`],
});
params.dispatcher.markComplete();
await params.dispatcher.waitForIdle();
return {
queuedFinal: true,
counts: params.dispatcher.getQueuedCounts(),
};
});
try {
const finalPromise = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "final" &&
o.payload?.runId === "idem-managed-image-history",
8000,
);
const res = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "show me an image",
idempotencyKey: "idem-managed-image-history",
});
expect(res.ok).toBe(true);
expect(res.payload?.runId).toBe("idem-managed-image-history");
await finalPromise;
let assistantMessage: Record<string, unknown> | undefined;
await vi.waitFor(
async () => {
const historyRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
});
expect(historyRes.ok).toBe(true);
const messages = historyRes.payload?.messages ?? [];
assistantMessage = messages.find(
(message): message is Record<string, unknown> =>
typeof message === "object" &&
message !== null &&
(message as { role?: unknown }).role === "assistant",
);
if (!assistantMessage) {
throw new Error("Expected assistant history message");
}
},
{ timeout: CHAT_RESPONSE_TIMEOUT_MS },
);
const assistantContent = (assistantMessage as { content?: unknown[] }).content ?? [];
expect(assistantContent).toHaveLength(2);
expect(assistantContent[0]).toEqual({ type: "text", text: "Image reply" });
const imageBlock = expectRecordFields(assistantContent[1], {
type: "image",
alt: "Generated image 1",
mimeType: "image/png",
width: 1,
height: 1,
});
expect(String(imageBlock.url)).toContain("/api/chat/media/outgoing/");
expect(String(imageBlock.openUrl)).toContain("/full");
const serializedAssistant = JSON.stringify(assistantMessage);
expect(serializedAssistant).not.toContain("data:image/png;base64");
expect(serializedAssistant).not.toContain(pngB64);
} finally {
envSnapshot.restore();
}
},
{ sessionId: "sess-managed-image-history" },
);
});
test("chat.history hides assistant NO_REPLY-only entries and keeps mixed-content assistant entries", async () => {
const historyMessages = await loadChatHistoryWithMessages(buildNoReplyHistoryFixture(true));
const roleAndText = historyMessages
.map((message) => {
const role =
message &&
typeof message === "object" &&
typeof (message as { role?: unknown }).role === "string"
? (message as { role: string }).role
: "unknown";
const text =
message &&
typeof message === "object" &&
typeof (message as { text?: unknown }).text === "string"
? (message as { text: string }).text
: (extractFirstTextBlock(message) ?? "");
return `${role}:${text}`;
})
.filter((entry) => entry !== "unknown:");
expect(roleAndText).toEqual([
"user:hello",
"assistant:real reply",
"assistant:real text field reply",
"user:NO_REPLY",
"assistant:NO_REPLY",
]);
});
test("chat.history uses the owning agent thinkingDefault for non-default agent sessions", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
try {
testState.sessionStorePath = path.join(dir, "sessions.json");
testState.agentConfig = {
model: { primary: "openai/gpt-5" },
thinkingDefault: "low",
};
testState.agentsConfig = {
list: [
{ id: "main", default: true },
{ id: "alpha", thinkingDefault: "minimal" },
],
};
await writeSessionStore({
entries: {
"agent:alpha:main": {
sessionId: "sess-alpha",
updatedAt: Date.now(),
modelProvider: "openai",
model: "gpt-5",
},
},
});
const historyRes = await rpcReq<{
thinkingLevel?: string;
sessionInfo?: { thinkingLevel?: string };
}>(ws, "chat.history", { sessionKey: "agent:alpha:main" });
expect(historyRes.ok).toBe(true);
expect(historyRes.payload?.thinkingLevel).toBe("minimal");
expect(historyRes.payload?.sessionInfo?.thinkingLevel).toBeUndefined();
} finally {
testState.agentConfig = undefined;
testState.agentsConfig = undefined;
testState.sessionStorePath = undefined;
await removeTempDir(dir);
}
});
test("chat.send does not persist verboseLevel for operator.write callers", async () => {
await withGatewayServer(async ({ port: portValue }) => {
await withMainSessionStore(async () => {
let scopedWs: WebSocket | undefined;
try {
scopedWs = new WebSocket(`ws://127.0.0.1:${portValue}`);
trackConnectChallengeNonce(scopedWs);
await new Promise<void>((resolve) => {
scopedWs?.once("open", resolve);
});
await connectOk(scopedWs, {
scopes: ["operator.write"],
});
const sendRes = await rpcReq(scopedWs, "chat.send", {
sessionKey: "main",
message: "/verbose full",
idempotencyKey: "idem-write-scope-verbose-no-persist",
});
expect(sendRes.ok).toBe(true);
const waitRes = await rpcReq(scopedWs, "agent.wait", {
runId: "idem-write-scope-verbose-no-persist",
timeoutMs: 1_000,
});
expect(waitRes.ok).toBe(true);
expect(waitRes.payload?.status).toBe("ok");
const sessionStorePath = testState.sessionStorePath;
if (!sessionStorePath) {
throw new Error("session store path was not initialized");
}
expect(
loadSessionEntry({ sessionKey: "agent:main:main", storePath: sessionStorePath })
?.verboseLevel,
).toBeUndefined();
} finally {
scopedWs?.close();
}
});
});
});
test("chat.send does not persist one-turn thinking metadata", async () => {
await withMainSessionStore(async () => {
const sendRes = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "hello from phone",
thinking: "low",
idempotencyKey: "idem-chat-thinking-no-persist",
});
expect(sendRes.ok).toBe(true);
const waitRes = await rpcReq(ws, "agent.wait", {
runId: "idem-chat-thinking-no-persist",
timeoutMs: 1_000,
});
expect(waitRes.ok).toBe(true);
expect(waitRes.payload?.status).toBe("ok");
const sessionStorePath = testState.sessionStorePath;
if (!sessionStorePath) {
throw new Error("session store path was not initialized");
}
expect(
loadSessionEntry({ sessionKey: "agent:main:main", storePath: sessionStorePath })
?.thinkingLevel,
).toBeUndefined();
expect(
loadSessionEntry({ sessionKey: "main", storePath: sessionStorePath })?.thinkingLevel,
).toBeUndefined();
});
});
test("chat.send does not rotate sessions for operator.write reset triggers", async () => {
await withGatewayServer(async ({ port: portLocal }) => {
await withMainSessionStore(async () => {
let scopedWs: WebSocket | undefined;
try {
scopedWs = new WebSocket(`ws://127.0.0.1:${portLocal}`);
trackConnectChallengeNonce(scopedWs);
await new Promise<void>((resolve) => {
scopedWs?.once("open", resolve);
});
await connectOk(scopedWs, {
scopes: ["operator.write"],
});
const sendRes = await rpcReq(scopedWs, "chat.send", {
sessionKey: "main",
message: "/reset",
idempotencyKey: "idem-write-scope-reset-no-rotate",
});
expect(sendRes.ok).toBe(true);
const waitRes = await rpcReq(scopedWs, "agent.wait", {
runId: "idem-write-scope-reset-no-rotate",
timeoutMs: 1_000,
});
expect(waitRes.ok).toBe(true);
expect(waitRes.payload?.status).toBe("ok");
const sessionStorePath = testState.sessionStorePath;
if (!sessionStorePath) {
throw new Error("session store path was not initialized");
}
expect(
loadSessionEntry({ sessionKey: "agent:main:main", storePath: sessionStorePath })
?.sessionId,
).toBe("sess-main");
} finally {
scopedWs?.close();
}
});
});
});
test("agent.wait resolves chat.send runs that finish without lifecycle events", async () => {
await withMainSessionStore(async () => {
const runId = "idem-wait-chat-1";
await sendChatAndExpectStarted(runId);
await waitForAgentRunOk(runId);
});
});
test("agent.wait ignores stale chat dedupe when an agent run with the same runId is in flight", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
let resolveAgentRun: (() => void) | undefined;
const blockedAgentRun = new Promise<void>((resolve) => {
resolveAgentRun = resolve;
});
const agentSpy = vi.mocked(agentCommand);
agentSpy.mockImplementationOnce(async () => {
await blockedAgentRun;
return undefined;
});
try {
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
const runId = "idem-wait-chat-vs-agent";
await sendChatAndExpectStarted(runId);
await waitForAgentRunOk(runId);
const agentRes = await rpcReq(ws, "agent", {
sessionKey: "main",
message: "hold this run open",
idempotencyKey: runId,
});
expect(agentRes.ok).toBe(true);
expect(agentRes.payload?.status).toBe("accepted");
const waitWhileAgentInFlight = await rpcReq(ws, "agent.wait", {
runId,
timeoutMs: 40,
});
expectAgentWaitTimeout(waitWhileAgentInFlight);
resolveAgentRun?.();
await waitForAgentRunOk(runId);
} finally {
resolveAgentRun?.();
testState.sessionStorePath = undefined;
await removeTempDir(dir);
}
});
test("agent.wait ignores stale agent snapshots while same-runId chat.send is active", async () => {
await withMainSessionStore(async () => {
const runId = "idem-wait-chat-active-vs-stale-agent";
const seedAgentRes = await rpcReq(ws, "agent", {
sessionKey: "main",
message: "seed stale agent snapshot",
idempotencyKey: runId,
});
expect(seedAgentRes.ok).toBe(true);
expect(seedAgentRes.payload?.status).toBe("accepted");
const seedWaitRes = await rpcReq(ws, "agent.wait", {
runId,
timeoutMs: 1_000,
});
expect(seedWaitRes.ok).toBe(true);
expect(seedWaitRes.payload?.status).toBe("ok");
const releaseBlockedReply = mockBlockedChatReply();
try {
await sendChatAndExpectStarted(runId, "hold chat run open");
const waitWhileChatActive = await rpcReq(ws, "agent.wait", {
runId,
timeoutMs: 40,
});
expectAgentWaitTimeout(waitWhileChatActive);
await abortChatRun(runId);
} finally {
releaseBlockedReply();
}
});
});
test("agent.wait ignores lifecycle completion while same-runId chat.send is active", async () => {
await withMainSessionStore(async () => {
const runId = "idem-wait-chat-active-with-agent-lifecycle";
const releaseBlockedReply = mockBlockedChatReply();
try {
await sendChatAndExpectStarted(runId, "hold chat run open");
emitAgentEvent({
runId,
stream: "lifecycle",
data: { phase: "start", startedAt: 1 },
});
emitAgentEvent({
runId,
stream: "lifecycle",
data: { phase: "end", startedAt: 1, endedAt: 2 },
});
const waitWhileChatActive = await rpcReq(ws, "agent.wait", {
runId,
timeoutMs: 40,
});
expectAgentWaitTimeout(waitWhileChatActive);
releaseBlockedReply();
await waitForAgentRunOk(runId);
} finally {
releaseBlockedReply();
}
});
});
test("agent events include sessionKey and agent.wait covers lifecycle flows", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
verboseLevel: "off",
},
},
});
const webchatWs = new WebSocket(`ws://127.0.0.1:${port}`, {
headers: { origin: `http://127.0.0.1:${port}` },
});
trackConnectChallengeNonce(webchatWs);
await new Promise<void>((resolve) => {
webchatWs.once("open", resolve);
});
await connectOk(webchatWs, {
client: {
id: GATEWAY_CLIENT_NAMES.WEBCHAT,
version: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
try {
registerAgentRunContext("run-tool-1", {
sessionKey: "main",
verboseLevel: "on",
});
{
const agentEvtP = onceMessage(
webchatWs,
(o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-1",
8000,
);
emitAgentEvent({
runId: "run-tool-1",
stream: "assistant",
data: { text: "hello" },
});
const evt = await agentEvtP;
const payload = evt.payload && typeof evt.payload === "object" ? evt.payload : {};
expect(payload.sessionKey).toBe("main");
expect(payload.stream).toBe("assistant");
}
{
const waitP = rpcReq(webchatWs, "agent.wait", {
runId: "run-wait-1",
timeoutMs: 200,
});
queueMicrotask(() => {
emitAgentEvent({
runId: "run-wait-1",
stream: "lifecycle",
data: { phase: "end", startedAt: 200, endedAt: 210 },
});
});
const res = await waitP;
expectAgentWaitStartedAt(res, 200);
}
{
emitAgentEvent({
runId: "run-wait-early",
stream: "lifecycle",
data: { phase: "end", startedAt: 50, endedAt: 55 },
});
const res = await rpcReq(webchatWs, "agent.wait", {
runId: "run-wait-early",
timeoutMs: 200,
});
expect(res.ok).toBe(true);
expect(res.payload?.status).toBe("ok");
expect(res.payload?.startedAt).toBe(50);
}
{
const res = await rpcReq(webchatWs, "agent.wait", {
runId: "run-wait-3",
timeoutMs: 30,
});
expectAgentWaitTimeout(res);
}
{
const waitP = rpcReq(webchatWs, "agent.wait", {
runId: "run-wait-err",
timeoutMs: 50,
});
queueMicrotask(() => {
emitAgentEvent({
runId: "run-wait-err",
stream: "lifecycle",
data: { phase: "error", error: "boom" },
});
});
const res = await waitP;
expectAgentWaitTimeout(res, "boom");
}
{
const waitP = rpcReq(webchatWs, "agent.wait", {
runId: "run-wait-start",
timeoutMs: 200,
});
emitAgentEvent({
runId: "run-wait-start",
stream: "lifecycle",
data: { phase: "start", startedAt: 123 },
});
queueMicrotask(() => {
emitAgentEvent({
runId: "run-wait-start",
stream: "lifecycle",
data: { phase: "end", endedAt: 456 },
});
});
const res = await waitP;
expectAgentWaitStartedAt(res, 123);
expect(res.payload?.endedAt).toBe(456);
}
} finally {
webchatWs.close();
await removeTempDir(dir);
testState.sessionStorePath = undefined;
}
});
});