Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/code/src/renderer/desktop-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,8 @@ container
.toConstantValue(new RendererHedgehogModeHost());
container
.bind<AgentPromptSender>(AGENT_PROMPT_SENDER)
.toConstantValue((taskId, prompt) => {
void resolveService<SessionService>(SESSION_SERVICE).sendPrompt(
.toConstantValue(async (taskId, prompt) => {
await resolveService<SessionService>(SESSION_SERVICE).sendPrompt(
taskId,
prompt,
);
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/features/sessions/agentPromptSender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ import type { ContentBlock } from "@agentclientprotocol/sdk";
export type AgentPromptSender = (
taskId: string,
prompt: string | ContentBlock[],
) => void;
) => Promise<void>;

export const AGENT_PROMPT_SENDER = Symbol.for("posthog.ui.AgentPromptSender");
113 changes: 113 additions & 0 deletions packages/ui/src/features/sessions/sendPromptToAgent.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { panelTree: unknown }>,
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,
);
},
);
});
15 changes: 14 additions & 1 deletion packages/ui/src/features/sessions/sendPromptToAgent.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -17,7 +18,19 @@ export function sendPromptToAgent(
taskId: string,
prompt: string | ContentBlock[],
): void {
resolveService<AgentPromptSender>(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<AgentPromptSender>(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") {
Expand Down
Loading