From ee3abb22781bba57070f89009db327cc2f7875c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 08:59:46 +0000 Subject: [PATCH] test(reply): merge duplicate runReplyAgent streaming and fallback cases --- .../reply/agent-runner.runreplyagent.test.ts | 328 ++++++++---------- 1 file changed, 149 insertions(+), 179 deletions(-) diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts index a56248e73..0a915778f 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts @@ -337,66 +337,62 @@ describe("runReplyAgent typing (heartbeat)", () => { expect(typing.startTypingLoop).not.toHaveBeenCalled(); }); - it("suppresses partial streaming for NO_REPLY", async () => { - const onPartialReply = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onPartialReply?.({ text: "NO_REPLY" }); - return { payloads: [{ text: "NO_REPLY" }], meta: {} }; - }); + it("suppresses NO_REPLY partials but allows normal No-prefix partials", async () => { + const cases = [ + { + partials: ["NO_REPLY"], + finalText: "NO_REPLY", + expectedForwarded: [] as string[], + shouldType: false, + }, + { + partials: ["NO_", "NO_RE", "NO_REPLY"], + finalText: "NO_REPLY", + expectedForwarded: [] as string[], + shouldType: false, + }, + { + partials: ["No", "No, that is valid"], + finalText: "No, that is valid", + expectedForwarded: ["No", "No, that is valid"], + shouldType: true, + }, + ] as const; - const { run, typing } = createMinimalRun({ - opts: { isHeartbeat: false, onPartialReply }, - typingMode: "message", - }); - await run(); + for (const testCase of cases) { + const onPartialReply = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + for (const text of testCase.partials) { + await params.onPartialReply?.({ text }); + } + return { payloads: [{ text: testCase.finalText }], meta: {} }; + }); - expect(onPartialReply).not.toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); + const { run, typing } = createMinimalRun({ + opts: { isHeartbeat: false, onPartialReply }, + typingMode: "message", + }); + await run(); - it("suppresses partial streaming for NO_REPLY prefixes", async () => { - const onPartialReply = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onPartialReply?.({ text: "NO_" }); - await params.onPartialReply?.({ text: "NO_RE" }); - await params.onPartialReply?.({ text: "NO_REPLY" }); - return { payloads: [{ text: "NO_REPLY" }], meta: {} }; - }); + if (testCase.expectedForwarded.length === 0) { + expect(onPartialReply).not.toHaveBeenCalled(); + } else { + expect(onPartialReply).toHaveBeenCalledTimes(testCase.expectedForwarded.length); + testCase.expectedForwarded.forEach((text, index) => { + expect(onPartialReply).toHaveBeenNthCalledWith(index + 1, { + text, + mediaUrls: undefined, + }); + }); + } - const { run, typing } = createMinimalRun({ - opts: { isHeartbeat: false, onPartialReply }, - typingMode: "message", - }); - await run(); - - expect(onPartialReply).not.toHaveBeenCalled(); - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - }); - - it("does not suppress partial streaming for normal 'No' prefixes", async () => { - const onPartialReply = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onPartialReply?.({ text: "No" }); - await params.onPartialReply?.({ text: "No, that is valid" }); - return { payloads: [{ text: "No, that is valid" }], meta: {} }; - }); - - const { run, typing } = createMinimalRun({ - opts: { isHeartbeat: false, onPartialReply }, - typingMode: "message", - }); - await run(); - - expect(onPartialReply).toHaveBeenCalledTimes(2); - expect(onPartialReply).toHaveBeenNthCalledWith(1, { text: "No", mediaUrls: undefined }); - expect(onPartialReply).toHaveBeenNthCalledWith(2, { - text: "No, that is valid", - mediaUrls: undefined, - }); - expect(typing.startTypingOnText).toHaveBeenCalled(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); + if (testCase.shouldType) { + expect(typing.startTypingOnText).toHaveBeenCalled(); + } else { + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + } + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + } }); it("does not start typing on assistant message start without prior text in message mode", async () => { @@ -488,41 +484,48 @@ describe("runReplyAgent typing (heartbeat)", () => { }); }); - it("signals typing on tool results", async () => { - const onToolResult = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onToolResult?.({ text: "tooling", mediaUrls: [] }); - return { payloads: [{ text: "final" }], meta: {} }; - }); + it("handles typing for normal and silent tool results", async () => { + const cases = [ + { + toolText: "tooling", + shouldType: true, + shouldForward: true, + }, + { + toolText: "NO_REPLY", + shouldType: false, + shouldForward: false, + }, + ] as const; - const { run, typing } = createMinimalRun({ - typingMode: "message", - opts: { onToolResult }, - }); - await run(); + for (const testCase of cases) { + const onToolResult = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onToolResult?.({ text: testCase.toolText, mediaUrls: [] }); + return { payloads: [{ text: "final" }], meta: {} }; + }); - expect(typing.startTypingOnText).toHaveBeenCalledWith("tooling"); - expect(onToolResult).toHaveBeenCalledWith({ - text: "tooling", - mediaUrls: [], - }); - }); + const { run, typing } = createMinimalRun({ + typingMode: "message", + opts: { onToolResult }, + }); + await run(); - it("skips typing for silent tool results", async () => { - const onToolResult = vi.fn(); - state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { - await params.onToolResult?.({ text: "NO_REPLY", mediaUrls: [] }); - return { payloads: [{ text: "final" }], meta: {} }; - }); + if (testCase.shouldType) { + expect(typing.startTypingOnText).toHaveBeenCalledWith(testCase.toolText); + } else { + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + } - const { run, typing } = createMinimalRun({ - typingMode: "message", - opts: { onToolResult }, - }); - await run(); - - expect(typing.startTypingOnText).not.toHaveBeenCalled(); - expect(onToolResult).not.toHaveBeenCalled(); + if (testCase.shouldForward) { + expect(onToolResult).toHaveBeenCalledWith({ + text: testCase.toolText, + mediaUrls: [], + }); + } else { + expect(onToolResult).not.toHaveBeenCalled(); + } + } }); it("retries transient HTTP failures once with timer-driven backoff", async () => { @@ -979,100 +982,67 @@ describe("runReplyAgent typing (heartbeat)", () => { } }); - it("backfills fallback reason when fallback is already active", async () => { - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - fallbackNoticeSelectedModel: "anthropic/claude", - fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5", - modelProvider: "deepinfra", - model: "moonshotai/Kimi-K2.5", - }; - const sessionStore = { main: sessionEntry }; + it("updates fallback reason summary while fallback stays active", async () => { + const cases = [ + { + existingReason: undefined, + reportedReason: "rate_limit", + expectedReason: "rate limit", + }, + { + existingReason: "rate limit", + reportedReason: "timeout", + expectedReason: "timeout", + }, + ] as const; - state.runEmbeddedPiAgentMock.mockResolvedValue({ - payloads: [{ text: "final" }], - meta: {}, - }); - const fallbackSpy = vi - .spyOn(modelFallbackModule, "runWithModelFallback") - .mockImplementation( - async ({ run }: { run: (provider: string, model: string) => Promise }) => ({ - result: await run("deepinfra", "moonshotai/Kimi-K2.5"), - provider: "deepinfra", - model: "moonshotai/Kimi-K2.5", - attempts: [ - { - provider: "anthropic", - model: "claude", - error: "Provider anthropic is in cooldown (all profiles unavailable)", - reason: "rate_limit", - }, - ], - }), - ); - try { - const { run } = createMinimalRun({ - resolvedVerboseLevel: "on", - sessionEntry, - sessionStore, - sessionKey: "main", + for (const testCase of cases) { + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + fallbackNoticeSelectedModel: "anthropic/claude", + fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5", + ...(testCase.existingReason ? { fallbackNoticeReason: testCase.existingReason } : {}), + modelProvider: "deepinfra", + model: "moonshotai/Kimi-K2.5", + }; + const sessionStore = { main: sessionEntry }; + + state.runEmbeddedPiAgentMock.mockResolvedValue({ + payloads: [{ text: "final" }], + meta: {}, }); - const res = await run(); - const firstText = Array.isArray(res) ? res[0]?.text : res?.text; - expect(firstText).not.toContain("Model Fallback:"); - expect(sessionEntry.fallbackNoticeReason).toBe("rate limit"); - } finally { - fallbackSpy.mockRestore(); - } - }); - - it("refreshes fallback reason summary while fallback stays active", async () => { - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - fallbackNoticeSelectedModel: "anthropic/claude", - fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5", - fallbackNoticeReason: "rate limit", - modelProvider: "deepinfra", - model: "moonshotai/Kimi-K2.5", - }; - const sessionStore = { main: sessionEntry }; - - state.runEmbeddedPiAgentMock.mockResolvedValue({ - payloads: [{ text: "final" }], - meta: {}, - }); - const fallbackSpy = vi - .spyOn(modelFallbackModule, "runWithModelFallback") - .mockImplementation( - async ({ run }: { run: (provider: string, model: string) => Promise }) => ({ - result: await run("deepinfra", "moonshotai/Kimi-K2.5"), - provider: "deepinfra", - model: "moonshotai/Kimi-K2.5", - attempts: [ - { - provider: "anthropic", - model: "claude", - error: "Provider anthropic is in cooldown (all profiles unavailable)", - reason: "timeout", - }, - ], - }), - ); - try { - const { run } = createMinimalRun({ - resolvedVerboseLevel: "on", - sessionEntry, - sessionStore, - sessionKey: "main", - }); - const res = await run(); - const firstText = Array.isArray(res) ? res[0]?.text : res?.text; - expect(firstText).not.toContain("Model Fallback:"); - expect(sessionEntry.fallbackNoticeReason).toBe("timeout"); - } finally { - fallbackSpy.mockRestore(); + const fallbackSpy = vi + .spyOn(modelFallbackModule, "runWithModelFallback") + .mockImplementation( + async ({ run }: { run: (provider: string, model: string) => Promise }) => ({ + result: await run("deepinfra", "moonshotai/Kimi-K2.5"), + provider: "deepinfra", + model: "moonshotai/Kimi-K2.5", + attempts: [ + { + provider: "anthropic", + model: "claude", + error: "Provider anthropic is in cooldown (all profiles unavailable)", + reason: testCase.reportedReason, + }, + ], + }), + ); + try { + const { run } = createMinimalRun({ + resolvedVerboseLevel: "on", + sessionEntry, + sessionStore, + sessionKey: "main", + }); + const res = await run(); + const firstText = Array.isArray(res) ? res[0]?.text : res?.text; + expect(firstText).not.toContain("Model Fallback:"); + expect(sessionEntry.fallbackNoticeReason).toBe(testCase.expectedReason); + } finally { + fallbackSpy.mockRestore(); + } } });