From 0cd18e1fb9ff0c83fe10e0cf613b2031184afb8d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 14 Aug 2026 10:43:29 -0700 Subject: [PATCH] test: split compact command lifecycle coverage (#123765) --- .../reply/commands-compact.lifecycle.test.ts | 213 +++++++++++++ .../reply/commands-compact.test-support.ts | 104 ++++++ src/auto-reply/reply/commands-compact.test.ts | 296 +----------------- 3 files changed, 328 insertions(+), 285 deletions(-) create mode 100644 src/auto-reply/reply/commands-compact.lifecycle.test.ts create mode 100644 src/auto-reply/reply/commands-compact.test-support.ts diff --git a/src/auto-reply/reply/commands-compact.lifecycle.test.ts b/src/auto-reply/reply/commands-compact.lifecycle.test.ts new file mode 100644 index 000000000000..0bb49e45b523 --- /dev/null +++ b/src/auto-reply/reply/commands-compact.lifecycle.test.ts @@ -0,0 +1,213 @@ +// Tests compact-command session authority across awaited lifecycle transitions. +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { + abortEmbeddedAgentRun, + buildCompactParams, + compactEmbeddedAgentSession, + handleCompactCommand, + incrementCompactionCount, + isCurrentSessionEntry, + isEmbeddedAgentRunAbortableForCompaction, + resetCompactCommandMocks, + waitForEmbeddedAgentRunEnd, +} from "./commands-compact.test-support.js"; +import type { HandleCommandsParams } from "./commands-types.js"; + +describe("handleCompactCommand lifecycle authority", () => { + beforeEach(resetCompactCommandMocks); + + it("does not abort the command reply run before compacting", async () => { + vi.mocked(isEmbeddedAgentRunAbortableForCompaction).mockReturnValueOnce(false); + vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ + ok: true, + compacted: false, + }); + + const result = await handleCompactCommand( + { + ...buildCompactParams("/compact", { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig), + sessionEntry: { + sessionId: "session-1", + updatedAt: Date.now(), + }, + } as HandleCommandsParams, + true, + ); + + expect(result?.shouldContinue).toBe(false); + expect(vi.mocked(isEmbeddedAgentRunAbortableForCompaction)).toHaveBeenCalledWith("session-1"); + expect(vi.mocked(abortEmbeddedAgentRun)).not.toHaveBeenCalled(); + expect(vi.mocked(waitForEmbeddedAgentRunEnd)).not.toHaveBeenCalled(); + expect(vi.mocked(compactEmbeddedAgentSession)).toHaveBeenCalledOnce(); + }); + + it("does not abort a run after the bound session changes", async () => { + vi.mocked(isCurrentSessionEntry).mockReturnValueOnce(false); + vi.mocked(isEmbeddedAgentRunAbortableForCompaction).mockReturnValueOnce(true); + + const result = await handleCompactCommand( + { + ...buildCompactParams("/compact", { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig), + sessionEntry: { + sessionId: "session-1", + updatedAt: Date.now(), + }, + } as HandleCommandsParams, + true, + ); + + expect(result?.sessionCompaction).toEqual({ + compacted: false, + reason: "command session changed", + }); + expect(vi.mocked(isEmbeddedAgentRunAbortableForCompaction)).not.toHaveBeenCalled(); + expect(vi.mocked(abortEmbeddedAgentRun)).not.toHaveBeenCalled(); + expect(vi.mocked(compactEmbeddedAgentSession)).not.toHaveBeenCalled(); + }); + + it("waits for an active embedded run before compacting even when abort is rejected", async () => { + vi.mocked(isEmbeddedAgentRunAbortableForCompaction).mockReturnValueOnce(true); + vi.mocked(abortEmbeddedAgentRun).mockReturnValueOnce(false); + vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ + ok: true, + compacted: false, + }); + + await handleCompactCommand( + { + ...buildCompactParams("/compact", { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig), + sessionEntry: { + sessionId: "session-1", + updatedAt: Date.now(), + }, + } as HandleCommandsParams, + true, + ); + + expect(vi.mocked(abortEmbeddedAgentRun)).toHaveBeenCalledWith("session-1"); + expect(vi.mocked(waitForEmbeddedAgentRunEnd)).toHaveBeenCalledWith("session-1", 15_000); + expect(vi.mocked(compactEmbeddedAgentSession)).toHaveBeenCalledOnce(); + }); + + it("does not replace an active run when abort drain times out", async () => { + vi.mocked(isEmbeddedAgentRunAbortableForCompaction).mockReturnValueOnce(true); + vi.mocked(waitForEmbeddedAgentRunEnd).mockResolvedValueOnce(false); + + const result = await handleCompactCommand( + { + ...buildCompactParams("/compact", { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig), + sessionEntry: { + sessionId: "session-1", + updatedAt: Date.now(), + }, + } as HandleCommandsParams, + true, + ); + + expect(result).toEqual({ + shouldContinue: false, + sessionCompaction: { + compacted: false, + reason: "the previous run is still stopping", + }, + reply: { + text: "⚙️ Compaction unavailable: the previous run is still stopping.", + isStatusNotice: true, + }, + }); + expect(vi.mocked(abortEmbeddedAgentRun)).toHaveBeenCalledWith("session-1"); + expect(vi.mocked(waitForEmbeddedAgentRunEnd)).toHaveBeenCalledWith("session-1", 15_000); + expect(vi.mocked(compactEmbeddedAgentSession)).not.toHaveBeenCalled(); + }); + + it("accepts the successor session produced by context-engine accounting", async () => { + let currentSessionId = "native-session"; + vi.mocked(isCurrentSessionEntry).mockImplementation( + ({ expected }) => expected.sessionId === currentSessionId, + ); + vi.mocked(incrementCompactionCount).mockImplementationOnce(async () => { + currentSessionId = "successor-session"; + return 1; + }); + vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ + ok: true, + compacted: true, + compactionKind: "context-engine", + result: { + summary: "compacted", + firstKeptEntryId: "first-kept", + sessionId: "successor-session", + tokensBefore: 999, + tokensAfter: 321, + }, + }); + + const result = await handleCompactCommand( + { + ...buildCompactParams("/compact", { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig), + sessionEntry: { + sessionId: "native-session", + updatedAt: Date.now(), + }, + } as HandleCommandsParams, + true, + ); + + expect(result?.sessionCompaction).toMatchObject({ compacted: true, tokensAfter: 321 }); + }); + + it("rejects a successor session when compaction accounting did not commit", async () => { + let currentSessionId = "native-session"; + vi.mocked(isCurrentSessionEntry).mockImplementation( + ({ expected }) => expected.sessionId === currentSessionId, + ); + vi.mocked(incrementCompactionCount).mockImplementationOnce(async () => { + currentSessionId = "successor-session"; + return undefined; + }); + vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ + ok: true, + compacted: true, + compactionKind: "context-engine", + result: { + summary: "compacted", + firstKeptEntryId: "first-kept", + sessionId: "successor-session", + tokensBefore: 999, + tokensAfter: 321, + }, + }); + + const result = await handleCompactCommand( + { + ...buildCompactParams("/compact", { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig), + sessionEntry: { sessionId: "native-session", updatedAt: Date.now() }, + } as HandleCommandsParams, + true, + ); + + expect(result?.sessionCompaction).toEqual({ + compacted: false, + reason: "command session changed", + }); + }); +}); diff --git a/src/auto-reply/reply/commands-compact.test-support.ts b/src/auto-reply/reply/commands-compact.test-support.ts new file mode 100644 index 000000000000..6dc9853d1670 --- /dev/null +++ b/src/auto-reply/reply/commands-compact.test-support.ts @@ -0,0 +1,104 @@ +// Shared compact-command mocks and fixtures for focused behavior suites. +import { vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { + resolveAgentDirMock, + resolveSessionAgentIdMock, +} from "./commands-agent-scope.test-support.js"; +import type { HandleCommandsParams } from "./commands-types.js"; + +vi.mock("./commands-compact.runtime.js", () => ({ + abortEmbeddedAgentRun: vi.fn(), + compactEmbeddedAgentSession: vi.fn(), + enqueueSystemEvent: vi.fn(), + formatContextUsageShort: vi.fn(() => "Context 12.1k"), + formatTokenCount: vi.fn((value: number) => `${value}`), + incrementCompactionCount: vi.fn(), + isCurrentSessionEntry: vi.fn(() => true), + isEmbeddedAgentRunAbortableForCompaction: vi.fn().mockReturnValue(false), + resolveFreshSessionTotalTokens: vi.fn(() => 12_345), + resolveSessionFilePathOptions: vi.fn(() => ({})), + waitForEmbeddedAgentRunEnd: vi.fn().mockResolvedValue(true), +})); + +export const { + abortEmbeddedAgentRun, + compactEmbeddedAgentSession, + formatContextUsageShort, + incrementCompactionCount, + isCurrentSessionEntry, + isEmbeddedAgentRunAbortableForCompaction, + waitForEmbeddedAgentRunEnd, +} = await import("./commands-compact.runtime.js"); +export const { handleCompactCommand } = await import("./commands-compact.js"); + +export function buildCompactParams( + commandBodyNormalized: string, + cfg: OpenClawConfig, +): HandleCommandsParams { + return { + cfg, + ctx: { + Provider: "whatsapp", + Surface: "whatsapp", + CommandSource: "text", + CommandBody: commandBodyNormalized, + commandText: commandBodyNormalized, + }, + command: { + commandBodyNormalized, + isAuthorizedSender: true, + senderIsOwner: false, + senderId: "owner", + channel: "whatsapp", + ownerList: [], + }, + sessionKey: "agent:main:main", + sessionStore: {}, + resolveDefaultThinkingLevel: async () => "medium", + } as unknown as HandleCommandsParams; +} + +export function resetCompactCommandMocks() { + vi.clearAllMocks(); + vi.mocked(incrementCompactionCount).mockResolvedValue(1); + vi.mocked(isCurrentSessionEntry).mockReturnValue(true); + resolveAgentDirMock.mockImplementation( + (_cfg: unknown, agentId: string) => `/tmp/workspace/.openclaw/agents/${agentId}/agent`, + ); + resolveSessionAgentIdMock.mockReturnValue("main"); +} + +export function requireCompactEmbeddedAgentSessionCall(index = 0) { + const call = vi.mocked(compactEmbeddedAgentSession).mock.calls[index]?.[0]; + if (!call) { + throw new Error(`compactEmbeddedAgentSession call ${index} missing`); + } + return call; +} + +export function requireIncrementCompactionCountCall(index = 0) { + const call = vi.mocked(incrementCompactionCount).mock.calls[index]?.[0]; + if (!call) { + throw new Error(`incrementCompactionCount call ${index} missing`); + } + return call; +} + +export function requireResolveSessionAgentIdCall(index = 0) { + const call = ( + resolveSessionAgentIdMock.mock.calls[index] as unknown as [unknown] | undefined + )?.[0] as { sessionKey?: string; config?: OpenClawConfig } | undefined; + if (!call) { + throw new Error(`resolveSessionAgentId call ${index} missing`); + } + return call; +} + +export function requireResolveAgentDirCall(index = 0) { + const call = resolveAgentDirMock.mock.calls[index] as [OpenClawConfig, string] | undefined; + if (!call) { + throw new Error(`resolveAgentDir call ${index} missing`); + } + return call; +} diff --git a/src/auto-reply/reply/commands-compact.test.ts b/src/auto-reply/reply/commands-compact.test.ts index fa91098cbad8..9254ea28ed16 100644 --- a/src/auto-reply/reply/commands-compact.test.ts +++ b/src/auto-reply/reply/commands-compact.test.ts @@ -6,104 +6,24 @@ import { resolveAgentDirMock, resolveSessionAgentIdMock, } from "./commands-agent-scope.test-support.js"; -import type { HandleCommandsParams } from "./commands-types.js"; - -vi.mock("./commands-compact.runtime.js", () => ({ - abortEmbeddedAgentRun: vi.fn(), - compactEmbeddedAgentSession: vi.fn(), - enqueueSystemEvent: vi.fn(), - formatContextUsageShort: vi.fn(() => "Context 12.1k"), - formatTokenCount: vi.fn((value: number) => `${value}`), - incrementCompactionCount: vi.fn(), - isCurrentSessionEntry: vi.fn(() => true), - isEmbeddedAgentRunAbortableForCompaction: vi.fn().mockReturnValue(false), - resolveFreshSessionTotalTokens: vi.fn(() => 12_345), - resolveSessionFilePathOptions: vi.fn(() => ({})), - waitForEmbeddedAgentRunEnd: vi.fn().mockResolvedValue(true), -})); - -const { +import { abortEmbeddedAgentRun, + buildCompactParams, compactEmbeddedAgentSession, formatContextUsageShort, + handleCompactCommand, incrementCompactionCount, - isCurrentSessionEntry, - isEmbeddedAgentRunAbortableForCompaction, + requireCompactEmbeddedAgentSessionCall, + requireIncrementCompactionCountCall, + requireResolveAgentDirCall, + requireResolveSessionAgentIdCall, + resetCompactCommandMocks, waitForEmbeddedAgentRunEnd, -} = await import("./commands-compact.runtime.js"); -const { handleCompactCommand } = await import("./commands-compact.js"); - -function buildCompactParams( - commandBodyNormalized: string, - cfg: OpenClawConfig, -): HandleCommandsParams { - return { - cfg, - ctx: { - Provider: "whatsapp", - Surface: "whatsapp", - CommandSource: "text", - CommandBody: commandBodyNormalized, - commandText: commandBodyNormalized, - }, - command: { - commandBodyNormalized, - isAuthorizedSender: true, - senderIsOwner: false, - senderId: "owner", - channel: "whatsapp", - ownerList: [], - }, - sessionKey: "agent:main:main", - sessionStore: {}, - resolveDefaultThinkingLevel: async () => "medium", - } as unknown as HandleCommandsParams; -} - -function requireCompactEmbeddedAgentSessionCall(index = 0) { - const call = vi.mocked(compactEmbeddedAgentSession).mock.calls[index]?.[0]; - if (!call) { - throw new Error(`compactEmbeddedAgentSession call ${index} missing`); - } - return call; -} - -function requireResolveSessionAgentIdCall(index = 0) { - const call = ( - resolveSessionAgentIdMock.mock.calls[index] as unknown as [unknown] | undefined - )?.[0] as { sessionKey?: string; config?: OpenClawConfig } | undefined; - if (!call) { - throw new Error(`resolveSessionAgentId call ${index} missing`); - } - return call; -} - -function requireResolveAgentDirCall(index = 0) { - const call = resolveAgentDirMock.mock.calls[index] as [OpenClawConfig, string] | undefined; - if (!call) { - throw new Error(`resolveAgentDir call ${index} missing`); - } - return call; -} - -function requireIncrementCompactionCountCall(index = 0) { - const call = vi.mocked(incrementCompactionCount).mock.calls[index]?.[0]; - if (!call) { - throw new Error(`incrementCompactionCount call ${index} missing`); - } - return call; -} +} from "./commands-compact.test-support.js"; +import type { HandleCommandsParams } from "./commands-types.js"; describe("handleCompactCommand", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(incrementCompactionCount).mockResolvedValue(1); - vi.mocked(isCurrentSessionEntry).mockReturnValue(true); - resolveAgentDirMock.mockImplementation( - (_cfg: unknown, agentId: string) => `/tmp/workspace/.openclaw/agents/${agentId}/agent`, - ); - resolveSessionAgentIdMock.mockReturnValue("main"); - }); + beforeEach(resetCompactCommandMocks); it("returns null when command is not /compact", async () => { const result = await handleCompactCommand( @@ -237,122 +157,6 @@ describe("handleCompactCommand", () => { expect(params.command.ownerList).toEqual(ownerIds); }); - it("does not abort the command reply run before compacting", async () => { - vi.mocked(isEmbeddedAgentRunAbortableForCompaction).mockReturnValueOnce(false); - vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ - ok: true, - compacted: false, - }); - - const result = await handleCompactCommand( - { - ...buildCompactParams("/compact", { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig), - sessionEntry: { - sessionId: "session-1", - updatedAt: Date.now(), - }, - } as HandleCommandsParams, - true, - ); - - expect(result?.shouldContinue).toBe(false); - expect(vi.mocked(isEmbeddedAgentRunAbortableForCompaction)).toHaveBeenCalledWith("session-1"); - expect(vi.mocked(abortEmbeddedAgentRun)).not.toHaveBeenCalled(); - expect(vi.mocked(waitForEmbeddedAgentRunEnd)).not.toHaveBeenCalled(); - expect(vi.mocked(compactEmbeddedAgentSession)).toHaveBeenCalledOnce(); - }); - - it("does not abort a run after the bound session changes", async () => { - vi.mocked(isCurrentSessionEntry).mockReturnValueOnce(false); - vi.mocked(isEmbeddedAgentRunAbortableForCompaction).mockReturnValueOnce(true); - - const result = await handleCompactCommand( - { - ...buildCompactParams("/compact", { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig), - sessionEntry: { - sessionId: "session-1", - updatedAt: Date.now(), - }, - } as HandleCommandsParams, - true, - ); - - expect(result?.sessionCompaction).toEqual({ - compacted: false, - reason: "command session changed", - }); - expect(vi.mocked(isEmbeddedAgentRunAbortableForCompaction)).not.toHaveBeenCalled(); - expect(vi.mocked(abortEmbeddedAgentRun)).not.toHaveBeenCalled(); - expect(vi.mocked(compactEmbeddedAgentSession)).not.toHaveBeenCalled(); - }); - - it("waits for an active embedded run before compacting even when abort is rejected", async () => { - vi.mocked(isEmbeddedAgentRunAbortableForCompaction).mockReturnValueOnce(true); - vi.mocked(abortEmbeddedAgentRun).mockReturnValueOnce(false); - vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ - ok: true, - compacted: false, - }); - - await handleCompactCommand( - { - ...buildCompactParams("/compact", { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig), - sessionEntry: { - sessionId: "session-1", - updatedAt: Date.now(), - }, - } as HandleCommandsParams, - true, - ); - - expect(vi.mocked(abortEmbeddedAgentRun)).toHaveBeenCalledWith("session-1"); - expect(vi.mocked(waitForEmbeddedAgentRunEnd)).toHaveBeenCalledWith("session-1", 15_000); - expect(vi.mocked(compactEmbeddedAgentSession)).toHaveBeenCalledOnce(); - }); - - it("does not replace an active run when abort drain times out", async () => { - vi.mocked(isEmbeddedAgentRunAbortableForCompaction).mockReturnValueOnce(true); - vi.mocked(waitForEmbeddedAgentRunEnd).mockResolvedValueOnce(false); - - const result = await handleCompactCommand( - { - ...buildCompactParams("/compact", { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig), - sessionEntry: { - sessionId: "session-1", - updatedAt: Date.now(), - }, - } as HandleCommandsParams, - true, - ); - - expect(result).toEqual({ - shouldContinue: false, - sessionCompaction: { - compacted: false, - reason: "the previous run is still stopping", - }, - reply: { - text: "⚙️ Compaction unavailable: the previous run is still stopping.", - isStatusNotice: true, - }, - }); - expect(vi.mocked(abortEmbeddedAgentRun)).toHaveBeenCalledWith("session-1"); - expect(vi.mocked(waitForEmbeddedAgentRunEnd)).toHaveBeenCalledWith("session-1", 15_000); - expect(vi.mocked(compactEmbeddedAgentSession)).not.toHaveBeenCalled(); - }); - it("treats already-under-target manual compaction as skipped", async () => { vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ ok: false, @@ -863,84 +667,6 @@ describe("handleCompactCommand", () => { expect(requireIncrementCompactionCountCall().compactionKind).toBe("server-endpoint"); }); - it("accepts the successor session produced by context-engine accounting", async () => { - let currentSessionId = "native-session"; - vi.mocked(isCurrentSessionEntry).mockImplementation( - ({ expected }) => expected.sessionId === currentSessionId, - ); - vi.mocked(incrementCompactionCount).mockImplementationOnce(async () => { - currentSessionId = "successor-session"; - return 1; - }); - vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ - ok: true, - compacted: true, - compactionKind: "context-engine", - result: { - summary: "compacted", - firstKeptEntryId: "first-kept", - sessionId: "successor-session", - tokensBefore: 999, - tokensAfter: 321, - }, - }); - - const result = await handleCompactCommand( - { - ...buildCompactParams("/compact", { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig), - sessionEntry: { - sessionId: "native-session", - updatedAt: Date.now(), - }, - } as HandleCommandsParams, - true, - ); - - expect(result?.sessionCompaction).toMatchObject({ compacted: true, tokensAfter: 321 }); - }); - - it("rejects a successor session when compaction accounting did not commit", async () => { - let currentSessionId = "native-session"; - vi.mocked(isCurrentSessionEntry).mockImplementation( - ({ expected }) => expected.sessionId === currentSessionId, - ); - vi.mocked(incrementCompactionCount).mockImplementationOnce(async () => { - currentSessionId = "successor-session"; - return undefined; - }); - vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({ - ok: true, - compacted: true, - compactionKind: "context-engine", - result: { - summary: "compacted", - firstKeptEntryId: "first-kept", - sessionId: "successor-session", - tokensBefore: 999, - tokensAfter: 321, - }, - }); - - const result = await handleCompactCommand( - { - ...buildCompactParams("/compact", { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig), - sessionEntry: { sessionId: "native-session", updatedAt: Date.now() }, - } as HandleCommandsParams, - true, - ); - - expect(result?.sessionCompaction).toEqual({ - compacted: false, - reason: "command session changed", - }); - }); - it.each([ { owner: "Codex",