From 3ffae75c19a4e1eaf8d5872c778d6c143f3157a2 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 15 Jun 2026 22:11:22 -0700 Subject: [PATCH 1/2] surface failed agent prompt sends to the user --- apps/code/src/renderer/desktop-services.ts | 4 +- .../features/sessions/agentPromptSender.ts | 2 +- .../sessions/sendPromptToAgent.test.ts | 69 +++++++++++++++++++ .../features/sessions/sendPromptToAgent.ts | 15 +++- 4 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/features/sessions/sendPromptToAgent.test.ts diff --git a/apps/code/src/renderer/desktop-services.ts b/apps/code/src/renderer/desktop-services.ts index bfcb12b7f..a5dd91428 100644 --- a/apps/code/src/renderer/desktop-services.ts +++ b/apps/code/src/renderer/desktop-services.ts @@ -152,8 +152,8 @@ container .toConstantValue(new RendererHedgehogModeHost()); container .bind(AGENT_PROMPT_SENDER) - .toConstantValue((taskId, prompt) => { - void resolveService(SESSION_SERVICE).sendPrompt( + .toConstantValue(async (taskId, prompt) => { + await resolveService(SESSION_SERVICE).sendPrompt( taskId, prompt, ); diff --git a/packages/ui/src/features/sessions/agentPromptSender.ts b/packages/ui/src/features/sessions/agentPromptSender.ts index a3bdcc8d5..70e214ff5 100644 --- a/packages/ui/src/features/sessions/agentPromptSender.ts +++ b/packages/ui/src/features/sessions/agentPromptSender.ts @@ -3,6 +3,6 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; export type AgentPromptSender = ( taskId: string, prompt: string | ContentBlock[], -) => void; +) => Promise; export const AGENT_PROMPT_SENDER = Symbol.for("posthog.ui.AgentPromptSender"); diff --git a/packages/ui/src/features/sessions/sendPromptToAgent.test.ts b/packages/ui/src/features/sessions/sendPromptToAgent.test.ts new file mode 100644 index 000000000..56bd4de30 --- /dev/null +++ b/packages/ui/src/features/sessions/sendPromptToAgent.test.ts @@ -0,0 +1,69 @@ +import { toast } from "@posthog/ui/primitives/toast"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AGENT_PROMPT_SENDER } from "./agentPromptSender"; +import { sendPromptToAgent } from "./sendPromptToAgent"; + +const { mockSender } = vi.hoisted(() => ({ mockSender: vi.fn() })); + +vi.mock("@posthog/ui/primitives/toast", () => ({ + toast: { error: vi.fn(), info: vi.fn(), success: vi.fn() }, +})); + +vi.mock("@posthog/di/container", () => ({ + resolveService: (token: unknown) => { + if (token === AGENT_PROMPT_SENDER) return mockSender; + throw new Error(`resolveService: unmocked token ${String(token)}`); + }, +})); + +vi.mock("../code-review/reviewNavigationStore", () => ({ + useReviewNavigationStore: { + getState: () => ({ getReviewMode: () => "split", setReviewMode: vi.fn() }), + }, +})); + +vi.mock("../panels/panelLayoutStore", () => ({ + usePanelLayoutStore: { + getState: () => ({ taskLayouts: {}, setActiveTab: vi.fn() }), + }, +})); + +describe("sendPromptToAgent", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("surfaces a rejected send as an error toast", async () => { + mockSender.mockRejectedValueOnce( + new Error("Agent server is not reachable"), + ); + + sendPromptToAgent("task-1", "hello"); + + await vi.waitFor(() => + expect(toast.error).toHaveBeenCalledWith("Agent server is not reachable"), + ); + }); + + it("falls back to a generic message when the rejection is not an Error", async () => { + mockSender.mockRejectedValueOnce("boom"); + + sendPromptToAgent("task-1", "hello"); + + await vi.waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + "Failed to send your message to the agent. Please try again.", + ), + ); + }); + + it("does not toast when the send resolves", async () => { + mockSender.mockResolvedValueOnce(undefined); + + sendPromptToAgent("task-1", "hello"); + + await Promise.resolve(); + await Promise.resolve(); + expect(toast.error).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/features/sessions/sendPromptToAgent.ts b/packages/ui/src/features/sessions/sendPromptToAgent.ts index 03c8b8560..b70990130 100644 --- a/packages/ui/src/features/sessions/sendPromptToAgent.ts +++ b/packages/ui/src/features/sessions/sendPromptToAgent.ts @@ -1,5 +1,6 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; import { resolveService } from "@posthog/di/container"; +import { toast } from "@posthog/ui/primitives/toast"; import { useReviewNavigationStore } from "../code-review/reviewNavigationStore"; import { DEFAULT_TAB_IDS } from "../panels/panelConstants"; import { usePanelLayoutStore } from "../panels/panelLayoutStore"; @@ -17,7 +18,19 @@ export function sendPromptToAgent( taskId: string, prompt: string | ContentBlock[], ): void { - resolveService(AGENT_PROMPT_SENDER)(taskId, prompt); + // Button/review/skill-initiated prompts are fire-and-forget, but a rejected + // send (auth failure, sandbox unreachable, agent process died) must still be + // surfaced — otherwise the turn just shows "Generated in Xs" with no reply. + void resolveService(AGENT_PROMPT_SENDER)( + taskId, + prompt, + ).catch((error: unknown) => { + toast.error( + error instanceof Error + ? error.message + : "Failed to send your message to the agent. Please try again.", + ); + }); const { getReviewMode, setReviewMode } = useReviewNavigationStore.getState(); if (getReviewMode(taskId) === "expanded") { From e021d7b5505f837f317de06609f265e80176d810 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 15 Jun 2026 22:25:50 -0700 Subject: [PATCH 2/2] refine prompt-send tests and comment --- .../sessions/sendPromptToAgent.test.ts | 94 ++++++++++++++----- .../features/sessions/sendPromptToAgent.ts | 2 +- 2 files changed, 70 insertions(+), 26 deletions(-) diff --git a/packages/ui/src/features/sessions/sendPromptToAgent.test.ts b/packages/ui/src/features/sessions/sendPromptToAgent.test.ts index 56bd4de30..1440d8839 100644 --- a/packages/ui/src/features/sessions/sendPromptToAgent.test.ts +++ b/packages/ui/src/features/sessions/sendPromptToAgent.test.ts @@ -1,9 +1,23 @@ import { toast } from "@posthog/ui/primitives/toast"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_TAB_IDS } from "../panels/panelConstants"; import { AGENT_PROMPT_SENDER } from "./agentPromptSender"; import { sendPromptToAgent } from "./sendPromptToAgent"; -const { mockSender } = vi.hoisted(() => ({ mockSender: vi.fn() })); +const { mockSender, reviewState, panelState, findTabInTree } = vi.hoisted( + () => ({ + mockSender: vi.fn(), + reviewState: { + mode: "split" as "split" | "expanded", + setReviewMode: vi.fn(), + }, + panelState: { + taskLayouts: {} as Record, + setActiveTab: vi.fn(), + }, + findTabInTree: vi.fn(), + }), +); vi.mock("@posthog/ui/primitives/toast", () => ({ toast: { error: vi.fn(), info: vi.fn(), success: vi.fn() }, @@ -18,52 +32,82 @@ vi.mock("@posthog/di/container", () => ({ vi.mock("../code-review/reviewNavigationStore", () => ({ useReviewNavigationStore: { - getState: () => ({ getReviewMode: () => "split", setReviewMode: vi.fn() }), + getState: () => ({ + getReviewMode: () => reviewState.mode, + setReviewMode: reviewState.setReviewMode, + }), }, })); vi.mock("../panels/panelLayoutStore", () => ({ usePanelLayoutStore: { - getState: () => ({ taskLayouts: {}, setActiveTab: vi.fn() }), + getState: () => ({ + taskLayouts: panelState.taskLayouts, + setActiveTab: panelState.setActiveTab, + }), }, })); +vi.mock("../panels/panelTree", () => ({ findTabInTree })); + describe("sendPromptToAgent", () => { beforeEach(() => { vi.clearAllMocks(); + reviewState.mode = "split"; + panelState.taskLayouts = {}; + findTabInTree.mockReturnValue({ panelId: "panel-logs" }); }); - it("surfaces a rejected send as an error toast", async () => { - mockSender.mockRejectedValueOnce( + it.each([ + [ new Error("Agent server is not reachable"), - ); - - sendPromptToAgent("task-1", "hello"); + "Agent server is not reachable", + ], + ["boom", "Failed to send your message to the agent. Please try again."], + ])( + "surfaces a rejected send as an error toast (%#)", + async (rejection, expectedMessage) => { + mockSender.mockRejectedValueOnce(rejection); - await vi.waitFor(() => - expect(toast.error).toHaveBeenCalledWith("Agent server is not reachable"), - ); - }); - - it("falls back to a generic message when the rejection is not an Error", async () => { - mockSender.mockRejectedValueOnce("boom"); + sendPromptToAgent("task-1", "hello"); - sendPromptToAgent("task-1", "hello"); - - await vi.waitFor(() => - expect(toast.error).toHaveBeenCalledWith( - "Failed to send your message to the agent. Please try again.", - ), - ); - }); + await vi.waitFor(() => + expect(toast.error).toHaveBeenCalledWith(expectedMessage), + ); + }, + ); it("does not toast when the send resolves", async () => { mockSender.mockResolvedValueOnce(undefined); sendPromptToAgent("task-1", "hello"); - await Promise.resolve(); - await Promise.resolve(); + // Await the exact promise the sender returned rather than guessing how many + // microtasks the catch chain takes to settle. + await mockSender.mock.results[0]?.value; expect(toast.error).not.toHaveBeenCalled(); }); + + // The send is fire-and-forget, so the panel/review side effects must run + // regardless of whether it ultimately resolves or rejects. + it.each([ + ["resolves", () => mockSender.mockResolvedValueOnce(undefined)], + ["rejects", () => mockSender.mockRejectedValueOnce(new Error("nope"))], + ])( + "collapses review and switches to the logs tab when the send %s", + (_label, primeSender) => { + primeSender(); + reviewState.mode = "expanded"; + panelState.taskLayouts = { "task-1": { panelTree: {} } }; + + sendPromptToAgent("task-1", "hello"); + + expect(reviewState.setReviewMode).toHaveBeenCalledWith("task-1", "split"); + expect(panelState.setActiveTab).toHaveBeenCalledWith( + "task-1", + "panel-logs", + DEFAULT_TAB_IDS.LOGS, + ); + }, + ); }); diff --git a/packages/ui/src/features/sessions/sendPromptToAgent.ts b/packages/ui/src/features/sessions/sendPromptToAgent.ts index b70990130..37558cb6c 100644 --- a/packages/ui/src/features/sessions/sendPromptToAgent.ts +++ b/packages/ui/src/features/sessions/sendPromptToAgent.ts @@ -20,7 +20,7 @@ export function sendPromptToAgent( ): void { // Button/review/skill-initiated prompts are fire-and-forget, but a rejected // send (auth failure, sandbox unreachable, agent process died) must still be - // surfaced — otherwise the turn just shows "Generated in Xs" with no reply. + // surfaced, or the turn just shows "Generated in Xs" with no reply. void resolveService(AGENT_PROMPT_SENDER)( taskId, prompt,