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..1440d8839 --- /dev/null +++ b/packages/ui/src/features/sessions/sendPromptToAgent.test.ts @@ -0,0 +1,113 @@ +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, 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() }, +})); + +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: () => reviewState.mode, + setReviewMode: reviewState.setReviewMode, + }), + }, +})); + +vi.mock("../panels/panelLayoutStore", () => ({ + usePanelLayoutStore: { + 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.each([ + [ + new Error("Agent server is not reachable"), + "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); + + sendPromptToAgent("task-1", "hello"); + + 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 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 03c8b8560..37558cb6c 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, or 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") {