diff --git a/packages/app/e2e/browser/chat-outline.spec.ts b/packages/app/e2e/browser/chat-outline.spec.ts index bc58e46ab5..9dd3c33313 100644 --- a/packages/app/e2e/browser/chat-outline.spec.ts +++ b/packages/app/e2e/browser/chat-outline.spec.ts @@ -17,12 +17,14 @@ import { hoverChatOutlinePrompt, chatOutlineRail, movePointerOffChatOutline, + observePromptIndexRequests, pointAtChatOutlineRowEdge, pressEnterOnFocusedPrompt, splitCurrentPanelRight, withStreamingMarkdownOutline, expectReadingStreamedMarkdown, } from "../support/helpers/chat-outline"; +import { createCreationScenario } from "../support/helpers/creation"; import { expectTimelineAtMaximumScrollWithPromptVisible, expectTimelinePromptNotMounted, @@ -266,3 +268,21 @@ test.describe("desktop chat outline", () => { } }); }); + +test("indexes a new agent's prompts only once the agent exists", async ({ page }) => { + const promptIndex = await observePromptIndexRequests(page); + const creation = await createCreationScenario(page); + try { + await page.setViewportSize(WIDE_VIEWPORT); + await creation.openAgentDraft(); + await creation.submitPrompt("Start an agent with an outline."); + await creation.expectPromptVisible(); + + const agentIds = await creation.agentIds(); + expect(agentIds).toHaveLength(1); + await promptIndex.waitForRequestFor(agentIds[0]!); + expect(promptIndex.requestedAgentIds()).toEqual(agentIds); + } finally { + await creation.cleanup(); + } +}); diff --git a/packages/app/e2e/support/helpers/chat-outline.ts b/packages/app/e2e/support/helpers/chat-outline.ts index 09e28cd3af..d219979fcf 100644 --- a/packages/app/e2e/support/helpers/chat-outline.ts +++ b/packages/app/e2e/support/helpers/chat-outline.ts @@ -3,6 +3,7 @@ import { openSettings } from "./app"; import { openSettingsSection } from "./settings"; import { runWorkspaceActionFromCommandCenter } from "./command-center-workspace-actions"; import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; +import { loadSessionMessageReaders } from "./new-workspace"; export async function withStreamingMarkdownOutline( run: (agent: MockAgentWorkspace) => Promise, @@ -229,3 +230,22 @@ async function requireBoundingBox( } return box; } + +export async function observePromptIndexRequests(page: Page) { + const frames = await loadSessionMessageReaders(); + const agentIds: string[] = []; + page.on("websocket", (socket) => { + socket.on("framesent", ({ payload }) => { + const request = frames.client(payload); + if (request?.type === "agent.timeline.list_prompts.request") { + agentIds.push(request.agentId); + } + }); + }); + return { + async waitForRequestFor(agentId: string) { + await expect.poll(() => agentIds).toContain(agentId); + }, + requestedAgentIds: () => [...new Set(agentIds)], + }; +} diff --git a/packages/app/e2e/support/helpers/creation.ts b/packages/app/e2e/support/helpers/creation.ts index df0ec30066..09cd2ea290 100644 --- a/packages/app/e2e/support/helpers/creation.ts +++ b/packages/app/e2e/support/helpers/creation.ts @@ -206,6 +206,12 @@ export async function createCreationScenario(page: Page) { ) .toBe(count); }, + async agentIds() { + await requests.settled(); + return (await project.client.fetchAgents()).entries + .filter(({ agent }) => agent.workspaceId === workspaceId) + .map(({ agent }) => agent.id); + }, async expectAgentTitle(title: string) { await expect .poll( diff --git a/packages/app/src/agent-stream/chat-outline/model.test.ts b/packages/app/src/agent-stream/chat-outline/model.test.ts index 81811c7d78..f11be1ea77 100644 --- a/packages/app/src/agent-stream/chat-outline/model.test.ts +++ b/packages/app/src/agent-stream/chat-outline/model.test.ts @@ -12,7 +12,6 @@ describe("chat outline prompt index epoch", () => { it("accepts only the authoritative timeline epoch", () => { expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-2")).toBe(true); expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-1")).toBe(false); - expect(shouldAcceptPromptIndexEpoch(null, "epoch-1")).toBe(true); }); }); diff --git a/packages/app/src/agent-stream/chat-outline/model.ts b/packages/app/src/agent-stream/chat-outline/model.ts index 3d71d52b5f..0fb4b5dcb6 100644 --- a/packages/app/src/agent-stream/chat-outline/model.ts +++ b/packages/app/src/agent-stream/chat-outline/model.ts @@ -2,11 +2,8 @@ import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/ export type ChatOutlinePrompt = AgentTimelinePromptIndexPayload["prompts"][number]; -export function shouldAcceptPromptIndexEpoch( - timelineEpoch: string | null, - indexEpoch: string, -): boolean { - return timelineEpoch === null || timelineEpoch === indexEpoch; +export function shouldAcceptPromptIndexEpoch(timelineEpoch: string, indexEpoch: string): boolean { + return timelineEpoch === indexEpoch; } /** diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx index d84db755e9..4b2139fef6 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx @@ -37,6 +37,31 @@ describe("useChatOutline", () => { runtime.subscribeAgentTimeline.mockClear(); }); + it("waits for a served timeline before requesting its prompt index", async () => { + runtime.listAgentTimelinePrompts.mockResolvedValue({ epoch: "epoch-1", prompts: [] }); + const viewportRef = createRef(); + const { rerender } = renderHook( + ({ agentId, timelineEpoch }) => + useChatOutline({ + agentId, + serverId: "server-1", + timelineEpoch, + tail: [], + head: [], + enabled: true, + viewportRef, + onJumpError: vi.fn(), + }), + { initialProps: { agentId: "draft_msg_1", timelineEpoch: null as string | null } }, + ); + + expect(runtime.listAgentTimelinePrompts).not.toHaveBeenCalled(); + + rerender({ agentId: "agent-1", timelineEpoch: "epoch-1" }); + await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(1)); + expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledWith("agent-1"); + }); + it("drops a late prompt index after the authoritative timeline epoch changes", async () => { const first = deferred<{ epoch: string; prompts: [] }>(); const second = deferred<{ diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts index b59e670a5d..015b883d21 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts @@ -77,8 +77,10 @@ export function useChatOutline({ useEffect(() => setIndex(null), [agentId, enabled, serverId, timelineEpoch]); + // Only a timeline the daemon has served can be indexed. A draft's optimistic stream has no + // epoch, and its id names no agent the daemon knows. useEffect(() => { - if (!isWeb || !enabled) { + if (!isWeb || !enabled || timelineEpoch === null) { setIndex(null); return; }