diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts deleted file mode 100644 index 145b93bd6..000000000 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/src/auto-reply/reply/history.test.ts b/src/auto-reply/reply/history.test.ts deleted file mode 100644 index 7991731da..000000000 --- a/src/auto-reply/reply/history.test.ts +++ /dev/null @@ -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(); - - 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(); - 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(); - 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(); - - 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(); - 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([]); - }); -}); diff --git a/src/auto-reply/reply/memory-flush.test.ts b/src/auto-reply/reply/memory-flush.test.ts deleted file mode 100644 index e3dcc124e..000000000 --- a/src/auto-reply/reply/memory-flush.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/auto-reply/reply/reply-payloads.auto-threading.test.ts b/src/auto-reply/reply/reply-payloads.auto-threading.test.ts deleted file mode 100644 index 80578f4b7..000000000 --- a/src/auto-reply/reply/reply-payloads.auto-threading.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/auto-reply/reply/reply-plumbing.test.ts b/src/auto-reply/reply/reply-plumbing.test.ts new file mode 100644 index 000000000..2b1d1367a --- /dev/null +++ b/src/auto-reply/reply/reply-plumbing.test.ts @@ -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"); + }); +}); diff --git a/src/auto-reply/reply/reply-state.test.ts b/src/auto-reply/reply/reply-state.test.ts new file mode 100644 index 000000000..182506b4e --- /dev/null +++ b/src/auto-reply/reply/reply-state.test.ts @@ -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; +}) { + 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(); + + 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(); + 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(); + 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(); + + 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(); + 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 = { [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 = { [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 = { [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); + }); +}); diff --git a/src/auto-reply/reply/session-updates.incrementcompactioncount.test.ts b/src/auto-reply/reply/session-updates.incrementcompactioncount.test.ts deleted file mode 100644 index 5a90b4ed5..000000000 --- a/src/auto-reply/reply/session-updates.incrementcompactioncount.test.ts +++ /dev/null @@ -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; -}) { - 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 = { [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 = { [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 = { [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); - }); -}); diff --git a/src/auto-reply/reply/subagents-utils.test.ts b/src/auto-reply/reply/subagents-utils.test.ts deleted file mode 100644 index b66a70680..000000000 --- a/src/auto-reply/reply/subagents-utils.test.ts +++ /dev/null @@ -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"); - }); -});