mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-05 04:48:17 +00:00
perf(test): consolidate reply plumbing/state suites
This commit is contained in:
@@ -1,106 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
|
||||||
import type { TemplateContext } from "../templating.js";
|
|
||||||
import { buildThreadingToolContext } from "./agent-runner-utils.js";
|
|
||||||
|
|
||||||
describe("buildThreadingToolContext", () => {
|
|
||||||
const cfg = {} as OpenClawConfig;
|
|
||||||
|
|
||||||
it("uses conversation id for WhatsApp", () => {
|
|
||||||
const sessionCtx = {
|
|
||||||
Provider: "whatsapp",
|
|
||||||
From: "123@g.us",
|
|
||||||
To: "+15550001",
|
|
||||||
} as TemplateContext;
|
|
||||||
|
|
||||||
const result = buildThreadingToolContext({
|
|
||||||
sessionCtx,
|
|
||||||
config: cfg,
|
|
||||||
hasRepliedRef: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.currentChannelId).toBe("123@g.us");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to To for WhatsApp when From is missing", () => {
|
|
||||||
const sessionCtx = {
|
|
||||||
Provider: "whatsapp",
|
|
||||||
To: "+15550001",
|
|
||||||
} as TemplateContext;
|
|
||||||
|
|
||||||
const result = buildThreadingToolContext({
|
|
||||||
sessionCtx,
|
|
||||||
config: cfg,
|
|
||||||
hasRepliedRef: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.currentChannelId).toBe("+15550001");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the recipient id for other channels", () => {
|
|
||||||
const sessionCtx = {
|
|
||||||
Provider: "telegram",
|
|
||||||
From: "user:42",
|
|
||||||
To: "chat:99",
|
|
||||||
} as TemplateContext;
|
|
||||||
|
|
||||||
const result = buildThreadingToolContext({
|
|
||||||
sessionCtx,
|
|
||||||
config: cfg,
|
|
||||||
hasRepliedRef: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.currentChannelId).toBe("chat:99");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the sender handle for iMessage direct chats", () => {
|
|
||||||
const sessionCtx = {
|
|
||||||
Provider: "imessage",
|
|
||||||
ChatType: "direct",
|
|
||||||
From: "imessage:+15550001",
|
|
||||||
To: "chat_id:12",
|
|
||||||
} as TemplateContext;
|
|
||||||
|
|
||||||
const result = buildThreadingToolContext({
|
|
||||||
sessionCtx,
|
|
||||||
config: cfg,
|
|
||||||
hasRepliedRef: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.currentChannelId).toBe("imessage:+15550001");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses chat_id for iMessage groups", () => {
|
|
||||||
const sessionCtx = {
|
|
||||||
Provider: "imessage",
|
|
||||||
ChatType: "group",
|
|
||||||
From: "imessage:group:7",
|
|
||||||
To: "chat_id:7",
|
|
||||||
} as TemplateContext;
|
|
||||||
|
|
||||||
const result = buildThreadingToolContext({
|
|
||||||
sessionCtx,
|
|
||||||
config: cfg,
|
|
||||||
hasRepliedRef: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.currentChannelId).toBe("chat_id:7");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers MessageThreadId for Slack tool threading", () => {
|
|
||||||
const sessionCtx = {
|
|
||||||
Provider: "slack",
|
|
||||||
To: "channel:C1",
|
|
||||||
MessageThreadId: "123.456",
|
|
||||||
} as TemplateContext;
|
|
||||||
|
|
||||||
const result = buildThreadingToolContext({
|
|
||||||
sessionCtx,
|
|
||||||
config: { channels: { slack: { replyToMode: "all" } } } as OpenClawConfig,
|
|
||||||
hasRepliedRef: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.currentChannelId).toBe("C1");
|
|
||||||
expect(result.currentThreadTs).toBe("123.456");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
appendHistoryEntry,
|
|
||||||
buildHistoryContext,
|
|
||||||
buildHistoryContextFromEntries,
|
|
||||||
buildHistoryContextFromMap,
|
|
||||||
buildPendingHistoryContextFromMap,
|
|
||||||
clearHistoryEntriesIfEnabled,
|
|
||||||
HISTORY_CONTEXT_MARKER,
|
|
||||||
recordPendingHistoryEntryIfEnabled,
|
|
||||||
} from "./history.js";
|
|
||||||
import { CURRENT_MESSAGE_MARKER } from "./mentions.js";
|
|
||||||
|
|
||||||
describe("history helpers", () => {
|
|
||||||
it("returns current message when history is empty", () => {
|
|
||||||
const result = buildHistoryContext({
|
|
||||||
historyText: " ",
|
|
||||||
currentMessage: "hello",
|
|
||||||
});
|
|
||||||
expect(result).toBe("hello");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("wraps history entries and excludes current by default", () => {
|
|
||||||
const result = buildHistoryContextFromEntries({
|
|
||||||
entries: [
|
|
||||||
{ sender: "A", body: "one" },
|
|
||||||
{ sender: "B", body: "two" },
|
|
||||||
],
|
|
||||||
currentMessage: "current",
|
|
||||||
formatEntry: (entry) => `${entry.sender}: ${entry.body}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toContain(HISTORY_CONTEXT_MARKER);
|
|
||||||
expect(result).toContain("A: one");
|
|
||||||
expect(result).not.toContain("B: two");
|
|
||||||
expect(result).toContain(CURRENT_MESSAGE_MARKER);
|
|
||||||
expect(result).toContain("current");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("trims history to configured limit", () => {
|
|
||||||
const historyMap = new Map<string, { sender: string; body: string }[]>();
|
|
||||||
|
|
||||||
appendHistoryEntry({
|
|
||||||
historyMap,
|
|
||||||
historyKey: "group",
|
|
||||||
limit: 2,
|
|
||||||
entry: { sender: "A", body: "one" },
|
|
||||||
});
|
|
||||||
appendHistoryEntry({
|
|
||||||
historyMap,
|
|
||||||
historyKey: "group",
|
|
||||||
limit: 2,
|
|
||||||
entry: { sender: "B", body: "two" },
|
|
||||||
});
|
|
||||||
appendHistoryEntry({
|
|
||||||
historyMap,
|
|
||||||
historyKey: "group",
|
|
||||||
limit: 2,
|
|
||||||
entry: { sender: "C", body: "three" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two", "three"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds context from map and appends entry", () => {
|
|
||||||
const historyMap = new Map<string, { sender: string; body: string }[]>();
|
|
||||||
historyMap.set("group", [
|
|
||||||
{ sender: "A", body: "one" },
|
|
||||||
{ sender: "B", body: "two" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = buildHistoryContextFromMap({
|
|
||||||
historyMap,
|
|
||||||
historyKey: "group",
|
|
||||||
limit: 3,
|
|
||||||
entry: { sender: "C", body: "three" },
|
|
||||||
currentMessage: "current",
|
|
||||||
formatEntry: (entry) => `${entry.sender}: ${entry.body}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two", "three"]);
|
|
||||||
expect(result).toContain(HISTORY_CONTEXT_MARKER);
|
|
||||||
expect(result).toContain("A: one");
|
|
||||||
expect(result).toContain("B: two");
|
|
||||||
expect(result).not.toContain("C: three");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds context from pending map without appending", () => {
|
|
||||||
const historyMap = new Map<string, { sender: string; body: string }[]>();
|
|
||||||
historyMap.set("group", [
|
|
||||||
{ sender: "A", body: "one" },
|
|
||||||
{ sender: "B", body: "two" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = buildPendingHistoryContextFromMap({
|
|
||||||
historyMap,
|
|
||||||
historyKey: "group",
|
|
||||||
limit: 3,
|
|
||||||
currentMessage: "current",
|
|
||||||
formatEntry: (entry) => `${entry.sender}: ${entry.body}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]);
|
|
||||||
expect(result).toContain(HISTORY_CONTEXT_MARKER);
|
|
||||||
expect(result).toContain("A: one");
|
|
||||||
expect(result).toContain("B: two");
|
|
||||||
expect(result).toContain(CURRENT_MESSAGE_MARKER);
|
|
||||||
expect(result).toContain("current");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("records pending entries only when enabled", () => {
|
|
||||||
const historyMap = new Map<string, { sender: string; body: string }[]>();
|
|
||||||
|
|
||||||
recordPendingHistoryEntryIfEnabled({
|
|
||||||
historyMap,
|
|
||||||
historyKey: "group",
|
|
||||||
limit: 0,
|
|
||||||
entry: { sender: "A", body: "one" },
|
|
||||||
});
|
|
||||||
expect(historyMap.get("group")).toEqual(undefined);
|
|
||||||
|
|
||||||
recordPendingHistoryEntryIfEnabled({
|
|
||||||
historyMap,
|
|
||||||
historyKey: "group",
|
|
||||||
limit: 2,
|
|
||||||
entry: null,
|
|
||||||
});
|
|
||||||
expect(historyMap.get("group")).toEqual(undefined);
|
|
||||||
|
|
||||||
recordPendingHistoryEntryIfEnabled({
|
|
||||||
historyMap,
|
|
||||||
historyKey: "group",
|
|
||||||
limit: 2,
|
|
||||||
entry: { sender: "B", body: "two" },
|
|
||||||
});
|
|
||||||
expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears history entries only when enabled", () => {
|
|
||||||
const historyMap = new Map<string, { sender: string; body: string }[]>();
|
|
||||||
historyMap.set("group", [
|
|
||||||
{ sender: "A", body: "one" },
|
|
||||||
{ sender: "B", body: "two" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 0 });
|
|
||||||
expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]);
|
|
||||||
|
|
||||||
clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 2 });
|
|
||||||
expect(historyMap.get("group")).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
DEFAULT_MEMORY_FLUSH_SOFT_TOKENS,
|
|
||||||
resolveMemoryFlushContextWindowTokens,
|
|
||||||
resolveMemoryFlushSettings,
|
|
||||||
shouldRunMemoryFlush,
|
|
||||||
} from "./memory-flush.js";
|
|
||||||
|
|
||||||
describe("memory flush settings", () => {
|
|
||||||
it("defaults to enabled with fallback prompt and system prompt", () => {
|
|
||||||
const settings = resolveMemoryFlushSettings();
|
|
||||||
expect(settings).not.toBeNull();
|
|
||||||
expect(settings?.enabled).toBe(true);
|
|
||||||
expect(settings?.prompt.length).toBeGreaterThan(0);
|
|
||||||
expect(settings?.systemPrompt.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("respects disable flag", () => {
|
|
||||||
expect(
|
|
||||||
resolveMemoryFlushSettings({
|
|
||||||
agents: {
|
|
||||||
defaults: { compaction: { memoryFlush: { enabled: false } } },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("appends NO_REPLY hint when missing", () => {
|
|
||||||
const settings = resolveMemoryFlushSettings({
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
compaction: {
|
|
||||||
memoryFlush: {
|
|
||||||
prompt: "Write memories now.",
|
|
||||||
systemPrompt: "Flush memory.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(settings?.prompt).toContain("NO_REPLY");
|
|
||||||
expect(settings?.systemPrompt).toContain("NO_REPLY");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("shouldRunMemoryFlush", () => {
|
|
||||||
it("requires totalTokens and threshold", () => {
|
|
||||||
expect(
|
|
||||||
shouldRunMemoryFlush({
|
|
||||||
entry: { totalTokens: 0 },
|
|
||||||
contextWindowTokens: 16_000,
|
|
||||||
reserveTokensFloor: 20_000,
|
|
||||||
softThresholdTokens: DEFAULT_MEMORY_FLUSH_SOFT_TOKENS,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips when entry is missing", () => {
|
|
||||||
expect(
|
|
||||||
shouldRunMemoryFlush({
|
|
||||||
entry: undefined,
|
|
||||||
contextWindowTokens: 16_000,
|
|
||||||
reserveTokensFloor: 1_000,
|
|
||||||
softThresholdTokens: DEFAULT_MEMORY_FLUSH_SOFT_TOKENS,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips when under threshold", () => {
|
|
||||||
expect(
|
|
||||||
shouldRunMemoryFlush({
|
|
||||||
entry: { totalTokens: 10_000 },
|
|
||||||
contextWindowTokens: 100_000,
|
|
||||||
reserveTokensFloor: 20_000,
|
|
||||||
softThresholdTokens: 10_000,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("triggers at the threshold boundary", () => {
|
|
||||||
expect(
|
|
||||||
shouldRunMemoryFlush({
|
|
||||||
entry: { totalTokens: 85 },
|
|
||||||
contextWindowTokens: 100,
|
|
||||||
reserveTokensFloor: 10,
|
|
||||||
softThresholdTokens: 5,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips when already flushed for current compaction count", () => {
|
|
||||||
expect(
|
|
||||||
shouldRunMemoryFlush({
|
|
||||||
entry: {
|
|
||||||
totalTokens: 90_000,
|
|
||||||
compactionCount: 2,
|
|
||||||
memoryFlushCompactionCount: 2,
|
|
||||||
},
|
|
||||||
contextWindowTokens: 100_000,
|
|
||||||
reserveTokensFloor: 5_000,
|
|
||||||
softThresholdTokens: 2_000,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runs when above threshold and not flushed", () => {
|
|
||||||
expect(
|
|
||||||
shouldRunMemoryFlush({
|
|
||||||
entry: { totalTokens: 96_000, compactionCount: 1 },
|
|
||||||
contextWindowTokens: 100_000,
|
|
||||||
reserveTokensFloor: 5_000,
|
|
||||||
softThresholdTokens: 2_000,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores stale cached totals", () => {
|
|
||||||
expect(
|
|
||||||
shouldRunMemoryFlush({
|
|
||||||
entry: { totalTokens: 96_000, totalTokensFresh: false, compactionCount: 1 },
|
|
||||||
contextWindowTokens: 100_000,
|
|
||||||
reserveTokensFloor: 5_000,
|
|
||||||
softThresholdTokens: 2_000,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveMemoryFlushContextWindowTokens", () => {
|
|
||||||
it("falls back to agent config or default tokens", () => {
|
|
||||||
expect(resolveMemoryFlushContextWindowTokens({ agentCfgContextTokens: 42_000 })).toBe(42_000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { applyReplyThreading } from "./reply-payloads.js";
|
|
||||||
|
|
||||||
describe("applyReplyThreading auto-threading", () => {
|
|
||||||
it("sets replyToId to currentMessageId even without [[reply_to_current]] tag", () => {
|
|
||||||
const result = applyReplyThreading({
|
|
||||||
payloads: [{ text: "Hello" }],
|
|
||||||
replyToMode: "first",
|
|
||||||
currentMessageId: "42",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].replyToId).toBe("42");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("threads only first payload when mode is 'first'", () => {
|
|
||||||
const result = applyReplyThreading({
|
|
||||||
payloads: [{ text: "A" }, { text: "B" }],
|
|
||||||
replyToMode: "first",
|
|
||||||
currentMessageId: "42",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
expect(result[0].replyToId).toBe("42");
|
|
||||||
expect(result[1].replyToId).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("threads all payloads when mode is 'all'", () => {
|
|
||||||
const result = applyReplyThreading({
|
|
||||||
payloads: [{ text: "A" }, { text: "B" }],
|
|
||||||
replyToMode: "all",
|
|
||||||
currentMessageId: "42",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
expect(result[0].replyToId).toBe("42");
|
|
||||||
expect(result[1].replyToId).toBe("42");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("strips replyToId when mode is 'off'", () => {
|
|
||||||
const result = applyReplyThreading({
|
|
||||||
payloads: [{ text: "A" }],
|
|
||||||
replyToMode: "off",
|
|
||||||
currentMessageId: "42",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].replyToId).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not bypass off mode for Slack when reply is implicit", () => {
|
|
||||||
const result = applyReplyThreading({
|
|
||||||
payloads: [{ text: "A" }],
|
|
||||||
replyToMode: "off",
|
|
||||||
replyToChannel: "slack",
|
|
||||||
currentMessageId: "42",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].replyToId).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps explicit tags for Slack when off mode allows tags", () => {
|
|
||||||
const result = applyReplyThreading({
|
|
||||||
payloads: [{ text: "[[reply_to_current]]A" }],
|
|
||||||
replyToMode: "off",
|
|
||||||
replyToChannel: "slack",
|
|
||||||
currentMessageId: "42",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].replyToId).toBe("42");
|
|
||||||
expect(result[0].replyToTag).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps explicit tags for Telegram when off mode is enabled", () => {
|
|
||||||
const result = applyReplyThreading({
|
|
||||||
payloads: [{ text: "[[reply_to_current]]A" }],
|
|
||||||
replyToMode: "off",
|
|
||||||
replyToChannel: "telegram",
|
|
||||||
currentMessageId: "42",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].replyToId).toBe("42");
|
|
||||||
expect(result[0].replyToTag).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
253
src/auto-reply/reply/reply-plumbing.test.ts
Normal file
253
src/auto-reply/reply/reply-plumbing.test.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
|
||||||
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
|
import type { TemplateContext } from "../templating.js";
|
||||||
|
import { formatDurationCompact } from "../../infra/format-time/format-duration.js";
|
||||||
|
import { buildThreadingToolContext } from "./agent-runner-utils.js";
|
||||||
|
import { applyReplyThreading } from "./reply-payloads.js";
|
||||||
|
import {
|
||||||
|
formatRunLabel,
|
||||||
|
formatRunStatus,
|
||||||
|
resolveSubagentLabel,
|
||||||
|
sortSubagentRuns,
|
||||||
|
} from "./subagents-utils.js";
|
||||||
|
|
||||||
|
describe("buildThreadingToolContext", () => {
|
||||||
|
const cfg = {} as OpenClawConfig;
|
||||||
|
|
||||||
|
it("uses conversation id for WhatsApp", () => {
|
||||||
|
const sessionCtx = {
|
||||||
|
Provider: "whatsapp",
|
||||||
|
From: "123@g.us",
|
||||||
|
To: "+15550001",
|
||||||
|
} as TemplateContext;
|
||||||
|
|
||||||
|
const result = buildThreadingToolContext({
|
||||||
|
sessionCtx,
|
||||||
|
config: cfg,
|
||||||
|
hasRepliedRef: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.currentChannelId).toBe("123@g.us");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to To for WhatsApp when From is missing", () => {
|
||||||
|
const sessionCtx = {
|
||||||
|
Provider: "whatsapp",
|
||||||
|
To: "+15550001",
|
||||||
|
} as TemplateContext;
|
||||||
|
|
||||||
|
const result = buildThreadingToolContext({
|
||||||
|
sessionCtx,
|
||||||
|
config: cfg,
|
||||||
|
hasRepliedRef: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.currentChannelId).toBe("+15550001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the recipient id for other channels", () => {
|
||||||
|
const sessionCtx = {
|
||||||
|
Provider: "telegram",
|
||||||
|
From: "user:42",
|
||||||
|
To: "chat:99",
|
||||||
|
} as TemplateContext;
|
||||||
|
|
||||||
|
const result = buildThreadingToolContext({
|
||||||
|
sessionCtx,
|
||||||
|
config: cfg,
|
||||||
|
hasRepliedRef: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.currentChannelId).toBe("chat:99");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the sender handle for iMessage direct chats", () => {
|
||||||
|
const sessionCtx = {
|
||||||
|
Provider: "imessage",
|
||||||
|
ChatType: "direct",
|
||||||
|
From: "imessage:+15550001",
|
||||||
|
To: "chat_id:12",
|
||||||
|
} as TemplateContext;
|
||||||
|
|
||||||
|
const result = buildThreadingToolContext({
|
||||||
|
sessionCtx,
|
||||||
|
config: cfg,
|
||||||
|
hasRepliedRef: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.currentChannelId).toBe("imessage:+15550001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses chat_id for iMessage groups", () => {
|
||||||
|
const sessionCtx = {
|
||||||
|
Provider: "imessage",
|
||||||
|
ChatType: "group",
|
||||||
|
From: "imessage:group:7",
|
||||||
|
To: "chat_id:7",
|
||||||
|
} as TemplateContext;
|
||||||
|
|
||||||
|
const result = buildThreadingToolContext({
|
||||||
|
sessionCtx,
|
||||||
|
config: cfg,
|
||||||
|
hasRepliedRef: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.currentChannelId).toBe("chat_id:7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers MessageThreadId for Slack tool threading", () => {
|
||||||
|
const sessionCtx = {
|
||||||
|
Provider: "slack",
|
||||||
|
To: "channel:C1",
|
||||||
|
MessageThreadId: "123.456",
|
||||||
|
} as TemplateContext;
|
||||||
|
|
||||||
|
const result = buildThreadingToolContext({
|
||||||
|
sessionCtx,
|
||||||
|
config: { channels: { slack: { replyToMode: "all" } } } as OpenClawConfig,
|
||||||
|
hasRepliedRef: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.currentChannelId).toBe("C1");
|
||||||
|
expect(result.currentThreadTs).toBe("123.456");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyReplyThreading auto-threading", () => {
|
||||||
|
it("sets replyToId to currentMessageId even without [[reply_to_current]] tag", () => {
|
||||||
|
const result = applyReplyThreading({
|
||||||
|
payloads: [{ text: "Hello" }],
|
||||||
|
replyToMode: "first",
|
||||||
|
currentMessageId: "42",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].replyToId).toBe("42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("threads only first payload when mode is 'first'", () => {
|
||||||
|
const result = applyReplyThreading({
|
||||||
|
payloads: [{ text: "A" }, { text: "B" }],
|
||||||
|
replyToMode: "first",
|
||||||
|
currentMessageId: "42",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].replyToId).toBe("42");
|
||||||
|
expect(result[1].replyToId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("threads all payloads when mode is 'all'", () => {
|
||||||
|
const result = applyReplyThreading({
|
||||||
|
payloads: [{ text: "A" }, { text: "B" }],
|
||||||
|
replyToMode: "all",
|
||||||
|
currentMessageId: "42",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].replyToId).toBe("42");
|
||||||
|
expect(result[1].replyToId).toBe("42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips replyToId when mode is 'off'", () => {
|
||||||
|
const result = applyReplyThreading({
|
||||||
|
payloads: [{ text: "A" }],
|
||||||
|
replyToMode: "off",
|
||||||
|
currentMessageId: "42",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].replyToId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not bypass off mode for Slack when reply is implicit", () => {
|
||||||
|
const result = applyReplyThreading({
|
||||||
|
payloads: [{ text: "A" }],
|
||||||
|
replyToMode: "off",
|
||||||
|
replyToChannel: "slack",
|
||||||
|
currentMessageId: "42",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].replyToId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps explicit tags for Slack when off mode allows tags", () => {
|
||||||
|
const result = applyReplyThreading({
|
||||||
|
payloads: [{ text: "[[reply_to_current]]A" }],
|
||||||
|
replyToMode: "off",
|
||||||
|
replyToChannel: "slack",
|
||||||
|
currentMessageId: "42",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].replyToId).toBe("42");
|
||||||
|
expect(result[0].replyToTag).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps explicit tags for Telegram when off mode is enabled", () => {
|
||||||
|
const result = applyReplyThreading({
|
||||||
|
payloads: [{ text: "[[reply_to_current]]A" }],
|
||||||
|
replyToMode: "off",
|
||||||
|
replyToChannel: "telegram",
|
||||||
|
currentMessageId: "42",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].replyToId).toBe("42");
|
||||||
|
expect(result[0].replyToTag).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseRun: SubagentRunRecord = {
|
||||||
|
runId: "run-1",
|
||||||
|
childSessionKey: "agent:main:subagent:abc",
|
||||||
|
requesterSessionKey: "agent:main:main",
|
||||||
|
requesterDisplayKey: "main",
|
||||||
|
task: "do thing",
|
||||||
|
cleanup: "keep",
|
||||||
|
createdAt: 1000,
|
||||||
|
startedAt: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("subagents utils", () => {
|
||||||
|
it("resolves labels from label, task, or fallback", () => {
|
||||||
|
expect(resolveSubagentLabel({ ...baseRun, label: "Label" })).toBe("Label");
|
||||||
|
expect(resolveSubagentLabel({ ...baseRun, label: " ", task: "Task" })).toBe("Task");
|
||||||
|
expect(resolveSubagentLabel({ ...baseRun, label: " ", task: " " }, "fallback")).toBe(
|
||||||
|
"fallback",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats run labels with truncation", () => {
|
||||||
|
const long = "x".repeat(100);
|
||||||
|
const run = { ...baseRun, label: long };
|
||||||
|
const formatted = formatRunLabel(run, { maxLength: 10 });
|
||||||
|
expect(formatted.startsWith("x".repeat(10))).toBe(true);
|
||||||
|
expect(formatted.endsWith("…")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts subagent runs by newest start/created time", () => {
|
||||||
|
const runs: SubagentRunRecord[] = [
|
||||||
|
{ ...baseRun, runId: "run-1", createdAt: 1000, startedAt: 1000 },
|
||||||
|
{ ...baseRun, runId: "run-2", createdAt: 1200, startedAt: 1200 },
|
||||||
|
{ ...baseRun, runId: "run-3", createdAt: 900 },
|
||||||
|
];
|
||||||
|
const sorted = sortSubagentRuns(runs);
|
||||||
|
expect(sorted.map((run) => run.runId)).toEqual(["run-2", "run-1", "run-3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats run status from outcome and timestamps", () => {
|
||||||
|
expect(formatRunStatus({ ...baseRun })).toBe("running");
|
||||||
|
expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "ok" } })).toBe("done");
|
||||||
|
expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "timeout" } })).toBe(
|
||||||
|
"timeout",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats duration compact for seconds and minutes", () => {
|
||||||
|
expect(formatDurationCompact(45_000)).toBe("45s");
|
||||||
|
expect(formatDurationCompact(65_000)).toBe("1m5s");
|
||||||
|
});
|
||||||
|
});
|
||||||
381
src/auto-reply/reply/reply-state.test.ts
Normal file
381
src/auto-reply/reply/reply-state.test.ts
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { SessionEntry } from "../../config/sessions.js";
|
||||||
|
import {
|
||||||
|
appendHistoryEntry,
|
||||||
|
buildHistoryContext,
|
||||||
|
buildHistoryContextFromEntries,
|
||||||
|
buildHistoryContextFromMap,
|
||||||
|
buildPendingHistoryContextFromMap,
|
||||||
|
clearHistoryEntriesIfEnabled,
|
||||||
|
HISTORY_CONTEXT_MARKER,
|
||||||
|
recordPendingHistoryEntryIfEnabled,
|
||||||
|
} from "./history.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_MEMORY_FLUSH_SOFT_TOKENS,
|
||||||
|
resolveMemoryFlushContextWindowTokens,
|
||||||
|
resolveMemoryFlushSettings,
|
||||||
|
shouldRunMemoryFlush,
|
||||||
|
} from "./memory-flush.js";
|
||||||
|
import { CURRENT_MESSAGE_MARKER } from "./mentions.js";
|
||||||
|
import { incrementCompactionCount } from "./session-updates.js";
|
||||||
|
|
||||||
|
async function seedSessionStore(params: {
|
||||||
|
storePath: string;
|
||||||
|
sessionKey: string;
|
||||||
|
entry: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
await fs.mkdir(path.dirname(params.storePath), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
params.storePath,
|
||||||
|
JSON.stringify({ [params.sessionKey]: params.entry }, null, 2),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("history helpers", () => {
|
||||||
|
it("returns current message when history is empty", () => {
|
||||||
|
const result = buildHistoryContext({
|
||||||
|
historyText: " ",
|
||||||
|
currentMessage: "hello",
|
||||||
|
});
|
||||||
|
expect(result).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps history entries and excludes current by default", () => {
|
||||||
|
const result = buildHistoryContextFromEntries({
|
||||||
|
entries: [
|
||||||
|
{ sender: "A", body: "one" },
|
||||||
|
{ sender: "B", body: "two" },
|
||||||
|
],
|
||||||
|
currentMessage: "current",
|
||||||
|
formatEntry: (entry) => `${entry.sender}: ${entry.body}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toContain(HISTORY_CONTEXT_MARKER);
|
||||||
|
expect(result).toContain("A: one");
|
||||||
|
expect(result).not.toContain("B: two");
|
||||||
|
expect(result).toContain(CURRENT_MESSAGE_MARKER);
|
||||||
|
expect(result).toContain("current");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims history to configured limit", () => {
|
||||||
|
const historyMap = new Map<string, { sender: string; body: string }[]>();
|
||||||
|
|
||||||
|
appendHistoryEntry({
|
||||||
|
historyMap,
|
||||||
|
historyKey: "group",
|
||||||
|
limit: 2,
|
||||||
|
entry: { sender: "A", body: "one" },
|
||||||
|
});
|
||||||
|
appendHistoryEntry({
|
||||||
|
historyMap,
|
||||||
|
historyKey: "group",
|
||||||
|
limit: 2,
|
||||||
|
entry: { sender: "B", body: "two" },
|
||||||
|
});
|
||||||
|
appendHistoryEntry({
|
||||||
|
historyMap,
|
||||||
|
historyKey: "group",
|
||||||
|
limit: 2,
|
||||||
|
entry: { sender: "C", body: "three" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two", "three"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds context from map and appends entry", () => {
|
||||||
|
const historyMap = new Map<string, { sender: string; body: string }[]>();
|
||||||
|
historyMap.set("group", [
|
||||||
|
{ sender: "A", body: "one" },
|
||||||
|
{ sender: "B", body: "two" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = buildHistoryContextFromMap({
|
||||||
|
historyMap,
|
||||||
|
historyKey: "group",
|
||||||
|
limit: 3,
|
||||||
|
entry: { sender: "C", body: "three" },
|
||||||
|
currentMessage: "current",
|
||||||
|
formatEntry: (entry) => `${entry.sender}: ${entry.body}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two", "three"]);
|
||||||
|
expect(result).toContain(HISTORY_CONTEXT_MARKER);
|
||||||
|
expect(result).toContain("A: one");
|
||||||
|
expect(result).toContain("B: two");
|
||||||
|
expect(result).not.toContain("C: three");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds context from pending map without appending", () => {
|
||||||
|
const historyMap = new Map<string, { sender: string; body: string }[]>();
|
||||||
|
historyMap.set("group", [
|
||||||
|
{ sender: "A", body: "one" },
|
||||||
|
{ sender: "B", body: "two" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = buildPendingHistoryContextFromMap({
|
||||||
|
historyMap,
|
||||||
|
historyKey: "group",
|
||||||
|
limit: 3,
|
||||||
|
currentMessage: "current",
|
||||||
|
formatEntry: (entry) => `${entry.sender}: ${entry.body}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]);
|
||||||
|
expect(result).toContain(HISTORY_CONTEXT_MARKER);
|
||||||
|
expect(result).toContain("A: one");
|
||||||
|
expect(result).toContain("B: two");
|
||||||
|
expect(result).toContain(CURRENT_MESSAGE_MARKER);
|
||||||
|
expect(result).toContain("current");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records pending entries only when enabled", () => {
|
||||||
|
const historyMap = new Map<string, { sender: string; body: string }[]>();
|
||||||
|
|
||||||
|
recordPendingHistoryEntryIfEnabled({
|
||||||
|
historyMap,
|
||||||
|
historyKey: "group",
|
||||||
|
limit: 0,
|
||||||
|
entry: { sender: "A", body: "one" },
|
||||||
|
});
|
||||||
|
expect(historyMap.get("group")).toEqual(undefined);
|
||||||
|
|
||||||
|
recordPendingHistoryEntryIfEnabled({
|
||||||
|
historyMap,
|
||||||
|
historyKey: "group",
|
||||||
|
limit: 2,
|
||||||
|
entry: null,
|
||||||
|
});
|
||||||
|
expect(historyMap.get("group")).toEqual(undefined);
|
||||||
|
|
||||||
|
recordPendingHistoryEntryIfEnabled({
|
||||||
|
historyMap,
|
||||||
|
historyKey: "group",
|
||||||
|
limit: 2,
|
||||||
|
entry: { sender: "B", body: "two" },
|
||||||
|
});
|
||||||
|
expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears history entries only when enabled", () => {
|
||||||
|
const historyMap = new Map<string, { sender: string; body: string }[]>();
|
||||||
|
historyMap.set("group", [
|
||||||
|
{ sender: "A", body: "one" },
|
||||||
|
{ sender: "B", body: "two" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 0 });
|
||||||
|
expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]);
|
||||||
|
|
||||||
|
clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 2 });
|
||||||
|
expect(historyMap.get("group")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("memory flush settings", () => {
|
||||||
|
it("defaults to enabled with fallback prompt and system prompt", () => {
|
||||||
|
const settings = resolveMemoryFlushSettings();
|
||||||
|
expect(settings).not.toBeNull();
|
||||||
|
expect(settings?.enabled).toBe(true);
|
||||||
|
expect(settings?.prompt.length).toBeGreaterThan(0);
|
||||||
|
expect(settings?.systemPrompt.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects disable flag", () => {
|
||||||
|
expect(
|
||||||
|
resolveMemoryFlushSettings({
|
||||||
|
agents: {
|
||||||
|
defaults: { compaction: { memoryFlush: { enabled: false } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends NO_REPLY hint when missing", () => {
|
||||||
|
const settings = resolveMemoryFlushSettings({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
compaction: {
|
||||||
|
memoryFlush: {
|
||||||
|
prompt: "Write memories now.",
|
||||||
|
systemPrompt: "Flush memory.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(settings?.prompt).toContain("NO_REPLY");
|
||||||
|
expect(settings?.systemPrompt).toContain("NO_REPLY");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldRunMemoryFlush", () => {
|
||||||
|
it("requires totalTokens and threshold", () => {
|
||||||
|
expect(
|
||||||
|
shouldRunMemoryFlush({
|
||||||
|
entry: { totalTokens: 0 },
|
||||||
|
contextWindowTokens: 16_000,
|
||||||
|
reserveTokensFloor: 20_000,
|
||||||
|
softThresholdTokens: DEFAULT_MEMORY_FLUSH_SOFT_TOKENS,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips when entry is missing", () => {
|
||||||
|
expect(
|
||||||
|
shouldRunMemoryFlush({
|
||||||
|
entry: undefined,
|
||||||
|
contextWindowTokens: 16_000,
|
||||||
|
reserveTokensFloor: 1_000,
|
||||||
|
softThresholdTokens: DEFAULT_MEMORY_FLUSH_SOFT_TOKENS,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips when under threshold", () => {
|
||||||
|
expect(
|
||||||
|
shouldRunMemoryFlush({
|
||||||
|
entry: { totalTokens: 10_000 },
|
||||||
|
contextWindowTokens: 100_000,
|
||||||
|
reserveTokensFloor: 20_000,
|
||||||
|
softThresholdTokens: 10_000,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggers at the threshold boundary", () => {
|
||||||
|
expect(
|
||||||
|
shouldRunMemoryFlush({
|
||||||
|
entry: { totalTokens: 85 },
|
||||||
|
contextWindowTokens: 100,
|
||||||
|
reserveTokensFloor: 10,
|
||||||
|
softThresholdTokens: 5,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips when already flushed for current compaction count", () => {
|
||||||
|
expect(
|
||||||
|
shouldRunMemoryFlush({
|
||||||
|
entry: {
|
||||||
|
totalTokens: 90_000,
|
||||||
|
compactionCount: 2,
|
||||||
|
memoryFlushCompactionCount: 2,
|
||||||
|
},
|
||||||
|
contextWindowTokens: 100_000,
|
||||||
|
reserveTokensFloor: 5_000,
|
||||||
|
softThresholdTokens: 2_000,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs when above threshold and not flushed", () => {
|
||||||
|
expect(
|
||||||
|
shouldRunMemoryFlush({
|
||||||
|
entry: { totalTokens: 96_000, compactionCount: 1 },
|
||||||
|
contextWindowTokens: 100_000,
|
||||||
|
reserveTokensFloor: 5_000,
|
||||||
|
softThresholdTokens: 2_000,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores stale cached totals", () => {
|
||||||
|
expect(
|
||||||
|
shouldRunMemoryFlush({
|
||||||
|
entry: { totalTokens: 96_000, totalTokensFresh: false, compactionCount: 1 },
|
||||||
|
contextWindowTokens: 100_000,
|
||||||
|
reserveTokensFloor: 5_000,
|
||||||
|
softThresholdTokens: 2_000,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveMemoryFlushContextWindowTokens", () => {
|
||||||
|
it("falls back to agent config or default tokens", () => {
|
||||||
|
expect(resolveMemoryFlushContextWindowTokens({ agentCfgContextTokens: 42_000 })).toBe(42_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("incrementCompactionCount", () => {
|
||||||
|
it("increments compaction count", async () => {
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-"));
|
||||||
|
const storePath = path.join(tmp, "sessions.json");
|
||||||
|
const sessionKey = "main";
|
||||||
|
const entry = { sessionId: "s1", updatedAt: Date.now(), compactionCount: 2 } as SessionEntry;
|
||||||
|
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: entry };
|
||||||
|
await seedSessionStore({ storePath, sessionKey, entry });
|
||||||
|
|
||||||
|
const count = await incrementCompactionCount({
|
||||||
|
sessionEntry: entry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey,
|
||||||
|
storePath,
|
||||||
|
});
|
||||||
|
expect(count).toBe(3);
|
||||||
|
|
||||||
|
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||||
|
expect(stored[sessionKey].compactionCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates totalTokens when tokensAfter is provided", async () => {
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-"));
|
||||||
|
const storePath = path.join(tmp, "sessions.json");
|
||||||
|
const sessionKey = "main";
|
||||||
|
const entry = {
|
||||||
|
sessionId: "s1",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
compactionCount: 0,
|
||||||
|
totalTokens: 180_000,
|
||||||
|
inputTokens: 170_000,
|
||||||
|
outputTokens: 10_000,
|
||||||
|
} as SessionEntry;
|
||||||
|
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: entry };
|
||||||
|
await seedSessionStore({ storePath, sessionKey, entry });
|
||||||
|
|
||||||
|
await incrementCompactionCount({
|
||||||
|
sessionEntry: entry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey,
|
||||||
|
storePath,
|
||||||
|
tokensAfter: 12_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||||
|
expect(stored[sessionKey].compactionCount).toBe(1);
|
||||||
|
expect(stored[sessionKey].totalTokens).toBe(12_000);
|
||||||
|
// input/output cleared since we only have the total estimate
|
||||||
|
expect(stored[sessionKey].inputTokens).toBeUndefined();
|
||||||
|
expect(stored[sessionKey].outputTokens).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not update totalTokens when tokensAfter is not provided", async () => {
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-"));
|
||||||
|
const storePath = path.join(tmp, "sessions.json");
|
||||||
|
const sessionKey = "main";
|
||||||
|
const entry = {
|
||||||
|
sessionId: "s1",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
compactionCount: 0,
|
||||||
|
totalTokens: 180_000,
|
||||||
|
} as SessionEntry;
|
||||||
|
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: entry };
|
||||||
|
await seedSessionStore({ storePath, sessionKey, entry });
|
||||||
|
|
||||||
|
await incrementCompactionCount({
|
||||||
|
sessionEntry: entry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey,
|
||||||
|
storePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||||
|
expect(stored[sessionKey].compactionCount).toBe(1);
|
||||||
|
// totalTokens unchanged
|
||||||
|
expect(stored[sessionKey].totalTokens).toBe(180_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { SessionEntry } from "../../config/sessions.js";
|
|
||||||
import { incrementCompactionCount } from "./session-updates.js";
|
|
||||||
|
|
||||||
async function seedSessionStore(params: {
|
|
||||||
storePath: string;
|
|
||||||
sessionKey: string;
|
|
||||||
entry: Record<string, unknown>;
|
|
||||||
}) {
|
|
||||||
await fs.mkdir(path.dirname(params.storePath), { recursive: true });
|
|
||||||
await fs.writeFile(
|
|
||||||
params.storePath,
|
|
||||||
JSON.stringify({ [params.sessionKey]: params.entry }, null, 2),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("incrementCompactionCount", () => {
|
|
||||||
it("increments compaction count", async () => {
|
|
||||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-"));
|
|
||||||
const storePath = path.join(tmp, "sessions.json");
|
|
||||||
const sessionKey = "main";
|
|
||||||
const entry = { sessionId: "s1", updatedAt: Date.now(), compactionCount: 2 } as SessionEntry;
|
|
||||||
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: entry };
|
|
||||||
await seedSessionStore({ storePath, sessionKey, entry });
|
|
||||||
|
|
||||||
const count = await incrementCompactionCount({
|
|
||||||
sessionEntry: entry,
|
|
||||||
sessionStore,
|
|
||||||
sessionKey,
|
|
||||||
storePath,
|
|
||||||
});
|
|
||||||
expect(count).toBe(3);
|
|
||||||
|
|
||||||
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
|
||||||
expect(stored[sessionKey].compactionCount).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates totalTokens when tokensAfter is provided", async () => {
|
|
||||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-"));
|
|
||||||
const storePath = path.join(tmp, "sessions.json");
|
|
||||||
const sessionKey = "main";
|
|
||||||
const entry = {
|
|
||||||
sessionId: "s1",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
compactionCount: 0,
|
|
||||||
totalTokens: 180_000,
|
|
||||||
inputTokens: 170_000,
|
|
||||||
outputTokens: 10_000,
|
|
||||||
} as SessionEntry;
|
|
||||||
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: entry };
|
|
||||||
await seedSessionStore({ storePath, sessionKey, entry });
|
|
||||||
|
|
||||||
await incrementCompactionCount({
|
|
||||||
sessionEntry: entry,
|
|
||||||
sessionStore,
|
|
||||||
sessionKey,
|
|
||||||
storePath,
|
|
||||||
tokensAfter: 12_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
|
||||||
expect(stored[sessionKey].compactionCount).toBe(1);
|
|
||||||
expect(stored[sessionKey].totalTokens).toBe(12_000);
|
|
||||||
// input/output cleared since we only have the total estimate
|
|
||||||
expect(stored[sessionKey].inputTokens).toBeUndefined();
|
|
||||||
expect(stored[sessionKey].outputTokens).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not update totalTokens when tokensAfter is not provided", async () => {
|
|
||||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-"));
|
|
||||||
const storePath = path.join(tmp, "sessions.json");
|
|
||||||
const sessionKey = "main";
|
|
||||||
const entry = {
|
|
||||||
sessionId: "s1",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
compactionCount: 0,
|
|
||||||
totalTokens: 180_000,
|
|
||||||
} as SessionEntry;
|
|
||||||
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: entry };
|
|
||||||
await seedSessionStore({ storePath, sessionKey, entry });
|
|
||||||
|
|
||||||
await incrementCompactionCount({
|
|
||||||
sessionEntry: entry,
|
|
||||||
sessionStore,
|
|
||||||
sessionKey,
|
|
||||||
storePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
|
||||||
expect(stored[sessionKey].compactionCount).toBe(1);
|
|
||||||
// totalTokens unchanged
|
|
||||||
expect(stored[sessionKey].totalTokens).toBe(180_000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
|
|
||||||
import { formatDurationCompact } from "../../infra/format-time/format-duration.js";
|
|
||||||
import {
|
|
||||||
formatRunLabel,
|
|
||||||
formatRunStatus,
|
|
||||||
resolveSubagentLabel,
|
|
||||||
sortSubagentRuns,
|
|
||||||
} from "./subagents-utils.js";
|
|
||||||
|
|
||||||
const baseRun: SubagentRunRecord = {
|
|
||||||
runId: "run-1",
|
|
||||||
childSessionKey: "agent:main:subagent:abc",
|
|
||||||
requesterSessionKey: "agent:main:main",
|
|
||||||
requesterDisplayKey: "main",
|
|
||||||
task: "do thing",
|
|
||||||
cleanup: "keep",
|
|
||||||
createdAt: 1000,
|
|
||||||
startedAt: 1000,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("subagents utils", () => {
|
|
||||||
it("resolves labels from label, task, or fallback", () => {
|
|
||||||
expect(resolveSubagentLabel({ ...baseRun, label: "Label" })).toBe("Label");
|
|
||||||
expect(resolveSubagentLabel({ ...baseRun, label: " ", task: "Task" })).toBe("Task");
|
|
||||||
expect(resolveSubagentLabel({ ...baseRun, label: " ", task: " " }, "fallback")).toBe(
|
|
||||||
"fallback",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats run labels with truncation", () => {
|
|
||||||
const long = "x".repeat(100);
|
|
||||||
const run = { ...baseRun, label: long };
|
|
||||||
const formatted = formatRunLabel(run, { maxLength: 10 });
|
|
||||||
expect(formatted.startsWith("x".repeat(10))).toBe(true);
|
|
||||||
expect(formatted.endsWith("…")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sorts subagent runs by newest start/created time", () => {
|
|
||||||
const runs: SubagentRunRecord[] = [
|
|
||||||
{ ...baseRun, runId: "run-1", createdAt: 1000, startedAt: 1000 },
|
|
||||||
{ ...baseRun, runId: "run-2", createdAt: 1200, startedAt: 1200 },
|
|
||||||
{ ...baseRun, runId: "run-3", createdAt: 900 },
|
|
||||||
];
|
|
||||||
const sorted = sortSubagentRuns(runs);
|
|
||||||
expect(sorted.map((run) => run.runId)).toEqual(["run-2", "run-1", "run-3"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats run status from outcome and timestamps", () => {
|
|
||||||
expect(formatRunStatus({ ...baseRun })).toBe("running");
|
|
||||||
expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "ok" } })).toBe("done");
|
|
||||||
expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "timeout" } })).toBe(
|
|
||||||
"timeout",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats duration compact for seconds and minutes", () => {
|
|
||||||
expect(formatDurationCompact(45_000)).toBe("45s");
|
|
||||||
expect(formatDurationCompact(65_000)).toBe("1m5s");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user