Files
openclaw/src/web/auto-reply.web-auto-reply.sends-tool-summaries-immediately-responseprefix.test.ts
max 223eee0a20 refactor: unify peer kind to ChatType, rename dm to direct (#11881)
* fix: use .js extension for ESM imports of RoutePeerKind

The imports incorrectly used .ts extension which doesn't resolve
with moduleResolution: NodeNext. Changed to .js and added 'type'
import modifier.

* fix tsconfig

* refactor: unify peer kind to ChatType, rename dm to direct

- Replace RoutePeerKind with ChatType throughout codebase
- Change 'dm' literal values to 'direct' in routing/session keys
- Keep backward compat: normalizeChatType accepts 'dm' -> 'direct'
- Add ChatType export to plugin-sdk, deprecate RoutePeerKind
- Update session key parsing to accept both 'dm' and 'direct' markers
- Update all channel monitors and extensions to use ChatType

BREAKING CHANGE: Session keys now use 'direct' instead of 'dm'.
Existing 'dm' keys still work via backward compat layer.

* fix tests

* test: update session key expectations for dmdirect migration

- Fix test expectations to expect :direct: in generated output
- Add explicit backward compat test for normalizeChatType('dm')
- Keep input test data with :dm: keys to verify backward compat

* fix: accept legacy 'dm' in session key parsing for backward compat

getDmHistoryLimitFromSessionKey now accepts both :dm: and :direct:
to ensure old session keys continue to work correctly.

* test: add explicit backward compat tests for dmdirect migration

- session-key.test.ts: verify both :dm: and :direct: keys are valid
- getDmHistoryLimitFromSessionKey: verify both formats work

* feat: backward compat for resetByType.dm config key

* test: skip unix-path Nix tests on Windows
2026-02-09 09:20:52 +09:00

263 lines
7.6 KiB
TypeScript

import "./test-helpers.js";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
}));
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import { resetLogger, setLoggerOverride } from "../logging.js";
import { monitorWebChannel } from "./auto-reply.js";
import { resetBaileysMocks, resetLoadConfigMock, setLoadConfigMock } from "./test-helpers.js";
let previousHome: string | undefined;
let tempHome: string | undefined;
const rmDirWithRetries = async (dir: string): Promise<void> => {
// Some tests can leave async session-store writes in-flight; recursive deletion can race and throw ENOTEMPTY.
for (let attempt = 0; attempt < 10; attempt += 1) {
try {
await fs.rm(dir, { recursive: true, force: true });
return;
} catch (err) {
const code =
err && typeof err === "object" && "code" in err
? String((err as { code?: unknown }).code)
: null;
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
await new Promise((resolve) => setTimeout(resolve, 25));
continue;
}
throw err;
}
}
await fs.rm(dir, { recursive: true, force: true });
};
beforeEach(async () => {
resetInboundDedupe();
previousHome = process.env.HOME;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-web-home-"));
process.env.HOME = tempHome;
});
afterEach(async () => {
process.env.HOME = previousHome;
if (tempHome) {
await rmDirWithRetries(tempHome);
tempHome = undefined;
}
});
const _makeSessionStore = async (
entries: Record<string, unknown> = {},
): Promise<{ storePath: string; cleanup: () => Promise<void> }> => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-"));
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(storePath, JSON.stringify(entries));
const cleanup = async () => {
// Session store writes can be in-flight when the test finishes (e.g. updateLastRoute
// after a message flush). `fs.rm({ recursive })` can race and throw ENOTEMPTY.
for (let attempt = 0; attempt < 10; attempt += 1) {
try {
await fs.rm(dir, { recursive: true, force: true });
return;
} catch (err) {
const code =
err && typeof err === "object" && "code" in err
? String((err as { code?: unknown }).code)
: null;
if (code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM") {
await new Promise((resolve) => setTimeout(resolve, 25));
continue;
}
throw err;
}
}
await fs.rm(dir, { recursive: true, force: true });
};
return {
storePath,
cleanup,
};
};
describe("web auto-reply", () => {
beforeEach(() => {
vi.clearAllMocks();
resetBaileysMocks();
resetLoadConfigMock();
});
afterEach(() => {
resetLogger();
setLoggerOverride(null);
vi.useRealTimers();
});
it("skips tool summaries and sends final reply with responsePrefix", async () => {
setLoadConfigMock(() => ({
channels: { whatsapp: { allowFrom: ["*"] } },
messages: {
messagePrefix: undefined,
responsePrefix: "🦞",
},
}));
let capturedOnMessage:
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
| undefined;
const reply = vi.fn();
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
return { close: vi.fn() };
};
const resolver = vi.fn().mockResolvedValue({ text: "final" });
await monitorWebChannel(false, listenerFactory, false, resolver);
expect(capturedOnMessage).toBeDefined();
await capturedOnMessage?.({
body: "hi",
from: "+1555",
to: "+2666",
id: "msg1",
sendComposing: vi.fn(),
reply,
sendMedia: vi.fn(),
});
const replies = reply.mock.calls.map((call) => call[0]);
expect(replies).toEqual(["🦞 final"]);
resetLoadConfigMock();
});
it("uses identity.name for messagePrefix when set", async () => {
setLoadConfigMock(() => ({
agents: {
list: [
{
id: "main",
default: true,
identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" },
},
{
id: "rich",
identity: { name: "Richbot", emoji: "🦁", theme: "lion bot" },
},
],
},
bindings: [
{
agentId: "rich",
match: {
channel: "whatsapp",
peer: { kind: "direct", id: "+1555" },
},
},
],
}));
let capturedOnMessage:
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
| undefined;
const reply = vi.fn();
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
return { close: vi.fn() };
};
const resolver = vi.fn().mockResolvedValue({ text: "hello" });
await monitorWebChannel(false, listenerFactory, false, resolver);
expect(capturedOnMessage).toBeDefined();
await capturedOnMessage?.({
body: "hi",
from: "+1555",
to: "+2666",
id: "msg1",
sendComposing: vi.fn(),
reply,
sendMedia: vi.fn(),
});
// Check that resolver received the message with identity-based prefix
expect(resolver).toHaveBeenCalled();
const resolverArg = resolver.mock.calls[0][0];
expect(resolverArg.Body).toContain("[Richbot]");
expect(resolverArg.Body).not.toContain("[openclaw]");
resetLoadConfigMock();
});
it("does not derive responsePrefix from identity.name when unset", async () => {
setLoadConfigMock(() => ({
agents: {
list: [
{
id: "main",
default: true,
identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" },
},
{
id: "rich",
identity: { name: "Richbot", emoji: "🦁", theme: "lion bot" },
},
],
},
bindings: [
{
agentId: "rich",
match: {
channel: "whatsapp",
peer: { kind: "direct", id: "+1555" },
},
},
],
}));
let capturedOnMessage:
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
| undefined;
const reply = vi.fn();
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
return { close: vi.fn() };
};
const resolver = vi.fn().mockResolvedValue({ text: "hello there" });
await monitorWebChannel(false, listenerFactory, false, resolver);
expect(capturedOnMessage).toBeDefined();
await capturedOnMessage?.({
body: "hi",
from: "+1555",
to: "+2666",
id: "msg1",
sendComposing: vi.fn(),
reply,
sendMedia: vi.fn(),
});
// No implicit responsePrefix.
expect(reply).toHaveBeenCalledWith("hello there");
resetLoadConfigMock();
});
});