fix(app): make forked chat history readable

This commit is contained in:
wengshihao
2026-11-06 12:07:29 +08:00
parent 5abfdf7107
commit 0a0f443691
28 changed files with 617 additions and 32 deletions
@@ -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,
@@ -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,
@@ -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<AgentAttachment, { type: "text" }>;
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 ? (
<Text style={styles.description} numberOfLines={1}>
{source.title}
</Text>
) : undefined,
}),
[source?.title, t],
);
const footer = useMemo(
() =>
source ? (
<Button variant="outline" size="sm" leftIcon={ArrowUpRight} onPress={openSource}>
{t("message.chatHistory.openSource")}
</Button>
) : undefined,
[source, openSource, t],
);
const history = useMemo(() => {
const text = attachment.text;
const bodyStart = text.indexOf("\n\n");
return text.startsWith("<chat-history-summary>\n") &&
text.endsWith("\n</chat-history-summary>") &&
bodyStart !== -1
? text.slice(bodyStart + 2, -"\n</chat-history-summary>".length)
: text;
}, [attachment.text]);
const content = getAgentAttachmentPillContent(attachment, t);
const label = (
<AttachmentLabel icon={content.icon} title={content.title} subtitle={content.subtitle} />
);
const openLabel = t("message.chatHistory.open");
return (
<>
{onRemove ? (
<AttachmentPill
testID={testID}
onOpen={open}
onRemove={onRemove}
disabled={disabled}
openAccessibilityLabel={openLabel}
removeAccessibilityLabel={t("message.chatHistory.remove")}
>
{label}
</AttachmentPill>
) : (
<AttachmentFrame testID={testID} onPress={open} accessibilityLabel={openLabel}>
{label}
</AttachmentFrame>
)}
<AdaptiveModalSheet
visible={visible}
onClose={close}
header={header}
snapPoints={FULL_SHEET_SNAP_POINTS}
desktopMaxWidth={720}
desktopHeight="75%"
testID="chat-history-preview"
contentStyle={styles.content}
footer={footer}
>
<Text style={styles.description}>{t("message.chatHistory.description")}</Text>
<Text selectable testID="chat-history-content" style={styles.history}>
{history}
</Text>
</AdaptiveModalSheet>
</>
);
}
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,
},
}));
+2 -3
View File
@@ -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<AgentAttachment, { type: "text" }>;
source: {
serverId: string;
agentId: string;
source: ChatHistorySource & {
boundaryMessageId?: string | null;
boundaryCursor?: { epoch: string; seq: number } | null;
itemCount?: number;
@@ -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);
});
@@ -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;
}
+12 -3
View File
@@ -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 ? (
<View style={attachmentPreviewContainerStyle}>
{attachments.map((attachment, index) => {
const key = `${attachment.type}:${"number" in attachment ? attachment.number : index}`;
if (attachment.type === "text" && attachment.contextKind === "chat_history") {
return (
<ChatHistoryAttachment
key={key}
attachment={attachment}
testID="message-chat-history-attachment"
/>
);
}
const content = getAgentAttachmentPillContent(attachment, t);
return (
<AttachmentFrame
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
>
<AttachmentFrame key={key}>
<AttachmentLabel
icon={content.icon}
title={content.title}
@@ -1,3 +1,4 @@
import { ChatHistoryAttachment } from "@/attachments/chat-history";
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import type {
@@ -255,12 +256,27 @@ function WorkspaceAttachmentPill({
}: WorkspaceAttachmentPillProps) {
const { t } = useTranslation();
const content = getWorkspaceAttachmentPillContent(attachment, t);
const chatHistory = useMemo(() => {
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 (
<ChatHistoryAttachment
attachment={chatHistory}
onRemove={handleRemove}
disabled={disabled}
testID={getPillTestID(attachment)}
/>
);
}
return (
<AttachmentPill
testID={getPillTestID(attachment)}
+3
View File
@@ -68,6 +68,7 @@ export interface UseForkAgentInput {
function buildChatHistoryAttachment(input: {
draftId: string;
title: string | null;
serverId: string;
agentId: string;
payload: Awaited<ReturnType<DaemonClient["buildAgentForkContext"]>>;
@@ -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,
+8
View File
@@ -342,6 +342,14 @@ export const ar: TranslationResources = {
openFile: "افتح الملف",
copied: "منقول",
},
chatHistory: {
title: "سجل المحادثة",
previousConversation: "المحادثة السابقة",
open: "فتح مرفق سجل المحادثة",
remove: "إزالة مرفق سجل المحادثة",
openSource: "فتح المحادثة الأصلية",
description: "السياق المنسوخ عند تفريع هذه المحادثة.",
},
attachments: {
dismissImage: "تجاهل الصورة",
closeImage: "إغلاق الصورة",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -347,6 +347,14 @@ export const ja: TranslationResources = {
openFile: "ファイルを開く",
copied: "コピーしました",
},
chatHistory: {
title: "チャット履歴",
previousConversation: "以前の会話",
open: "チャット履歴の添付を開く",
remove: "チャット履歴の添付を削除",
openSource: "元のチャットを開く",
description: "このチャットを分岐した時点でコピーされたコンテキストです。",
},
attachments: {
dismissImage: "画像を閉じる",
closeImage: "画像を閉じる",
+8
View File
@@ -343,6 +343,14 @@ export const ko: TranslationResources = {
openFile: "파일 열기",
copied: "복사됨",
},
chatHistory: {
title: "채팅 기록",
previousConversation: "이전 대화",
open: "채팅 기록 첨부 열기",
remove: "채팅 기록 첨부 제거",
openSource: "원본 채팅 열기",
description: "이 채팅을 분기할 때 복사된 컨텍스트입니다.",
},
attachments: {
dismissImage: "이미지 닫기",
closeImage: "이미지 닫기",
+8
View File
@@ -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",
+8
View File
@@ -346,6 +346,14 @@ export const ru: TranslationResources = {
openFile: "Открыть файл",
copied: "Скопировано",
},
chatHistory: {
title: "История чата",
previousConversation: "Предыдущий разговор",
open: "Открыть вложенную историю чата",
remove: "Удалить вложенную историю чата",
openSource: "Открыть исходный чат",
description: "Контекст, скопированный при создании этой ветки чата.",
},
attachments: {
dismissImage: "Закрыть изображение",
closeImage: "Закрыть изображение",
+8
View File
@@ -342,6 +342,14 @@ export const zhCN: TranslationResources = {
openFile: "打开文件",
copied: "已复制",
},
chatHistory: {
title: "聊天历史",
previousConversation: "之前的对话",
open: "打开聊天历史附件",
remove: "移除聊天历史附件",
openSource: "打开来源对话",
description: "创建此分叉时复制的上下文。",
},
attachments: {
dismissImage: "关闭图片",
closeImage: "关闭图片",
+24
View File
@@ -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",
+4 -1
View File
@@ -900,9 +900,10 @@ function appendUserMessage(
clientMessageId?: string,
timelineCursor?: TimelinePosition,
turnId?: string,
chatHistory?: Extract<AgentTimelineItem, { type: "user_message" }>["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(
+8 -2
View File
@@ -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
@@ -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",
+21
View File
@@ -712,6 +712,25 @@ const ToolCallTimelineItemPayloadSchema: z.ZodType<ToolCallTimelineItem, unknown
ToolCallCanceledPayloadSchema,
]);
export const ChatHistorySourceSchema = z.object({
serverId: z.string(),
agentId: z.string(),
title: z.string().optional(),
});
export type ChatHistorySource = z.infer<typeof ChatHistorySourceSchema>;
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<typeof ChatHistoryAttachmentSchema>;
// 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<AgentTimelineItem, unknown> = z.union([
@@ -720,6 +739,7 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, unknow
text: z.string(),
messageId: z.string().optional(),
clientMessageId: z.string().optional(),
chatHistory: ChatHistoryAttachmentSchema.optional(),
}),
z.object({
type: z.literal("assistant_message"),
@@ -1180,6 +1200,7 @@ export const TextAttachmentSchema = z
title: z.string().nullable().optional(),
text: z.string(),
externalResource: ExternalResourceAttachmentMetadataSchema.optional(),
chatHistorySource: ChatHistorySourceSchema.optional(),
})
.transform(({ contextKind, ...attachment }) => ({
...attachment,
@@ -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: "<chat-history-summary>\nChat history from a previous Paseo agent.\n\n[User] Original question\n[Assistant] Original answer\n</chat-history-summary>",
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<AgentStreamEvent> {
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<AgentSession> {
return new HistorySession(config);
}
override async resumeSession(): Promise<AgentSession> {
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();
@@ -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<void> {
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);
@@ -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,
@@ -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
@@ -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(),