diff --git a/src/auto-reply/reply/agent-runner-helpers.test.ts b/src/auto-reply/reply/agent-runner-helpers.test.ts new file mode 100644 index 00000000000..388d53b3c54 --- /dev/null +++ b/src/auto-reply/reply/agent-runner-helpers.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ReplyPayload } from "../types.js"; + +const hoisted = vi.hoisted(() => { + const loadSessionStoreMock = vi.fn(); + const scheduleFollowupDrainMock = vi.fn(); + return { loadSessionStoreMock, scheduleFollowupDrainMock }; +}); + +vi.mock("../../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSessionStore: (...args: unknown[]) => hoisted.loadSessionStoreMock(...args), + }; +}); + +vi.mock("./queue.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + scheduleFollowupDrain: (...args: unknown[]) => hoisted.scheduleFollowupDrainMock(...args), + }; +}); + +const { + createShouldEmitToolOutput, + createShouldEmitToolResult, + finalizeWithFollowup, + isAudioPayload, + signalTypingIfNeeded, +} = await import("./agent-runner-helpers.js"); + +describe("agent runner helpers", () => { + beforeEach(() => { + hoisted.loadSessionStoreMock.mockReset(); + hoisted.scheduleFollowupDrainMock.mockReset(); + }); + + it("detects audio payloads from mediaUrl/mediaUrls", () => { + expect(isAudioPayload({ mediaUrl: "https://example.test/audio.mp3" })).toBe(true); + expect(isAudioPayload({ mediaUrls: ["https://example.test/video.mp4"] })).toBe(false); + expect(isAudioPayload({ mediaUrls: ["https://example.test/voice.m4a"] })).toBe(true); + }); + + it("uses fallback verbose level when session context is missing", () => { + expect(createShouldEmitToolResult({ resolvedVerboseLevel: "off" })()).toBe(false); + expect(createShouldEmitToolResult({ resolvedVerboseLevel: "on" })()).toBe(true); + expect(createShouldEmitToolOutput({ resolvedVerboseLevel: "on" })()).toBe(false); + expect(createShouldEmitToolOutput({ resolvedVerboseLevel: "full" })()).toBe(true); + }); + + it("uses session verbose level when present", () => { + hoisted.loadSessionStoreMock.mockReturnValue({ + "agent:main:main": { verboseLevel: "full" }, + }); + const shouldEmitResult = createShouldEmitToolResult({ + sessionKey: "agent:main:main", + storePath: "/tmp/store.json", + resolvedVerboseLevel: "off", + }); + const shouldEmitOutput = createShouldEmitToolOutput({ + sessionKey: "agent:main:main", + storePath: "/tmp/store.json", + resolvedVerboseLevel: "off", + }); + expect(shouldEmitResult()).toBe(true); + expect(shouldEmitOutput()).toBe(true); + }); + + it("falls back when store read fails or session value is invalid", () => { + hoisted.loadSessionStoreMock.mockImplementation(() => { + throw new Error("boom"); + }); + const fallbackOn = createShouldEmitToolResult({ + sessionKey: "agent:main:main", + storePath: "/tmp/store.json", + resolvedVerboseLevel: "on", + }); + expect(fallbackOn()).toBe(true); + + hoisted.loadSessionStoreMock.mockReset(); + hoisted.loadSessionStoreMock.mockReturnValue({ + "agent:main:main": { verboseLevel: "weird" }, + }); + const fallbackFull = createShouldEmitToolOutput({ + sessionKey: "agent:main:main", + storePath: "/tmp/store.json", + resolvedVerboseLevel: "full", + }); + expect(fallbackFull()).toBe(true); + }); + + it("schedules followup drain and returns the original value", () => { + const runFollowupTurn = vi.fn(); + const value = { ok: true }; + expect(finalizeWithFollowup(value, "queue-key", runFollowupTurn)).toBe(value); + expect(hoisted.scheduleFollowupDrainMock).toHaveBeenCalledWith("queue-key", runFollowupTurn); + }); + + it("signals typing only when any payload has text or media", async () => { + const signalRunStart = vi.fn().mockResolvedValue(undefined); + const typingSignals = { signalRunStart }; + const emptyPayloads: ReplyPayload[] = [{ text: " " }, {}]; + await signalTypingIfNeeded(emptyPayloads, typingSignals); + expect(signalRunStart).not.toHaveBeenCalled(); + + await signalTypingIfNeeded([{ mediaUrl: "https://example.test/img.png" }], typingSignals); + expect(signalRunStart).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/auto-reply/reply/agent-runner-helpers.ts b/src/auto-reply/reply/agent-runner-helpers.ts index 6f3658b7436..11ea0fe9f53 100644 --- a/src/auto-reply/reply/agent-runner-helpers.ts +++ b/src/auto-reply/reply/agent-runner-helpers.ts @@ -11,54 +11,43 @@ const hasAudioMedia = (urls?: string[]): boolean => export const isAudioPayload = (payload: ReplyPayload): boolean => hasAudioMedia(payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined)); -export const createShouldEmitToolResult = (params: { +type VerboseGateParams = { sessionKey?: string; storePath?: string; resolvedVerboseLevel: VerboseLevel; -}): (() => boolean) => { - // Normalize verbose values from session store/config so false/"false" still means off. - const fallbackVerbose = normalizeVerboseLevel(String(params.resolvedVerboseLevel ?? "")) ?? "off"; - return () => { - if (!params.sessionKey || !params.storePath) { - return fallbackVerbose !== "off"; - } - try { - const store = loadSessionStore(params.storePath); - const entry = store[params.sessionKey]; - const current = normalizeVerboseLevel(String(entry?.verboseLevel ?? "")); - if (current) { - return current !== "off"; - } - } catch { - // ignore store read failures - } - return fallbackVerbose !== "off"; - }; }; -export const createShouldEmitToolOutput = (params: { - sessionKey?: string; - storePath?: string; - resolvedVerboseLevel: VerboseLevel; -}): (() => boolean) => { +function resolveCurrentVerboseLevel(params: VerboseGateParams): VerboseLevel | undefined { + if (!params.sessionKey || !params.storePath) { + return undefined; + } + try { + const store = loadSessionStore(params.storePath); + const entry = store[params.sessionKey]; + return normalizeVerboseLevel(String(entry?.verboseLevel ?? "")); + } catch { + // ignore store read failures + return undefined; + } +} + +function createVerboseGate( + params: VerboseGateParams, + shouldEmit: (level: VerboseLevel) => boolean, +): () => boolean { // Normalize verbose values from session store/config so false/"false" still means off. const fallbackVerbose = normalizeVerboseLevel(String(params.resolvedVerboseLevel ?? "")) ?? "off"; return () => { - if (!params.sessionKey || !params.storePath) { - return fallbackVerbose === "full"; - } - try { - const store = loadSessionStore(params.storePath); - const entry = store[params.sessionKey]; - const current = normalizeVerboseLevel(String(entry?.verboseLevel ?? "")); - if (current) { - return current === "full"; - } - } catch { - // ignore store read failures - } - return fallbackVerbose === "full"; + return shouldEmit(resolveCurrentVerboseLevel(params) ?? fallbackVerbose); }; +} + +export const createShouldEmitToolResult = (params: VerboseGateParams): (() => boolean) => { + return createVerboseGate(params, (level) => level !== "off"); +}; + +export const createShouldEmitToolOutput = (params: VerboseGateParams): (() => boolean) => { + return createVerboseGate(params, (level) => level === "full"); }; export const finalizeWithFollowup = (