diff --git a/src/agents/cli-runner.before-agent-reply-cron.test.ts b/src/agents/cli-runner.before-agent-reply-cron.test.ts new file mode 100644 index 00000000000..72418f80b7e --- /dev/null +++ b/src/agents/cli-runner.before-agent-reply-cron.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; + +// vi.mock factories are hoisted above imports, so any references inside them +// must come from vi.hoisted() so they exist at hoist time (otherwise they'd +// be TDZ-undefined and the mocks would silently misbehave). This test only +// exercises the hook-gate decision at the runCliAgent entry point — we mock +// the prepareCliRunContext + executePreparedCliRun seams so no broader CLI +// runtime needs to load. +type BeforeAgentReplyResult = + | undefined + | { + handled?: boolean; + reply?: { text?: string }; + }; + +const { + hasHooksMock, + runBeforeAgentReplyMock, + executePreparedCliRunMock, + prepareCliRunContextMock, +} = vi.hoisted(() => ({ + hasHooksMock: vi.fn<(hookName: string) => boolean>(() => false), + runBeforeAgentReplyMock: vi.fn<(event: unknown, ctx: unknown) => Promise>( + async () => undefined, + ), + executePreparedCliRunMock: vi.fn(async (_context: unknown, _cliSessionIdToUse?: string) => ({ + text: "", + })), + prepareCliRunContextMock: vi.fn(), +})); + +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: vi.fn(() => ({ + hasHooks: hasHooksMock, + runBeforeAgentReply: runBeforeAgentReplyMock, + })), +})); + +vi.mock("./cli-runner/prepare.runtime.js", () => ({ + prepareCliRunContext: prepareCliRunContextMock, +})); + +vi.mock("./cli-runner/execute.runtime.js", () => ({ + executePreparedCliRun: executePreparedCliRunMock, +})); + +const baseRunParams = { + sessionId: "test-session", + sessionKey: "test-session-key", + agentId: "main", + sessionFile: "/tmp/test-session.jsonl", + workspaceDir: "/tmp/test-workspace", + prompt: "__openclaw_memory_core_short_term_promotion_dream__", + provider: "codex-cli", + model: "gpt-5.5", + timeoutMs: 30_000, + runId: "test-run-id", +} as const; + +function makeStubContext(params: typeof baseRunParams & { trigger?: string }) { + return { + params, + started: Date.now(), + workspaceDir: params.workspaceDir, + modelId: params.model, + normalizedModel: params.model, + systemPrompt: "", + systemPromptReport: {}, + bootstrapPromptWarningLines: [], + authEpochVersion: 0, + backendResolved: {}, + preparedBackend: {}, + reusableCliSession: {}, + } as unknown; +} + +beforeEach(() => { + hasHooksMock.mockReset(); + hasHooksMock.mockReturnValue(false); + runBeforeAgentReplyMock.mockReset(); + runBeforeAgentReplyMock.mockResolvedValue(undefined); + executePreparedCliRunMock.mockReset(); + executePreparedCliRunMock.mockResolvedValue({ text: "" }); + prepareCliRunContextMock.mockReset(); + prepareCliRunContextMock.mockImplementation(async (params) => + makeStubContext(params as typeof baseRunParams & { trigger?: string }), + ); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("runCliAgent cron before_agent_reply seam", () => { + it("lets before_agent_reply claim cron runs before the CLI subprocess is invoked", async () => { + const { runCliAgent } = await import("./cli-runner.js"); + hasHooksMock.mockImplementation((hookName) => hookName === "before_agent_reply"); + runBeforeAgentReplyMock.mockResolvedValue({ + handled: true, + reply: { text: "dreaming claimed via cli runner" }, + }); + + const result = await runCliAgent({ ...baseRunParams, trigger: "cron" }); + + expect(runBeforeAgentReplyMock).toHaveBeenCalledTimes(1); + expect(runBeforeAgentReplyMock).toHaveBeenCalledWith( + { cleanedBody: baseRunParams.prompt }, + expect.objectContaining({ + agentId: baseRunParams.agentId, + sessionId: baseRunParams.sessionId, + sessionKey: baseRunParams.sessionKey, + workspaceDir: baseRunParams.workspaceDir, + trigger: "cron", + }), + ); + expect(executePreparedCliRunMock).not.toHaveBeenCalled(); + expect(result.payloads?.[0]?.text).toBe("dreaming claimed via cli runner"); + }); + + it("does not run prepareCliRunContext when the cron hook claims (no resource allocation, no leak)", async () => { + // Regression for PR #70950 review (greptile-apps, P1): the gate must fire + // before any backend resources are allocated, otherwise preparedBackend.cleanup + // is silently skipped on every claimed cron turn. + const { runCliAgent } = await import("./cli-runner.js"); + hasHooksMock.mockImplementation((hookName) => hookName === "before_agent_reply"); + runBeforeAgentReplyMock.mockResolvedValue({ handled: true }); + + await runCliAgent({ ...baseRunParams, trigger: "cron" }); + + expect(prepareCliRunContextMock).not.toHaveBeenCalled(); + expect(executePreparedCliRunMock).not.toHaveBeenCalled(); + }); + + it("returns a silent payload when a cron hook claims without a reply body", async () => { + const { runCliAgent } = await import("./cli-runner.js"); + hasHooksMock.mockImplementation((hookName) => hookName === "before_agent_reply"); + runBeforeAgentReplyMock.mockResolvedValue({ handled: true }); + + const result = await runCliAgent({ ...baseRunParams, trigger: "cron" }); + + expect(executePreparedCliRunMock).not.toHaveBeenCalled(); + expect(result.payloads?.[0]?.text).toBe(SILENT_REPLY_TOKEN); + }); + + it("does not invoke before_agent_reply for non-cron triggers", async () => { + const { runCliAgent } = await import("./cli-runner.js"); + hasHooksMock.mockImplementation((hookName) => hookName === "before_agent_reply"); + executePreparedCliRunMock.mockResolvedValue({ text: "real reply" }); + + await runCliAgent({ ...baseRunParams, trigger: "user" }); + + expect(runBeforeAgentReplyMock).not.toHaveBeenCalled(); + expect(executePreparedCliRunMock).toHaveBeenCalled(); + }); + + it("falls through to the CLI subprocess when no before_agent_reply hook is registered", async () => { + const { runCliAgent } = await import("./cli-runner.js"); + hasHooksMock.mockReturnValue(false); + executePreparedCliRunMock.mockResolvedValue({ text: "real reply" }); + + await runCliAgent({ ...baseRunParams, trigger: "cron" }); + + expect(runBeforeAgentReplyMock).not.toHaveBeenCalled(); + expect(executePreparedCliRunMock).toHaveBeenCalled(); + }); +}); diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 41d28529340..ecb23331387 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -1,3 +1,5 @@ +import type { ReplyPayload } from "../auto-reply/reply-payload.js"; +import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { formatErrorMessage } from "../infra/errors.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import { loadCliSessionHistoryMessages } from "./cli-runner/session-history.js"; @@ -12,6 +14,21 @@ import { import { classifyFailoverReason, isFailoverErrorMessage } from "./pi-embedded-helpers.js"; import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js"; +function buildHandledReplyPayloads(reply?: ReplyPayload) { + const normalized = reply ?? { text: SILENT_REPLY_TOKEN }; + return [ + { + text: normalized.text, + mediaUrl: normalized.mediaUrl, + mediaUrls: normalized.mediaUrls, + replyToId: normalized.replyToId, + audioAsVoice: normalized.audioAsVoice, + isError: normalized.isError, + isReasoning: normalized.isReasoning, + }, + ]; +} + function buildCliHookUserMessage(prompt: string): unknown { return { role: "user", @@ -45,6 +62,43 @@ function buildCliHookAssistantMessage(params: { } export async function runCliAgent(params: RunCliAgentParams): Promise { + // Cron gate must fire before prepareCliRunContext — that call allocates + // backend resources released only by runPreparedCliAgent's try…finally. + if (params.trigger === "cron") { + const startedAt = Date.now(); + const hookRunner = getGlobalHookRunner(); + if (hookRunner?.hasHooks("before_agent_reply")) { + const hookContext = { + runId: params.runId, + agentId: params.agentId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + workspaceDir: params.workspaceDir, + messageProvider: params.messageProvider, + trigger: params.trigger, + channelId: params.messageChannel ?? params.messageProvider, + } as const; + const hookResult = await hookRunner.runBeforeAgentReply( + { cleanedBody: params.prompt }, + hookContext, + ); + if (hookResult?.handled) { + return { + payloads: buildHandledReplyPayloads(hookResult.reply), + meta: { + durationMs: Date.now() - startedAt, + agentMeta: { + sessionId: params.sessionId, + provider: params.provider, + model: params.model ?? "", + }, + finalAssistantVisibleText: hookResult.reply?.text ?? SILENT_REPLY_TOKEN, + finalAssistantRawText: hookResult.reply?.text ?? SILENT_REPLY_TOKEN, + }, + }; + } + } + } const { prepareCliRunContext } = await import("./cli-runner/prepare.runtime.js"); const context = await prepareCliRunContext(params); return runPreparedCliAgent(context);