From 0a0f44369176459b8ccf6e5528a1c88c94f4a2fc Mon Sep 17 00:00:00 2001 From: wengshihao <45631373+wengshihao@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:03:59 +0800 Subject: [PATCH] fix(app): make forked chat history readable --- .../e2e/browser/assistant-fork-menu.spec.ts | 71 +++++++++++ .../attachments/attachment-pill-content.tsx | 9 +- .../src/attachments/chat-history/index.tsx | 117 ++++++++++++++++++ packages/app/src/attachments/types.ts | 5 +- .../workspace-attachment-utils.test.ts | 23 ++++ .../attachments/workspace-attachment-utils.ts | 6 +- packages/app/src/components/message.tsx | 15 ++- .../src/composer/attachments/workspace.tsx | 16 +++ packages/app/src/hooks/use-fork-agent.ts | 3 + packages/app/src/i18n/resources/ar.ts | 8 ++ packages/app/src/i18n/resources/en.ts | 8 ++ packages/app/src/i18n/resources/es.ts | 8 ++ packages/app/src/i18n/resources/fr.ts | 8 ++ packages/app/src/i18n/resources/ja.ts | 8 ++ packages/app/src/i18n/resources/ko.ts | 8 ++ packages/app/src/i18n/resources/pt-BR.ts | 8 ++ packages/app/src/i18n/resources/ru.ts | 8 ++ packages/app/src/i18n/resources/zh-CN.ts | 8 ++ packages/app/src/types/stream.test.ts | 24 ++++ packages/app/src/types/stream.ts | 5 +- packages/protocol/src/agent-types.ts | 10 +- .../protocol/src/messages.attachments.test.ts | 42 +++++++ packages/protocol/src/messages.ts | 21 ++++ .../src/server/agent/agent-manager.test.ts | 91 ++++++++++++++ .../server/src/server/agent/agent-manager.ts | 97 ++++++++++++--- .../src/server/agent/agent-projections.ts | 1 + .../src/server/agent/agent-sdk-types.ts | 10 +- .../server/src/server/agent/agent-storage.ts | 11 +- 28 files changed, 617 insertions(+), 32 deletions(-) create mode 100644 packages/app/src/attachments/chat-history/index.tsx diff --git a/packages/app/e2e/browser/assistant-fork-menu.spec.ts b/packages/app/e2e/browser/assistant-fork-menu.spec.ts index b5a0b0c27a..533ac6cf33 100644 --- a/packages/app/e2e/browser/assistant-fork-menu.spec.ts +++ b/packages/app/e2e/browser/assistant-fork-menu.spec.ts @@ -38,6 +38,77 @@ const test = base.extend<{ test.describe("Assistant fork menu", () => { test.describe.configure({ timeout: 180_000 }); + test("reads fork history before and after sending and opens its source after reload", async ({ + page, + seedForkWorkspace, + }, testInfo) => { + const initialPrompt = "emit 1 coalesced agent stream updates for readable fork history."; + const session = await seedForkWorkspace({ + repoPrefix: "assistant-fork-readable-", + title: "Original conversation", + initialPrompt, + model: "ten-second-stream", + }); + await openAgentRoute(page, session); + await expectComposerVisible(page); + 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 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_", ""); + 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", + ); + + // Fork the fork, then follow its immediate source rather than guessing by title. + await openAgentRoute(page, { workspaceId: session.workspaceId, agentId: forkAgentId }); + await awaitAssistantMessage(page); + 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", + ); + }); + test("forks a failed assistant turn that has no provider message id", async ({ page, seedForkWorkspace, diff --git a/packages/app/src/attachments/attachment-pill-content.tsx b/packages/app/src/attachments/attachment-pill-content.tsx index ed37c0d787..5577ecc58a 100644 --- a/packages/app/src/attachments/attachment-pill-content.tsx +++ b/packages/app/src/attachments/attachment-pill-content.tsx @@ -49,7 +49,7 @@ function getTextAttachmentSubtitle( t: TFunction, ): string { if (attachment.contextKind === "chat_history") { - return "Previous conversation"; + return attachment.chatHistorySource?.title ?? t("message.chatHistory.previousConversation"); } return t("message.attachments.text"); } @@ -87,6 +87,13 @@ export function getAgentAttachmentPillContent( subtitle: `Issue #${attachment.number}`, }; case "text": + if (attachment.contextKind === "chat_history") { + return { + icon: attachmentFileIcon, + title: t("message.chatHistory.title"), + subtitle: getTextAttachmentSubtitle(attachment, t), + }; + } if (attachment.externalResource) { return { icon: attachmentGithubIssueIcon, diff --git a/packages/app/src/attachments/chat-history/index.tsx b/packages/app/src/attachments/chat-history/index.tsx new file mode 100644 index 0000000000..fdf354de6a --- /dev/null +++ b/packages/app/src/attachments/chat-history/index.tsx @@ -0,0 +1,117 @@ +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Text } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { ArrowUpRight } from "lucide-react-native"; +import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content"; +import { AttachmentFrame, AttachmentLabel, AttachmentPill } from "@/components/attachment-pill"; +import { AdaptiveModalSheet, FULL_SHEET_SNAP_POINTS } from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; +import { navigateToAgent } from "@/utils/navigate-to-agent"; + +interface ChatHistoryAttachmentProps { + attachment: Extract; + onRemove?: () => void; + disabled?: boolean; + testID?: string; +} + +export function ChatHistoryAttachment({ + attachment, + onRemove, + disabled = false, + testID, +}: ChatHistoryAttachmentProps) { + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const open = useCallback(() => setVisible(true), []); + const close = useCallback(() => setVisible(false), []); + const source = attachment.chatHistorySource; + const openSource = useCallback(() => { + if (!source) return; + close(); + navigateToAgent({ serverId: source.serverId, agentId: source.agentId }); + }, [close, source]); + const header = useMemo( + () => ({ + title: t("message.chatHistory.title"), + subtitle: source?.title ? ( + + {source.title} + + ) : undefined, + }), + [source?.title, t], + ); + const footer = useMemo( + () => + source ? ( + + ) : 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 content = getAgentAttachmentPillContent(attachment, t); + const label = ( + + ); + const openLabel = t("message.chatHistory.open"); + + return ( + <> + {onRemove ? ( + + {label} + + ) : ( + + {label} + + )} + + {t("message.chatHistory.description")} + + {history} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + content: { gap: theme.spacing[4] }, + description: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted }, + history: { + fontSize: theme.fontSize.content, + lineHeight: theme.fontSize.content * 1.6, + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index 75ae6ae152..c32d430969 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -1,5 +1,6 @@ import type { AgentAttachment, + ChatHistorySource, ForgeSearchItem, UploadedFileAttachment, } from "@getpaseo/protocol/messages"; @@ -82,9 +83,7 @@ export interface ChatHistoryContextAttachment { kind: "chat_history"; id: string; attachment: Extract; - source: { - serverId: string; - agentId: string; + source: ChatHistorySource & { boundaryMessageId?: string | null; boundaryCursor?: { epoch: string; seq: number } | null; itemCount?: number; diff --git a/packages/app/src/attachments/workspace-attachment-utils.test.ts b/packages/app/src/attachments/workspace-attachment-utils.test.ts index 5fd12cd344..2d21e1c3fe 100644 --- a/packages/app/src/attachments/workspace-attachment-utils.test.ts +++ b/packages/app/src/attachments/workspace-attachment-utils.test.ts @@ -21,6 +21,29 @@ function contextAttachment( } describe("workspace attachment utilities", () => { + it("submits chat history with its source host and agent", () => { + const source = { serverId: "original-host", agentId: "original-agent", title: "Original" }; + expect( + workspaceAttachmentToSubmitAttachment({ + kind: "chat_history", + id: "history-draft", + attachment: { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + text: "Earlier context", + }, + source, + }), + ).toEqual({ + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + text: "Earlier context", + chatHistorySource: source, + }); + }); + it("treats pull request context as a workspace attachment", () => { expect(isWorkspaceAttachment(contextAttachment())).toBe(true); }); diff --git a/packages/app/src/attachments/workspace-attachment-utils.ts b/packages/app/src/attachments/workspace-attachment-utils.ts index d41d11d6b4..821fd4ee58 100644 --- a/packages/app/src/attachments/workspace-attachment-utils.ts +++ b/packages/app/src/attachments/workspace-attachment-utils.ts @@ -62,7 +62,11 @@ export function workspaceAttachmentToSubmitAttachment( }; } if (attachment.kind === "chat_history") { - return attachment.attachment; + const { serverId, agentId, title } = attachment.source; + return { + ...attachment.attachment, + chatHistorySource: { serverId, agentId, ...(title ? { title } : {}) }, + }; } return attachment.kind === "review" ? attachment.attachment : null; } diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index e73dd4171f..4c0d29ac63 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1,3 +1,4 @@ +import { ChatHistoryAttachment } from "@/attachments/chat-history"; import { useDisclosureHold } from "@/agent-stream/disclosure"; import { MessagePreview, MessageSender } from "@/components/user-message"; import { useStableEvent } from "@/hooks/use-stable-event"; @@ -496,11 +497,19 @@ export const UserMessage = memo(function UserMessage({ {hasAttachments ? ( {attachments.map((attachment, index) => { + const key = `${attachment.type}:${"number" in attachment ? attachment.number : index}`; + if (attachment.type === "text" && attachment.contextKind === "chat_history") { + return ( + + ); + } const content = getAgentAttachmentPillContent(attachment, t); return ( - + { + if (attachment.kind !== "chat_history") return null; + const { serverId, agentId, title } = attachment.source; + return { ...attachment.attachment, chatHistorySource: { serverId, agentId, title } }; + }, [attachment]); const handleOpen = useCallback(() => { onOpen(attachment); }, [onOpen, attachment]); const handleRemove = useCallback(() => { onRemove(index); }, [onRemove, index]); + if (chatHistory) { + return ( + + ); + } return ( >; @@ -83,6 +84,7 @@ function buildChatHistoryAttachment(input: { source: { serverId: input.serverId, agentId: input.agentId, + ...(input.title ? { title: input.title } : {}), boundaryMessageId: input.payload.boundaryMessageId, boundaryCursor: input.payload.boundaryCursor, itemCount: input.payload.itemCount, @@ -147,6 +149,7 @@ export function useForkAgent( const payload = await client.buildAgentForkContext(agentId, boundary); const attachment = buildChatHistoryAttachment({ draftId, + title: useSessionStore.getState().sessions[serverId]?.agents.get(agentId)?.title ?? null, serverId, agentId, payload, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index f86d81b6e9..f89f7ecf47 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -342,6 +342,14 @@ export const ar: TranslationResources = { openFile: "افتح الملف", copied: "منقول", }, + chatHistory: { + title: "سجل المحادثة", + previousConversation: "المحادثة السابقة", + open: "فتح مرفق سجل المحادثة", + remove: "إزالة مرفق سجل المحادثة", + openSource: "فتح المحادثة الأصلية", + description: "السياق المنسوخ عند تفريع هذه المحادثة.", + }, attachments: { dismissImage: "تجاهل الصورة", closeImage: "إغلاق الصورة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 103a981abc..f8fde001bc 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -339,6 +339,14 @@ export const en = { openFile: "Open file", copied: "Copied", }, + chatHistory: { + title: "Chat history", + previousConversation: "Previous conversation", + open: "Open chat history attachment", + remove: "Remove chat history attachment", + openSource: "Open source chat", + description: "Context copied when this chat was forked.", + }, attachments: { dismissImage: "Dismiss image", closeImage: "Close image", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 48737ee20e..31d28723d4 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -347,6 +347,14 @@ export const es: TranslationResources = { openFile: "Abrir archivo", copied: "Copiado", }, + chatHistory: { + title: "Historial del chat", + previousConversation: "Conversación anterior", + open: "Abrir el historial adjunto", + remove: "Quitar el historial adjunto", + openSource: "Abrir el chat de origen", + description: "Contexto copiado al bifurcar este chat.", + }, attachments: { dismissImage: "Descartar imagen", closeImage: "Cerrar imagen", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 0d2e4aabce..3665ac6525 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -347,6 +347,14 @@ export const fr: TranslationResources = { openFile: "Ouvrir le fichier", copied: "Copié", }, + chatHistory: { + title: "Historique du chat", + previousConversation: "Conversation précédente", + open: "Ouvrir l’historique joint", + remove: "Retirer l’historique joint", + openSource: "Ouvrir le chat d’origine", + description: "Contexte copié lors de la bifurcation de ce chat.", + }, attachments: { dismissImage: "Ignorer l’image", closeImage: "Fermer l’image", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index ca93aa3106..e5789a556e 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -347,6 +347,14 @@ export const ja: TranslationResources = { openFile: "ファイルを開く", copied: "コピーしました", }, + chatHistory: { + title: "チャット履歴", + previousConversation: "以前の会話", + open: "チャット履歴の添付を開く", + remove: "チャット履歴の添付を削除", + openSource: "元のチャットを開く", + description: "このチャットを分岐した時点でコピーされたコンテキストです。", + }, attachments: { dismissImage: "画像を閉じる", closeImage: "画像を閉じる", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 51ea225983..d2d722bda1 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -343,6 +343,14 @@ export const ko: TranslationResources = { openFile: "파일 열기", copied: "복사됨", }, + chatHistory: { + title: "채팅 기록", + previousConversation: "이전 대화", + open: "채팅 기록 첨부 열기", + remove: "채팅 기록 첨부 제거", + openSource: "원본 채팅 열기", + description: "이 채팅을 분기할 때 복사된 컨텍스트입니다.", + }, attachments: { dismissImage: "이미지 닫기", closeImage: "이미지 닫기", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 5c2695f3bf..bb225e5868 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -346,6 +346,14 @@ export const ptBR: TranslationResources = { openFile: "Abrir arquivo", copied: "Copiado", }, + chatHistory: { + title: "Histórico do chat", + previousConversation: "Conversa anterior", + open: "Abrir histórico anexado", + remove: "Remover histórico anexado", + openSource: "Abrir chat de origem", + description: "Contexto copiado ao ramificar este chat.", + }, attachments: { dismissImage: "Dispensar imagem", closeImage: "Fechar imagem", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index ea8fb1eb25..eca13b87d1 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -346,6 +346,14 @@ export const ru: TranslationResources = { openFile: "Открыть файл", copied: "Скопировано", }, + chatHistory: { + title: "История чата", + previousConversation: "Предыдущий разговор", + open: "Открыть вложенную историю чата", + remove: "Удалить вложенную историю чата", + openSource: "Открыть исходный чат", + description: "Контекст, скопированный при создании этой ветки чата.", + }, attachments: { dismissImage: "Закрыть изображение", closeImage: "Закрыть изображение", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 54454d9bff..4bc7b83f6a 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -342,6 +342,14 @@ export const zhCN: TranslationResources = { openFile: "打开文件", copied: "已复制", }, + chatHistory: { + title: "聊天历史", + previousConversation: "之前的对话", + open: "打开聊天历史附件", + remove: "移除聊天历史附件", + openSource: "打开来源对话", + description: "创建此分叉时复制的上下文。", + }, attachments: { dismissImage: "关闭图片", closeImage: "关闭图片", diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 376d04ea67..fb644b0db3 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -158,6 +158,30 @@ describe("plugin timeline rows", () => { }); describe("user message identity", () => { + it.each(["", "Continue from here"])( + "hydrates fork history without optimistic state: %s", + (text) => { + const chatHistory = { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + text: "[User] Earlier question\n[Assistant] Earlier answer", + chatHistorySource: { serverId: "source-host", agentId: "source-agent", title: "Original" }, + } as const; + const result = reduceStreamUpdate( + [], + { + type: "timeline", + provider: "codex", + item: { type: "user_message", text, messageId: "fork-prompt", chatHistory }, + }, + new Date(0), + ); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ kind: "user_message", text, 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 0226993a8c..bd6588ca6b 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -900,9 +900,10 @@ function appendUserMessage( clientMessageId?: string, timelineCursor?: TimelinePosition, turnId?: string, + chatHistory?: Extract["chatHistory"], ): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); - if (!hasContent) { + if (!hasContent && !chatHistory) { return state; } @@ -914,6 +915,7 @@ function appendUserMessage( timelineCursor, turnId, text: chunk, + ...(chatHistory ? { attachments: [chatHistory] } : {}), timestamp, }); return upsertUserMessage(state, nextItem); @@ -1523,6 +1525,7 @@ function reduceTimelineEvent( item.clientMessageId, timelineCursor, event.turnId, + item.chatHistory, ); case "assistant_message": return appendAssistantMessage( diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index ca04655430..532d56b67c 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -1,5 +1,5 @@ import type { AgentMessage } from "./agent-message.js"; -import type { AgentAttachment } from "./messages.js"; +import type { AgentAttachment, ChatHistoryAttachment } from "./messages.js"; export type AgentProvider = string; @@ -373,7 +373,13 @@ export interface AgentTaskItem { } export type AgentTimelineItem = - | { type: "user_message"; text: string; messageId?: string; clientMessageId?: string } + | { + type: "user_message"; + text: string; + messageId?: string; + clientMessageId?: string; + chatHistory?: ChatHistoryAttachment; + } | { type: "assistant_message"; text: string; messageId?: string } | { type: "reasoning"; text: string } | ToolCallTimelineItem diff --git a/packages/protocol/src/messages.attachments.test.ts b/packages/protocol/src/messages.attachments.test.ts index 7d544e3e65..dabf7ef46f 100644 --- a/packages/protocol/src/messages.attachments.test.ts +++ b/packages/protocol/src/messages.attachments.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + AgentTimelineItemPayloadSchema, AgentForkContextRequestMessageSchema, AgentForkContextResponseMessageSchema, CreateAgentRequestMessageSchema, @@ -9,6 +10,24 @@ import { } from "./messages.js"; describe("shared messages attachments", () => { + it("round-trips fork history on a timeline row and accepts rows without it", () => { + const row = { + type: "user_message", + text: "", + messageId: "fork-prompt", + chatHistory: { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + text: "Earlier context", + chatHistorySource: { serverId: "source-host", agentId: "source-agent", title: "Original" }, + }, + }; + expect(AgentTimelineItemPayloadSchema.parse(row)).toEqual(row); + const legacy = { type: "user_message", text: "Earlier message" }; + expect(AgentTimelineItemPayloadSchema.parse(legacy)).toEqual(legacy); + }); + it("preserves an optional timeline cursor on fork-context messages", () => { const boundaryCursor = { epoch: "timeline-1", seq: 42 }; const request = AgentForkContextRequestMessageSchema.parse({ @@ -269,6 +288,29 @@ describe("shared messages attachments", () => { ]); }); + it("preserves the source of a fork across message submission", () => { + const attachment = { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + title: "Chat history", + text: "Earlier context", + chatHistorySource: { + serverId: "source-host", + agentId: "source-agent", + title: "Original conversation", + }, + }; + const parsed = SendAgentMessageRequestSchema.parse({ + type: "send_agent_message_request", + requestId: "req-fork-source", + agentId: "fork-agent", + text: "Continue", + attachments: [attachment], + }); + expect(parsed.attachments).toEqual([attachment]); + }); + it("keeps known text attachment context kinds and ignores future ones", () => { const parsed = SendAgentMessageRequestSchema.parse({ type: "send_agent_message_request", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 5436e1737d..ed1c4df33b 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -712,6 +712,25 @@ const ToolCallTimelineItemPayloadSchema: z.ZodType; + +export const ChatHistoryAttachmentSchema = z.object({ + type: z.literal("text"), + mimeType: z.literal("text/plain"), + contextKind: z.literal("chat_history"), + title: z.string().nullable().optional(), + text: z.string(), + chatHistorySource: ChatHistorySourceSchema.optional(), +}); + +export type ChatHistoryAttachment = z.infer; + // zod-aot 0.20.4 miscompiles this as a nested discriminated union by omitting // the inner tool_call branch from the generated outer dispatch. export const AgentTimelineItemPayloadSchema: z.ZodType = z.union([ @@ -720,6 +739,7 @@ export const AgentTimelineItemPayloadSchema: z.ZodType ({ ...attachment, diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 6c7396d657..6b3d5833ed 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -729,6 +729,97 @@ test("uses an injected timeline store without making it a production requirement } }); +test.each([true, false])( + "fork history survives storage-backed replay (provider echo: %s)", + async (echo) => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-fork-history-")); + const storagePath = join(workdir, "agents"); + const attachment = { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + text: "\nChat history from a previous Paseo agent.\n\n[User] Original question\n[Assistant] Original answer\n", + chatHistorySource: { serverId: "source-host", agentId: "source-agent", title: "Original" }, + } as const; + const clientMessageId = "fork-prompt"; + let hasHistory = false; + let includeFork = true; + class HistorySession extends TestAgentSession { + override async startTurn(): Promise<{ turnId: string }> { + hasHistory = true; + if (echo) + this.pushEvent({ + type: "timeline", + provider: "codex", + item: { + type: "user_message", + text: attachment.text, + messageId: "native-fork", + clientMessageId, + }, + }); + return super.startTurn(); + } + override async *streamHistory(): AsyncGenerator { + if (!hasHistory) return; + if (includeFork) + yield { + type: "timeline", + provider: "codex", + item: { type: "user_message", text: attachment.text, messageId: "native-fork" }, + }; + yield { + type: "timeline", + provider: "codex", + item: { type: "user_message", text: attachment.text, messageId: "native-later" }, + }; + } + } + class HistoryClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new HistorySession(config); + } + override async resumeSession(): Promise { + return new HistorySession({ provider: "codex", cwd: workdir }); + } + } + const client = new HistoryClient(); + let storage = new AgentStorage(storagePath, logger); + let manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + onTestFinished(async () => { + await manager.closeAgent(agent.id); + await manager.flush(); + rmSync(workdir, { recursive: true, force: true }); + }); + await drainAsyncGenerator(manager.streamAgent(agent.id, [attachment], { clientMessageId })); + expect((await manager.getTimelineRows(agent.id))[0]?.item).toMatchObject({ + type: "user_message", + text: "", + chatHistory: attachment, + }); + await manager.closeAgent(agent.id); + await manager.flush(); + + storage = new AgentStorage(storagePath, logger); + manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + await ensureAgentLoaded(agent.id, { agentManager: manager, agentStorage: storage, logger }); + await manager.hydrateTimelineFromProvider(agent.id); + expect((await manager.getTimelineRows(agent.id)).map((row) => row.item)).toEqual([ + { type: "user_message", text: "", messageId: "native-fork", chatHistory: attachment }, + { type: "user_message", text: attachment.text, messageId: "native-later" }, + ]); + // A provider rewind must not recreate a removed fork prompt. + includeFork = false; + await manager.hydrateTimelineFromProvider(agent.id, { force: true }); + expect((await manager.getTimelineRows(agent.id)).map((row) => row.item)).toEqual([ + { type: "user_message", text: attachment.text, messageId: "native-later" }, + ]); + }, +); + test("refreshing an agent replaces the injected timeline store instead of appending a second copy", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-rehydrate-")); const store = new RecordingTimelineStore(); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index fea3eed438..0f791b3e68 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -441,6 +441,7 @@ interface HandleStreamEventOptions { } interface ManagedAgentBase { + forkContext?: StoredAgentRecord["forkContext"]; id: string; provider: AgentProvider; cwd: string; @@ -2701,11 +2702,15 @@ export class AgentManager { if (event.type === "timeline") { this.touchUpdatedAt(agent); const row = this.recordTimeline(agent.id, event.item); - this.dispatchStream(agent.id, event, { - seq: row.seq, - epoch: this.timelineStore.getEpoch(agent.id), - timestamp: row.timestamp, - }); + this.dispatchStream( + agent.id, + { ...event, item: row.item }, + { + seq: row.seq, + epoch: this.timelineStore.getEpoch(agent.id), + timestamp: row.timestamp, + }, + ); return; } this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() }); @@ -3919,6 +3924,8 @@ export class AgentManager { options, }); + await this.restoreForkContext(managed); + // Read history before publishing the agent: a provider failure must leave the // session unregistered so the registration catch closes it. const startupHistory: AgentStreamEvent[] = []; @@ -4491,11 +4498,15 @@ export class AgentManager { event.timestamp ? { timestamp: event.timestamp } : undefined, ); if (broadcastTimeline) { - this.dispatchStream(agent.id, event, { - seq: row.seq, - epoch: this.timelineStore.getEpoch(agent.id), - timestamp: row.timestamp, - }); + this.dispatchStream( + agent.id, + { ...event, item: row.item }, + { + seq: row.seq, + epoch: this.timelineStore.getEpoch(agent.id), + timestamp: row.timestamp, + }, + ); } } this.touchUpdatedAt(agent); @@ -4560,13 +4571,17 @@ export class AgentManager { event.timestamp ? { timestamp: event.timestamp } : undefined, ); if (deferredBroadcast) { - timelineEvents.push({ event, row }); + timelineEvents.push({ event: { ...event, item: row.item }, row }); } else if (broadcast) { - this.dispatchStream(agent.id, event, { - seq: row.seq, - epoch: this.timelineStore.getEpoch(agent.id), - timestamp: row.timestamp, - }); + this.dispatchStream( + agent.id, + { ...event, item: row.item }, + { + seq: row.seq, + epoch: this.timelineStore.getEpoch(agent.id), + timestamp: row.timestamp, + }, + ); } } agent.historyPrimed = true; @@ -5101,7 +5116,7 @@ export class AgentManager { const row = this.recordTimeline(agentId, item, { ...options, turnId }); const event: AgentStreamEvent = { type: "timeline", - item, + item: row.item, provider, ...(turnId !== undefined ? { turnId } : {}), }; @@ -5133,9 +5148,24 @@ export class AgentManager { outcome: "accepted" | "rejected", options?: { messageId?: string; providerMessageId?: string; turnId?: string }, ): void { + const text = submittedPromptText(prompt); + if (typeof prompt !== "string") { + const attachment = prompt.find( + (block) => + block.type === "text" && "contextKind" in block && block.contextKind === "chat_history", + ); + if (attachment?.type === "text" && "mimeType" in attachment) { + agent.forkContext = { + clientMessageId, + providerMessageId: options?.providerMessageId, + text, + attachment: { ...attachment, contextKind: "chat_history" }, + }; + } + } const items = projectAgentMessages({ type: "user_message", - text: submittedPromptText(prompt), + text, clientMessageId, ...(options?.messageId ? { messageId: options.messageId } : {}), }); @@ -5183,6 +5213,10 @@ export class AgentManager { } if (!existing || existing.item.type !== "user_message") return null; if (messageId) { + if (agent.forkContext?.clientMessageId === clientMessageId) { + agent.forkContext.providerMessageId = messageId; + this.enqueueBackgroundPersist(agent); + } const enriched = this.timelineStore.enrichSubmittedUserMessage( agent.id, clientMessageId, @@ -5247,6 +5281,10 @@ export class AgentManager { return parts.join("\n\n"); } + private async restoreForkContext(agent: ManagedAgent): Promise { + agent.forkContext = (await this.registry?.get(agent.id))?.forkContext; + } + private recordTimeline( agentId: string, item: AgentTimelineItem, @@ -5256,6 +5294,29 @@ export class AgentManager { turnId?: string; }, ): AgentTimelineRow { + const agent = this.agents.get(agentId); + const context = agent?.forkContext; + if ( + context && + item.type === "user_message" && + (item.clientMessageId === context.clientMessageId || + (context.providerMessageId + ? item.messageId === context.providerMessageId + : context.attachment.text.length > 0 && item.text.includes(context.attachment.text))) + ) { + // Providers without client IDs can be matched once by the exact snapshot. + // Remember their native ID so a later quotation is not mistaken for the fork. + if ( + agent && + !context.providerMessageId && + item.messageId && + item.messageId !== context.clientMessageId + ) { + context.providerMessageId = item.messageId; + this.enqueueBackgroundPersist(agent); + } + item = { ...item, text: context.text, chatHistory: context.attachment }; + } item = limitAgentTimelineItemContent(item); const row = this.timelineStore.append(agentId, item, options); this.enqueueDurableTimelineAppend(agentId, row); diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 3229a70e0f..a0cc8ae8d7 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -92,6 +92,7 @@ export function toStoredAgentRecord( lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null, title: options?.title ?? null, labels: agent.labels, + forkContext: agent.forkContext, lastStatus: agent.lifecycle, lastModeId: agent.currentModeId ?? config?.modeId ?? null, config: config ?? null, diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 903bbe5c99..c93e24a617 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -9,7 +9,7 @@ import type { ToolPolicy, } from "@getpaseo/protocol/agent-types"; import type { AgentMessage } from "@getpaseo/protocol/agent-message"; -import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import type { AgentAttachment, ChatHistoryAttachment } from "@getpaseo/protocol/messages"; import type { PaseoToolCatalog } from "./tools/types.js"; export type { @@ -398,7 +398,13 @@ export interface PluginTimelineItem { } export type AgentTimelineItem = - | { type: "user_message"; text: string; messageId?: string; clientMessageId?: string } + | { + type: "user_message"; + text: string; + messageId?: string; + clientMessageId?: string; + chatHistory?: ChatHistoryAttachment; + } | { type: "assistant_message"; text: string; messageId?: string } | { type: "reasoning"; text: string } | ToolCallTimelineItem diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 41ad500969..d9ba88df8e 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import type { Logger } from "pino"; import { writeJsonFileAtomic } from "../atomic-file.js"; -import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js"; +import { AgentFeatureSchema, AgentStatusSchema, ChatHistoryAttachmentSchema } from "../messages.js"; import { toStoredAgentRecord } from "./agent-projections.js"; import type { ManagedAgent } from "./agent-manager.js"; import type { AgentSessionConfig } from "./agent-sdk-types.js"; @@ -53,6 +53,15 @@ const STORED_AGENT_SCHEMA = z.object({ lastUserMessageAt: z.string().nullable().optional(), title: z.string().nullable().optional(), labels: z.record(z.string(), z.string()).default({}), + // Provider transcripts retain the rendered prompt, not attachment metadata. + forkContext: z + .object({ + clientMessageId: z.string(), + providerMessageId: z.string().optional(), + text: z.string(), + attachment: ChatHistoryAttachmentSchema, + }) + .optional(), lastStatus: AgentStatusSchema.default("closed"), resumeOnStartup: z.boolean().optional(), lastModeId: z.string().nullable().optional(),