import { afterEach, describe, expect, it, vi } from "vitest"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; import { parseAudioTag } from "./audio-tags.js"; import { createBlockReplyCoalescer } from "./block-reply-coalescer.js"; import { matchesMentionWithExplicit } from "./mentions.js"; import { normalizeReplyPayload } from "./normalize-reply.js"; import { createReplyReferencePlanner } from "./reply-reference.js"; import { extractShortModelName, hasTemplateVariables, resolveResponsePrefixTemplate, } from "./response-prefix-template.js"; import { createStreamingDirectiveAccumulator } from "./streaming-directives.js"; import { createMockTypingController } from "./test-helpers.js"; import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js"; import { createTypingController } from "./typing.js"; describe("matchesMentionWithExplicit", () => { const mentionRegexes = [/\bopenclaw\b/i]; 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, }, { name: "no explicit and no regex match", text: "<@999999> hello", mentionRegexes, explicit: { hasAnyMention: true, isExplicitlyMentioned: false, canResolveExplicit: true, }, expected: false, }, { name: "explicit mention even without regex", text: "<@123456>", mentionRegexes: [], explicit: { hasAnyMention: true, isExplicitlyMentioned: true, canResolveExplicit: true, }, expected: true, }, { name: "falls back to regex when explicit cannot resolve", text: "openclaw please", mentionRegexes, explicit: { hasAnyMention: true, isExplicitlyMentioned: false, canResolveExplicit: false, }, expected: 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); } }); }); // Keep channelData-only payloads so channel-specific replies survive normalization. describe("normalizeReplyPayload", () => { it("keeps channelData-only replies", () => { const payload = { channelData: { line: { flexMessage: { type: "bubble" }, }, }, }; const normalized = normalizeReplyPayload(payload); expect(normalized).not.toBeNull(); expect(normalized?.text).toBeUndefined(); expect(normalized?.channelData).toEqual(payload.channelData); }); 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, testCase.name).toBeNull(); expect(reasons, testCase.name).toEqual([testCase.reason]); } }); it("strips NO_REPLY from mixed emoji message (#30916)", () => { const result = normalizeReplyPayload({ text: "😄 NO_REPLY" }); expect(result).not.toBeNull(); expect(result!.text).toContain("😄"); expect(result!.text).not.toContain("NO_REPLY"); }); it("strips NO_REPLY appended after substantive text (#30916)", () => { const result = normalizeReplyPayload({ text: "File's there. Not urgent.\n\nNO_REPLY", }); expect(result).not.toBeNull(); expect(result!.text).toContain("File's there"); expect(result!.text).not.toContain("NO_REPLY"); }); it("keeps NO_REPLY when used as leading substantive text", () => { const result = normalizeReplyPayload({ text: "NO_REPLY -- nope" }); expect(result).not.toBeNull(); expect(result!.text).toBe("NO_REPLY -- nope"); }); it("suppresses message when stripping NO_REPLY leaves nothing", () => { const reasons: string[] = []; const result = normalizeReplyPayload( { text: " NO_REPLY " }, { onSkip: (reason) => reasons.push(reason) }, ); expect(result).toBeNull(); expect(reasons).toEqual(["silent"]); }); it("strips NO_REPLY but keeps media payload", () => { const result = normalizeReplyPayload({ text: "NO_REPLY", mediaUrl: "https://example.com/img.png", }); expect(result).not.toBeNull(); expect(result!.text).toBe(""); expect(result!.mediaUrl).toBe("https://example.com/img.png"); }); }); describe("typing controller", () => { afterEach(() => { vi.useRealTimers(); }); function createTestTypingController() { const onReplyStart = vi.fn(); const typing = createTypingController({ onReplyStart, typingIntervalSeconds: 1, typingTtlMs: 30_000, }); return { typing, onReplyStart }; } function markTypingState( typing: ReturnType, state: "run" | "idle", ) { if (state === "run") { typing.markRunComplete(); return; } typing.markDispatchIdle(); } it("stops only after both run completion and dispatcher idle are set (any order)", async () => { vi.useFakeTimers(); const cases = [ { name: "run-complete first", first: "run", second: "idle" }, { name: "dispatch-idle first", first: "idle", second: "run" }, ] as const; for (const testCase of cases) { const { typing, onReplyStart } = createTestTypingController(); await typing.startTypingLoop(); expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(2_000); expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(3); markTypingState(typing, testCase.first); await vi.advanceTimersByTimeAsync(2_000); expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(testCase.first === "run" ? 3 : 5); markTypingState(typing, testCase.second); await vi.advanceTimersByTimeAsync(2_000); expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(testCase.first === "run" ? 3 : 5); } }); it("does not start typing after run completion", async () => { vi.useFakeTimers(); const { typing, onReplyStart } = createTestTypingController(); typing.markRunComplete(); await typing.startTypingOnText("late text"); await vi.advanceTimersByTimeAsync(2_000); expect(onReplyStart).not.toHaveBeenCalled(); }); it("does not restart typing after it has stopped", async () => { vi.useFakeTimers(); const { typing, onReplyStart } = createTestTypingController(); await typing.startTypingLoop(); expect(onReplyStart).toHaveBeenCalledTimes(1); typing.markRunComplete(); typing.markDispatchIdle(); await vi.advanceTimersByTimeAsync(5_000); expect(onReplyStart).toHaveBeenCalledTimes(1); // Late callbacks should be ignored and must not restart the interval. await typing.startTypingOnText("late tool result"); await vi.advanceTimersByTimeAsync(5_000); expect(onReplyStart).toHaveBeenCalledTimes(1); }); }); describe("resolveTypingMode", () => { 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", }, { name: "suppressTyping forces never", input: { configured: "instant" as const, isGroupChat: false, wasMentioned: false, isHeartbeat: false, suppressTyping: true, }, expected: "never", }, { name: "typingPolicy system_event forces never", input: { configured: "instant" as const, isGroupChat: false, wasMentioned: false, isHeartbeat: false, typingPolicy: "system_event" as const, }, 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", () => { function expectResolvedTemplateCases< T extends ReadonlyArray<{ name: string; template: string | undefined; values: Parameters[1]; expected: string | undefined; }>, >(cases: T) { for (const testCase of cases) { expect(resolveResponsePrefixTemplate(testCase.template, testCase.values), testCase.name).toBe( testCase.expected, ); } } 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; expectResolvedTemplateCases(cases); }); 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; expectResolvedTemplateCases(cases); }); }); describe("createTypingSignaler", () => { 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, testCase.name).toHaveBeenCalledTimes( testCase.expectedStartCalls, ); } }); it("signals on message-mode boundaries and text deltas", async () => { const typing = createMockTypingController(); const signaler = createTypingSignaler({ typing, mode: "message", isHeartbeat: false, }); await signaler.signalMessageStart(); expect(typing.startTypingLoop).not.toHaveBeenCalled(); await signaler.signalTextDelta("hello"); expect(typing.startTypingOnText).toHaveBeenCalledWith("hello"); expect(typing.startTypingLoop).not.toHaveBeenCalled(); }); it("starts typing and refreshes ttl on text for thinking mode", async () => { const typing = createMockTypingController(); const signaler = createTypingSignaler({ typing, mode: "thinking", isHeartbeat: false, }); await signaler.signalReasoningDelta(); expect(typing.startTypingLoop).not.toHaveBeenCalled(); await signaler.signalTextDelta("hi"); expect(typing.startTypingLoop).toHaveBeenCalled(); expect(typing.refreshTypingTtl).toHaveBeenCalled(); expect(typing.startTypingOnText).not.toHaveBeenCalled(); }); it("handles tool-start typing before and after active text mode", async () => { const typing = createMockTypingController(); const signaler = createTypingSignaler({ typing, mode: "message", isHeartbeat: false, }); await signaler.signalToolStart(); expect(typing.startTypingLoop).toHaveBeenCalled(); expect(typing.refreshTypingTtl).toHaveBeenCalled(); expect(typing.startTypingOnText).not.toHaveBeenCalled(); (typing.isActive as ReturnType).mockReturnValue(true); (typing.startTypingLoop as ReturnType).mockClear(); (typing.refreshTypingTtl as ReturnType).mockClear(); await signaler.signalToolStart(); expect(typing.refreshTypingTtl).toHaveBeenCalled(); expect(typing.startTypingLoop).not.toHaveBeenCalled(); }); it("suppresses typing when disabled", async () => { const typing = createMockTypingController(); const signaler = createTypingSignaler({ typing, mode: "instant", isHeartbeat: true, }); await signaler.signalRunStart(); await signaler.signalTextDelta("hi"); await signaler.signalReasoningDelta(); expect(typing.startTypingLoop).not.toHaveBeenCalled(); expect(typing.startTypingOnText).not.toHaveBeenCalled(); }); }); describe("block reply coalescer", () => { afterEach(() => { vi.useRealTimers(); }); function createBlockCoalescerHarness(config: { minChars: number; maxChars: number; idleMs: number; joiner: string; flushOnEnqueue?: boolean; }) { const flushes: string[] = []; const coalescer = createBlockReplyCoalescer({ config, shouldAbort: () => false, onFlush: (payload) => { flushes.push(payload.text ?? ""); }, }); return { flushes, coalescer }; } it("coalesces chunks within the idle window", async () => { vi.useFakeTimers(); const { flushes, coalescer } = createBlockCoalescerHarness({ minChars: 1, maxChars: 200, idleMs: 100, joiner: " ", }); coalescer.enqueue({ text: "Hello" }); coalescer.enqueue({ text: "world" }); await vi.advanceTimersByTimeAsync(100); expect(flushes).toEqual(["Hello world"]); coalescer.stop(); }); it("waits until minChars before idle flush", async () => { vi.useFakeTimers(); const { flushes, coalescer } = createBlockCoalescerHarness({ minChars: 10, maxChars: 200, idleMs: 50, joiner: " ", }); coalescer.enqueue({ text: "short" }); await vi.advanceTimersByTimeAsync(50); expect(flushes).toEqual([]); coalescer.enqueue({ text: "message" }); await vi.advanceTimersByTimeAsync(50); expect(flushes).toEqual(["short message"]); coalescer.stop(); }); it("still accumulates when flushOnEnqueue is not set (default)", async () => { vi.useFakeTimers(); const { flushes, coalescer } = createBlockCoalescerHarness({ minChars: 1, maxChars: 2000, idleMs: 100, joiner: "\n\n", }); coalescer.enqueue({ text: "First paragraph" }); coalescer.enqueue({ text: "Second paragraph" }); await vi.advanceTimersByTimeAsync(100); expect(flushes).toEqual(["First paragraph\n\nSecond paragraph"]); coalescer.stop(); }); 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"], }, { config: { minChars: 1, maxChars: 30, idleMs: 100, joiner: "\n\n", flushOnEnqueue: true }, inputs: ["12345678901234567890", "abcdefghijklmnopqrst"], expected: ["12345678901234567890", "abcdefghijklmnopqrst"], }, ] as const; for (const testCase of cases) { const { flushes, coalescer } = createBlockCoalescerHarness(testCase.config); 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", () => { const flushes: Array<{ text?: string; mediaUrls?: string[] }> = []; const coalescer = createBlockReplyCoalescer({ config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " }, shouldAbort: () => false, onFlush: (payload) => { flushes.push({ text: payload.text, mediaUrls: payload.mediaUrls, }); }, }); coalescer.enqueue({ text: "Hello" }); coalescer.enqueue({ text: "world" }); coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] }); void coalescer.flush({ force: true }); expect(flushes[0].text).toBe("Hello world"); expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]); coalescer.stop(); }); }); describe("createReplyReferencePlanner", () => { it("plans references correctly for off/first/all modes", () => { const offPlanner = createReplyReferencePlanner({ replyToMode: "off", startId: "parent", }); expect(offPlanner.use()).toBeUndefined(); const firstPlanner = createReplyReferencePlanner({ replyToMode: "first", startId: "parent", }); expect(firstPlanner.use()).toBe("parent"); expect(firstPlanner.hasReplied()).toBe(true); firstPlanner.markSent(); expect(firstPlanner.use()).toBeUndefined(); const allPlanner = createReplyReferencePlanner({ replyToMode: "all", startId: "parent", }); expect(allPlanner.use()).toBe("parent"); expect(allPlanner.use()).toBe("parent"); const existingIdPlanner = createReplyReferencePlanner({ replyToMode: "first", existingId: "thread-1", startId: "parent", }); expect(existingIdPlanner.use()).toBe("thread-1"); expect(existingIdPlanner.use()).toBeUndefined(); }); it("honors allowReference=false", () => { const planner = createReplyReferencePlanner({ replyToMode: "all", startId: "parent", allowReference: false, }); expect(planner.use()).toBeUndefined(); expect(planner.hasReplied()).toBe(false); planner.markSent(); expect(planner.hasReplied()).toBe(true); }); }); describe("createStreamingDirectiveAccumulator", () => { it("stashes reply_to_current until a renderable chunk arrives", () => { const accumulator = createStreamingDirectiveAccumulator(); expect(accumulator.consume("[[reply_to_current]]")).toBeNull(); const result = accumulator.consume("Hello"); expect(result?.text).toBe("Hello"); expect(result?.replyToCurrent).toBe(true); expect(result?.replyToTag).toBe(true); }); it("handles reply tags split across chunks", () => { const accumulator = createStreamingDirectiveAccumulator(); expect(accumulator.consume("[[reply_to_")).toBeNull(); const result = accumulator.consume("current]] Yo"); expect(result?.text).toBe("Yo"); expect(result?.replyToCurrent).toBe(true); }); it("propagates explicit reply ids across current and subsequent chunks", () => { const accumulator = createStreamingDirectiveAccumulator(); expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull(); const first = accumulator.consume("Hi"); expect(first?.text).toBe("Hi"); expect(first?.replyToId).toBe("abc-123"); expect(first?.replyToTag).toBe(true); const second = accumulator.consume("test 2"); expect(second?.replyToId).toBe("abc-123"); expect(second?.replyToTag).toBe(true); }); it("clears sticky reply context on reset", () => { const accumulator = createStreamingDirectiveAccumulator(); expect(accumulator.consume("[[reply_to_current]]")).toBeNull(); expect(accumulator.consume("first")?.replyToCurrent).toBe(true); accumulator.reset(); const afterReset = accumulator.consume("second"); expect(afterReset?.replyToCurrent).toBe(false); expect(afterReset?.replyToTag).toBe(false); expect(afterReset?.replyToId).toBeUndefined(); }); }); describe("extractShortModelName", () => { 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("handles empty, static, and repeated variable checks", () => { expect(hasTemplateVariables("")).toBe(false); expect(hasTemplateVariables("[{model}]")).toBe(true); expect(hasTemplateVariables("[{model}]")).toBe(true); expect(hasTemplateVariables("[Claude]")).toBe(false); }); });