diff --git a/packages/app/e2e/browser/assistant-fork-menu.spec.ts b/packages/app/e2e/browser/assistant-fork-menu.spec.ts index 8a55d3baf6..84308d5ab3 100644 --- a/packages/app/e2e/browser/assistant-fork-menu.spec.ts +++ b/packages/app/e2e/browser/assistant-fork-menu.spec.ts @@ -1,6 +1,16 @@ +import { + captureChatHistory, + closeChatHistory, + followChatHistorySource, + openChatHistory, + sendChatHistoryFromAnotherClient, + setChatHistoryViewport, +} from "../support/helpers/chat-history"; import { expect, test as base } from "../support/fixtures"; import { awaitAssistantMessage } from "../support/helpers/agent-stream"; import { + getActiveForkAgentId, + expectActiveForkAgent, expectChatHistoryAttachment, expectInFlightForkAvailable, expectLiveAssistantText, @@ -54,62 +64,45 @@ test.describe("Assistant fork menu", () => { await awaitAssistantMessage(page); await session.client.waitForFinish(session.agentId, 45_000); await forkMostRecentAssistantTurnToNewTab(page); - await page.getByTestId("composer-chat-history-attachment-pill").click(); - const preview = page.getByTestId("chat-history-preview"); - await expect(page.getByTestId("chat-history-content")).toContainText(initialPrompt); - await expect(page.getByRole("button", { name: "Open source chat" })).toBeVisible(); - // Settle the sheet's spring animation and hide Expo's development-only overlay for QA. - await page.waitForTimeout(600); - await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" }); - await page.screenshot({ - path: testInfo.outputPath("history-wide.png"), - animations: "disabled", - }); - await preview.getByRole("button", { name: "Close", exact: true }).click(); + await openChatHistory(page, "draft", [initialPrompt]); + await captureChatHistory(page, testInfo, "history-wide.png"); + await closeChatHistory(page); await submitMessage(page, "emit 1 coalesced agent stream updates for the fork."); - await expect(page.getByTestId("message-chat-history-attachment")).toBeVisible(); - const selectedTab = page - .getByTestId("workspace-tabs-row") - .getByRole("button") - .and(page.locator('[aria-selected="true"]')); - const tabId = await selectedTab.getAttribute("data-testid"); - expect(tabId).toMatch(/^workspace-tab-agent_/); - const forkAgentId = tabId!.replace("workspace-tab-agent_", ""); + const forkAgentId = await getActiveForkAgentId(page); await session.client.waitForFinish(forkAgentId, 45_000); + await openAgentRoute(page, { workspaceId: session.workspaceId, agentId: forkAgentId }); await page.reload(); - await page.setViewportSize({ width: 390, height: 844 }); - await page.getByTestId("message-chat-history-attachment").click(); - await expect(page.getByTestId("chat-history-content")).toContainText(initialPrompt); - // Settle the sheet's spring animation and hide Expo's development-only overlay for QA. - await page.waitForTimeout(600); - await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" }); - await page.screenshot({ - path: testInfo.outputPath("history-compact.png"), - animations: "disabled", - }); - await page.getByRole("button", { name: "Open source chat" }).click(); - await expect(preview).not.toBeVisible(); - await page.setViewportSize({ width: 1280, height: 720 }); - await expect(page.getByTestId(`workspace-tab-agent_${session.agentId}`)).toHaveAttribute( - "aria-selected", - "true", - ); + await setChatHistoryViewport(page, "compact"); + await openChatHistory(page, "sent", [initialPrompt]); + await captureChatHistory(page, testInfo, "history-compact.png"); + await followChatHistorySource(page); + await setChatHistoryViewport(page, "wide"); + await expectActiveForkAgent(page, session.agentId); - // Fork the fork, then follow its immediate source rather than guessing by title. await openAgentRoute(page, { workspaceId: session.workspaceId, agentId: forkAgentId }); - await expect(page.getByTestId(`workspace-tab-agent_${forkAgentId}`)).toHaveAttribute( - "aria-selected", - "true", - ); + await expectActiveForkAgent(page, forkAgentId); await forkMostRecentAssistantTurnToNewTab(page); - await page.getByTestId("composer-chat-history-attachment-pill").click(); - await expect(page.getByTestId("chat-history-content")).toContainText("updates for the fork."); - await page.getByRole("button", { name: "Open source chat" }).click(); - await expect(page.getByTestId(`workspace-tab-agent_${forkAgentId}`)).toHaveAttribute( - "aria-selected", - "true", - ); + await openChatHistory(page, "draft", [initialPrompt, "updates for the fork."]); + await followChatHistorySource(page); + await expectActiveForkAgent(page, forkAgentId); + }); + + test("shows history sent from another connected client without reloading", async ({ + page, + seedForkWorkspace, + }) => { + const session = await seedForkWorkspace({ + repoPrefix: "assistant-fork-live-history-", + title: "Live history recipient", + model: "ten-second-stream", + }); + await openAgentRoute(page, session); + await expectComposerVisible(page); + const history = "[User] Context inherited on another device."; + await sendChatHistoryFromAnotherClient(session, history); + await openChatHistory(page, "sent", [history]); + await session.client.waitForFinish(session.agentId, 45_000); }); test("forks a failed assistant turn that has no provider message id", async ({ diff --git a/packages/app/e2e/support/helpers/assistant-fork.ts b/packages/app/e2e/support/helpers/assistant-fork.ts index 7ee4cdc4fd..4f593ff054 100644 --- a/packages/app/e2e/support/helpers/assistant-fork.ts +++ b/packages/app/e2e/support/helpers/assistant-fork.ts @@ -112,3 +112,21 @@ export async function expectLiveAssistantText(page: Page, text: string): Promise timeout: 60_000, }); } + +export async function getActiveForkAgentId(page: Page): Promise { + const selectedTab = page + .getByTestId("workspace-tabs-row") + .getByRole("button") + .and(page.locator('[aria-selected="true"]')); + await expect(selectedTab).toHaveAttribute("data-testid", /^workspace-tab-agent_/); + const tabId = await selectedTab.getAttribute("data-testid"); + if (!tabId) throw new Error("Expected a selected agent tab"); + return tabId.replace("workspace-tab-agent_", ""); +} + +export async function expectActiveForkAgent(page: Page, agentId: string) { + await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveAttribute( + "aria-selected", + "true", + ); +} diff --git a/packages/app/e2e/support/helpers/chat-history.ts b/packages/app/e2e/support/helpers/chat-history.ts new file mode 100644 index 0000000000..c1ec5ff64f --- /dev/null +++ b/packages/app/e2e/support/helpers/chat-history.ts @@ -0,0 +1,58 @@ +import { expect, type Page, type TestInfo } from "@playwright/test"; +import type { MockAgentWorkspace } from "./mock-agent"; +import { getServerId } from "./server-id"; + +export function setChatHistoryViewport(page: Page, layout: "wide" | "compact") { + return page.setViewportSize( + layout === "wide" ? { width: 1280, height: 720 } : { width: 390, height: 844 }, + ); +} + +export async function openChatHistory(page: Page, location: "draft" | "sent", contents: string[]) { + const testId = + location === "draft" + ? "composer-chat-history-attachment-pill" + : "message-chat-history-attachment"; + await page.getByTestId(testId).click(); + for (const text of contents) { + await expect(page.getByTestId("chat-history-content")).toContainText(text); + } + await expect(page.getByRole("button", { name: "Open source chat" })).toBeVisible(); +} + +export async function closeChatHistory(page: Page) { + await page + .getByTestId("chat-history-preview") + .getByRole("button", { name: "Close", exact: true }) + .click(); +} + +export async function followChatHistorySource(page: Page) { + await page.getByRole("button", { name: "Open source chat" }).click(); + await expect(page.getByTestId("chat-history-preview")).not.toBeVisible(); +} + +export async function captureChatHistory(page: Page, testInfo: TestInfo, name: string) { + // Follow the existing QA helpers: settle spring animations and hide Expo-only chrome. + await page.waitForTimeout(600); + await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" }); + await page.screenshot({ path: testInfo.outputPath(name), animations: "disabled" }); +} + +export async function sendChatHistoryFromAnotherClient( + session: MockAgentWorkspace, + history: string, +) { + const serverId = getServerId(); + await session.client.sendAgentMessage(session.agentId, "", { + attachments: [ + { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + text: history, + chatHistorySource: { serverId, agentId: "source-agent", title: "Original conversation" }, + }, + ], + }); +} diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index 2debc70406..fee7d14553 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -157,7 +157,10 @@ export interface SeedDaemonClient { sendAgentMessage( agentId: string, text: string, - options?: { sourceAgentId?: string }, + options?: { + sourceAgentId?: string; + attachments?: import("@getpaseo/protocol/messages").AgentAttachment[]; + }, ): Promise; waitForFinish( agentId: string, diff --git a/packages/app/src/attachments/chat-history/index.tsx b/packages/app/src/attachments/chat-history/index.tsx index fdf354de6a..6237ffc5aa 100644 --- a/packages/app/src/attachments/chat-history/index.tsx +++ b/packages/app/src/attachments/chat-history/index.tsx @@ -1,3 +1,4 @@ +import { extractChatHistoryBody } from "@getpaseo/protocol/chat-history"; import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Text } from "react-native"; @@ -53,15 +54,7 @@ export function ChatHistoryAttachment({ ) : undefined, [source, openSource, t], ); - const history = useMemo(() => { - const text = attachment.text; - const bodyStart = text.indexOf("\n\n"); - return text.startsWith("\n") && - text.endsWith("\n") && - bodyStart !== -1 - ? text.slice(bodyStart + 2, -"\n".length) - : text; - }, [attachment.text]); + const history = useMemo(() => extractChatHistoryBody(attachment.text), [attachment.text]); const content = getAgentAttachmentPillContent(attachment, t); const label = ( diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index fb644b0db3..5c551c1191 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -182,6 +182,47 @@ describe("user message identity", () => { }, ); + it.each([ + { text: "", insert: "tail" }, + { text: "Continue", insert: "tail" }, + { text: "", insert: "head" }, + { text: "Continue", insert: "head" }, + ] as const)("delivers fork history to a live viewer ($insert, $text)", ({ text, insert }) => { + const chatHistory = { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + text: "[User] Original question", + chatHistorySource: { serverId: "source-host", agentId: "source-agent" }, + } as const; + const event = { + type: "timeline", + provider: "codex", + item: { type: "user_message", text, messageId: "fork-message", chatHistory }, + } as const; + const first = applyStreamEvent({ + tail: [], + head: [], + event, + timestamp: new Date(0), + unmatchedUserMessageInsert: insert, + }); + expect(first[insert]).toHaveLength(1); + expect(first[insert][0]).toMatchObject({ + kind: "user_message", + text, + attachments: [chatHistory], + }); + const repeated = applyStreamEvent({ + ...first, + event, + timestamp: new Date(0), + unmatchedUserMessageInsert: insert, + }); + expect([...repeated.tail, ...repeated.head]).toHaveLength(1); + expect([...repeated.tail, ...repeated.head][0]).toMatchObject({ attachments: [chatHistory] }); + }); + it("replaces provisional optimistic turn membership with canonical membership", () => { const optimistic = createUserMessage({ clientMessageId: "hello-client", diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index bd6588ca6b..46e98d5b46 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -891,6 +891,18 @@ export function handoffCreatedAgentUserMessageToStream(params: { }); } +function normalizeUserMessageContent( + text: string, + chatHistory?: Extract["chatHistory"], +) { + const normalized = normalizeChunk(text); + return { + ...normalized, + hasContent: normalized.hasContent || chatHistory !== undefined, + attachments: chatHistory ? [chatHistory] : undefined, + }; +} + function appendUserMessage( state: StreamItem[], text: string, @@ -902,8 +914,8 @@ function appendUserMessage( turnId?: string, chatHistory?: Extract["chatHistory"], ): StreamItem[] { - const { chunk, hasContent } = normalizeChunk(text); - if (!hasContent && !chatHistory) { + const { chunk, hasContent, attachments } = normalizeUserMessageContent(text, chatHistory); + if (!hasContent) { return state; } @@ -915,7 +927,7 @@ function appendUserMessage( timelineCursor, turnId, text: chunk, - ...(chatHistory ? { attachments: [chatHistory] } : {}), + attachments, timestamp, }); return upsertUserMessage(state, nextItem); @@ -1877,7 +1889,7 @@ function applyCanonicalUserMessageEvent(params: { }): ApplyStreamEventResult | null { const { tail, head, event, timestamp, timelineCursor, unmatchedInsert = "tail" } = params; if (event.type !== "timeline" || event.item.type !== "user_message") return null; - const normalized = normalizeChunk(event.item.text); + const normalized = normalizeUserMessageContent(event.item.text, event.item.chatHistory); const flushedTail = head.length > 0 ? flushHeadToTail(tail, head) : tail; const flushedHead = head.length > 0 ? [] : head; @@ -1890,6 +1902,7 @@ function applyCanonicalUserMessageEvent(params: { turnId: event.turnId, timelineCursor, text: normalized.chunk, + attachments: normalized.attachments, timestamp, }); if (unmatchedInsert === "head") { diff --git a/packages/protocol/src/chat-history.ts b/packages/protocol/src/chat-history.ts new file mode 100644 index 0000000000..fd030d1dcb --- /dev/null +++ b/packages/protocol/src/chat-history.ts @@ -0,0 +1,9 @@ +/** Remove the fork prompt envelope while preserving the snapshot body verbatim. */ +export function extractChatHistoryBody(text: string): string { + const bodyStart = text.indexOf("\n\n"); + return text.startsWith("\n") && + text.endsWith("\n") && + bodyStart !== -1 + ? text.slice(bodyStart + 2, -"\n".length) + : text; +} diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 6a1d85bcf0..23b3240b7f 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -572,3 +572,57 @@ it("preserves every message in fork history below the character budget", () => { `\nChat history from a previous Paseo agent.\n\n[User] ${text}\n`, ); }); + +it.each(["", "Continue in B"])("carries inherited history through repeated forks (%s)", (text) => { + const original = buildAgentForkContextAttachment({ + rows: [ + row(1, { type: "user_message", text: "Question in A" }), + row(2, { type: "assistant_message", text: "Answer in A" }), + ], + }); + const child = buildAgentForkContextAttachment({ + rows: [ + row(1, { + type: "user_message", + text, + chatHistory: { ...original.attachment, contextKind: "chat_history" }, + }), + row(2, { type: "assistant_message", text: "Answer in B" }), + ], + }); + const grandchild = buildAgentForkContextAttachment({ + rows: [ + row(1, { + type: "user_message", + text: "Continue in C", + chatHistory: { ...child.attachment, contextKind: "chat_history" }, + }), + row(2, { type: "assistant_message", text: "Answer in C" }), + ], + }); + expect(grandchild.attachment.text).toContain("[User] Question in A\n[Assistant] Answer in A"); + expect(grandchild.attachment.text).toContain("[Assistant] Answer in B"); + expect(grandchild.attachment.text).toContain("[User] Continue in C\n[Assistant] Answer in C"); + expect(grandchild.attachment.text.match(//g)).toHaveLength(1); +}); + +it("keeps the fork budget when inherited history exceeds the remaining space", () => { + const result = buildAgentForkContextAttachment({ + rows: [ + row(1, { + type: "user_message", + text: "Current question", + chatHistory: { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + text: "Old ".repeat(30_000), + }, + }), + row(2, { type: "assistant_message", text: "Latest answer" }), + ], + }); + expect(result.attachment.text.length).toBeLessThanOrEqual(100_000); + expect(result.attachment.text).toContain("[Earlier chat history omitted]"); + expect(result.attachment.text).toContain("[User] Current question\n[Assistant] Latest answer"); +}); diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index a0c537254a..10dc9ba15c 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -1,3 +1,4 @@ +import { extractChatHistoryBody } from "@getpaseo/protocol/chat-history"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; import type { AgentMessage } from "@getpaseo/protocol/agent-message"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; @@ -21,6 +22,7 @@ const FORK_CONTEXT_KINDS: readonly AgentTimelineItem["type"][] = [ ]; interface ActivityCuratorOptions { + includeChatHistory?: boolean; maxItems?: number; labelAssistantMessages?: boolean; includeKinds?: readonly AgentTimelineItem["type"][]; @@ -37,6 +39,17 @@ function activityEntry(text: string): ActivityEntry { return { text }; } +function appendUserEntry( + entries: ActivityEntry[], + item: Extract, + options?: ActivityCuratorOptions, +) { + if (options?.includeChatHistory && item.chatHistory) { + entries.push(activityEntry(extractChatHistoryBody(item.chatHistory.text))); + } + entries.push(activityEntry(`[User] ${item.text.trim()}`)); +} + function appendTextEntry( entries: ActivityEntry[], item: Extract, @@ -158,7 +171,7 @@ function curateProjectedActivityEntries( switch (item.type) { case "user_message": - entries.push(activityEntry(`[User] ${item.text.trim()}`)); + appendUserEntry(entries, item, options); break; case "assistant_message": case "reasoning": @@ -361,6 +374,7 @@ export function buildAgentForkContextAttachment(input: { }); const entries = curateProjectedActivityEntries(selected.items, { maxItems: 0, + includeChatHistory: true, labelAssistantMessages: true, includeKinds: FORK_CONTEXT_KINDS, includeExternalToolInput: false,