mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-14 11:30:41 +00:00
829 lines
25 KiB
TypeScript
829 lines
25 KiB
TypeScript
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("checks mentionPatterns even when explicit mention is available", () => {
|
|
const result = matchesMentionWithExplicit({
|
|
text: "@openclaw hello",
|
|
mentionRegexes,
|
|
explicit: {
|
|
hasAnyMention: true,
|
|
isExplicitlyMentioned: false,
|
|
canResolveExplicit: 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,
|
|
},
|
|
});
|
|
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,
|
|
},
|
|
});
|
|
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,
|
|
},
|
|
});
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
// 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 silent skips", () => {
|
|
const reasons: string[] = [];
|
|
const normalized = normalizeReplyPayload(
|
|
{ text: SILENT_REPLY_TOKEN },
|
|
{
|
|
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"]);
|
|
});
|
|
});
|
|
|
|
describe("typing controller", () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("stops after run completion and dispatcher idle", async () => {
|
|
vi.useFakeTimers();
|
|
const onReplyStart = vi.fn(async () => {});
|
|
const typing = createTypingController({
|
|
onReplyStart,
|
|
typingIntervalSeconds: 1,
|
|
typingTtlMs: 30_000,
|
|
});
|
|
|
|
await typing.startTypingLoop();
|
|
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
|
|
|
vi.advanceTimersByTime(2_000);
|
|
expect(onReplyStart).toHaveBeenCalledTimes(3);
|
|
|
|
typing.markRunComplete();
|
|
vi.advanceTimersByTime(1_000);
|
|
expect(onReplyStart).toHaveBeenCalledTimes(4);
|
|
|
|
typing.markDispatchIdle();
|
|
vi.advanceTimersByTime(2_000);
|
|
expect(onReplyStart).toHaveBeenCalledTimes(4);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it("does not start typing after run completion", async () => {
|
|
vi.useFakeTimers();
|
|
const onReplyStart = vi.fn(async () => {});
|
|
const typing = createTypingController({
|
|
onReplyStart,
|
|
typingIntervalSeconds: 1,
|
|
typingTtlMs: 30_000,
|
|
});
|
|
|
|
typing.markRunComplete();
|
|
await typing.startTypingOnText("late text");
|
|
vi.advanceTimersByTime(2_000);
|
|
expect(onReplyStart).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not restart typing after it has stopped", 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.markRunComplete();
|
|
typing.markDispatchIdle();
|
|
|
|
vi.advanceTimersByTime(5_000);
|
|
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
|
|
|
// Late callbacks should be ignored and must not restart the interval.
|
|
await typing.startTypingOnText("late tool result");
|
|
vi.advanceTimersByTime(5_000);
|
|
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("resolveTypingMode", () => {
|
|
it("defaults to instant for direct chats", () => {
|
|
expect(
|
|
resolveTypingMode({
|
|
configured: undefined,
|
|
isGroupChat: false,
|
|
wasMentioned: false,
|
|
isHeartbeat: false,
|
|
}),
|
|
).toBe("instant");
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|
|
|
|
describe("createTypingSignaler", () => {
|
|
it("signals immediately for instant mode", async () => {
|
|
const typing = createMockTypingController();
|
|
const signaler = createTypingSignaler({
|
|
typing,
|
|
mode: "instant",
|
|
isHeartbeat: false,
|
|
});
|
|
|
|
await signaler.signalRunStart();
|
|
|
|
expect(typing.startTypingLoop).toHaveBeenCalled();
|
|
});
|
|
|
|
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 () => {
|
|
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");
|
|
});
|
|
|
|
it("signals on reasoning 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();
|
|
});
|
|
|
|
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 () => {
|
|
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();
|
|
});
|
|
|
|
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.startTypingLoop.mockClear();
|
|
typing.startTypingOnText.mockClear();
|
|
typing.refreshTypingTtl.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("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();
|
|
});
|
|
|
|
it("coalesces chunks within the idle window", async () => {
|
|
vi.useFakeTimers();
|
|
const flushes: string[] = [];
|
|
const coalescer = createBlockReplyCoalescer({
|
|
config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " },
|
|
shouldAbort: () => false,
|
|
onFlush: (payload) => {
|
|
flushes.push(payload.text ?? "");
|
|
},
|
|
});
|
|
|
|
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: string[] = [];
|
|
const coalescer = createBlockReplyCoalescer({
|
|
config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " },
|
|
shouldAbort: () => false,
|
|
onFlush: (payload) => {
|
|
flushes.push(payload.text ?? "");
|
|
},
|
|
});
|
|
|
|
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("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[] = [];
|
|
const coalescer = createBlockReplyCoalescer({
|
|
config: { minChars: 1, maxChars: 2000, idleMs: 100, joiner: "\n\n" },
|
|
shouldAbort: () => false,
|
|
onFlush: (payload) => {
|
|
flushes.push(payload.text ?? "");
|
|
},
|
|
});
|
|
|
|
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 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 ?? "");
|
|
},
|
|
});
|
|
|
|
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 ?? "");
|
|
},
|
|
});
|
|
|
|
// 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();
|
|
});
|
|
|
|
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("disables references when mode is off", () => {
|
|
const planner = createReplyReferencePlanner({
|
|
replyToMode: "off",
|
|
startId: "parent",
|
|
});
|
|
expect(planner.use()).toBeUndefined();
|
|
expect(planner.hasReplied()).toBe(false);
|
|
});
|
|
|
|
it("uses startId once when mode is first", () => {
|
|
const planner = createReplyReferencePlanner({
|
|
replyToMode: "first",
|
|
startId: "parent",
|
|
});
|
|
expect(planner.use()).toBe("parent");
|
|
expect(planner.hasReplied()).toBe(true);
|
|
planner.markSent();
|
|
expect(planner.use()).toBeUndefined();
|
|
});
|
|
|
|
it("returns startId for every call when mode is all", () => {
|
|
const planner = createReplyReferencePlanner({
|
|
replyToMode: "all",
|
|
startId: "parent",
|
|
});
|
|
expect(planner.use()).toBe("parent");
|
|
expect(planner.use()).toBe("parent");
|
|
});
|
|
|
|
it("respects replyToMode off even with existingId", () => {
|
|
const planner = createReplyReferencePlanner({
|
|
replyToMode: "off",
|
|
existingId: "thread-1",
|
|
startId: "parent",
|
|
});
|
|
expect(planner.use()).toBeUndefined();
|
|
expect(planner.hasReplied()).toBe(false);
|
|
});
|
|
|
|
it("uses existingId once when mode is first", () => {
|
|
const planner = createReplyReferencePlanner({
|
|
replyToMode: "first",
|
|
existingId: "thread-1",
|
|
startId: "parent",
|
|
});
|
|
expect(planner.use()).toBe("thread-1");
|
|
expect(planner.hasReplied()).toBe(true);
|
|
expect(planner.use()).toBeUndefined();
|
|
});
|
|
|
|
it("uses existingId on every call when mode is all", () => {
|
|
const planner = createReplyReferencePlanner({
|
|
replyToMode: "all",
|
|
existingId: "thread-1",
|
|
startId: "parent",
|
|
});
|
|
expect(planner.use()).toBe("thread-1");
|
|
expect(planner.use()).toBe("thread-1");
|
|
});
|
|
|
|
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);
|
|
expect(result?.replyToTag).toBe(true);
|
|
});
|
|
|
|
it("propagates explicit reply ids across 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);
|
|
});
|
|
});
|
|
|
|
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("resolves multiple variables", () => {
|
|
const result = resolveResponsePrefixTemplate("[{model} | think:{thinkingLevel}]", {
|
|
model: "claude-opus-4-5",
|
|
thinkingLevel: "high",
|
|
});
|
|
expect(result).toBe("[claude-opus-4-5 | think:high]");
|
|
});
|
|
|
|
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/gpt-5.2")).toBe("gpt-5.2");
|
|
expect(extractShortModelName("anthropic/claude-opus-4-5")).toBe("claude-opus-4-5");
|
|
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");
|
|
expect(extractShortModelName("gpt-5.2-20250115")).toBe("gpt-5.2");
|
|
});
|
|
|
|
it("strips -latest suffix", () => {
|
|
expect(extractShortModelName("gpt-5.2-latest")).toBe("gpt-5.2");
|
|
expect(extractShortModelName("claude-sonnet-latest")).toBe("claude-sonnet");
|
|
});
|
|
|
|
it("handles model without provider", () => {
|
|
expect(extractShortModelName("gpt-5.2")).toBe("gpt-5.2");
|
|
expect(extractShortModelName("claude-opus-4-5")).toBe("claude-opus-4-5");
|
|
});
|
|
|
|
it("preserves version numbers that look like dates but are not", () => {
|
|
// Date suffix must be exactly 8 digits at the end
|
|
expect(extractShortModelName("model-v1234567")).toBe("model-v1234567");
|
|
expect(extractShortModelName("model-123456789")).toBe("model-123456789");
|
|
});
|
|
});
|
|
|
|
describe("hasTemplateVariables", () => {
|
|
it("returns false for empty string", () => {
|
|
expect(hasTemplateVariables("")).toBe(false);
|
|
});
|
|
|
|
it("returns true when template variables present", () => {
|
|
expect(hasTemplateVariables("[{model}]")).toBe(true);
|
|
expect(hasTemplateVariables("{provider}")).toBe(true);
|
|
expect(hasTemplateVariables("prefix {thinkingLevel} suffix")).toBe(true);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|