mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
0a8e3604ba
* 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>
2287 lines
71 KiB
TypeScript
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;
|
|
}
|
|
});
|
|
});
|