diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index 14819dd9c..0803e5ea2 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../../config/sessions.js"; import { loadSessionStore, saveSessionStore } from "../../config/sessions.js"; import { onAgentEvent } from "../../infra/agent-events.js"; +import { peekSystemEvents, resetSystemEventsForTest } from "../../infra/system-events.js"; import type { TemplateContext } from "../templating.js"; import type { FollowupRun, QueueSettings } from "./queue.js"; import { createMockTypingController } from "./test-helpers.js"; @@ -79,6 +80,7 @@ beforeEach(() => { runCliAgentMock.mockClear(); runWithModelFallbackMock.mockClear(); runtimeErrorMock.mockClear(); + resetSystemEventsForTest(); // Default: no provider switch; execute the chosen provider+model. runWithModelFallbackMock.mockImplementation( @@ -92,6 +94,7 @@ beforeEach(() => { afterEach(() => { vi.useRealTimers(); + resetSystemEventsForTest(); }); describe("runReplyAgent onAgentRunStart", () => { @@ -328,6 +331,8 @@ describe("runReplyAgent auto-compaction token update", () => { storePath: string; sessionEntry: Record; config?: Record; + sessionFile?: string; + workspaceDir?: string; }) { const typing = createMockTypingController(); const sessionCtx = { @@ -347,8 +352,8 @@ describe("runReplyAgent auto-compaction token update", () => { sessionId: "session", sessionKey: "main", messageProvider: "whatsapp", - sessionFile: "/tmp/session.jsonl", - workspaceDir: "/tmp", + sessionFile: params.sessionFile ?? "/tmp/session.jsonl", + workspaceDir: params.workspaceDir ?? "/tmp", config: params.config ?? {}, skillsSnapshot: {}, provider: "anthropic", @@ -495,6 +500,84 @@ describe("runReplyAgent auto-compaction token update", () => { // totalTokens should use lastCallUsage (55k), not accumulated (75k) expect(stored[sessionKey].totalTokens).toBe(55_000); }); + + it("does not enqueue legacy post-compaction audit warnings", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-no-audit-warning-")); + const workspaceDir = path.join(tmp, "workspace"); + await fs.mkdir(workspaceDir, { recursive: true }); + const sessionFile = path.join(tmp, "session.jsonl"); + await fs.writeFile( + sessionFile, + `${JSON.stringify({ type: "message", message: { role: "assistant", content: [] } })}\n`, + "utf-8", + ); + + const storePath = path.join(tmp, "sessions.json"); + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 10_000, + compactionCount: 0, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => { + params.onAgentEvent?.({ stream: "compaction", data: { phase: "start" } }); + params.onAgentEvent?.({ stream: "compaction", data: { phase: "end", willRetry: false } }); + return { + payloads: [{ text: "done" }], + meta: { + agentMeta: { + usage: { input: 11_000, output: 500, total: 11_500 }, + lastCallUsage: { input: 10_500, output: 500, total: 11_000 }, + compactionCount: 1, + }, + }, + }; + }); + + const config = { + agents: { defaults: { compaction: { memoryFlush: { enabled: false } } } }, + }; + const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({ + storePath, + sessionEntry, + config, + sessionFile, + workspaceDir, + }); + + await runReplyAgent({ + commandBody: "hello", + followupRun, + queueKey: "main", + resolvedQueue, + shouldSteer: false, + shouldFollowup: false, + isActive: false, + isStreaming: false, + typing, + sessionCtx, + sessionEntry, + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, + storePath, + defaultModel: "anthropic/claude-opus-4-5", + agentCfgContextTokens: 200_000, + resolvedVerboseLevel: "off", + isNewSession: false, + blockStreamingEnabled: false, + resolvedBlockStreamingBreak: "message_end", + shouldInjectGroupIntro: false, + typingMode: "instant", + }); + + const queuedSystemEvents = peekSystemEvents(sessionKey); + expect(queuedSystemEvents.some((event) => event.includes("Post-Compaction Audit"))).toBe(false); + expect(queuedSystemEvents.some((event) => event.includes("WORKFLOW_AUTO.md"))).toBe(false); + }); }); describe("runReplyAgent block streaming", () => { diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 22efc6b96..702e5b7a6 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -44,12 +44,6 @@ import { createAudioAsVoiceBuffer, createBlockReplyPipeline } from "./block-repl import { resolveEffectiveBlockStreamingConfig } from "./block-streaming.js"; import { createFollowupRunner } from "./followup-runner.js"; import { resolveOriginMessageProvider, resolveOriginMessageTo } from "./origin-routing.js"; -import { - auditPostCompactionReads, - extractReadPaths, - formatAuditWarning, - readSessionMessages, -} from "./post-compaction-audit.js"; import { readPostCompactionContext } from "./post-compaction-context.js"; import { resolveActiveRunQueueAction } from "./queue-policy.js"; import { enqueueFollowupRun, type FollowupRun, type QueueSettings } from "./queue.js"; @@ -95,9 +89,6 @@ function appendUnscheduledReminderNote(payloads: ReplyPayload[]): ReplyPayload[] }); } -// Track sessions pending post-compaction read audit (Layer 3) -const pendingPostCompactionAudits = new Map(); - export async function runReplyAgent(params: { commandBody: string; followupRun: FollowupRun; @@ -704,9 +695,6 @@ export async function runReplyAgent(params: { .catch(() => { // Silent failure — post-compaction context is best-effort }); - - // Set pending audit flag for Layer 3 (post-compaction read audit) - pendingPostCompactionAudits.set(sessionKey, true); } if (verboseEnabled) { @@ -721,25 +709,6 @@ export async function runReplyAgent(params: { finalPayloads = appendUsageLine(finalPayloads, responseUsageLine); } - // Post-compaction read audit (Layer 3) - if (sessionKey && pendingPostCompactionAudits.get(sessionKey)) { - pendingPostCompactionAudits.delete(sessionKey); // Delete FIRST — one-shot only - try { - const sessionFile = activeSessionEntry?.sessionFile; - if (sessionFile) { - const messages = readSessionMessages(sessionFile); - const readPaths = extractReadPaths(messages); - const workspaceDir = process.cwd(); - const audit = auditPostCompactionReads(readPaths, workspaceDir); - if (!audit.passed) { - enqueueSystemEvent(formatAuditWarning(audit.missingPatterns), { sessionKey }); - } - } - } catch { - // Silent failure — audit is best-effort - } - } - return finalizeWithFollowup( finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads, queueKey, diff --git a/src/auto-reply/reply/post-compaction-audit.test.ts b/src/auto-reply/reply/post-compaction-audit.test.ts deleted file mode 100644 index d6fdf1763..000000000 --- a/src/auto-reply/reply/post-compaction-audit.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - auditPostCompactionReads, - extractReadPaths, - formatAuditWarning, -} from "./post-compaction-audit.js"; - -describe("extractReadPaths", () => { - it("extracts file paths from Read tool calls", () => { - const messages = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - name: "read", - input: { file_path: "WORKFLOW_AUTO.md" }, - }, - ], - }, - { - role: "assistant", - content: [ - { - type: "tool_use", - name: "read", - input: { file_path: "memory/2026-02-16.md" }, - }, - ], - }, - ]; - - const paths = extractReadPaths(messages); - expect(paths).toEqual(["WORKFLOW_AUTO.md", "memory/2026-02-16.md"]); - }); - - it("handles path parameter (alternative to file_path)", () => { - const messages = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - name: "read", - input: { path: "AGENTS.md" }, - }, - ], - }, - ]; - - const paths = extractReadPaths(messages); - expect(paths).toEqual(["AGENTS.md"]); - }); - - it("ignores non-assistant messages", () => { - const messages = [ - { - role: "user", - content: [ - { - type: "tool_use", - name: "read", - input: { file_path: "should_be_ignored.md" }, - }, - ], - }, - ]; - - const paths = extractReadPaths(messages); - expect(paths).toEqual([]); - }); - - it("ignores non-read tool calls", () => { - const messages = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - name: "exec", - input: { command: "cat WORKFLOW_AUTO.md" }, - }, - ], - }, - ]; - - const paths = extractReadPaths(messages); - expect(paths).toEqual([]); - }); - - it("handles empty messages array", () => { - const paths = extractReadPaths([]); - expect(paths).toEqual([]); - }); - - it("handles messages with non-array content", () => { - const messages = [ - { - role: "assistant", - content: "text only", - }, - ]; - - const paths = extractReadPaths(messages); - expect(paths).toEqual([]); - }); -}); - -describe("auditPostCompactionReads", () => { - const workspaceDir = "/Users/test/workspace"; - - it("passes when all required files are read", () => { - const readPaths = ["WORKFLOW_AUTO.md", "memory/2026-02-16.md"]; - const result = auditPostCompactionReads(readPaths, workspaceDir); - - expect(result.passed).toBe(true); - expect(result.missingPatterns).toEqual([]); - }); - - it("fails when no files are read", () => { - const result = auditPostCompactionReads([], workspaceDir); - - expect(result.passed).toBe(false); - expect(result.missingPatterns).toContain("WORKFLOW_AUTO.md"); - expect(result.missingPatterns.some((p) => p.includes("memory"))).toBe(true); - }); - - it("reports only missing files", () => { - const readPaths = ["WORKFLOW_AUTO.md"]; - const result = auditPostCompactionReads(readPaths, workspaceDir); - - expect(result.passed).toBe(false); - expect(result.missingPatterns).not.toContain("WORKFLOW_AUTO.md"); - expect(result.missingPatterns.some((p) => p.includes("memory"))).toBe(true); - }); - - it("matches RegExp patterns against relative paths", () => { - const readPaths = ["memory/2026-02-16.md"]; - const result = auditPostCompactionReads(readPaths, workspaceDir); - - expect(result.passed).toBe(false); - expect(result.missingPatterns).toContain("WORKFLOW_AUTO.md"); - expect(result.missingPatterns.length).toBe(1); - }); - - it("normalizes relative paths when matching", () => { - const readPaths = ["./WORKFLOW_AUTO.md", "memory/2026-02-16.md"]; - const result = auditPostCompactionReads(readPaths, workspaceDir); - - expect(result.passed).toBe(true); - expect(result.missingPatterns).toEqual([]); - }); - - it("normalizes absolute paths when matching", () => { - const readPaths = [ - "/Users/test/workspace/WORKFLOW_AUTO.md", - "/Users/test/workspace/memory/2026-02-16.md", - ]; - const result = auditPostCompactionReads(readPaths, workspaceDir); - - expect(result.passed).toBe(true); - expect(result.missingPatterns).toEqual([]); - }); - - it("accepts custom required reads list", () => { - const readPaths = ["custom.md"]; - const customRequired = ["custom.md"]; - const result = auditPostCompactionReads(readPaths, workspaceDir, customRequired); - - expect(result.passed).toBe(true); - expect(result.missingPatterns).toEqual([]); - }); -}); - -describe("formatAuditWarning", () => { - it("formats warning message with missing patterns", () => { - const missingPatterns = ["WORKFLOW_AUTO.md", "memory\\/\\d{4}-\\d{2}-\\d{2}\\.md"]; - const message = formatAuditWarning(missingPatterns); - - expect(message).toContain("⚠️ Post-Compaction Audit"); - expect(message).toContain("WORKFLOW_AUTO.md"); - expect(message).toContain("memory"); - expect(message).toContain("Please read them now"); - }); - - it("formats single missing pattern", () => { - const missingPatterns = ["WORKFLOW_AUTO.md"]; - const message = formatAuditWarning(missingPatterns); - - expect(message).toContain("WORKFLOW_AUTO.md"); - // Check that the missing patterns list only contains WORKFLOW_AUTO.md - const lines = message.split("\n"); - const patternLines = lines.filter((l) => l.trim().startsWith("- ")); - expect(patternLines).toHaveLength(1); - expect(patternLines[0]).toContain("WORKFLOW_AUTO.md"); - }); -}); diff --git a/src/auto-reply/reply/post-compaction-audit.ts b/src/auto-reply/reply/post-compaction-audit.ts deleted file mode 100644 index 12741fc29..000000000 --- a/src/auto-reply/reply/post-compaction-audit.ts +++ /dev/null @@ -1,111 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; - -// Default required files — constants, extensible to config later -const DEFAULT_REQUIRED_READS: Array = [ - "WORKFLOW_AUTO.md", - /memory\/\d{4}-\d{2}-\d{2}\.md/, // daily memory files -]; - -/** - * Audit whether agent read required startup files after compaction. - * Returns list of missing file patterns. - */ -export function auditPostCompactionReads( - readFilePaths: string[], - workspaceDir: string, - requiredReads: Array = DEFAULT_REQUIRED_READS, -): { passed: boolean; missingPatterns: string[] } { - const normalizedReads = readFilePaths.map((p) => path.resolve(workspaceDir, p)); - const missingPatterns: string[] = []; - - for (const required of requiredReads) { - if (typeof required === "string") { - const requiredResolved = path.resolve(workspaceDir, required); - const found = normalizedReads.some((r) => r === requiredResolved); - if (!found) { - missingPatterns.push(required); - } - } else { - // RegExp — match against relative paths from workspace - const found = readFilePaths.some((p) => { - const rel = path.relative(workspaceDir, path.resolve(workspaceDir, p)); - // Normalize to forward slashes for cross-platform RegExp matching - const normalizedRel = rel.split(path.sep).join("/"); - return required.test(normalizedRel); - }); - if (!found) { - missingPatterns.push(required.source); - } - } - } - - return { passed: missingPatterns.length === 0, missingPatterns }; -} - -/** - * Read messages from a session JSONL file. - * Returns messages from the last N lines (default 100). - */ -export function readSessionMessages( - sessionFile: string, - maxLines = 100, -): Array<{ role?: string; content?: unknown }> { - if (!fs.existsSync(sessionFile)) { - return []; - } - - try { - const content = fs.readFileSync(sessionFile, "utf-8"); - const lines = content.trim().split("\n"); - const recentLines = lines.slice(-maxLines); - - const messages: Array<{ role?: string; content?: unknown }> = []; - for (const line of recentLines) { - try { - const entry = JSON.parse(line); - if (entry.type === "message" && entry.message) { - messages.push(entry.message); - } - } catch { - // Skip malformed lines - } - } - return messages; - } catch { - return []; - } -} - -/** - * Extract file paths from Read tool calls in agent messages. - * Looks for tool_use blocks with name="read" and extracts path/file_path args. - */ -export function extractReadPaths(messages: Array<{ role?: string; content?: unknown }>): string[] { - const paths: string[] = []; - for (const msg of messages) { - if (msg.role !== "assistant" || !Array.isArray(msg.content)) { - continue; - } - for (const block of msg.content) { - if (block.type === "tool_use" && block.name === "read") { - const filePath = block.input?.file_path ?? block.input?.path; - if (typeof filePath === "string") { - paths.push(filePath); - } - } - } - } - return paths; -} - -/** Format the audit warning message */ -export function formatAuditWarning(missingPatterns: string[]): string { - const fileList = missingPatterns.map((p) => ` - ${p}`).join("\n"); - return ( - "⚠️ Post-Compaction Audit: The following required startup files were not read after context reset:\n" + - fileList + - "\n\nPlease read them now using the Read tool before continuing. " + - "This ensures your operating protocols are restored after memory compaction." - ); -}