mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 06:14:23 +00:00
test: streamline auto-reply and tts suites
This commit is contained in:
@@ -154,56 +154,48 @@ describe("chunkMarkdownText", () => {
|
||||
expectFencesBalanced(chunks);
|
||||
});
|
||||
|
||||
it("reopens fenced blocks when forced to split inside them", () => {
|
||||
const text = `\`\`\`txt\n${"a".repeat(500)}\n\`\`\``;
|
||||
const limit = 120;
|
||||
const chunks = chunkMarkdownText(text, limit);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(limit);
|
||||
expect(chunk.startsWith("```txt\n")).toBe(true);
|
||||
expect(chunk.trimEnd().endsWith("```")).toBe(true);
|
||||
}
|
||||
expectFencesBalanced(chunks);
|
||||
});
|
||||
it("handles multiple fence marker styles when splitting inside fences", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "backtick fence",
|
||||
text: `\`\`\`txt\n${"a".repeat(500)}\n\`\`\``,
|
||||
limit: 120,
|
||||
expectedPrefix: "```txt\n",
|
||||
expectedSuffix: "```",
|
||||
},
|
||||
{
|
||||
name: "tilde fence",
|
||||
text: `~~~sh\n${"x".repeat(600)}\n~~~`,
|
||||
limit: 140,
|
||||
expectedPrefix: "~~~sh\n",
|
||||
expectedSuffix: "~~~",
|
||||
},
|
||||
{
|
||||
name: "long backtick fence",
|
||||
text: `\`\`\`\`md\n${"y".repeat(600)}\n\`\`\`\``,
|
||||
limit: 140,
|
||||
expectedPrefix: "````md\n",
|
||||
expectedSuffix: "````",
|
||||
},
|
||||
{
|
||||
name: "indented fence",
|
||||
text: ` \`\`\`js\n ${"z".repeat(600)}\n \`\`\``,
|
||||
limit: 160,
|
||||
expectedPrefix: " ```js\n",
|
||||
expectedSuffix: " ```",
|
||||
},
|
||||
] as const;
|
||||
|
||||
it("supports tilde fences", () => {
|
||||
const text = `~~~sh\n${"x".repeat(600)}\n~~~`;
|
||||
const limit = 140;
|
||||
const chunks = chunkMarkdownText(text, limit);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(limit);
|
||||
expect(chunk.startsWith("~~~sh\n")).toBe(true);
|
||||
expect(chunk.trimEnd().endsWith("~~~")).toBe(true);
|
||||
for (const testCase of cases) {
|
||||
const chunks = chunkMarkdownText(testCase.text, testCase.limit);
|
||||
expect(chunks.length, testCase.name).toBeGreaterThan(1);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.length, testCase.name).toBeLessThanOrEqual(testCase.limit);
|
||||
expect(chunk.startsWith(testCase.expectedPrefix), testCase.name).toBe(true);
|
||||
expect(chunk.trimEnd().endsWith(testCase.expectedSuffix), testCase.name).toBe(true);
|
||||
}
|
||||
expectFencesBalanced(chunks);
|
||||
}
|
||||
expectFencesBalanced(chunks);
|
||||
});
|
||||
|
||||
it("supports longer fence markers for close", () => {
|
||||
const text = `\`\`\`\`md\n${"y".repeat(600)}\n\`\`\`\``;
|
||||
const limit = 140;
|
||||
const chunks = chunkMarkdownText(text, limit);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(limit);
|
||||
expect(chunk.startsWith("````md\n")).toBe(true);
|
||||
expect(chunk.trimEnd().endsWith("````")).toBe(true);
|
||||
}
|
||||
expectFencesBalanced(chunks);
|
||||
});
|
||||
|
||||
it("preserves indentation for indented fences", () => {
|
||||
const text = ` \`\`\`js\n ${"z".repeat(600)}\n \`\`\``;
|
||||
const limit = 160;
|
||||
const chunks = chunkMarkdownText(text, limit);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(limit);
|
||||
expect(chunk.startsWith(" ```js\n")).toBe(true);
|
||||
expect(chunk.trimEnd().endsWith(" ```")).toBe(true);
|
||||
}
|
||||
expectFencesBalanced(chunks);
|
||||
});
|
||||
|
||||
it("never produces an empty fenced chunk when splitting", () => {
|
||||
@@ -269,12 +261,10 @@ describe("chunkByNewline", () => {
|
||||
expect(chunks).toEqual([text]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(chunkByNewline("", 100)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for whitespace-only input", () => {
|
||||
expect(chunkByNewline(" \n\n ", 100)).toEqual([]);
|
||||
it("returns empty array for empty and whitespace-only input", () => {
|
||||
for (const text of ["", " \n\n "]) {
|
||||
expect(chunkByNewline(text, 100)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves trailing blank lines on the last chunk", () => {
|
||||
@@ -291,83 +281,107 @@ describe("chunkByNewline", () => {
|
||||
});
|
||||
|
||||
describe("chunkTextWithMode", () => {
|
||||
it("uses length-based chunking for length mode", () => {
|
||||
const text = "Line one\nLine two";
|
||||
const chunks = chunkTextWithMode(text, 1000, "length");
|
||||
expect(chunks).toEqual(["Line one\nLine two"]);
|
||||
});
|
||||
it("applies mode-specific chunking behavior", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "length mode",
|
||||
text: "Line one\nLine two",
|
||||
mode: "length" as const,
|
||||
expected: ["Line one\nLine two"],
|
||||
},
|
||||
{
|
||||
name: "newline mode (single paragraph)",
|
||||
text: "Line one\nLine two",
|
||||
mode: "newline" as const,
|
||||
expected: ["Line one\nLine two"],
|
||||
},
|
||||
{
|
||||
name: "newline mode (blank-line split)",
|
||||
text: "Para one\n\nPara two",
|
||||
mode: "newline" as const,
|
||||
expected: ["Para one", "Para two"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
it("uses paragraph-based chunking for newline mode", () => {
|
||||
const text = "Line one\nLine two";
|
||||
const chunks = chunkTextWithMode(text, 1000, "newline");
|
||||
expect(chunks).toEqual(["Line one\nLine two"]);
|
||||
});
|
||||
|
||||
it("splits on blank lines for newline mode", () => {
|
||||
const text = "Para one\n\nPara two";
|
||||
const chunks = chunkTextWithMode(text, 1000, "newline");
|
||||
expect(chunks).toEqual(["Para one", "Para two"]);
|
||||
for (const testCase of cases) {
|
||||
const chunks = chunkTextWithMode(testCase.text, 1000, testCase.mode);
|
||||
expect(chunks, testCase.name).toEqual(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("chunkMarkdownTextWithMode", () => {
|
||||
it("uses markdown-aware chunking for length mode", () => {
|
||||
const text = "Line one\nLine two";
|
||||
expect(chunkMarkdownTextWithMode(text, 1000, "length")).toEqual(chunkMarkdownText(text, 1000));
|
||||
it("applies markdown/newline mode behavior", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "length mode uses markdown-aware chunker",
|
||||
text: "Line one\nLine two",
|
||||
mode: "length" as const,
|
||||
expected: chunkMarkdownText("Line one\nLine two", 1000),
|
||||
},
|
||||
{
|
||||
name: "newline mode keeps single paragraph",
|
||||
text: "Line one\nLine two",
|
||||
mode: "newline" as const,
|
||||
expected: ["Line one\nLine two"],
|
||||
},
|
||||
{
|
||||
name: "newline mode splits by blank line",
|
||||
text: "Para one\n\nPara two",
|
||||
mode: "newline" as const,
|
||||
expected: ["Para one", "Para two"],
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(chunkMarkdownTextWithMode(testCase.text, 1000, testCase.mode), testCase.name).toEqual(
|
||||
testCase.expected,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses paragraph-based chunking for newline mode", () => {
|
||||
const text = "Line one\nLine two";
|
||||
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual(["Line one\nLine two"]);
|
||||
});
|
||||
|
||||
it("splits on blank lines for newline mode", () => {
|
||||
const text = "Para one\n\nPara two";
|
||||
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual(["Para one", "Para two"]);
|
||||
});
|
||||
|
||||
it("does not split single-newline code fences in newline mode", () => {
|
||||
const text = "```js\nconst a = 1;\nconst b = 2;\n```\nAfter";
|
||||
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([text]);
|
||||
});
|
||||
|
||||
it("defers long markdown paragraphs to markdown chunking in newline mode", () => {
|
||||
const text = `\`\`\`js\n${"const a = 1;\n".repeat(20)}\`\`\``;
|
||||
expect(chunkMarkdownTextWithMode(text, 40, "newline")).toEqual(chunkMarkdownText(text, 40));
|
||||
});
|
||||
|
||||
it("does not split on blank lines inside a fenced code block", () => {
|
||||
const text = "```python\ndef my_function():\n x = 1\n\n y = 2\n return x + y\n```";
|
||||
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([text]);
|
||||
});
|
||||
|
||||
it("splits on blank lines between a code fence and following paragraph", () => {
|
||||
it("handles newline mode fence splitting rules", () => {
|
||||
const fence = "```python\ndef my_function():\n x = 1\n\n y = 2\n return x + y\n```";
|
||||
const text = `${fence}\n\nAfter`;
|
||||
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([fence, "After"]);
|
||||
const longFence = `\`\`\`js\n${"const a = 1;\n".repeat(20)}\`\`\``;
|
||||
const cases = [
|
||||
{
|
||||
name: "keeps single-newline fence+paragraph together",
|
||||
text: "```js\nconst a = 1;\nconst b = 2;\n```\nAfter",
|
||||
limit: 1000,
|
||||
expected: ["```js\nconst a = 1;\nconst b = 2;\n```\nAfter"],
|
||||
},
|
||||
{
|
||||
name: "keeps blank lines inside fence together",
|
||||
text: fence,
|
||||
limit: 1000,
|
||||
expected: [fence],
|
||||
},
|
||||
{
|
||||
name: "splits between fence and following paragraph",
|
||||
text: `${fence}\n\nAfter`,
|
||||
limit: 1000,
|
||||
expected: [fence, "After"],
|
||||
},
|
||||
{
|
||||
name: "defers long markdown blocks to markdown chunker",
|
||||
text: longFence,
|
||||
limit: 40,
|
||||
expected: chunkMarkdownText(longFence, 40),
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
expect(
|
||||
chunkMarkdownTextWithMode(testCase.text, testCase.limit, "newline"),
|
||||
testCase.name,
|
||||
).toEqual(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveChunkMode", () => {
|
||||
it("returns length as default", () => {
|
||||
expect(resolveChunkMode(undefined, "telegram")).toBe("length");
|
||||
expect(resolveChunkMode({}, "discord")).toBe("length");
|
||||
expect(resolveChunkMode(undefined, "bluebubbles")).toBe("length");
|
||||
});
|
||||
|
||||
it("returns length for internal channel", () => {
|
||||
const cfg = { channels: { bluebubbles: { chunkMode: "newline" as const } } };
|
||||
expect(resolveChunkMode(cfg, "__internal__")).toBe("length");
|
||||
});
|
||||
|
||||
it("supports provider-level overrides for slack", () => {
|
||||
const cfg = { channels: { slack: { chunkMode: "newline" as const } } };
|
||||
expect(resolveChunkMode(cfg, "slack")).toBe("newline");
|
||||
expect(resolveChunkMode(cfg, "discord")).toBe("length");
|
||||
});
|
||||
|
||||
it("supports account-level overrides for slack", () => {
|
||||
const cfg = {
|
||||
it("resolves default, provider, account, and internal channel modes", () => {
|
||||
const providerCfg = { channels: { slack: { chunkMode: "newline" as const } } };
|
||||
const accountCfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
chunkMode: "length" as const,
|
||||
@@ -377,7 +391,21 @@ describe("resolveChunkMode", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(resolveChunkMode(cfg, "slack", "primary")).toBe("newline");
|
||||
expect(resolveChunkMode(cfg, "slack", "other")).toBe("length");
|
||||
const cases = [
|
||||
{ cfg: undefined, provider: "telegram", accountId: undefined, expected: "length" },
|
||||
{ cfg: {}, provider: "discord", accountId: undefined, expected: "length" },
|
||||
{ cfg: undefined, provider: "bluebubbles", accountId: undefined, expected: "length" },
|
||||
{ cfg: providerCfg, provider: "__internal__", accountId: undefined, expected: "length" },
|
||||
{ cfg: providerCfg, provider: "slack", accountId: undefined, expected: "newline" },
|
||||
{ cfg: providerCfg, provider: "discord", accountId: undefined, expected: "length" },
|
||||
{ cfg: accountCfg, provider: "slack", accountId: "primary", expected: "newline" },
|
||||
{ cfg: accountCfg, provider: "slack", accountId: "other", expected: "length" },
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
expect(resolveChunkMode(testCase.cfg as never, testCase.provider, testCase.accountId)).toBe(
|
||||
testCase.expected,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,9 @@ const state = vi.hoisted(() => ({
|
||||
runCliAgentMock: vi.fn(),
|
||||
}));
|
||||
|
||||
let modelFallbackModule: typeof import("../../agents/model-fallback.js");
|
||||
let onAgentEvent: typeof import("../../infra/agent-events.js").onAgentEvent;
|
||||
|
||||
let runReplyAgentPromise:
|
||||
| Promise<(typeof import("./agent-runner.js"))["runReplyAgent"]>
|
||||
| undefined;
|
||||
@@ -75,6 +78,8 @@ vi.mock("./queue.js", () => ({
|
||||
|
||||
beforeAll(async () => {
|
||||
// Avoid attributing the initial agent-runner import cost to the first test case.
|
||||
modelFallbackModule = await import("../../agents/model-fallback.js");
|
||||
({ onAgentEvent } = await import("../../infra/agent-events.js"));
|
||||
await getRunReplyAgent();
|
||||
});
|
||||
|
||||
@@ -629,83 +634,70 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("announces model fallback in verbose mode", async () => {
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore = { main: sessionEntry };
|
||||
state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ payloads: [{ text: "final" }], meta: {} });
|
||||
const modelFallback = await import("../../agents/model-fallback.js");
|
||||
vi.spyOn(modelFallback, "runWithModelFallback").mockImplementationOnce(
|
||||
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
||||
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
|
||||
provider: "deepinfra",
|
||||
model: "moonshotai/Kimi-K2.5",
|
||||
attempts: [
|
||||
{
|
||||
provider: "fireworks",
|
||||
model: "fireworks/minimax-m2p5",
|
||||
error: "Provider fireworks is in cooldown (all profiles unavailable)",
|
||||
reason: "rate_limit",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
it("announces model fallback only when verbose mode is enabled", async () => {
|
||||
const cases = [
|
||||
{ name: "verbose on", verbose: "on" as const, expectNotice: true },
|
||||
{ name: "verbose off", verbose: "off" as const, expectNotice: false },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore = { main: sessionEntry };
|
||||
state.runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "final" }],
|
||||
meta: {},
|
||||
});
|
||||
vi.spyOn(modelFallbackModule, "runWithModelFallback").mockImplementationOnce(
|
||||
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
||||
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
|
||||
provider: "deepinfra",
|
||||
model: "moonshotai/Kimi-K2.5",
|
||||
attempts: [
|
||||
{
|
||||
provider: "fireworks",
|
||||
model: "fireworks/minimax-m2p5",
|
||||
error: "Provider fireworks is in cooldown (all profiles unavailable)",
|
||||
reason: "rate_limit",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const { run } = createMinimalRun({
|
||||
resolvedVerboseLevel: "on",
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
});
|
||||
const res = await run();
|
||||
expect(Array.isArray(res)).toBe(true);
|
||||
const payloads = res as { text?: string }[];
|
||||
expect(payloads[0]?.text).toContain("Model Fallback:");
|
||||
expect(payloads[0]?.text).toContain("deepinfra/moonshotai/Kimi-K2.5");
|
||||
expect(sessionEntry.fallbackNoticeReason).toBe("rate limit");
|
||||
});
|
||||
|
||||
it("does not announce model fallback when verbose is off", async () => {
|
||||
const { onAgentEvent } = await import("../../infra/agent-events.js");
|
||||
state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ payloads: [{ text: "final" }], meta: {} });
|
||||
const modelFallback = await import("../../agents/model-fallback.js");
|
||||
vi.spyOn(modelFallback, "runWithModelFallback").mockImplementationOnce(
|
||||
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
||||
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
|
||||
provider: "deepinfra",
|
||||
model: "moonshotai/Kimi-K2.5",
|
||||
attempts: [
|
||||
{
|
||||
provider: "fireworks",
|
||||
model: "fireworks/minimax-m2p5",
|
||||
error: "Provider fireworks is in cooldown (all profiles unavailable)",
|
||||
reason: "rate_limit",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const { run } = createMinimalRun({
|
||||
resolvedVerboseLevel: "off",
|
||||
});
|
||||
const phases: string[] = [];
|
||||
const off = onAgentEvent((evt) => {
|
||||
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : null;
|
||||
if (evt.stream === "lifecycle" && phase) {
|
||||
phases.push(phase);
|
||||
const { run } = createMinimalRun({
|
||||
resolvedVerboseLevel: testCase.verbose,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
});
|
||||
const phases: string[] = [];
|
||||
const off = onAgentEvent((evt) => {
|
||||
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : null;
|
||||
if (evt.stream === "lifecycle" && phase) {
|
||||
phases.push(phase);
|
||||
}
|
||||
});
|
||||
const res = await run();
|
||||
off();
|
||||
const payload = Array.isArray(res)
|
||||
? (res[0] as { text?: string })
|
||||
: (res as { text?: string });
|
||||
if (testCase.expectNotice) {
|
||||
expect(payload.text, testCase.name).toContain("Model Fallback:");
|
||||
expect(payload.text, testCase.name).toContain("deepinfra/moonshotai/Kimi-K2.5");
|
||||
expect(sessionEntry.fallbackNoticeReason, testCase.name).toBe("rate limit");
|
||||
continue;
|
||||
}
|
||||
});
|
||||
const res = await run();
|
||||
off();
|
||||
const payload = Array.isArray(res) ? (res[0] as { text?: string }) : (res as { text?: string });
|
||||
expect(payload.text).not.toContain("Model Fallback:");
|
||||
expect(phases.filter((phase) => phase === "fallback")).toHaveLength(1);
|
||||
expect(payload.text, testCase.name).not.toContain("Model Fallback:");
|
||||
expect(
|
||||
phases.filter((phase) => phase === "fallback"),
|
||||
testCase.name,
|
||||
).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("announces model fallback only once per active fallback state", async () => {
|
||||
const { onAgentEvent } = await import("../../infra/agent-events.js");
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
@@ -716,9 +708,8 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
payloads: [{ text: "final" }],
|
||||
meta: {},
|
||||
});
|
||||
const modelFallback = await import("../../agents/model-fallback.js");
|
||||
const fallbackSpy = vi
|
||||
.spyOn(modelFallback, "runWithModelFallback")
|
||||
.spyOn(modelFallbackModule, "runWithModelFallback")
|
||||
.mockImplementation(
|
||||
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
||||
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
|
||||
@@ -773,9 +764,8 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
payloads: [{ text: "final" }],
|
||||
meta: {},
|
||||
});
|
||||
const modelFallback = await import("../../agents/model-fallback.js");
|
||||
const fallbackSpy = vi
|
||||
.spyOn(modelFallback, "runWithModelFallback")
|
||||
.spyOn(modelFallbackModule, "runWithModelFallback")
|
||||
.mockImplementation(
|
||||
async ({
|
||||
provider,
|
||||
@@ -833,7 +823,6 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
});
|
||||
|
||||
it("announces fallback-cleared once when runtime returns to selected model", async () => {
|
||||
const { onAgentEvent } = await import("../../infra/agent-events.js");
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
@@ -845,9 +834,8 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
payloads: [{ text: "final" }],
|
||||
meta: {},
|
||||
});
|
||||
const modelFallback = await import("../../agents/model-fallback.js");
|
||||
const fallbackSpy = vi
|
||||
.spyOn(modelFallback, "runWithModelFallback")
|
||||
.spyOn(modelFallbackModule, "runWithModelFallback")
|
||||
.mockImplementation(
|
||||
async ({
|
||||
provider,
|
||||
@@ -915,7 +903,6 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
});
|
||||
|
||||
it("emits fallback lifecycle events while verbose is off", async () => {
|
||||
const { onAgentEvent } = await import("../../infra/agent-events.js");
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
@@ -927,9 +914,8 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
payloads: [{ text: "final" }],
|
||||
meta: {},
|
||||
});
|
||||
const modelFallback = await import("../../agents/model-fallback.js");
|
||||
const fallbackSpy = vi
|
||||
.spyOn(modelFallback, "runWithModelFallback")
|
||||
.spyOn(modelFallbackModule, "runWithModelFallback")
|
||||
.mockImplementation(
|
||||
async ({
|
||||
provider,
|
||||
@@ -1008,9 +994,8 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
payloads: [{ text: "final" }],
|
||||
meta: {},
|
||||
});
|
||||
const modelFallback = await import("../../agents/model-fallback.js");
|
||||
const fallbackSpy = vi
|
||||
.spyOn(modelFallback, "runWithModelFallback")
|
||||
.spyOn(modelFallbackModule, "runWithModelFallback")
|
||||
.mockImplementation(
|
||||
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
||||
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
|
||||
@@ -1058,9 +1043,8 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
payloads: [{ text: "final" }],
|
||||
meta: {},
|
||||
});
|
||||
const modelFallback = await import("../../agents/model-fallback.js");
|
||||
const fallbackSpy = vi
|
||||
.spyOn(modelFallback, "runWithModelFallback")
|
||||
.spyOn(modelFallbackModule, "runWithModelFallback")
|
||||
.mockImplementation(
|
||||
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
||||
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
|
||||
|
||||
@@ -165,26 +165,21 @@ describe("handleCommands gating", () => {
|
||||
expect(result.reply?.text).toContain("elevated is not available");
|
||||
});
|
||||
|
||||
it("blocks /config when disabled", async () => {
|
||||
it("blocks /config and /debug when disabled", async () => {
|
||||
const cfg = {
|
||||
commands: { config: false, debug: false, text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/config show", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("/config is disabled");
|
||||
});
|
||||
|
||||
it("blocks /debug when disabled", async () => {
|
||||
const cfg = {
|
||||
commands: { config: false, debug: false, text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/debug show", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("/debug is disabled");
|
||||
const cases = [
|
||||
{ commandBody: "/config show", expectedText: "/config is disabled" },
|
||||
{ commandBody: "/debug show", expectedText: "/debug is disabled" },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const params = buildParams(testCase.commandBody, cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain(testCase.expectedText);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not enable gated commands from inherited command flags", async () => {
|
||||
@@ -266,50 +261,29 @@ describe("/approve command", () => {
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows gateway clients with approvals scope", async () => {
|
||||
it("allows gateway clients with approvals or admin scopes", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/approve abc allow-once", cfg, {
|
||||
Provider: "webchat",
|
||||
Surface: "webchat",
|
||||
GatewayClientScopes: ["operator.approvals"],
|
||||
});
|
||||
const scopeCases = [["operator.approvals"], ["operator.admin"]];
|
||||
for (const scopes of scopeCases) {
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
const params = buildParams("/approve abc allow-once", cfg, {
|
||||
Provider: "webchat",
|
||||
Surface: "webchat",
|
||||
GatewayClientScopes: scopes,
|
||||
});
|
||||
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
|
||||
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "exec.approval.resolve",
|
||||
params: { id: "abc", decision: "allow-once" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows gateway clients with admin scope", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/approve abc allow-once", cfg, {
|
||||
Provider: "webchat",
|
||||
Surface: "webchat",
|
||||
GatewayClientScopes: ["operator.admin"],
|
||||
});
|
||||
|
||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
|
||||
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "exec.approval.resolve",
|
||||
params: { id: "abc", decision: "allow-once" },
|
||||
}),
|
||||
);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
|
||||
expect(callGatewayMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "exec.approval.resolve",
|
||||
params: { id: "abc", decision: "allow-once" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -420,67 +394,76 @@ describe("buildCommandsPaginationKeyboard", () => {
|
||||
});
|
||||
|
||||
describe("parseConfigCommand", () => {
|
||||
it("parses show/unset", () => {
|
||||
expect(parseConfigCommand("/config")).toEqual({ action: "show" });
|
||||
expect(parseConfigCommand("/config show")).toEqual({
|
||||
action: "show",
|
||||
path: undefined,
|
||||
});
|
||||
expect(parseConfigCommand("/config show foo.bar")).toEqual({
|
||||
action: "show",
|
||||
path: "foo.bar",
|
||||
});
|
||||
expect(parseConfigCommand("/config get foo.bar")).toEqual({
|
||||
action: "show",
|
||||
path: "foo.bar",
|
||||
});
|
||||
expect(parseConfigCommand("/config unset foo.bar")).toEqual({
|
||||
action: "unset",
|
||||
path: "foo.bar",
|
||||
});
|
||||
});
|
||||
it("parses config/debug command actions and JSON payloads", () => {
|
||||
const cases: Array<{
|
||||
parse: (input: string) => unknown;
|
||||
input: string;
|
||||
expected: unknown;
|
||||
}> = [
|
||||
{ parse: parseConfigCommand, input: "/config", expected: { action: "show" } },
|
||||
{
|
||||
parse: parseConfigCommand,
|
||||
input: "/config show",
|
||||
expected: { action: "show", path: undefined },
|
||||
},
|
||||
{
|
||||
parse: parseConfigCommand,
|
||||
input: "/config show foo.bar",
|
||||
expected: { action: "show", path: "foo.bar" },
|
||||
},
|
||||
{
|
||||
parse: parseConfigCommand,
|
||||
input: "/config get foo.bar",
|
||||
expected: { action: "show", path: "foo.bar" },
|
||||
},
|
||||
{
|
||||
parse: parseConfigCommand,
|
||||
input: "/config unset foo.bar",
|
||||
expected: { action: "unset", path: "foo.bar" },
|
||||
},
|
||||
{
|
||||
parse: parseConfigCommand,
|
||||
input: '/config set foo={"a":1}',
|
||||
expected: { action: "set", path: "foo", value: { a: 1 } },
|
||||
},
|
||||
{ parse: parseDebugCommand, input: "/debug", expected: { action: "show" } },
|
||||
{ parse: parseDebugCommand, input: "/debug show", expected: { action: "show" } },
|
||||
{ parse: parseDebugCommand, input: "/debug reset", expected: { action: "reset" } },
|
||||
{
|
||||
parse: parseDebugCommand,
|
||||
input: "/debug unset foo.bar",
|
||||
expected: { action: "unset", path: "foo.bar" },
|
||||
},
|
||||
{
|
||||
parse: parseDebugCommand,
|
||||
input: '/debug set foo={"a":1}',
|
||||
expected: { action: "set", path: "foo", value: { a: 1 } },
|
||||
},
|
||||
];
|
||||
|
||||
it("parses set with JSON", () => {
|
||||
const cmd = parseConfigCommand('/config set foo={"a":1}');
|
||||
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDebugCommand", () => {
|
||||
it("parses show/reset", () => {
|
||||
expect(parseDebugCommand("/debug")).toEqual({ action: "show" });
|
||||
expect(parseDebugCommand("/debug show")).toEqual({ action: "show" });
|
||||
expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" });
|
||||
});
|
||||
|
||||
it("parses set with JSON", () => {
|
||||
const cmd = parseDebugCommand('/debug set foo={"a":1}');
|
||||
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
||||
});
|
||||
|
||||
it("parses unset", () => {
|
||||
const cmd = parseDebugCommand("/debug unset foo.bar");
|
||||
expect(cmd).toEqual({ action: "unset", path: "foo.bar" });
|
||||
for (const testCase of cases) {
|
||||
expect(testCase.parse(testCase.input)).toEqual(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMessageText", () => {
|
||||
it("preserves user text that looks like tool call markers", () => {
|
||||
const message = {
|
||||
role: "user",
|
||||
content: "Here [Tool Call: foo (ID: 1)] ok",
|
||||
};
|
||||
const result = extractMessageText(message);
|
||||
expect(result?.text).toContain("[Tool Call: foo (ID: 1)]");
|
||||
});
|
||||
it("preserves user markers and sanitizes assistant markers", () => {
|
||||
const cases = [
|
||||
{
|
||||
message: { role: "user", content: "Here [Tool Call: foo (ID: 1)] ok" },
|
||||
expectedText: "Here [Tool Call: foo (ID: 1)] ok",
|
||||
},
|
||||
{
|
||||
message: { role: "assistant", content: "Here [Tool Call: foo (ID: 1)] ok" },
|
||||
expectedText: "Here ok",
|
||||
},
|
||||
] as const;
|
||||
|
||||
it("sanitizes assistant tool call markers", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: "Here [Tool Call: foo (ID: 1)] ok",
|
||||
};
|
||||
const result = extractMessageText(message);
|
||||
expect(result?.text).toBe("Here ok");
|
||||
for (const testCase of cases) {
|
||||
const result = extractMessageText(testCase.message);
|
||||
expect(result?.text).toBe(testCase.expectedText);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -498,28 +481,18 @@ describe("handleCommands /config configWrites gating", () => {
|
||||
});
|
||||
|
||||
describe("handleCommands bash alias", () => {
|
||||
it("routes !poll through the /bash handler", async () => {
|
||||
resetBashChatCommandForTests();
|
||||
it("routes !poll and !stop through the /bash handler", async () => {
|
||||
const cfg = {
|
||||
commands: { bash: true, text: true },
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("!poll", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("No active bash job");
|
||||
});
|
||||
|
||||
it("routes !stop through the /bash handler", async () => {
|
||||
resetBashChatCommandForTests();
|
||||
const cfg = {
|
||||
commands: { bash: true, text: true },
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("!stop", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("No active bash job");
|
||||
for (const aliasCommand of ["!poll", "!stop"]) {
|
||||
resetBashChatCommandForTests();
|
||||
const params = buildParams(aliasCommand, cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("No active bash job");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -623,90 +596,66 @@ describe("handleCommands /allowlist", () => {
|
||||
expect(result.reply?.text).toContain("DM allowlist added");
|
||||
});
|
||||
|
||||
it("removes Slack DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => {
|
||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||
valid: true,
|
||||
parsed: {
|
||||
channels: {
|
||||
slack: {
|
||||
allowFrom: ["U111", "U222"],
|
||||
dm: { allowFrom: ["U111", "U222"] },
|
||||
configWrites: true,
|
||||
},
|
||||
},
|
||||
it("removes DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => {
|
||||
const cases = [
|
||||
{
|
||||
provider: "slack",
|
||||
removeId: "U111",
|
||||
initialAllowFrom: ["U111", "U222"],
|
||||
expectedAllowFrom: ["U222"],
|
||||
},
|
||||
});
|
||||
{
|
||||
provider: "discord",
|
||||
removeId: "111",
|
||||
initialAllowFrom: ["111", "222"],
|
||||
expectedAllowFrom: ["222"],
|
||||
},
|
||||
] as const;
|
||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||
ok: true,
|
||||
config,
|
||||
}));
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true, config: true },
|
||||
channels: {
|
||||
slack: {
|
||||
allowFrom: ["U111", "U222"],
|
||||
dm: { allowFrom: ["U111", "U222"] },
|
||||
configWrites: true,
|
||||
for (const testCase of cases) {
|
||||
const previousWriteCount = writeConfigFileMock.mock.calls.length;
|
||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||
valid: true,
|
||||
parsed: {
|
||||
channels: {
|
||||
[testCase.provider]: {
|
||||
allowFrom: testCase.initialAllowFrom,
|
||||
dm: { allowFrom: testCase.initialAllowFrom },
|
||||
configWrites: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
});
|
||||
|
||||
const params = buildPolicyParams("/allowlist remove dm U111", cfg, {
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
});
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(writeConfigFileMock).toHaveBeenCalledTimes(1);
|
||||
const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig;
|
||||
expect(written.channels?.slack?.allowFrom).toEqual(["U222"]);
|
||||
expect(written.channels?.slack?.dm?.allowFrom).toBeUndefined();
|
||||
expect(result.reply?.text).toContain("channels.slack.allowFrom");
|
||||
});
|
||||
|
||||
it("removes Discord DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => {
|
||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||
valid: true,
|
||||
parsed: {
|
||||
const cfg = {
|
||||
commands: { text: true, config: true },
|
||||
channels: {
|
||||
discord: {
|
||||
allowFrom: ["111", "222"],
|
||||
dm: { allowFrom: ["111", "222"] },
|
||||
[testCase.provider]: {
|
||||
allowFrom: testCase.initialAllowFrom,
|
||||
dm: { allowFrom: testCase.initialAllowFrom },
|
||||
configWrites: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||
ok: true,
|
||||
config,
|
||||
}));
|
||||
} as OpenClawConfig;
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true, config: true },
|
||||
channels: {
|
||||
discord: {
|
||||
allowFrom: ["111", "222"],
|
||||
dm: { allowFrom: ["111", "222"] },
|
||||
configWrites: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const params = buildPolicyParams(`/allowlist remove dm ${testCase.removeId}`, cfg, {
|
||||
Provider: testCase.provider,
|
||||
Surface: testCase.provider,
|
||||
});
|
||||
const result = await handleCommands(params);
|
||||
|
||||
const params = buildPolicyParams("/allowlist remove dm 111", cfg, {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
});
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(writeConfigFileMock).toHaveBeenCalledTimes(1);
|
||||
const written = writeConfigFileMock.mock.calls[0]?.[0] as OpenClawConfig;
|
||||
expect(written.channels?.discord?.allowFrom).toEqual(["222"]);
|
||||
expect(written.channels?.discord?.dm?.allowFrom).toBeUndefined();
|
||||
expect(result.reply?.text).toContain("channels.discord.allowFrom");
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount + 1);
|
||||
const written = writeConfigFileMock.mock.calls.at(-1)?.[0] as OpenClawConfig;
|
||||
const channelConfig = written.channels?.[testCase.provider];
|
||||
expect(channelConfig?.allowFrom).toEqual(testCase.expectedAllowFrom);
|
||||
expect(channelConfig?.dm?.allowFrom).toBeUndefined();
|
||||
expect(result.reply?.text).toContain(`channels.${testCase.provider}.allowFrom`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -736,44 +685,56 @@ describe("/models command", () => {
|
||||
expect(buttons?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("lists provider models with pagination hints", async () => {
|
||||
// Use discord surface for text-based output tests
|
||||
const params = buildPolicyParams("/models anthropic", cfg, { Surface: "discord" });
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Models (anthropic");
|
||||
expect(result.reply?.text).toContain("page 1/");
|
||||
expect(result.reply?.text).toContain("anthropic/claude-opus-4-5");
|
||||
expect(result.reply?.text).toContain("Switch: /model <provider/model>");
|
||||
expect(result.reply?.text).toContain("All: /models anthropic all");
|
||||
});
|
||||
it("handles provider model pagination, all mode, and unknown providers", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "lists provider models with pagination hints",
|
||||
command: "/models anthropic",
|
||||
includes: [
|
||||
"Models (anthropic",
|
||||
"page 1/",
|
||||
"anthropic/claude-opus-4-5",
|
||||
"Switch: /model <provider/model>",
|
||||
"All: /models anthropic all",
|
||||
],
|
||||
excludes: [],
|
||||
},
|
||||
{
|
||||
name: "ignores page argument when all flag is present",
|
||||
command: "/models anthropic 3 all",
|
||||
includes: ["Models (anthropic", "page 1/1", "anthropic/claude-opus-4-5"],
|
||||
excludes: ["Page out of range"],
|
||||
},
|
||||
{
|
||||
name: "errors on out-of-range pages",
|
||||
command: "/models anthropic 4",
|
||||
includes: ["Page out of range", "valid: 1-"],
|
||||
excludes: [],
|
||||
},
|
||||
{
|
||||
name: "handles unknown providers",
|
||||
command: "/models not-a-provider",
|
||||
includes: ["Unknown provider", "Available providers"],
|
||||
excludes: [],
|
||||
},
|
||||
] as const;
|
||||
|
||||
it("ignores page argument when all flag is present", async () => {
|
||||
// Use discord surface for text-based output tests
|
||||
const params = buildPolicyParams("/models anthropic 3 all", cfg, { Surface: "discord" });
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Models (anthropic");
|
||||
expect(result.reply?.text).toContain("page 1/1");
|
||||
expect(result.reply?.text).toContain("anthropic/claude-opus-4-5");
|
||||
expect(result.reply?.text).not.toContain("Page out of range");
|
||||
});
|
||||
|
||||
it("errors on out-of-range pages", async () => {
|
||||
// Use discord surface for text-based output tests
|
||||
const params = buildPolicyParams("/models anthropic 4", cfg, { Surface: "discord" });
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Page out of range");
|
||||
expect(result.reply?.text).toContain("valid: 1-");
|
||||
});
|
||||
|
||||
it("handles unknown providers", async () => {
|
||||
const params = buildPolicyParams("/models not-a-provider", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Unknown provider");
|
||||
expect(result.reply?.text).toContain("Available providers");
|
||||
for (const testCase of cases) {
|
||||
// Use discord surface for deterministic text-based output assertions.
|
||||
const result = await handleCommands(
|
||||
buildPolicyParams(testCase.command, cfg, {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
}),
|
||||
);
|
||||
expect(result.shouldContinue, testCase.name).toBe(false);
|
||||
for (const expected of testCase.includes) {
|
||||
expect(result.reply?.text, `${testCase.name}: ${expected}`).toContain(expected);
|
||||
}
|
||||
for (const blocked of testCase.excludes ?? []) {
|
||||
expect(result.reply?.text, `${testCase.name}: !${blocked}`).not.toContain(blocked);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("lists configured models outside the curated catalog", async () => {
|
||||
@@ -867,40 +828,33 @@ describe("handleCommands hooks", () => {
|
||||
});
|
||||
|
||||
describe("handleCommands context", () => {
|
||||
it("returns context help for /context", async () => {
|
||||
it("returns expected details for /context commands", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/context", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("/context list");
|
||||
expect(result.reply?.text).toContain("Inline shortcut");
|
||||
});
|
||||
|
||||
it("returns a per-file breakdown for /context list", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/context list", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Injected workspace files:");
|
||||
expect(result.reply?.text).toContain("AGENTS.md");
|
||||
});
|
||||
|
||||
it("returns a detailed breakdown for /context detail", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/context detail", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Context breakdown (detailed)");
|
||||
expect(result.reply?.text).toContain("Top tools (schema size):");
|
||||
const cases = [
|
||||
{
|
||||
commandBody: "/context",
|
||||
expectedText: ["/context list", "Inline shortcut"],
|
||||
},
|
||||
{
|
||||
commandBody: "/context list",
|
||||
expectedText: ["Injected workspace files:", "AGENTS.md"],
|
||||
},
|
||||
{
|
||||
commandBody: "/context detail",
|
||||
expectedText: ["Context breakdown (detailed)", "Top tools (schema size):"],
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const params = buildParams(testCase.commandBody, cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
for (const expectedText of testCase.expectedText) {
|
||||
expect(result.reply?.text).toContain(expectedText);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1039,30 +993,23 @@ describe("handleCommands subagents", () => {
|
||||
expect(result.reply?.text).not.toContain("Subagents:");
|
||||
});
|
||||
|
||||
it("returns help for unknown subagents action", async () => {
|
||||
it("returns help/usage for invalid or incomplete subagents commands", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockReset();
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/subagents foo", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("/subagents");
|
||||
});
|
||||
|
||||
it("returns usage for subagents info without target", async () => {
|
||||
resetSubagentRegistryForTests();
|
||||
callGatewayMock.mockReset();
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as OpenClawConfig;
|
||||
const params = buildParams("/subagents info", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("/subagents info");
|
||||
const cases = [
|
||||
{ commandBody: "/subagents foo", expectedText: "/subagents" },
|
||||
{ commandBody: "/subagents info", expectedText: "/subagents info" },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const params = buildParams(testCase.commandBody, cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain(testCase.expectedText);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes subagent count in /status when active", async () => {
|
||||
|
||||
@@ -13,34 +13,22 @@ import { createReplyDispatcher } from "./reply-dispatcher.js";
|
||||
import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js";
|
||||
|
||||
describe("normalizeInboundTextNewlines", () => {
|
||||
it("converts CRLF to LF", () => {
|
||||
expect(normalizeInboundTextNewlines("hello\r\nworld")).toBe("hello\nworld");
|
||||
});
|
||||
it("normalizes real newlines and preserves literal backslash-n sequences", () => {
|
||||
const cases = [
|
||||
{ input: "hello\r\nworld", expected: "hello\nworld" },
|
||||
{ input: "hello\rworld", expected: "hello\nworld" },
|
||||
{ input: "C:\\Work\\nxxx\\README.md", expected: "C:\\Work\\nxxx\\README.md" },
|
||||
{
|
||||
input: "Please read the file at C:\\Work\\nxxx\\README.md",
|
||||
expected: "Please read the file at C:\\Work\\nxxx\\README.md",
|
||||
},
|
||||
{ input: "C:\\new\\notes\\nested", expected: "C:\\new\\notes\\nested" },
|
||||
{ input: "Line 1\r\nC:\\Work\\nxxx", expected: "Line 1\nC:\\Work\\nxxx" },
|
||||
] as const;
|
||||
|
||||
it("converts CR to LF", () => {
|
||||
expect(normalizeInboundTextNewlines("hello\rworld")).toBe("hello\nworld");
|
||||
});
|
||||
|
||||
it("preserves literal backslash-n sequences in Windows paths", () => {
|
||||
const windowsPath = "C:\\Work\\nxxx\\README.md";
|
||||
expect(normalizeInboundTextNewlines(windowsPath)).toBe("C:\\Work\\nxxx\\README.md");
|
||||
});
|
||||
|
||||
it("preserves backslash-n in messages containing Windows paths", () => {
|
||||
const message = "Please read the file at C:\\Work\\nxxx\\README.md";
|
||||
expect(normalizeInboundTextNewlines(message)).toBe(
|
||||
"Please read the file at C:\\Work\\nxxx\\README.md",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves multiple backslash-n sequences", () => {
|
||||
const message = "C:\\new\\notes\\nested";
|
||||
expect(normalizeInboundTextNewlines(message)).toBe("C:\\new\\notes\\nested");
|
||||
});
|
||||
|
||||
it("still normalizes actual CRLF while preserving backslash-n", () => {
|
||||
const message = "Line 1\r\nC:\\Work\\nxxx";
|
||||
expect(normalizeInboundTextNewlines(message)).toBe("Line 1\nC:\\Work\\nxxx");
|
||||
for (const testCase of cases) {
|
||||
expect(normalizeInboundTextNewlines(testCase.input)).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,348 +193,356 @@ const getLineData = (result: ReturnType<typeof parseLineDirectives>) =>
|
||||
(result.channelData?.line as Record<string, unknown> | undefined) ?? {};
|
||||
|
||||
describe("hasLineDirectives", () => {
|
||||
it("detects quick_replies directive", () => {
|
||||
expect(hasLineDirectives("Here are options [[quick_replies: A, B, C]]")).toBe(true);
|
||||
});
|
||||
it("matches expected detection across directive patterns", () => {
|
||||
const cases: Array<{ text: string; expected: boolean }> = [
|
||||
{ text: "Here are options [[quick_replies: A, B, C]]", expected: true },
|
||||
{ text: "[[location: Place | Address | 35.6 | 139.7]]", expected: true },
|
||||
{ text: "[[confirm: Continue? | Yes | No]]", expected: true },
|
||||
{ text: "[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]", expected: true },
|
||||
{ text: "Just regular text", expected: false },
|
||||
{ text: "[[not_a_directive: something]]", expected: false },
|
||||
{ text: "[[media_player: Song | Artist | Speaker]]", expected: true },
|
||||
{ text: "[[event: Meeting | Jan 24 | 2pm]]", expected: true },
|
||||
{ text: "[[agenda: Today | Meeting:9am, Lunch:12pm]]", expected: true },
|
||||
{ text: "[[device: TV | Room]]", expected: true },
|
||||
{ text: "[[appletv_remote: Apple TV | Playing]]", expected: true },
|
||||
];
|
||||
|
||||
it("detects location directive", () => {
|
||||
expect(hasLineDirectives("[[location: Place | Address | 35.6 | 139.7]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects confirm directive", () => {
|
||||
expect(hasLineDirectives("[[confirm: Continue? | Yes | No]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects buttons directive", () => {
|
||||
expect(hasLineDirectives("[[buttons: Menu | Choose | Opt1:data1, Opt2:data2]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for regular text", () => {
|
||||
expect(hasLineDirectives("Just regular text")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for similar but invalid patterns", () => {
|
||||
expect(hasLineDirectives("[[not_a_directive: something]]")).toBe(false);
|
||||
});
|
||||
|
||||
it("detects media_player directive", () => {
|
||||
expect(hasLineDirectives("[[media_player: Song | Artist | Speaker]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects event directive", () => {
|
||||
expect(hasLineDirectives("[[event: Meeting | Jan 24 | 2pm]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects agenda directive", () => {
|
||||
expect(hasLineDirectives("[[agenda: Today | Meeting:9am, Lunch:12pm]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects device directive", () => {
|
||||
expect(hasLineDirectives("[[device: TV | Room]]")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects appletv_remote directive", () => {
|
||||
expect(hasLineDirectives("[[appletv_remote: Apple TV | Playing]]")).toBe(true);
|
||||
for (const testCase of cases) {
|
||||
expect(hasLineDirectives(testCase.text)).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLineDirectives", () => {
|
||||
describe("quick_replies", () => {
|
||||
it("parses quick_replies and removes from text", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]",
|
||||
});
|
||||
it("parses quick replies variants", () => {
|
||||
const cases: Array<{
|
||||
text: string;
|
||||
channelData?: { line: { quickReplies: string[] } };
|
||||
quickReplies: string[];
|
||||
outputText?: string;
|
||||
}> = [
|
||||
{
|
||||
text: "Choose one:\n[[quick_replies: Option A, Option B, Option C]]",
|
||||
quickReplies: ["Option A", "Option B", "Option C"],
|
||||
outputText: "Choose one:",
|
||||
},
|
||||
{
|
||||
text: "Before [[quick_replies: A, B]] After",
|
||||
quickReplies: ["A", "B"],
|
||||
outputText: "Before After",
|
||||
},
|
||||
{
|
||||
text: "Text [[quick_replies: C, D]]",
|
||||
channelData: { line: { quickReplies: ["A", "B"] } },
|
||||
quickReplies: ["A", "B", "C", "D"],
|
||||
outputText: "Text",
|
||||
},
|
||||
];
|
||||
|
||||
expect(getLineData(result).quickReplies).toEqual(["Option A", "Option B", "Option C"]);
|
||||
expect(result.text).toBe("Choose one:");
|
||||
});
|
||||
|
||||
it("handles quick_replies in middle of text", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Before [[quick_replies: A, B]] After",
|
||||
});
|
||||
|
||||
expect(getLineData(result).quickReplies).toEqual(["A", "B"]);
|
||||
expect(result.text).toBe("Before After");
|
||||
});
|
||||
|
||||
it("merges with existing quickReplies", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Text [[quick_replies: C, D]]",
|
||||
channelData: { line: { quickReplies: ["A", "B"] } },
|
||||
});
|
||||
|
||||
expect(getLineData(result).quickReplies).toEqual(["A", "B", "C", "D"]);
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({
|
||||
text: testCase.text,
|
||||
channelData: testCase.channelData,
|
||||
});
|
||||
expect(getLineData(result).quickReplies).toEqual(testCase.quickReplies);
|
||||
if (testCase.outputText !== undefined) {
|
||||
expect(result.text).toBe(testCase.outputText);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("location", () => {
|
||||
it("parses location with all fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]",
|
||||
});
|
||||
|
||||
expect(getLineData(result).location).toEqual({
|
||||
title: "Tokyo Station",
|
||||
address: "Tokyo, Japan",
|
||||
latitude: 35.6812,
|
||||
longitude: 139.7671,
|
||||
});
|
||||
expect(result.text).toBe("Here's the location:");
|
||||
});
|
||||
|
||||
it("ignores invalid coordinates", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[location: Place | Address | invalid | 139.7]]",
|
||||
});
|
||||
|
||||
expect(getLineData(result).location).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not override existing location", () => {
|
||||
it("parses location variants", () => {
|
||||
const existing = { title: "Existing", address: "Addr", latitude: 1, longitude: 2 };
|
||||
const result = parseLineDirectives({
|
||||
text: "[[location: New | New Addr | 35.6 | 139.7]]",
|
||||
channelData: { line: { location: existing } },
|
||||
});
|
||||
const cases: Array<{
|
||||
text: string;
|
||||
channelData?: { line: { location: typeof existing } };
|
||||
location?: typeof existing;
|
||||
outputText?: string;
|
||||
}> = [
|
||||
{
|
||||
text: "Here's the location:\n[[location: Tokyo Station | Tokyo, Japan | 35.6812 | 139.7671]]",
|
||||
location: {
|
||||
title: "Tokyo Station",
|
||||
address: "Tokyo, Japan",
|
||||
latitude: 35.6812,
|
||||
longitude: 139.7671,
|
||||
},
|
||||
outputText: "Here's the location:",
|
||||
},
|
||||
{
|
||||
text: "[[location: Place | Address | invalid | 139.7]]",
|
||||
location: undefined,
|
||||
},
|
||||
{
|
||||
text: "[[location: New | New Addr | 35.6 | 139.7]]",
|
||||
channelData: { line: { location: existing } },
|
||||
location: existing,
|
||||
},
|
||||
];
|
||||
|
||||
expect(getLineData(result).location).toEqual(existing);
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({
|
||||
text: testCase.text,
|
||||
channelData: testCase.channelData,
|
||||
});
|
||||
expect(getLineData(result).location).toEqual(testCase.location);
|
||||
if (testCase.outputText !== undefined) {
|
||||
expect(result.text).toBe(testCase.outputText);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirm", () => {
|
||||
it("parses simple confirm", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[confirm: Delete this item? | Yes | No]]",
|
||||
});
|
||||
it("parses confirm directives with default and custom action payloads", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "default yes/no data",
|
||||
text: "[[confirm: Delete this item? | Yes | No]]",
|
||||
expectedTemplate: {
|
||||
type: "confirm",
|
||||
text: "Delete this item?",
|
||||
confirmLabel: "Yes",
|
||||
confirmData: "yes",
|
||||
cancelLabel: "No",
|
||||
cancelData: "no",
|
||||
altText: "Delete this item?",
|
||||
},
|
||||
expectedText: undefined,
|
||||
},
|
||||
{
|
||||
name: "custom action data",
|
||||
text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]",
|
||||
expectedTemplate: {
|
||||
type: "confirm",
|
||||
text: "Proceed?",
|
||||
confirmLabel: "OK",
|
||||
confirmData: "action=confirm",
|
||||
cancelLabel: "Cancel",
|
||||
cancelData: "action=cancel",
|
||||
altText: "Proceed?",
|
||||
},
|
||||
expectedText: undefined,
|
||||
},
|
||||
] as const;
|
||||
|
||||
expect(getLineData(result).templateMessage).toEqual({
|
||||
type: "confirm",
|
||||
text: "Delete this item?",
|
||||
confirmLabel: "Yes",
|
||||
confirmData: "yes",
|
||||
cancelLabel: "No",
|
||||
cancelData: "no",
|
||||
altText: "Delete this item?",
|
||||
});
|
||||
// Text is undefined when directive consumes entire text
|
||||
expect(result.text).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses confirm with custom data", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[confirm: Proceed? | OK:action=confirm | Cancel:action=cancel]]",
|
||||
});
|
||||
|
||||
expect(getLineData(result).templateMessage).toEqual({
|
||||
type: "confirm",
|
||||
text: "Proceed?",
|
||||
confirmLabel: "OK",
|
||||
confirmData: "action=confirm",
|
||||
cancelLabel: "Cancel",
|
||||
cancelData: "action=cancel",
|
||||
altText: "Proceed?",
|
||||
});
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
expect(getLineData(result).templateMessage, testCase.name).toEqual(
|
||||
testCase.expectedTemplate,
|
||||
);
|
||||
expect(result.text, testCase.name).toBe(testCase.expectedText);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buttons", () => {
|
||||
it("parses buttons with message actions", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]",
|
||||
});
|
||||
it("parses message/uri/postback button actions and enforces action caps", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "message actions",
|
||||
text: "[[buttons: Menu | Select an option | Help:/help, Status:/status]]",
|
||||
expectedTemplate: {
|
||||
type: "buttons",
|
||||
title: "Menu",
|
||||
text: "Select an option",
|
||||
actions: [
|
||||
{ type: "message", label: "Help", data: "/help" },
|
||||
{ type: "message", label: "Status", data: "/status" },
|
||||
],
|
||||
altText: "Menu: Select an option",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "uri action",
|
||||
text: "[[buttons: Links | Visit us | Site:https://example.com]]",
|
||||
expectedFirstAction: {
|
||||
type: "uri",
|
||||
label: "Site",
|
||||
uri: "https://example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "postback action",
|
||||
text: "[[buttons: Actions | Choose | Select:action=select&id=1]]",
|
||||
expectedFirstAction: {
|
||||
type: "postback",
|
||||
label: "Select",
|
||||
data: "action=select&id=1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "action cap",
|
||||
text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]",
|
||||
expectedActionCount: 4,
|
||||
},
|
||||
] as const;
|
||||
|
||||
expect(getLineData(result).templateMessage).toEqual({
|
||||
type: "buttons",
|
||||
title: "Menu",
|
||||
text: "Select an option",
|
||||
actions: [
|
||||
{ type: "message", label: "Help", data: "/help" },
|
||||
{ type: "message", label: "Status", data: "/status" },
|
||||
],
|
||||
altText: "Menu: Select an option",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses buttons with uri actions", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[buttons: Links | Visit us | Site:https://example.com]]",
|
||||
});
|
||||
|
||||
const templateMessage = getLineData(result).templateMessage as {
|
||||
type?: string;
|
||||
actions?: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(templateMessage?.type).toBe("buttons");
|
||||
if (templateMessage?.type === "buttons") {
|
||||
expect(templateMessage.actions?.[0]).toEqual({
|
||||
type: "uri",
|
||||
label: "Site",
|
||||
uri: "https://example.com",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("parses buttons with postback actions", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[buttons: Actions | Choose | Select:action=select&id=1]]",
|
||||
});
|
||||
|
||||
const templateMessage = getLineData(result).templateMessage as {
|
||||
type?: string;
|
||||
actions?: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(templateMessage?.type).toBe("buttons");
|
||||
if (templateMessage?.type === "buttons") {
|
||||
expect(templateMessage.actions?.[0]).toEqual({
|
||||
type: "postback",
|
||||
label: "Select",
|
||||
data: "action=select&id=1",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("limits to 4 actions", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[buttons: Menu | Text | A:a, B:b, C:c, D:d, E:e, F:f]]",
|
||||
});
|
||||
|
||||
const templateMessage = getLineData(result).templateMessage as {
|
||||
type?: string;
|
||||
actions?: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(templateMessage?.type).toBe("buttons");
|
||||
if (templateMessage?.type === "buttons") {
|
||||
expect(templateMessage.actions?.length).toBe(4);
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const templateMessage = getLineData(result).templateMessage as {
|
||||
type?: string;
|
||||
actions?: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(templateMessage?.type, testCase.name).toBe("buttons");
|
||||
if ("expectedTemplate" in testCase) {
|
||||
expect(templateMessage, testCase.name).toEqual(testCase.expectedTemplate);
|
||||
}
|
||||
if ("expectedFirstAction" in testCase) {
|
||||
expect(templateMessage?.actions?.[0], testCase.name).toEqual(
|
||||
testCase.expectedFirstAction,
|
||||
);
|
||||
}
|
||||
if ("expectedActionCount" in testCase) {
|
||||
expect(templateMessage?.actions?.length, testCase.name).toBe(
|
||||
testCase.expectedActionCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("media_player", () => {
|
||||
it("parses media_player with all fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]",
|
||||
});
|
||||
it("parses media_player directives across full/minimal/paused variants", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "all fields",
|
||||
text: "Now playing:\n[[media_player: Bohemian Rhapsody | Queen | Speaker | https://example.com/album.jpg | playing]]",
|
||||
expectedAltText: "🎵 Bohemian Rhapsody - Queen",
|
||||
expectedText: "Now playing:",
|
||||
expectFooter: true,
|
||||
},
|
||||
{
|
||||
name: "minimal",
|
||||
text: "[[media_player: Unknown Track]]",
|
||||
expectedAltText: "🎵 Unknown Track",
|
||||
expectedText: undefined,
|
||||
expectFooter: false,
|
||||
},
|
||||
{
|
||||
name: "paused status",
|
||||
text: "[[media_player: Song | Artist | Player | | paused]]",
|
||||
expectedAltText: undefined,
|
||||
expectedText: undefined,
|
||||
expectFooter: false,
|
||||
expectBodyContents: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as {
|
||||
altText?: string;
|
||||
contents?: { footer?: { contents?: unknown[] } };
|
||||
};
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("🎵 Bohemian Rhapsody - Queen");
|
||||
const contents = flexMessage?.contents as { footer?: { contents?: unknown[] } };
|
||||
expect(contents.footer?.contents?.length).toBeGreaterThan(0);
|
||||
expect(result.text).toBe("Now playing:");
|
||||
});
|
||||
|
||||
it("parses media_player with minimal fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[media_player: Unknown Track]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("🎵 Unknown Track");
|
||||
});
|
||||
|
||||
it("handles paused status", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[media_player: Song | Artist | Player | | paused]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as {
|
||||
contents?: { body: { contents: unknown[] } };
|
||||
};
|
||||
expect(flexMessage).toBeDefined();
|
||||
const contents = flexMessage?.contents as { body: { contents: unknown[] } };
|
||||
expect(contents).toBeDefined();
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = getLineData(result).flexMessage as {
|
||||
altText?: string;
|
||||
contents?: { footer?: { contents?: unknown[] }; body?: { contents?: unknown[] } };
|
||||
};
|
||||
expect(flexMessage, testCase.name).toBeDefined();
|
||||
if (testCase.expectedAltText !== undefined) {
|
||||
expect(flexMessage?.altText, testCase.name).toBe(testCase.expectedAltText);
|
||||
}
|
||||
if (testCase.expectedText !== undefined) {
|
||||
expect(result.text, testCase.name).toBe(testCase.expectedText);
|
||||
}
|
||||
if (testCase.expectFooter) {
|
||||
expect(flexMessage?.contents?.footer?.contents?.length, testCase.name).toBeGreaterThan(0);
|
||||
}
|
||||
if (testCase.expectBodyContents) {
|
||||
expect(flexMessage?.contents?.body?.contents, testCase.name).toBeDefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("event", () => {
|
||||
it("parses event with all fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]",
|
||||
});
|
||||
it("parses event variants", () => {
|
||||
const cases = [
|
||||
{
|
||||
text: "[[event: Team Meeting | January 24, 2026 | 2:00 PM - 3:00 PM | Conference Room A | Discuss Q1 roadmap]]",
|
||||
altText: "📅 Team Meeting - January 24, 2026 2:00 PM - 3:00 PM",
|
||||
},
|
||||
{
|
||||
text: "[[event: Birthday Party | March 15]]",
|
||||
altText: "📅 Birthday Party - March 15",
|
||||
},
|
||||
];
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("📅 Team Meeting - January 24, 2026 2:00 PM - 3:00 PM");
|
||||
});
|
||||
|
||||
it("parses event with minimal fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[event: Birthday Party | March 15]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("📅 Birthday Party - March 15");
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe(testCase.altText);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("agenda", () => {
|
||||
it("parses agenda with multiple events", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]",
|
||||
});
|
||||
it("parses agenda variants", () => {
|
||||
const cases = [
|
||||
{
|
||||
text: "[[agenda: Today's Schedule | Team Meeting:9:00 AM, Lunch:12:00 PM, Review:3:00 PM]]",
|
||||
altText: "📋 Today's Schedule (3 events)",
|
||||
},
|
||||
{
|
||||
text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]",
|
||||
altText: "📋 Tasks (3 events)",
|
||||
},
|
||||
];
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("📋 Today's Schedule (3 events)");
|
||||
});
|
||||
|
||||
it("parses agenda with events without times", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[agenda: Tasks | Buy groceries, Call mom, Workout]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("📋 Tasks (3 events)");
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe(testCase.altText);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("device", () => {
|
||||
it("parses device with controls", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]",
|
||||
});
|
||||
it("parses device variants", () => {
|
||||
const cases = [
|
||||
{
|
||||
text: "[[device: TV | Streaming Box | Playing | Play/Pause:toggle, Menu:menu]]",
|
||||
altText: "📱 TV: Playing",
|
||||
},
|
||||
{
|
||||
text: "[[device: Speaker]]",
|
||||
altText: "📱 Speaker",
|
||||
},
|
||||
];
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("📱 TV: Playing");
|
||||
});
|
||||
|
||||
it("parses device with minimal fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[device: Speaker]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe("📱 Speaker");
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toBe(testCase.altText);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("appletv_remote", () => {
|
||||
it("parses appletv_remote with status", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[appletv_remote: Apple TV | Playing]]",
|
||||
});
|
||||
it("parses appletv remote variants", () => {
|
||||
const cases = [
|
||||
{
|
||||
text: "[[appletv_remote: Apple TV | Playing]]",
|
||||
contains: "Apple TV",
|
||||
},
|
||||
{
|
||||
text: "[[appletv_remote: Apple TV]]",
|
||||
contains: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
expect(flexMessage?.altText).toContain("Apple TV");
|
||||
});
|
||||
|
||||
it("parses appletv_remote with minimal fields", () => {
|
||||
const result = parseLineDirectives({
|
||||
text: "[[appletv_remote: Apple TV]]",
|
||||
});
|
||||
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
for (const testCase of cases) {
|
||||
const result = parseLineDirectives({ text: testCase.text });
|
||||
const flexMessage = getLineData(result).flexMessage as { altText?: string };
|
||||
expect(flexMessage).toBeDefined();
|
||||
if (testCase.contains) {
|
||||
expect(flexMessage?.altText).toContain(testCase.contains);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1205,34 +1201,15 @@ describe("createReplyDispatcher", () => {
|
||||
});
|
||||
|
||||
describe("resolveReplyToMode", () => {
|
||||
it("defaults to off for Telegram", () => {
|
||||
expect(resolveReplyToMode(emptyCfg, "telegram")).toBe("off");
|
||||
});
|
||||
|
||||
it("defaults to off for Discord and Slack", () => {
|
||||
expect(resolveReplyToMode(emptyCfg, "discord")).toBe("off");
|
||||
expect(resolveReplyToMode(emptyCfg, "slack")).toBe("off");
|
||||
});
|
||||
|
||||
it("defaults to all when channel is unknown", () => {
|
||||
expect(resolveReplyToMode(emptyCfg, undefined)).toBe("all");
|
||||
});
|
||||
|
||||
it("uses configured value when present", () => {
|
||||
const cfg = {
|
||||
it("resolves defaults, channel overrides, chat-type overrides, and legacy dm overrides", () => {
|
||||
const configuredCfg = {
|
||||
channels: {
|
||||
telegram: { replyToMode: "all" },
|
||||
discord: { replyToMode: "first" },
|
||||
slack: { replyToMode: "all" },
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
expect(resolveReplyToMode(cfg, "telegram")).toBe("all");
|
||||
expect(resolveReplyToMode(cfg, "discord")).toBe("first");
|
||||
expect(resolveReplyToMode(cfg, "slack")).toBe("all");
|
||||
});
|
||||
|
||||
it("uses chat-type replyToMode overrides for Slack when configured", () => {
|
||||
const cfg = {
|
||||
const chatTypeCfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
@@ -1240,26 +1217,14 @@ describe("resolveReplyToMode", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "group")).toBe("first");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off");
|
||||
});
|
||||
|
||||
it("falls back to top-level replyToMode when no chat-type override is set", () => {
|
||||
const cfg = {
|
||||
const topLevelFallbackCfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "first",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first");
|
||||
});
|
||||
|
||||
it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => {
|
||||
const cfg = {
|
||||
const legacyDmCfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
@@ -1267,25 +1232,63 @@ describe("resolveReplyToMode", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
|
||||
|
||||
const cases: Array<{
|
||||
cfg: OpenClawConfig;
|
||||
channel?: "telegram" | "discord" | "slack";
|
||||
chatType?: "direct" | "group" | "channel";
|
||||
expected: "off" | "all" | "first";
|
||||
}> = [
|
||||
{ cfg: emptyCfg, channel: "telegram", expected: "off" },
|
||||
{ cfg: emptyCfg, channel: "discord", expected: "off" },
|
||||
{ cfg: emptyCfg, channel: "slack", expected: "off" },
|
||||
{ cfg: emptyCfg, channel: undefined, expected: "all" },
|
||||
{ cfg: configuredCfg, channel: "telegram", expected: "all" },
|
||||
{ cfg: configuredCfg, channel: "discord", expected: "first" },
|
||||
{ cfg: configuredCfg, channel: "slack", expected: "all" },
|
||||
{ cfg: chatTypeCfg, channel: "slack", chatType: "direct", expected: "all" },
|
||||
{ cfg: chatTypeCfg, channel: "slack", chatType: "group", expected: "first" },
|
||||
{ cfg: chatTypeCfg, channel: "slack", chatType: "channel", expected: "off" },
|
||||
{ cfg: chatTypeCfg, channel: "slack", chatType: undefined, expected: "off" },
|
||||
{ cfg: topLevelFallbackCfg, channel: "slack", chatType: "direct", expected: "first" },
|
||||
{ cfg: topLevelFallbackCfg, channel: "slack", chatType: "channel", expected: "first" },
|
||||
{ cfg: legacyDmCfg, channel: "slack", chatType: "direct", expected: "all" },
|
||||
{ cfg: legacyDmCfg, channel: "slack", chatType: "channel", expected: "off" },
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
expect(resolveReplyToMode(testCase.cfg, testCase.channel, null, testCase.chatType)).toBe(
|
||||
testCase.expected,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createReplyToModeFilter", () => {
|
||||
it("drops replyToId when mode is off", () => {
|
||||
const filter = createReplyToModeFilter("off");
|
||||
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps replyToId when mode is off and reply tags are allowed", () => {
|
||||
const filter = createReplyToModeFilter("off", { allowExplicitReplyTagsWhenOff: true });
|
||||
expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1");
|
||||
});
|
||||
|
||||
it("keeps replyToId when mode is all", () => {
|
||||
const filter = createReplyToModeFilter("all");
|
||||
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1");
|
||||
it("handles off/all mode behavior for replyToId", () => {
|
||||
const cases: Array<{
|
||||
filter: ReturnType<typeof createReplyToModeFilter>;
|
||||
input: { text: string; replyToId?: string; replyToTag?: boolean };
|
||||
expectedReplyToId?: string;
|
||||
}> = [
|
||||
{
|
||||
filter: createReplyToModeFilter("off"),
|
||||
input: { text: "hi", replyToId: "1" },
|
||||
expectedReplyToId: undefined,
|
||||
},
|
||||
{
|
||||
filter: createReplyToModeFilter("off", { allowExplicitReplyTagsWhenOff: true }),
|
||||
input: { text: "hi", replyToId: "1", replyToTag: true },
|
||||
expectedReplyToId: "1",
|
||||
},
|
||||
{
|
||||
filter: createReplyToModeFilter("all"),
|
||||
input: { text: "hi", replyToId: "1" },
|
||||
expectedReplyToId: "1",
|
||||
},
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
expect(testCase.filter(testCase.input).replyToId).toBe(testCase.expectedReplyToId);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps only the first replyToId when mode is first", () => {
|
||||
|
||||
@@ -18,56 +18,61 @@ import { createTypingController } from "./typing.js";
|
||||
describe("matchesMentionWithExplicit", () => {
|
||||
const mentionRegexes = [/\bopenclaw\b/i];
|
||||
|
||||
it("checks mentionPatterns even when explicit mention is available", () => {
|
||||
const result = matchesMentionWithExplicit({
|
||||
text: "@openclaw hello",
|
||||
mentionRegexes,
|
||||
explicit: {
|
||||
hasAnyMention: true,
|
||||
isExplicitlyMentioned: false,
|
||||
canResolveExplicit: true,
|
||||
it("combines explicit-mention state with regex fallback rules", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "regex match with explicit resolver available",
|
||||
text: "@openclaw hello",
|
||||
mentionRegexes,
|
||||
explicit: {
|
||||
hasAnyMention: true,
|
||||
isExplicitlyMentioned: false,
|
||||
canResolveExplicit: true,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when explicit is false and no regex match", () => {
|
||||
const result = matchesMentionWithExplicit({
|
||||
text: "<@999999> hello",
|
||||
mentionRegexes,
|
||||
explicit: {
|
||||
hasAnyMention: true,
|
||||
isExplicitlyMentioned: false,
|
||||
canResolveExplicit: true,
|
||||
{
|
||||
name: "no explicit and no regex match",
|
||||
text: "<@999999> hello",
|
||||
mentionRegexes,
|
||||
explicit: {
|
||||
hasAnyMention: true,
|
||||
isExplicitlyMentioned: false,
|
||||
canResolveExplicit: true,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when explicitly mentioned even if regexes do not match", () => {
|
||||
const result = matchesMentionWithExplicit({
|
||||
text: "<@123456>",
|
||||
mentionRegexes: [],
|
||||
explicit: {
|
||||
hasAnyMention: true,
|
||||
isExplicitlyMentioned: true,
|
||||
canResolveExplicit: true,
|
||||
{
|
||||
name: "explicit mention even without regex",
|
||||
text: "<@123456>",
|
||||
mentionRegexes: [],
|
||||
explicit: {
|
||||
hasAnyMention: true,
|
||||
isExplicitlyMentioned: true,
|
||||
canResolveExplicit: true,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to regex matching when explicit mention cannot be resolved", () => {
|
||||
const result = matchesMentionWithExplicit({
|
||||
text: "openclaw please",
|
||||
mentionRegexes,
|
||||
explicit: {
|
||||
hasAnyMention: true,
|
||||
isExplicitlyMentioned: false,
|
||||
canResolveExplicit: false,
|
||||
{
|
||||
name: "falls back to regex when explicit cannot resolve",
|
||||
text: "openclaw please",
|
||||
mentionRegexes,
|
||||
explicit: {
|
||||
hasAnyMention: true,
|
||||
isExplicitlyMentioned: false,
|
||||
canResolveExplicit: false,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const result = matchesMentionWithExplicit({
|
||||
text: testCase.text,
|
||||
mentionRegexes: [...testCase.mentionRegexes],
|
||||
explicit: testCase.explicit,
|
||||
});
|
||||
expect(result, testCase.name).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,30 +94,19 @@ describe("normalizeReplyPayload", () => {
|
||||
expect(normalized?.channelData).toEqual(payload.channelData);
|
||||
});
|
||||
|
||||
it("records silent skips", () => {
|
||||
const reasons: string[] = [];
|
||||
const normalized = normalizeReplyPayload(
|
||||
{ text: SILENT_REPLY_TOKEN },
|
||||
{
|
||||
it("records skip reasons for silent/empty payloads", () => {
|
||||
const cases = [
|
||||
{ name: "silent", payload: { text: SILENT_REPLY_TOKEN }, reason: "silent" },
|
||||
{ name: "empty", payload: { text: " " }, reason: "empty" },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const reasons: string[] = [];
|
||||
const normalized = normalizeReplyPayload(testCase.payload, {
|
||||
onSkip: (reason) => reasons.push(reason),
|
||||
},
|
||||
);
|
||||
|
||||
expect(normalized).toBeNull();
|
||||
expect(reasons).toEqual(["silent"]);
|
||||
});
|
||||
|
||||
it("records empty skips", () => {
|
||||
const reasons: string[] = [];
|
||||
const normalized = normalizeReplyPayload(
|
||||
{ text: " " },
|
||||
{
|
||||
onSkip: (reason) => reasons.push(reason),
|
||||
},
|
||||
);
|
||||
|
||||
expect(normalized).toBeNull();
|
||||
expect(reasons).toEqual(["empty"]);
|
||||
});
|
||||
expect(normalized, testCase.name).toBeNull();
|
||||
expect(reasons, testCase.name).toEqual([testCase.reason]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,49 +115,43 @@ describe("typing controller", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("stops after run completion and dispatcher idle", async () => {
|
||||
it("stops only after both run completion and dispatcher idle are set (any order)", async () => {
|
||||
vi.useFakeTimers();
|
||||
const onReplyStart = vi.fn(async () => {});
|
||||
const typing = createTypingController({
|
||||
onReplyStart,
|
||||
typingIntervalSeconds: 1,
|
||||
typingTtlMs: 30_000,
|
||||
});
|
||||
const cases = [
|
||||
{ name: "run-complete first", first: "run", second: "idle" },
|
||||
{ name: "dispatch-idle first", first: "idle", second: "run" },
|
||||
] as const;
|
||||
|
||||
await typing.startTypingLoop();
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
||||
for (const testCase of cases) {
|
||||
const onReplyStart = vi.fn(async () => {});
|
||||
const typing = createTypingController({
|
||||
onReplyStart,
|
||||
typingIntervalSeconds: 1,
|
||||
typingTtlMs: 30_000,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(3);
|
||||
await typing.startTypingLoop();
|
||||
expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(1);
|
||||
|
||||
typing.markRunComplete();
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(4);
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(3);
|
||||
|
||||
typing.markDispatchIdle();
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
if (testCase.first === "run") {
|
||||
typing.markRunComplete();
|
||||
} else {
|
||||
typing.markDispatchIdle();
|
||||
}
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(5);
|
||||
|
||||
it("keeps typing until both idle and run completion are set", async () => {
|
||||
vi.useFakeTimers();
|
||||
const onReplyStart = vi.fn(async () => {});
|
||||
const typing = createTypingController({
|
||||
onReplyStart,
|
||||
typingIntervalSeconds: 1,
|
||||
typingTtlMs: 30_000,
|
||||
});
|
||||
|
||||
await typing.startTypingLoop();
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
typing.markDispatchIdle();
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(3);
|
||||
|
||||
typing.markRunComplete();
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(onReplyStart).toHaveBeenCalledTimes(3);
|
||||
if (testCase.second === "run") {
|
||||
typing.markRunComplete();
|
||||
} else {
|
||||
typing.markDispatchIdle();
|
||||
}
|
||||
vi.advanceTimersByTime(2_000);
|
||||
expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(5);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not start typing after run completion", async () => {
|
||||
@@ -207,99 +195,228 @@ describe("typing controller", () => {
|
||||
});
|
||||
|
||||
describe("resolveTypingMode", () => {
|
||||
it("defaults to instant for direct chats", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: undefined,
|
||||
isGroupChat: false,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("instant");
|
||||
it("resolves defaults, configured overrides, and heartbeat suppression", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "default direct chat",
|
||||
input: {
|
||||
configured: undefined,
|
||||
isGroupChat: false,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: false,
|
||||
},
|
||||
expected: "instant",
|
||||
},
|
||||
{
|
||||
name: "default group chat without mention",
|
||||
input: {
|
||||
configured: undefined,
|
||||
isGroupChat: true,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: false,
|
||||
},
|
||||
expected: "message",
|
||||
},
|
||||
{
|
||||
name: "default mentioned group chat",
|
||||
input: {
|
||||
configured: undefined,
|
||||
isGroupChat: true,
|
||||
wasMentioned: true,
|
||||
isHeartbeat: false,
|
||||
},
|
||||
expected: "instant",
|
||||
},
|
||||
{
|
||||
name: "configured thinking override",
|
||||
input: {
|
||||
configured: "thinking" as const,
|
||||
isGroupChat: false,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: false,
|
||||
},
|
||||
expected: "thinking",
|
||||
},
|
||||
{
|
||||
name: "configured message override",
|
||||
input: {
|
||||
configured: "message" as const,
|
||||
isGroupChat: true,
|
||||
wasMentioned: true,
|
||||
isHeartbeat: false,
|
||||
},
|
||||
expected: "message",
|
||||
},
|
||||
{
|
||||
name: "heartbeat forces never",
|
||||
input: {
|
||||
configured: "instant" as const,
|
||||
isGroupChat: false,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: true,
|
||||
},
|
||||
expected: "never",
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
expect(resolveTypingMode(testCase.input), testCase.name).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAudioTag", () => {
|
||||
it("extracts audio tag state and cleaned text", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "tag in sentence",
|
||||
input: "Hello [[audio_as_voice]] world",
|
||||
expected: { audioAsVoice: true, hadTag: true, text: "Hello world" },
|
||||
},
|
||||
{
|
||||
name: "missing text",
|
||||
input: undefined,
|
||||
expected: { audioAsVoice: false, hadTag: false, text: "" },
|
||||
},
|
||||
{
|
||||
name: "tag-only content",
|
||||
input: "[[audio_as_voice]]",
|
||||
expected: { audioAsVoice: true, hadTag: true, text: "" },
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const result = parseAudioTag(testCase.input);
|
||||
expect(result.audioAsVoice, testCase.name).toBe(testCase.expected.audioAsVoice);
|
||||
expect(result.hadTag, testCase.name).toBe(testCase.expected.hadTag);
|
||||
expect(result.text, testCase.name).toBe(testCase.expected.text);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveResponsePrefixTemplate", () => {
|
||||
it("resolves known variables, aliases, and case-insensitive tokens", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "model",
|
||||
template: "[{model}]",
|
||||
values: { model: "gpt-5.2" },
|
||||
expected: "[gpt-5.2]",
|
||||
},
|
||||
{
|
||||
name: "modelFull",
|
||||
template: "[{modelFull}]",
|
||||
values: { modelFull: "openai-codex/gpt-5.2" },
|
||||
expected: "[openai-codex/gpt-5.2]",
|
||||
},
|
||||
{
|
||||
name: "provider",
|
||||
template: "[{provider}]",
|
||||
values: { provider: "anthropic" },
|
||||
expected: "[anthropic]",
|
||||
},
|
||||
{
|
||||
name: "thinkingLevel",
|
||||
template: "think:{thinkingLevel}",
|
||||
values: { thinkingLevel: "high" },
|
||||
expected: "think:high",
|
||||
},
|
||||
{
|
||||
name: "think alias",
|
||||
template: "think:{think}",
|
||||
values: { thinkingLevel: "low" },
|
||||
expected: "think:low",
|
||||
},
|
||||
{
|
||||
name: "identity.name",
|
||||
template: "[{identity.name}]",
|
||||
values: { identityName: "OpenClaw" },
|
||||
expected: "[OpenClaw]",
|
||||
},
|
||||
{
|
||||
name: "identityName alias",
|
||||
template: "[{identityName}]",
|
||||
values: { identityName: "OpenClaw" },
|
||||
expected: "[OpenClaw]",
|
||||
},
|
||||
{
|
||||
name: "case-insensitive variables",
|
||||
template: "[{MODEL} | {ThinkingLevel}]",
|
||||
values: { model: "gpt-5.2", thinkingLevel: "low" },
|
||||
expected: "[gpt-5.2 | low]",
|
||||
},
|
||||
{
|
||||
name: "all variables",
|
||||
template: "[{identity.name}] {provider}/{model} (think:{thinkingLevel})",
|
||||
values: {
|
||||
identityName: "OpenClaw",
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
thinkingLevel: "high",
|
||||
},
|
||||
expected: "[OpenClaw] anthropic/claude-opus-4-5 (think:high)",
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(resolveResponsePrefixTemplate(testCase.template, testCase.values), testCase.name).toBe(
|
||||
testCase.expected,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults to message for group chats without mentions", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: undefined,
|
||||
isGroupChat: true,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("message");
|
||||
});
|
||||
|
||||
it("defaults to instant for mentioned group chats", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: undefined,
|
||||
isGroupChat: true,
|
||||
wasMentioned: true,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("instant");
|
||||
});
|
||||
|
||||
it("honors configured mode across contexts", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: "thinking",
|
||||
isGroupChat: false,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("thinking");
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: "message",
|
||||
isGroupChat: true,
|
||||
wasMentioned: true,
|
||||
isHeartbeat: false,
|
||||
}),
|
||||
).toBe("message");
|
||||
});
|
||||
|
||||
it("forces never for heartbeat runs", () => {
|
||||
expect(
|
||||
resolveTypingMode({
|
||||
configured: "instant",
|
||||
isGroupChat: false,
|
||||
wasMentioned: false,
|
||||
isHeartbeat: true,
|
||||
}),
|
||||
).toBe("never");
|
||||
it("preserves unresolved/unknown placeholders and handles static inputs", () => {
|
||||
const cases = [
|
||||
{ name: "undefined template", template: undefined, values: {}, expected: undefined },
|
||||
{ name: "no variables", template: "[Claude]", values: {}, expected: "[Claude]" },
|
||||
{
|
||||
name: "unresolved known variable",
|
||||
template: "[{model}]",
|
||||
values: {},
|
||||
expected: "[{model}]",
|
||||
},
|
||||
{
|
||||
name: "unrecognized variable",
|
||||
template: "[{unknownVar}]",
|
||||
values: { model: "gpt-5.2" },
|
||||
expected: "[{unknownVar}]",
|
||||
},
|
||||
{
|
||||
name: "mixed resolved/unresolved",
|
||||
template: "[{model} | {provider}]",
|
||||
values: { model: "gpt-5.2" },
|
||||
expected: "[gpt-5.2 | {provider}]",
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(resolveResponsePrefixTemplate(testCase.template, testCase.values), testCase.name).toBe(
|
||||
testCase.expected,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTypingSignaler", () => {
|
||||
it("signals immediately for instant mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "instant",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
it("gates run-start typing by mode", async () => {
|
||||
const cases = [
|
||||
{ name: "instant", mode: "instant" as const, expectedStartCalls: 1 },
|
||||
{ name: "message", mode: "message" as const, expectedStartCalls: 0 },
|
||||
{ name: "thinking", mode: "thinking" as const, expectedStartCalls: 0 },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: testCase.mode,
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalRunStart();
|
||||
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
await signaler.signalRunStart();
|
||||
expect(typing.startTypingLoop, testCase.name).toHaveBeenCalledTimes(
|
||||
testCase.expectedStartCalls,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("signals on text for message mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "message",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalTextDelta("hello");
|
||||
|
||||
expect(typing.startTypingOnText).toHaveBeenCalledWith("hello");
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("signals on message start for message mode", async () => {
|
||||
it("signals on message-mode boundaries and text deltas", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
@@ -312,9 +429,10 @@ describe("createTypingSignaler", () => {
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
await signaler.signalTextDelta("hello");
|
||||
expect(typing.startTypingOnText).toHaveBeenCalledWith("hello");
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("signals on reasoning for thinking mode", async () => {
|
||||
it("starts typing and refreshes ttl on text for thinking mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
@@ -326,24 +444,11 @@ describe("createTypingSignaler", () => {
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
await signaler.signalTextDelta("hi");
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes ttl on text for thinking mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "thinking",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalTextDelta("hi");
|
||||
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts typing on tool start before text", async () => {
|
||||
it("handles tool-start typing before and after active text mode", async () => {
|
||||
const typing = createMockTypingController();
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
@@ -356,21 +461,8 @@ describe("createTypingSignaler", () => {
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes ttl on tool start when active after text", async () => {
|
||||
const typing = createMockTypingController({
|
||||
isActive: vi.fn(() => true),
|
||||
});
|
||||
const signaler = createTypingSignaler({
|
||||
typing,
|
||||
mode: "message",
|
||||
isHeartbeat: false,
|
||||
});
|
||||
|
||||
await signaler.signalTextDelta("hello");
|
||||
(typing.isActive as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
(typing.startTypingLoop as ReturnType<typeof vi.fn>).mockClear();
|
||||
(typing.startTypingOnText as ReturnType<typeof vi.fn>).mockClear();
|
||||
(typing.refreshTypingTtl as ReturnType<typeof vi.fn>).mockClear();
|
||||
await signaler.signalToolStart();
|
||||
|
||||
@@ -395,28 +487,6 @@ describe("createTypingSignaler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAudioTag", () => {
|
||||
it("detects audio_as_voice and strips the tag", () => {
|
||||
const result = parseAudioTag("Hello [[audio_as_voice]] world");
|
||||
expect(result.audioAsVoice).toBe(true);
|
||||
expect(result.hadTag).toBe(true);
|
||||
expect(result.text).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("returns empty output for missing text", () => {
|
||||
const result = parseAudioTag(undefined);
|
||||
expect(result.audioAsVoice).toBe(false);
|
||||
expect(result.hadTag).toBe(false);
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
|
||||
it("removes tag-only messages", () => {
|
||||
const result = parseAudioTag("[[audio_as_voice]]");
|
||||
expect(result.audioAsVoice).toBe(true);
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("block reply coalescer", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -462,25 +532,6 @@ describe("block reply coalescer", () => {
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("flushes each enqueued payload separately when flushOnEnqueue is set", async () => {
|
||||
const flushes: string[] = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true },
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push(payload.text ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "First paragraph" });
|
||||
coalescer.enqueue({ text: "Second paragraph" });
|
||||
coalescer.enqueue({ text: "Third paragraph" });
|
||||
|
||||
await Promise.resolve();
|
||||
expect(flushes).toEqual(["First paragraph", "Second paragraph", "Third paragraph"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("still accumulates when flushOnEnqueue is not set (default)", async () => {
|
||||
vi.useFakeTimers();
|
||||
const flushes: string[] = [];
|
||||
@@ -500,41 +551,36 @@ describe("block reply coalescer", () => {
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("flushes short payloads immediately when flushOnEnqueue is set", async () => {
|
||||
const flushes: string[] = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: "\n\n", flushOnEnqueue: true },
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push(payload.text ?? "");
|
||||
it("flushes immediately per enqueue when flushOnEnqueue is set", async () => {
|
||||
const cases = [
|
||||
{
|
||||
config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: "\n\n", flushOnEnqueue: true },
|
||||
inputs: ["Hi"],
|
||||
expected: ["Hi"],
|
||||
},
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "Hi" });
|
||||
await Promise.resolve();
|
||||
expect(flushes).toEqual(["Hi"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("resets char budget per paragraph with flushOnEnqueue", async () => {
|
||||
const flushes: string[] = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: { minChars: 1, maxChars: 30, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true },
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push(payload.text ?? "");
|
||||
{
|
||||
config: { minChars: 1, maxChars: 30, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true },
|
||||
inputs: ["12345678901234567890", "abcdefghijklmnopqrst"],
|
||||
expected: ["12345678901234567890", "abcdefghijklmnopqrst"],
|
||||
},
|
||||
});
|
||||
] as const;
|
||||
|
||||
// Each 20-char payload fits within maxChars=30 individually
|
||||
coalescer.enqueue({ text: "12345678901234567890" });
|
||||
coalescer.enqueue({ text: "abcdefghijklmnopqrst" });
|
||||
|
||||
await Promise.resolve();
|
||||
// Without flushOnEnqueue, these would be joined to 40+ chars and trigger maxChars split.
|
||||
// With flushOnEnqueue, each is sent independently within budget.
|
||||
expect(flushes).toEqual(["12345678901234567890", "abcdefghijklmnopqrst"]);
|
||||
coalescer.stop();
|
||||
for (const testCase of cases) {
|
||||
const flushes: string[] = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: testCase.config,
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push(payload.text ?? "");
|
||||
},
|
||||
});
|
||||
for (const input of testCase.inputs) {
|
||||
coalescer.enqueue({ text: input });
|
||||
}
|
||||
await Promise.resolve();
|
||||
expect(flushes).toEqual(testCase.expected);
|
||||
coalescer.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("flushes buffered text before media payloads", () => {
|
||||
@@ -562,42 +608,36 @@ describe("block reply coalescer", () => {
|
||||
});
|
||||
|
||||
describe("createReplyReferencePlanner", () => {
|
||||
it("disables references when mode is off", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
it("plans references correctly for off/first/all modes", () => {
|
||||
const offPlanner = createReplyReferencePlanner({
|
||||
replyToMode: "off",
|
||||
startId: "parent",
|
||||
});
|
||||
expect(planner.use()).toBeUndefined();
|
||||
});
|
||||
expect(offPlanner.use()).toBeUndefined();
|
||||
|
||||
it("uses startId once when mode is first", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
const firstPlanner = createReplyReferencePlanner({
|
||||
replyToMode: "first",
|
||||
startId: "parent",
|
||||
});
|
||||
expect(planner.use()).toBe("parent");
|
||||
expect(planner.hasReplied()).toBe(true);
|
||||
planner.markSent();
|
||||
expect(planner.use()).toBeUndefined();
|
||||
});
|
||||
expect(firstPlanner.use()).toBe("parent");
|
||||
expect(firstPlanner.hasReplied()).toBe(true);
|
||||
firstPlanner.markSent();
|
||||
expect(firstPlanner.use()).toBeUndefined();
|
||||
|
||||
it("returns startId for every call when mode is all", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
const allPlanner = createReplyReferencePlanner({
|
||||
replyToMode: "all",
|
||||
startId: "parent",
|
||||
});
|
||||
expect(planner.use()).toBe("parent");
|
||||
expect(planner.use()).toBe("parent");
|
||||
});
|
||||
expect(allPlanner.use()).toBe("parent");
|
||||
expect(allPlanner.use()).toBe("parent");
|
||||
|
||||
it("uses existingId once when mode is first", () => {
|
||||
const planner = createReplyReferencePlanner({
|
||||
const existingIdPlanner = createReplyReferencePlanner({
|
||||
replyToMode: "first",
|
||||
existingId: "thread-1",
|
||||
startId: "parent",
|
||||
});
|
||||
expect(planner.use()).toBe("thread-1");
|
||||
expect(planner.use()).toBeUndefined();
|
||||
expect(existingIdPlanner.use()).toBe("thread-1");
|
||||
expect(existingIdPlanner.use()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("honors allowReference=false", () => {
|
||||
@@ -634,23 +674,13 @@ describe("createStreamingDirectiveAccumulator", () => {
|
||||
expect(result?.replyToCurrent).toBe(true);
|
||||
});
|
||||
|
||||
it("propagates explicit reply ids across chunks", () => {
|
||||
it("propagates explicit reply ids across current and subsequent chunks", () => {
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
|
||||
expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull();
|
||||
|
||||
const result = accumulator.consume("Hi");
|
||||
expect(result?.text).toBe("Hi");
|
||||
expect(result?.replyToId).toBe("abc-123");
|
||||
expect(result?.replyToTag).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps explicit reply ids sticky across subsequent renderable chunks", () => {
|
||||
const accumulator = createStreamingDirectiveAccumulator();
|
||||
|
||||
expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull();
|
||||
|
||||
const first = accumulator.consume("test 1");
|
||||
const first = accumulator.consume("Hi");
|
||||
expect(first?.text).toBe("Hi");
|
||||
expect(first?.replyToId).toBe("abc-123");
|
||||
expect(first?.replyToTag).toBe(true);
|
||||
|
||||
@@ -674,136 +704,26 @@ describe("createStreamingDirectiveAccumulator", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveResponsePrefixTemplate", () => {
|
||||
it("returns undefined for undefined template", () => {
|
||||
expect(resolveResponsePrefixTemplate(undefined, {})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns template as-is when no variables present", () => {
|
||||
expect(resolveResponsePrefixTemplate("[Claude]", {})).toBe("[Claude]");
|
||||
});
|
||||
|
||||
it("resolves {model} variable", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{model}]", {
|
||||
model: "gpt-5.2",
|
||||
});
|
||||
expect(result).toBe("[gpt-5.2]");
|
||||
});
|
||||
|
||||
it("resolves {modelFull} variable", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{modelFull}]", {
|
||||
modelFull: "openai-codex/gpt-5.2",
|
||||
});
|
||||
expect(result).toBe("[openai-codex/gpt-5.2]");
|
||||
});
|
||||
|
||||
it("resolves {provider} variable", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{provider}]", {
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(result).toBe("[anthropic]");
|
||||
});
|
||||
|
||||
it("resolves {thinkingLevel} variable", () => {
|
||||
const result = resolveResponsePrefixTemplate("think:{thinkingLevel}", {
|
||||
thinkingLevel: "high",
|
||||
});
|
||||
expect(result).toBe("think:high");
|
||||
});
|
||||
|
||||
it("resolves {think} as alias for thinkingLevel", () => {
|
||||
const result = resolveResponsePrefixTemplate("think:{think}", {
|
||||
thinkingLevel: "low",
|
||||
});
|
||||
expect(result).toBe("think:low");
|
||||
});
|
||||
|
||||
it("resolves {identity.name} variable", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{identity.name}]", {
|
||||
identityName: "OpenClaw",
|
||||
});
|
||||
expect(result).toBe("[OpenClaw]");
|
||||
});
|
||||
|
||||
it("resolves {identityName} as alias", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{identityName}]", {
|
||||
identityName: "OpenClaw",
|
||||
});
|
||||
expect(result).toBe("[OpenClaw]");
|
||||
});
|
||||
|
||||
it("leaves unresolved variables as-is", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{model}]", {});
|
||||
expect(result).toBe("[{model}]");
|
||||
});
|
||||
|
||||
it("leaves unrecognized variables as-is", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{unknownVar}]", {
|
||||
model: "gpt-5.2",
|
||||
});
|
||||
expect(result).toBe("[{unknownVar}]");
|
||||
});
|
||||
|
||||
it("handles case insensitivity", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{MODEL} | {ThinkingLevel}]", {
|
||||
model: "gpt-5.2",
|
||||
thinkingLevel: "low",
|
||||
});
|
||||
expect(result).toBe("[gpt-5.2 | low]");
|
||||
});
|
||||
|
||||
it("handles mixed resolved and unresolved variables", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{model} | {provider}]", {
|
||||
model: "gpt-5.2",
|
||||
// provider not provided
|
||||
});
|
||||
expect(result).toBe("[gpt-5.2 | {provider}]");
|
||||
});
|
||||
|
||||
it("handles complex template with all variables", () => {
|
||||
const result = resolveResponsePrefixTemplate(
|
||||
"[{identity.name}] {provider}/{model} (think:{thinkingLevel})",
|
||||
{
|
||||
identityName: "OpenClaw",
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
thinkingLevel: "high",
|
||||
},
|
||||
);
|
||||
expect(result).toBe("[OpenClaw] anthropic/claude-opus-4-5 (think:high)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractShortModelName", () => {
|
||||
it("strips provider prefix", () => {
|
||||
expect(extractShortModelName("openai-codex/gpt-5.2-codex")).toBe("gpt-5.2-codex");
|
||||
});
|
||||
|
||||
it("strips date suffix", () => {
|
||||
expect(extractShortModelName("claude-opus-4-5-20251101")).toBe("claude-opus-4-5");
|
||||
});
|
||||
|
||||
it("strips -latest suffix", () => {
|
||||
expect(extractShortModelName("gpt-5.2-latest")).toBe("gpt-5.2");
|
||||
});
|
||||
|
||||
it("preserves version numbers that look like dates but are not", () => {
|
||||
// Date suffix must be exactly 8 digits at the end
|
||||
expect(extractShortModelName("model-123456789")).toBe("model-123456789");
|
||||
it("normalizes provider/date/latest suffixes while preserving other IDs", () => {
|
||||
const cases = [
|
||||
["openai-codex/gpt-5.2-codex", "gpt-5.2-codex"],
|
||||
["claude-opus-4-5-20251101", "claude-opus-4-5"],
|
||||
["gpt-5.2-latest", "gpt-5.2"],
|
||||
// Date suffix must be exactly 8 digits at the end.
|
||||
["model-123456789", "model-123456789"],
|
||||
] as const;
|
||||
for (const [input, expected] of cases) {
|
||||
expect(extractShortModelName(input), input).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasTemplateVariables", () => {
|
||||
it("returns false for empty string", () => {
|
||||
it("handles empty, static, and repeated variable checks", () => {
|
||||
expect(hasTemplateVariables("")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles consecutive calls correctly (regex lastIndex reset)", () => {
|
||||
// First call
|
||||
expect(hasTemplateVariables("[{model}]")).toBe(true);
|
||||
// Second call should still work
|
||||
expect(hasTemplateVariables("[{model}]")).toBe(true);
|
||||
// Static string should return false
|
||||
expect(hasTemplateVariables("[Claude]")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -561,210 +561,102 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
it("Reset trigger /new works for authorized sender in WhatsApp group", async () => {
|
||||
const storePath = await createStorePath("openclaw-group-reset-");
|
||||
it("applies WhatsApp group reset authorization across sender variants", async () => {
|
||||
const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us";
|
||||
const existingSessionId = "existing-session-123";
|
||||
await seedSessionStore({
|
||||
storePath,
|
||||
sessionKey,
|
||||
sessionId: existingSessionId,
|
||||
});
|
||||
const cases = [
|
||||
{
|
||||
name: "authorized sender",
|
||||
storePrefix: "openclaw-group-reset-",
|
||||
allowFrom: ["+41796666864"],
|
||||
body: `[Chat messages since your last reply - for context]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Peschiño: /new\\n[from: Peschiño (+41796666864)]`,
|
||||
senderName: "Peschiño",
|
||||
senderE164: "+41796666864",
|
||||
senderId: "41796666864:0@s.whatsapp.net",
|
||||
expectedIsNewSession: true,
|
||||
},
|
||||
{
|
||||
name: "unauthorized sender",
|
||||
storePrefix: "openclaw-group-reset-unauth-",
|
||||
allowFrom: ["+41796666864"],
|
||||
body: `[Context]\\n[WhatsApp ...] OtherPerson: /new\\n[from: OtherPerson (+1555123456)]`,
|
||||
senderName: "OtherPerson",
|
||||
senderE164: "+1555123456",
|
||||
senderId: "1555123456:0@s.whatsapp.net",
|
||||
expectedIsNewSession: false,
|
||||
},
|
||||
{
|
||||
name: "raw body clean while body wrapped",
|
||||
storePrefix: "openclaw-group-rawbody-",
|
||||
allowFrom: ["*"],
|
||||
body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Jake: /new\n[from: Jake (+1222)]`,
|
||||
senderName: undefined,
|
||||
senderE164: "+1222",
|
||||
senderId: undefined,
|
||||
expectedIsNewSession: true,
|
||||
},
|
||||
{
|
||||
name: "LID sender with authorized E164",
|
||||
storePrefix: "openclaw-group-reset-lid-",
|
||||
allowFrom: ["+41796666864"],
|
||||
body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Owner: /new\n[from: Owner (+41796666864)]`,
|
||||
senderName: "Owner",
|
||||
senderE164: "+41796666864",
|
||||
senderId: "123@lid",
|
||||
expectedIsNewSession: true,
|
||||
},
|
||||
{
|
||||
name: "LID sender with unauthorized E164",
|
||||
storePrefix: "openclaw-group-reset-lid-unauth-",
|
||||
allowFrom: ["+41796666864"],
|
||||
body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Other: /new\n[from: Other (+1555123456)]`,
|
||||
senderName: "Other",
|
||||
senderE164: "+1555123456",
|
||||
senderId: "123@lid",
|
||||
expectedIsNewSession: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const cfg = makeCfg({
|
||||
storePath,
|
||||
allowFrom: ["+41796666864"],
|
||||
});
|
||||
for (const testCase of cases) {
|
||||
const storePath = await createStorePath(testCase.storePrefix);
|
||||
await seedSessionStore({
|
||||
storePath,
|
||||
sessionKey,
|
||||
sessionId: existingSessionId,
|
||||
});
|
||||
const cfg = makeCfg({
|
||||
storePath,
|
||||
allowFrom: testCase.allowFrom,
|
||||
});
|
||||
|
||||
const groupMessageCtx = {
|
||||
Body: `[Chat messages since your last reply - for context]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Peschiño: /new\\n[from: Peschiño (+41796666864)]`,
|
||||
RawBody: "/new",
|
||||
CommandBody: "/new",
|
||||
From: "120363406150318674@g.us",
|
||||
To: "+41779241027",
|
||||
ChatType: "group",
|
||||
SessionKey: sessionKey,
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
SenderName: "Peschiño",
|
||||
SenderE164: "+41796666864",
|
||||
SenderId: "41796666864:0@s.whatsapp.net",
|
||||
};
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: testCase.body,
|
||||
RawBody: "/new",
|
||||
CommandBody: "/new",
|
||||
From: "120363406150318674@g.us",
|
||||
To: "+41779241027",
|
||||
ChatType: "group",
|
||||
SessionKey: sessionKey,
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
SenderName: testCase.senderName,
|
||||
SenderE164: testCase.senderE164,
|
||||
SenderId: testCase.senderId,
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: groupMessageCtx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.triggerBodyNormalized).toBe("/new");
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.sessionId).not.toBe(existingSessionId);
|
||||
expect(result.bodyStripped).toBe("");
|
||||
});
|
||||
|
||||
it("Reset trigger /new blocked for unauthorized sender in existing session", async () => {
|
||||
const storePath = await createStorePath("openclaw-group-reset-unauth-");
|
||||
const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us";
|
||||
const existingSessionId = "existing-session-123";
|
||||
|
||||
await seedSessionStore({
|
||||
storePath,
|
||||
sessionKey,
|
||||
sessionId: existingSessionId,
|
||||
});
|
||||
|
||||
const cfg = makeCfg({
|
||||
storePath,
|
||||
allowFrom: ["+41796666864"],
|
||||
});
|
||||
|
||||
const groupMessageCtx = {
|
||||
Body: `[Context]\\n[WhatsApp ...] OtherPerson: /new\\n[from: OtherPerson (+1555123456)]`,
|
||||
RawBody: "/new",
|
||||
CommandBody: "/new",
|
||||
From: "120363406150318674@g.us",
|
||||
To: "+41779241027",
|
||||
ChatType: "group",
|
||||
SessionKey: sessionKey,
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
SenderName: "OtherPerson",
|
||||
SenderE164: "+1555123456",
|
||||
SenderId: "1555123456:0@s.whatsapp.net",
|
||||
};
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: groupMessageCtx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.triggerBodyNormalized).toBe("/new");
|
||||
expect(result.sessionId).toBe(existingSessionId);
|
||||
expect(result.isNewSession).toBe(false);
|
||||
});
|
||||
|
||||
it("Reset trigger works when RawBody is clean but Body has wrapped context", async () => {
|
||||
const storePath = await createStorePath("openclaw-group-rawbody-");
|
||||
const sessionKey = "agent:main:whatsapp:group:g1";
|
||||
const existingSessionId = "existing-session-123";
|
||||
await seedSessionStore({
|
||||
storePath,
|
||||
sessionKey,
|
||||
sessionId: existingSessionId,
|
||||
});
|
||||
|
||||
const cfg = makeCfg({
|
||||
storePath,
|
||||
allowFrom: ["*"],
|
||||
});
|
||||
|
||||
const groupMessageCtx = {
|
||||
Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Jake: /new\n[from: Jake (+1222)]`,
|
||||
RawBody: "/new",
|
||||
CommandBody: "/new",
|
||||
From: "120363406150318674@g.us",
|
||||
To: "+1111",
|
||||
ChatType: "group",
|
||||
SessionKey: sessionKey,
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
};
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: groupMessageCtx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.triggerBodyNormalized).toBe("/new");
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.sessionId).not.toBe(existingSessionId);
|
||||
expect(result.bodyStripped).toBe("");
|
||||
});
|
||||
|
||||
it("Reset trigger /new works when SenderId is LID but SenderE164 is authorized", async () => {
|
||||
const storePath = await createStorePath("openclaw-group-reset-lid-");
|
||||
const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us";
|
||||
const existingSessionId = "existing-session-123";
|
||||
await seedSessionStore({
|
||||
storePath,
|
||||
sessionKey,
|
||||
sessionId: existingSessionId,
|
||||
});
|
||||
|
||||
const cfg = makeCfg({
|
||||
storePath,
|
||||
allowFrom: ["+41796666864"],
|
||||
});
|
||||
|
||||
const groupMessageCtx = {
|
||||
Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Owner: /new\n[from: Owner (+41796666864)]`,
|
||||
RawBody: "/new",
|
||||
CommandBody: "/new",
|
||||
From: "120363406150318674@g.us",
|
||||
To: "+41779241027",
|
||||
ChatType: "group",
|
||||
SessionKey: sessionKey,
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
SenderName: "Owner",
|
||||
SenderE164: "+41796666864",
|
||||
SenderId: "123@lid",
|
||||
};
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: groupMessageCtx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.triggerBodyNormalized).toBe("/new");
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.sessionId).not.toBe(existingSessionId);
|
||||
expect(result.bodyStripped).toBe("");
|
||||
});
|
||||
|
||||
it("Reset trigger /new blocked when SenderId is LID but SenderE164 is unauthorized", async () => {
|
||||
const storePath = await createStorePath("openclaw-group-reset-lid-unauth-");
|
||||
const sessionKey = "agent:main:whatsapp:group:120363406150318674@g.us";
|
||||
const existingSessionId = "existing-session-123";
|
||||
await seedSessionStore({
|
||||
storePath,
|
||||
sessionKey,
|
||||
sessionId: existingSessionId,
|
||||
});
|
||||
|
||||
const cfg = makeCfg({
|
||||
storePath,
|
||||
allowFrom: ["+41796666864"],
|
||||
});
|
||||
|
||||
const groupMessageCtx = {
|
||||
Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Other: /new\n[from: Other (+1555123456)]`,
|
||||
RawBody: "/new",
|
||||
CommandBody: "/new",
|
||||
From: "120363406150318674@g.us",
|
||||
To: "+41779241027",
|
||||
ChatType: "group",
|
||||
SessionKey: sessionKey,
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
SenderName: "Other",
|
||||
SenderE164: "+1555123456",
|
||||
SenderId: "123@lid",
|
||||
};
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: groupMessageCtx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.triggerBodyNormalized).toBe("/new");
|
||||
expect(result.sessionId).toBe(existingSessionId);
|
||||
expect(result.isNewSession).toBe(false);
|
||||
expect(result.triggerBodyNormalized, testCase.name).toBe("/new");
|
||||
expect(result.isNewSession, testCase.name).toBe(testCase.expectedIsNewSession);
|
||||
if (testCase.expectedIsNewSession) {
|
||||
expect(result.sessionId, testCase.name).not.toBe(existingSessionId);
|
||||
expect(result.bodyStripped, testCase.name).toBe("");
|
||||
} else {
|
||||
expect(result.sessionId, testCase.name).toBe(existingSessionId);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -782,84 +674,59 @@ describe("initSessionState reset triggers in Slack channels", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it("Reset trigger /reset works when Slack message has a leading <@...> mention token", async () => {
|
||||
const storePath = await createStorePath("openclaw-slack-channel-reset-");
|
||||
const sessionKey = "agent:main:slack:channel:c1";
|
||||
it("supports mention-prefixed Slack reset commands and preserves args", async () => {
|
||||
const existingSessionId = "existing-session-123";
|
||||
await seedSessionStore({
|
||||
storePath,
|
||||
sessionKey,
|
||||
sessionId: existingSessionId,
|
||||
});
|
||||
const cases = [
|
||||
{
|
||||
name: "reset command",
|
||||
storePrefix: "openclaw-slack-channel-reset-",
|
||||
sessionKey: "agent:main:slack:channel:c1",
|
||||
body: "<@U123> /reset",
|
||||
expectedBodyStripped: "",
|
||||
},
|
||||
{
|
||||
name: "new command with args",
|
||||
storePrefix: "openclaw-slack-channel-new-",
|
||||
sessionKey: "agent:main:slack:channel:c2",
|
||||
body: "<@U123> /new take notes",
|
||||
expectedBodyStripped: "take notes",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const cfg = {
|
||||
session: { store: storePath, idleMinutes: 999 },
|
||||
} as OpenClawConfig;
|
||||
for (const testCase of cases) {
|
||||
const storePath = await createStorePath(testCase.storePrefix);
|
||||
await seedSessionStore({
|
||||
storePath,
|
||||
sessionKey: testCase.sessionKey,
|
||||
sessionId: existingSessionId,
|
||||
});
|
||||
const cfg = {
|
||||
session: { store: storePath, idleMinutes: 999 },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const channelMessageCtx = {
|
||||
Body: "<@U123> /reset",
|
||||
RawBody: "<@U123> /reset",
|
||||
CommandBody: "<@U123> /reset",
|
||||
From: "slack:channel:C1",
|
||||
To: "channel:C1",
|
||||
ChatType: "channel",
|
||||
SessionKey: sessionKey,
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
SenderId: "U123",
|
||||
SenderName: "Owner",
|
||||
};
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: testCase.body,
|
||||
RawBody: testCase.body,
|
||||
CommandBody: testCase.body,
|
||||
From: "slack:channel:C1",
|
||||
To: "channel:C1",
|
||||
ChatType: "channel",
|
||||
SessionKey: testCase.sessionKey,
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
SenderId: "U123",
|
||||
SenderName: "Owner",
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: channelMessageCtx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.resetTriggered).toBe(true);
|
||||
expect(result.sessionId).not.toBe(existingSessionId);
|
||||
expect(result.bodyStripped).toBe("");
|
||||
});
|
||||
|
||||
it("Reset trigger /new preserves args when Slack message has a leading <@...> mention token", async () => {
|
||||
const storePath = await createStorePath("openclaw-slack-channel-new-");
|
||||
const sessionKey = "agent:main:slack:channel:c2";
|
||||
const existingSessionId = "existing-session-123";
|
||||
await seedSessionStore({
|
||||
storePath,
|
||||
sessionKey,
|
||||
sessionId: existingSessionId,
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
session: { store: storePath, idleMinutes: 999 },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const channelMessageCtx = {
|
||||
Body: "<@U123> /new take notes",
|
||||
RawBody: "<@U123> /new take notes",
|
||||
CommandBody: "<@U123> /new take notes",
|
||||
From: "slack:channel:C2",
|
||||
To: "channel:C2",
|
||||
ChatType: "channel",
|
||||
SessionKey: sessionKey,
|
||||
Provider: "slack",
|
||||
Surface: "slack",
|
||||
SenderId: "U123",
|
||||
SenderName: "Owner",
|
||||
};
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: channelMessageCtx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.resetTriggered).toBe(true);
|
||||
expect(result.sessionId).not.toBe(existingSessionId);
|
||||
expect(result.bodyStripped).toBe("take notes");
|
||||
expect(result.isNewSession, testCase.name).toBe(true);
|
||||
expect(result.resetTriggered, testCase.name).toBe(true);
|
||||
expect(result.sessionId, testCase.name).not.toBe(existingSessionId);
|
||||
expect(result.bodyStripped, testCase.name).toBe(testCase.expectedBodyStripped);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -88,50 +88,38 @@ describe("tts", () => {
|
||||
});
|
||||
|
||||
describe("isValidVoiceId", () => {
|
||||
it("accepts valid ElevenLabs voice IDs", () => {
|
||||
expect(isValidVoiceId("pMsXgVXv3BLzUgSXRplE")).toBe(true);
|
||||
expect(isValidVoiceId("21m00Tcm4TlvDq8ikWAM")).toBe(true);
|
||||
expect(isValidVoiceId("EXAVITQu4vr4xnSDxMaL")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts voice IDs of varying valid lengths", () => {
|
||||
expect(isValidVoiceId("a1b2c3d4e5")).toBe(true);
|
||||
expect(isValidVoiceId("a".repeat(40))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects too short voice IDs", () => {
|
||||
expect(isValidVoiceId("")).toBe(false);
|
||||
expect(isValidVoiceId("abc")).toBe(false);
|
||||
expect(isValidVoiceId("123456789")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects too long voice IDs", () => {
|
||||
expect(isValidVoiceId("a".repeat(41))).toBe(false);
|
||||
expect(isValidVoiceId("a".repeat(100))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects voice IDs with invalid characters", () => {
|
||||
expect(isValidVoiceId("pMsXgVXv3BLz-gSXRplE")).toBe(false);
|
||||
expect(isValidVoiceId("pMsXgVXv3BLz_gSXRplE")).toBe(false);
|
||||
expect(isValidVoiceId("pMsXgVXv3BLz gSXRplE")).toBe(false);
|
||||
expect(isValidVoiceId("../../../etc/passwd")).toBe(false);
|
||||
expect(isValidVoiceId("voice?param=value")).toBe(false);
|
||||
it("validates ElevenLabs voice ID length and character rules", () => {
|
||||
const cases = [
|
||||
{ value: "pMsXgVXv3BLzUgSXRplE", expected: true },
|
||||
{ value: "21m00Tcm4TlvDq8ikWAM", expected: true },
|
||||
{ value: "EXAVITQu4vr4xnSDxMaL", expected: true },
|
||||
{ value: "a1b2c3d4e5", expected: true },
|
||||
{ value: "a".repeat(40), expected: true },
|
||||
{ value: "", expected: false },
|
||||
{ value: "abc", expected: false },
|
||||
{ value: "123456789", expected: false },
|
||||
{ value: "a".repeat(41), expected: false },
|
||||
{ value: "a".repeat(100), expected: false },
|
||||
{ value: "pMsXgVXv3BLz-gSXRplE", expected: false },
|
||||
{ value: "pMsXgVXv3BLz_gSXRplE", expected: false },
|
||||
{ value: "pMsXgVXv3BLz gSXRplE", expected: false },
|
||||
{ value: "../../../etc/passwd", expected: false },
|
||||
{ value: "voice?param=value", expected: false },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(isValidVoiceId(testCase.value), testCase.value).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidOpenAIVoice", () => {
|
||||
it("accepts all valid OpenAI voices", () => {
|
||||
it("accepts all valid OpenAI voices including newer additions", () => {
|
||||
for (const voice of OPENAI_TTS_VOICES) {
|
||||
expect(isValidOpenAIVoice(voice)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes newer OpenAI voices (ballad, cedar, juniper, marin, verse) (#2393)", () => {
|
||||
expect(isValidOpenAIVoice("ballad")).toBe(true);
|
||||
expect(isValidOpenAIVoice("cedar")).toBe(true);
|
||||
expect(isValidOpenAIVoice("juniper")).toBe(true);
|
||||
expect(isValidOpenAIVoice("marin")).toBe(true);
|
||||
expect(isValidOpenAIVoice("verse")).toBe(true);
|
||||
for (const newerVoice of ["ballad", "cedar", "juniper", "marin", "verse"]) {
|
||||
expect(isValidOpenAIVoice(newerVoice), newerVoice).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid voice names", () => {
|
||||
@@ -144,48 +132,56 @@ describe("tts", () => {
|
||||
});
|
||||
|
||||
describe("isValidOpenAIModel", () => {
|
||||
it("accepts supported models", () => {
|
||||
expect(isValidOpenAIModel("gpt-4o-mini-tts")).toBe(true);
|
||||
expect(isValidOpenAIModel("tts-1")).toBe(true);
|
||||
expect(isValidOpenAIModel("tts-1-hd")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unsupported models", () => {
|
||||
expect(isValidOpenAIModel("invalid")).toBe(false);
|
||||
expect(isValidOpenAIModel("")).toBe(false);
|
||||
expect(isValidOpenAIModel("gpt-4")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OPENAI_TTS_MODELS", () => {
|
||||
it("contains supported models", () => {
|
||||
it("matches the supported model set and rejects unsupported values", () => {
|
||||
expect(OPENAI_TTS_MODELS).toContain("gpt-4o-mini-tts");
|
||||
expect(OPENAI_TTS_MODELS).toContain("tts-1");
|
||||
expect(OPENAI_TTS_MODELS).toContain("tts-1-hd");
|
||||
expect(OPENAI_TTS_MODELS).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("is a non-empty array", () => {
|
||||
expect(Array.isArray(OPENAI_TTS_MODELS)).toBe(true);
|
||||
expect(OPENAI_TTS_MODELS.length).toBeGreaterThan(0);
|
||||
const cases = [
|
||||
{ model: "gpt-4o-mini-tts", expected: true },
|
||||
{ model: "tts-1", expected: true },
|
||||
{ model: "tts-1-hd", expected: true },
|
||||
{ model: "invalid", expected: false },
|
||||
{ model: "", expected: false },
|
||||
{ model: "gpt-4", expected: false },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(isValidOpenAIModel(testCase.model), testCase.model).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOutputFormat", () => {
|
||||
it("uses Opus for Telegram", () => {
|
||||
const output = resolveOutputFormat("telegram");
|
||||
expect(output.openai).toBe("opus");
|
||||
expect(output.elevenlabs).toBe("opus_48000_64");
|
||||
expect(output.extension).toBe(".opus");
|
||||
expect(output.voiceCompatible).toBe(true);
|
||||
});
|
||||
|
||||
it("uses MP3 for other channels", () => {
|
||||
const output = resolveOutputFormat("discord");
|
||||
expect(output.openai).toBe("mp3");
|
||||
expect(output.elevenlabs).toBe("mp3_44100_128");
|
||||
expect(output.extension).toBe(".mp3");
|
||||
expect(output.voiceCompatible).toBe(false);
|
||||
it("selects opus for Telegram and mp3 for other channels", () => {
|
||||
const cases = [
|
||||
{
|
||||
channel: "telegram",
|
||||
expected: {
|
||||
openai: "opus",
|
||||
elevenlabs: "opus_48000_64",
|
||||
extension: ".opus",
|
||||
voiceCompatible: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
channel: "discord",
|
||||
expected: {
|
||||
openai: "mp3",
|
||||
elevenlabs: "mp3_44100_128",
|
||||
extension: ".mp3",
|
||||
voiceCompatible: false,
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const output = resolveOutputFormat(testCase.channel);
|
||||
expect(output.openai, testCase.channel).toBe(testCase.expected.openai);
|
||||
expect(output.elevenlabs, testCase.channel).toBe(testCase.expected.elevenlabs);
|
||||
expect(output.extension, testCase.channel).toBe(testCase.expected.extension);
|
||||
expect(output.voiceCompatible, testCase.channel).toBe(testCase.expected.voiceCompatible);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,21 +191,30 @@ describe("tts", () => {
|
||||
messages: { tts: {} },
|
||||
};
|
||||
|
||||
it("uses default output format when edge output format is not configured", () => {
|
||||
const config = resolveTtsConfig(baseCfg);
|
||||
expect(resolveEdgeOutputFormat(config)).toBe("audio-24khz-48kbitrate-mono-mp3");
|
||||
});
|
||||
|
||||
it("uses configured output format when provided", () => {
|
||||
const config = resolveTtsConfig({
|
||||
...baseCfg,
|
||||
messages: {
|
||||
tts: {
|
||||
edge: { outputFormat: "audio-24khz-96kbitrate-mono-mp3" },
|
||||
},
|
||||
it("uses default edge output format unless overridden", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "default",
|
||||
cfg: baseCfg,
|
||||
expected: "audio-24khz-48kbitrate-mono-mp3",
|
||||
},
|
||||
});
|
||||
expect(resolveEdgeOutputFormat(config)).toBe("audio-24khz-96kbitrate-mono-mp3");
|
||||
{
|
||||
name: "override",
|
||||
cfg: {
|
||||
...baseCfg,
|
||||
messages: {
|
||||
tts: {
|
||||
edge: { outputFormat: "audio-24khz-96kbitrate-mono-mp3" },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
expected: "audio-24khz-96kbitrate-mono-mp3",
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const config = resolveTtsConfig(testCase.cfg);
|
||||
expect(resolveEdgeOutputFormat(config), testCase.name).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -318,79 +323,52 @@ describe("tts", () => {
|
||||
expect(resolveModel).toHaveBeenCalledWith("openai", "gpt-4.1-mini", undefined, cfg);
|
||||
});
|
||||
|
||||
it("rejects targetLength below minimum (100)", async () => {
|
||||
await expect(
|
||||
summarizeText({
|
||||
it("validates targetLength bounds", async () => {
|
||||
const cases = [
|
||||
{ targetLength: 99, shouldThrow: true },
|
||||
{ targetLength: 100, shouldThrow: false },
|
||||
{ targetLength: 10000, shouldThrow: false },
|
||||
{ targetLength: 10001, shouldThrow: true },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const call = summarizeText({
|
||||
text: "text",
|
||||
targetLength: 99,
|
||||
targetLength: testCase.targetLength,
|
||||
cfg: baseCfg,
|
||||
config: baseConfig,
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
).rejects.toThrow("Invalid targetLength: 99");
|
||||
});
|
||||
if (testCase.shouldThrow) {
|
||||
await expect(call, String(testCase.targetLength)).rejects.toThrow(
|
||||
`Invalid targetLength: ${testCase.targetLength}`,
|
||||
);
|
||||
} else {
|
||||
await expect(call, String(testCase.targetLength)).resolves.toBeDefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects targetLength above maximum (10000)", async () => {
|
||||
await expect(
|
||||
summarizeText({
|
||||
text: "text",
|
||||
targetLength: 10001,
|
||||
cfg: baseCfg,
|
||||
config: baseConfig,
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
).rejects.toThrow("Invalid targetLength: 10001");
|
||||
});
|
||||
|
||||
it("accepts targetLength at boundaries", async () => {
|
||||
await expect(
|
||||
summarizeText({
|
||||
text: "text",
|
||||
targetLength: 100,
|
||||
cfg: baseCfg,
|
||||
config: baseConfig,
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
await expect(
|
||||
summarizeText({
|
||||
text: "text",
|
||||
targetLength: 10000,
|
||||
cfg: baseCfg,
|
||||
config: baseConfig,
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("throws error when no summary is returned", async () => {
|
||||
vi.mocked(completeSimple).mockResolvedValue(mockAssistantMessage([]));
|
||||
|
||||
await expect(
|
||||
summarizeText({
|
||||
text: "text",
|
||||
targetLength: 500,
|
||||
cfg: baseCfg,
|
||||
config: baseConfig,
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
).rejects.toThrow("No summary returned");
|
||||
});
|
||||
|
||||
it("throws error when summary content is empty", async () => {
|
||||
vi.mocked(completeSimple).mockResolvedValue(
|
||||
mockAssistantMessage([{ type: "text", text: " " }]),
|
||||
);
|
||||
|
||||
await expect(
|
||||
summarizeText({
|
||||
text: "text",
|
||||
targetLength: 500,
|
||||
cfg: baseCfg,
|
||||
config: baseConfig,
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
).rejects.toThrow("No summary returned");
|
||||
it("throws when summary output is missing or empty", async () => {
|
||||
const cases = [
|
||||
{ name: "no summary blocks", message: mockAssistantMessage([]) },
|
||||
{
|
||||
name: "empty summary content",
|
||||
message: mockAssistantMessage([{ type: "text", text: " " }]),
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
vi.mocked(completeSimple).mockResolvedValue(testCase.message);
|
||||
await expect(
|
||||
summarizeText({
|
||||
text: "text",
|
||||
targetLength: 500,
|
||||
cfg: baseCfg,
|
||||
config: baseConfig,
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
testCase.name,
|
||||
).rejects.toThrow("No summary returned");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -400,49 +378,44 @@ describe("tts", () => {
|
||||
messages: { tts: {} },
|
||||
};
|
||||
|
||||
it("prefers OpenAI when no provider is configured and API key exists", () => {
|
||||
withEnv(
|
||||
it("selects provider based on available API keys", () => {
|
||||
const cases = [
|
||||
{
|
||||
OPENAI_API_KEY: "test-openai-key",
|
||||
ELEVENLABS_API_KEY: undefined,
|
||||
XI_API_KEY: undefined,
|
||||
env: {
|
||||
OPENAI_API_KEY: "test-openai-key",
|
||||
ELEVENLABS_API_KEY: undefined,
|
||||
XI_API_KEY: undefined,
|
||||
},
|
||||
prefsPath: "/tmp/tts-prefs-openai.json",
|
||||
expected: "openai",
|
||||
},
|
||||
() => {
|
||||
const config = resolveTtsConfig(baseCfg);
|
||||
const provider = getTtsProvider(config, "/tmp/tts-prefs-openai.json");
|
||||
expect(provider).toBe("openai");
|
||||
{
|
||||
env: {
|
||||
OPENAI_API_KEY: undefined,
|
||||
ELEVENLABS_API_KEY: "test-elevenlabs-key",
|
||||
XI_API_KEY: undefined,
|
||||
},
|
||||
prefsPath: "/tmp/tts-prefs-elevenlabs.json",
|
||||
expected: "elevenlabs",
|
||||
},
|
||||
);
|
||||
});
|
||||
{
|
||||
env: {
|
||||
OPENAI_API_KEY: undefined,
|
||||
ELEVENLABS_API_KEY: undefined,
|
||||
XI_API_KEY: undefined,
|
||||
},
|
||||
prefsPath: "/tmp/tts-prefs-edge.json",
|
||||
expected: "edge",
|
||||
},
|
||||
] as const;
|
||||
|
||||
it("prefers ElevenLabs when OpenAI is missing and ElevenLabs key exists", () => {
|
||||
withEnv(
|
||||
{
|
||||
OPENAI_API_KEY: undefined,
|
||||
ELEVENLABS_API_KEY: "test-elevenlabs-key",
|
||||
XI_API_KEY: undefined,
|
||||
},
|
||||
() => {
|
||||
for (const testCase of cases) {
|
||||
withEnv(testCase.env, () => {
|
||||
const config = resolveTtsConfig(baseCfg);
|
||||
const provider = getTtsProvider(config, "/tmp/tts-prefs-elevenlabs.json");
|
||||
expect(provider).toBe("elevenlabs");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to Edge when no API keys are present", () => {
|
||||
withEnv(
|
||||
{
|
||||
OPENAI_API_KEY: undefined,
|
||||
ELEVENLABS_API_KEY: undefined,
|
||||
XI_API_KEY: undefined,
|
||||
},
|
||||
() => {
|
||||
const config = resolveTtsConfig(baseCfg);
|
||||
const provider = getTtsProvider(config, "/tmp/tts-prefs-edge.json");
|
||||
expect(provider).toBe("edge");
|
||||
},
|
||||
);
|
||||
const provider = getTtsProvider(config, testCase.prefsPath);
|
||||
expect(provider).toBe(testCase.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -485,48 +458,47 @@ describe("tts", () => {
|
||||
},
|
||||
};
|
||||
|
||||
it("skips auto-TTS when inbound audio gating is on and the message is not audio", async () => {
|
||||
await withMockedAutoTtsFetch(async (fetchMock) => {
|
||||
const payload = { text: "Hello world" };
|
||||
const result = await maybeApplyTtsToPayload({
|
||||
payload,
|
||||
cfg: baseCfg,
|
||||
kind: "final",
|
||||
inboundAudio: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(payload);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("skips auto-TTS when markdown stripping leaves text too short", async () => {
|
||||
await withMockedAutoTtsFetch(async (fetchMock) => {
|
||||
const payload = { text: "### **bold**" };
|
||||
const result = await maybeApplyTtsToPayload({
|
||||
payload,
|
||||
cfg: baseCfg,
|
||||
kind: "final",
|
||||
inboundAudio: true,
|
||||
});
|
||||
|
||||
expect(result).toBe(payload);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("attempts auto-TTS when inbound audio gating is on and the message is audio", async () => {
|
||||
await withMockedAutoTtsFetch(async (fetchMock) => {
|
||||
const result = await maybeApplyTtsToPayload({
|
||||
it("applies inbound auto-TTS gating by audio status and cleaned text length", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "inbound gating blocks non-audio",
|
||||
payload: { text: "Hello world" },
|
||||
cfg: baseCfg,
|
||||
kind: "final",
|
||||
inboundAudio: false,
|
||||
expectedFetchCalls: 0,
|
||||
expectSamePayload: true,
|
||||
},
|
||||
{
|
||||
name: "inbound gating blocks too-short cleaned text",
|
||||
payload: { text: "### **bold**" },
|
||||
inboundAudio: true,
|
||||
});
|
||||
expectedFetchCalls: 0,
|
||||
expectSamePayload: true,
|
||||
},
|
||||
{
|
||||
name: "inbound gating allows audio with real text",
|
||||
payload: { text: "Hello world" },
|
||||
inboundAudio: true,
|
||||
expectedFetchCalls: 1,
|
||||
expectSamePayload: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
expect(result.mediaUrl).toBeDefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
for (const testCase of cases) {
|
||||
await withMockedAutoTtsFetch(async (fetchMock) => {
|
||||
const result = await maybeApplyTtsToPayload({
|
||||
payload: testCase.payload,
|
||||
cfg: baseCfg,
|
||||
kind: "final",
|
||||
inboundAudio: testCase.inboundAudio,
|
||||
});
|
||||
expect(fetchMock, testCase.name).toHaveBeenCalledTimes(testCase.expectedFetchCalls);
|
||||
if (testCase.expectSamePayload) {
|
||||
expect(result, testCase.name).toBe(testCase.payload);
|
||||
} else {
|
||||
expect(result.mediaUrl, testCase.name).toBeDefined();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("skips auto-TTS in tagged mode unless a tts tag is present", async () => {
|
||||
|
||||
Reference in New Issue
Block a user