Files
openclaw/src/tui/embedded-backend.test.ts
Peter Steinberger 58591c37a4 fix(tui): emit v4 embedded chat deltas
(cherry picked from commit a6d878376b)
2026-05-13 16:28:12 +01:00

666 lines
20 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isEmbeddedMode, setEmbeddedMode } from "../infra/embedded-mode.js";
import { defaultRuntime } from "../runtime.js";
const agentCommandFromIngressMock = vi.fn();
let registeredListener: ((evt: unknown) => void) | undefined;
const embeddedEventTimestamp = Date.parse("2026-05-09T07:26:00.000Z");
vi.mock("../agents/agent-command.js", () => ({
agentCommandFromIngress: (...args: unknown[]) => agentCommandFromIngressMock(...args),
}));
vi.mock("../infra/agent-events.js", () => ({
onAgentEvent: (listener: (evt: unknown) => void) => {
registeredListener = listener;
return () => {
if (registeredListener === listener) {
registeredListener = undefined;
}
};
},
}));
vi.mock("../cli/deps.js", () => ({
createDefaultDeps: () => ({}),
}));
vi.mock("../config/sessions.js", () => ({
resolveAgentMainSessionKey: () => "agent:main:main",
resolveStorePath: () => "/tmp/openclaw-sessions.json",
updateSessionStore: vi.fn(),
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveSessionAgentId: () => "main",
}));
vi.mock("../agents/defaults.js", () => ({
DEFAULT_PROVIDER: "openai",
}));
vi.mock("../agents/model-selection.js", () => ({
buildAllowedModelSet: ({ catalog }: { catalog: unknown[] }) => ({ allowedCatalog: catalog }),
resolveThinkingDefault: () => undefined,
}));
vi.mock("../config/config.js", () => ({
getRuntimeConfig: () => ({}),
loadConfig: () => ({}),
}));
vi.mock("../gateway/cli-session-history.js", () => ({
augmentChatHistoryWithCliSessionImports: ({ localMessages }: { localMessages?: unknown[] }) =>
localMessages ?? [],
}));
vi.mock("../gateway/chat-display-projection.js", () => ({
projectChatDisplayMessages: (messages: unknown[]) => messages,
projectRecentChatDisplayMessages: (messages: unknown[]) => messages,
resolveEffectiveChatHistoryMaxChars: () => 100_000,
}));
vi.mock("../gateway/server-constants.js", () => ({
getMaxChatHistoryMessagesBytes: () => 100_000,
}));
vi.mock("../gateway/server-methods/chat.js", () => ({
CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES: 100_000,
augmentChatHistoryWithCanvasBlocks: (messages: unknown[]) => messages,
enforceChatHistoryFinalBudget: ({ messages }: { messages: unknown[] }) => ({ messages }),
replaceOversizedChatHistoryMessages: ({ messages }: { messages: unknown[] }) => ({ messages }),
}));
vi.mock("../gateway/session-utils.js", () => ({
listAgentsForGateway: () => [],
listSessionsFromStoreAsync: async () => ({ sessions: [] }),
loadCombinedSessionStoreForGateway: () => ({
storePath: "/tmp/openclaw-sessions.json",
store: {},
}),
loadSessionEntry: (sessionKey: string) => ({
cfg: {},
canonicalKey: sessionKey,
entry: {},
}),
migrateAndPruneGatewaySessionStoreKey: ({ key }: { key: string }) => ({ primaryKey: key }),
readSessionMessagesAsync: async () => [],
resolveGatewaySessionStoreTarget: ({ key }: { key: string }) => ({
canonicalKey: key,
storePath: "/tmp/openclaw-sessions.json",
}),
resolveSessionModelRef: () => ({ provider: "openai", model: "gpt-5.4" }),
}));
vi.mock("../gateway/server-model-catalog.js", () => ({
loadGatewayModelCatalog: () => [],
}));
vi.mock("../gateway/session-reset-service.js", () => ({
performGatewaySessionReset: () => ({ ok: true, key: "agent:main:main", entry: {} }),
}));
vi.mock("../gateway/session-utils.fs.js", () => ({
capArrayByJsonBytes: (items: unknown[]) => ({ items }),
}));
vi.mock("../gateway/sessions-patch.js", () => ({
applySessionsPatchToStore: () => ({ entry: {} }),
}));
vi.mock("../gateway/server-methods/agent-timestamp.js", () => ({
injectTimestamp: (message: string) => message,
timestampOptsFromConfig: () => ({}),
}));
function deferred<T>() {
let resolve: ((value: T) => void) | undefined;
let reject: ((error?: unknown) => void) | undefined;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
if (!resolve || !reject) {
throw new Error("Expected deferred callbacks to be initialized");
}
return { promise, resolve, reject };
}
async function flushMicrotasks() {
await Promise.resolve();
await Promise.resolve();
}
describe("EmbeddedTuiBackend", () => {
const originalRuntimeLog = defaultRuntime.log;
const originalRuntimeError = defaultRuntime.error;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(embeddedEventTimestamp);
agentCommandFromIngressMock.mockReset();
registeredListener = undefined;
setEmbeddedMode(false);
defaultRuntime.log = originalRuntimeLog;
defaultRuntime.error = originalRuntimeError;
});
afterEach(() => {
vi.useRealTimers();
setEmbeddedMode(false);
defaultRuntime.log = originalRuntimeLog;
defaultRuntime.error = originalRuntimeError;
});
it("bridges assistant and lifecycle events into chat events", async () => {
const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
const pending = deferred<{
payloads: Array<{ text: string }>;
meta: Record<string, unknown>;
}>();
agentCommandFromIngressMock.mockReturnValueOnce(pending.promise);
const backend = new EmbeddedTuiBackend();
const events: Array<{ event: string; payload: unknown }> = [];
const onConnected = vi.fn();
backend.onConnected = onConnected;
backend.onEvent = (evt) => {
events.push({ event: evt.event, payload: evt.payload });
};
backend.start();
await flushMicrotasks();
expect(onConnected).toHaveBeenCalledTimes(1);
await backend.sendChat({
sessionKey: "agent:main:main",
message: "hello",
runId: "run-local-1",
});
registeredListener?.({
runId: "run-local-1",
stream: "assistant",
data: { text: "hello", delta: "hello" },
});
registeredListener?.({
runId: "run-local-1",
stream: "lifecycle",
data: { phase: "end", stopReason: "stop" },
});
pending.resolve({ payloads: [{ text: "hello" }], meta: {} });
await flushMicrotasks();
expect(events).toEqual([
{
event: "agent",
payload: {
runId: "run-local-1",
stream: "assistant",
data: { text: "hello", delta: "hello" },
},
},
{
event: "chat",
payload: {
runId: "run-local-1",
sessionKey: "agent:main:main",
state: "delta",
deltaText: "hello",
message: {
role: "assistant",
content: [{ type: "text", text: "hello" }],
timestamp: embeddedEventTimestamp,
},
},
},
{
event: "agent",
payload: {
runId: "run-local-1",
stream: "lifecycle",
data: { phase: "end", stopReason: "stop" },
},
},
{
event: "chat",
payload: {
runId: "run-local-1",
sessionKey: "agent:main:main",
state: "final",
stopReason: "stop",
message: {
role: "assistant",
content: [{ type: "text", text: "hello" }],
timestamp: embeddedEventTimestamp,
},
},
},
]);
});
it("keeps final short replies like No after suppressing lead-fragment deltas", async () => {
const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
const pending = deferred<{
payloads: Array<{ text: string }>;
meta: Record<string, unknown>;
}>();
agentCommandFromIngressMock.mockReturnValueOnce(pending.promise);
const backend = new EmbeddedTuiBackend();
const events: Array<{ event: string; payload: unknown }> = [];
backend.onEvent = (evt) => {
events.push({ event: evt.event, payload: evt.payload });
};
backend.start();
await backend.sendChat({
sessionKey: "agent:main:main",
message: "answer shortly",
runId: "run-local-no",
});
registeredListener?.({
runId: "run-local-no",
stream: "assistant",
data: { text: "No", delta: "No" },
});
registeredListener?.({
runId: "run-local-no",
stream: "lifecycle",
data: { phase: "end", stopReason: "stop" },
});
pending.resolve({ payloads: [{ text: "No" }], meta: {} });
await flushMicrotasks();
const chatPayloads = events
.filter((entry) => entry.event === "chat")
.map(
(entry) =>
entry.payload as {
runId?: string;
sessionKey?: string;
state?: string;
stopReason?: string;
message?: { content?: Array<{ text?: string }> };
},
);
const nonEmptyDeltas = chatPayloads.filter(
(payload) => payload.state === "delta" && payload.message?.content?.[0]?.text,
);
expect(nonEmptyDeltas).toHaveLength(0);
expect(chatPayloads.at(-1)).toStrictEqual({
runId: "run-local-no",
sessionKey: "agent:main:main",
state: "final",
stopReason: "stop",
message: {
role: "assistant",
content: [{ type: "text", text: "No" }],
timestamp: embeddedEventTimestamp,
},
});
});
it("marks local embedded replacement deltas", async () => {
const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
const pending = deferred<{
payloads: Array<{ text: string }>;
meta: Record<string, unknown>;
}>();
agentCommandFromIngressMock.mockReturnValueOnce(pending.promise);
const backend = new EmbeddedTuiBackend();
const events: Array<{ event: string; payload: unknown }> = [];
backend.onEvent = (evt) => {
events.push({ event: evt.event, payload: evt.payload });
};
backend.start();
await backend.sendChat({
sessionKey: "agent:main:main",
message: "replace",
runId: "run-local-replace",
});
registeredListener?.({
runId: "run-local-replace",
stream: "assistant",
data: { text: "Hello world" },
});
registeredListener?.({
runId: "run-local-replace",
stream: "assistant",
data: { text: "Goodbye world" },
});
pending.resolve({ payloads: [{ text: "Goodbye world" }], meta: {} });
await flushMicrotasks();
const chatPayloads = events
.filter((entry) => entry.event === "chat")
.map(
(entry) =>
entry.payload as {
state?: string;
deltaText?: string;
replace?: boolean;
},
);
expect(
chatPayloads
.filter((payload) => payload.state === "delta")
.map((payload) => ({
state: payload.state,
deltaText: payload.deltaText,
replace: payload.replace,
})),
).toEqual([
{ state: "delta", deltaText: "Hello world", replace: undefined },
{ state: "delta", deltaText: "Goodbye world", replace: true },
]);
});
it("keeps a fallback response deliverable after a retryable lifecycle error", async () => {
const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
const pending = deferred<{
payloads: Array<{ text: string }>;
meta: Record<string, unknown>;
}>();
agentCommandFromIngressMock.mockReturnValueOnce(pending.promise);
const backend = new EmbeddedTuiBackend();
const events: Array<{ event: string; payload: unknown }> = [];
backend.onEvent = (evt) => {
events.push({ event: evt.event, payload: evt.payload });
};
backend.start();
await backend.sendChat({
sessionKey: "agent:main:main",
message: "recover after timeout",
runId: "run-local-fallback",
});
registeredListener?.({
runId: "run-local-fallback",
stream: "lifecycle",
data: { phase: "error", error: "primary model timed out" },
});
await flushMicrotasks();
expect(
events.some(
(entry) =>
entry.event === "chat" && (entry.payload as { state?: string }).state === "error",
),
).toBe(false);
registeredListener?.({
runId: "run-local-fallback",
stream: "lifecycle",
data: {
phase: "fallback_step",
fallbackStepFinalOutcome: "succeeded",
fallbackStepFromModel: "anthropic/claude-sonnet-4-6",
fallbackStepToModel: "anthropic/claude-sonnet-4-5",
},
});
registeredListener?.({
runId: "run-local-fallback",
stream: "assistant",
data: { text: "fallback answer", delta: "fallback answer" },
});
registeredListener?.({
runId: "run-local-fallback",
stream: "lifecycle",
data: { phase: "end", stopReason: "stop" },
});
pending.resolve({ payloads: [{ text: "fallback answer" }], meta: {} });
await flushMicrotasks();
vi.advanceTimersByTime(15_001);
const chatPayloads = events
.filter((entry) => entry.event === "chat")
.map((entry) => entry.payload as { state?: string; message?: { content?: unknown } });
expect(chatPayloads.some((payload) => payload.state === "error")).toBe(false);
const finalPayload = chatPayloads.at(-1);
expect(finalPayload?.state).toBe("final");
const finalContent = finalPayload?.message?.content as Array<{ type?: string; text?: string }>;
expect(finalContent).toHaveLength(1);
expect(finalContent[0]?.type).toBe("text");
expect(finalContent[0]?.text).toBe("fallback answer");
});
it("emits side-result events for local /btw runs", async () => {
const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
agentCommandFromIngressMock.mockResolvedValueOnce({
payloads: [{ text: "nothing important" }],
meta: {},
});
const backend = new EmbeddedTuiBackend();
const events: Array<{ event: string; payload: unknown }> = [];
backend.onEvent = (evt) => {
events.push({ event: evt.event, payload: evt.payload });
};
backend.start();
await backend.sendChat({
sessionKey: "agent:main:main",
message: "/btw what changed?",
runId: "run-btw-1",
});
await flushMicrotasks();
expect(events).toEqual([
{
event: "chat.side_result",
payload: {
kind: "btw",
runId: "run-btw-1",
sessionKey: "agent:main:main",
question: "what changed?",
text: "nothing important",
},
},
{
event: "chat",
payload: {
runId: "run-btw-1",
sessionKey: "agent:main:main",
state: "final",
},
},
]);
});
it("emits side-result events for local /side alias runs", async () => {
const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
agentCommandFromIngressMock.mockResolvedValueOnce({
payloads: [{ text: "alias answer" }],
meta: {},
});
const backend = new EmbeddedTuiBackend();
const events: Array<{ event: string; payload: unknown }> = [];
backend.onEvent = (evt) => {
events.push({ event: evt.event, payload: evt.payload });
};
backend.start();
await backend.sendChat({
sessionKey: "agent:main:main",
message: "/side what changed?",
runId: "run-side-1",
});
await flushMicrotasks();
expect(events).toEqual([
{
event: "chat.side_result",
payload: {
kind: "btw",
runId: "run-side-1",
sessionKey: "agent:main:main",
question: "what changed?",
text: "alias answer",
},
},
{
event: "chat",
payload: {
runId: "run-side-1",
sessionKey: "agent:main:main",
state: "final",
},
},
]);
});
it("registers tool-first local runs before forwarding agent events", async () => {
const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
const pending = deferred<{
payloads: Array<{ text: string }>;
meta: Record<string, unknown>;
}>();
agentCommandFromIngressMock.mockReturnValueOnce(pending.promise);
const backend = new EmbeddedTuiBackend();
const events: Array<{ event: string; payload: unknown }> = [];
backend.onEvent = (evt) => {
events.push({ event: evt.event, payload: evt.payload });
};
backend.start();
await backend.sendChat({
sessionKey: "agent:main:main",
message: "run tool first",
runId: "run-tool-first",
});
registeredListener?.({
runId: "run-tool-first",
stream: "tool",
data: { phase: "start", toolCallId: "tc-tool-first", name: "exec" },
});
pending.resolve({ payloads: [{ text: "done" }], meta: {} });
await flushMicrotasks();
expect(events).toEqual([
{
event: "chat",
payload: {
runId: "run-tool-first",
sessionKey: "agent:main:main",
state: "delta",
deltaText: "",
message: {
role: "assistant",
content: [{ type: "text", text: "" }],
timestamp: embeddedEventTimestamp,
},
},
},
{
event: "agent",
payload: {
runId: "run-tool-first",
stream: "tool",
data: { phase: "start", toolCallId: "tc-tool-first", name: "exec" },
},
},
{
event: "chat",
payload: {
runId: "run-tool-first",
sessionKey: "agent:main:main",
state: "final",
message: {
role: "assistant",
content: [{ type: "text", text: "done" }],
timestamp: embeddedEventTimestamp,
},
},
},
]);
});
it("aborts active local runs", async () => {
const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
let capturedSignal: AbortSignal | undefined;
agentCommandFromIngressMock.mockImplementationOnce((opts: { abortSignal?: AbortSignal }) => {
capturedSignal = opts.abortSignal;
return new Promise((_, reject) => {
opts.abortSignal?.addEventListener("abort", () => reject(new Error("aborted")), {
once: true,
});
});
});
const backend = new EmbeddedTuiBackend();
backend.start();
await backend.sendChat({
sessionKey: "agent:main:main",
message: "long task",
runId: "run-abort-1",
});
const result = await backend.abortChat({
sessionKey: "agent:main:main",
runId: "run-abort-1",
});
await flushMicrotasks();
expect(result).toEqual({ ok: true, aborted: true });
expect(capturedSignal?.aborted).toBe(true);
});
it("passes explicit chat timeouts to the agent command as seconds", async () => {
const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
agentCommandFromIngressMock.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
meta: {},
});
const backend = new EmbeddedTuiBackend();
backend.start();
try {
await backend.sendChat({
sessionKey: "agent:main:main",
message: "Wake up, my friend!",
runId: "run-explicit-timeout",
timeoutMs: 300_000,
});
await flushMicrotasks();
expect(agentCommandFromIngressMock).toHaveBeenCalledTimes(1);
const ingressOptions = agentCommandFromIngressMock.mock.calls.at(0)?.[0] as
| { timeout?: unknown }
| undefined;
expect(ingressOptions?.timeout).toBe("300");
} finally {
backend.stop();
}
});
it("restores embedded mode and runtime loggers on stop", async () => {
const { EmbeddedTuiBackend } = await import("./embedded-backend.js");
const backend = new EmbeddedTuiBackend();
backend.start();
expect(isEmbeddedMode()).toBe(true);
expect(defaultRuntime.log).not.toBe(originalRuntimeLog);
expect(defaultRuntime.error).not.toBe(originalRuntimeError);
backend.stop();
expect(isEmbeddedMode()).toBe(false);
expect(defaultRuntime.log).toBe(originalRuntimeLog);
expect(defaultRuntime.error).toBe(originalRuntimeError);
});
});