Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] e9d63185d1 fix(markdown): end bare URL links at table cell breaks
Keep a whitespace boundary before inline line separators so GFM autolinking cannot absorb the next line. Cover URL-to-file and URL-to-URL breaks through the actual parser and link recognizer.
2026-11-06 04:44:50 +00:00
paseo-bot[bot] 86b937a129 fix(markdown): preserve wrapped table breaks and separate file links
Use the HTML-disabled Markdown pass to find tables exposed by wrapper normalization. Treat inline line separators as file-link boundaries so adjacent paths remain independently usable.
2026-11-06 04:44:50 +00:00
paseo-bot[bot] fc4880539e fix(markdown): keep HTML line breaks inside table cells
Normalize table breaks to Unicode line separators so parsing does not split the row. Render those separators as explicit web line breaks, preserving paragraph and literal-code behavior.
2026-11-06 04:44:50 +00:00
46 changed files with 295 additions and 1124 deletions
@@ -1,18 +1,6 @@
import {
captureChatHistory,
expectRewoundHistoryIsPrivate,
rewindForkPrompt,
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,
@@ -50,97 +38,6 @@ 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 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.");
const forkAgentId = await getActiveForkAgentId(page);
await session.client.waitForFinish(forkAgentId, 45_000);
await openAgentRoute(page, { workspaceId: session.workspaceId, agentId: forkAgentId });
await page.reload();
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);
await openAgentRoute(page, { workspaceId: session.workspaceId, agentId: forkAgentId });
await expectActiveForkAgent(page, forkAgentId);
await forkMostRecentAssistantTurnToNewTab(page);
await openChatHistory(page, "draft", [initialPrompt, "updates for the fork."]);
await followChatHistorySource(page);
await expectActiveForkAgent(page, forkAgentId);
});
for (const prompt of ["", "Continue the fork"]) {
test(`retains inherited context when rewinding the first fork prompt (${prompt || "history only"})`, async ({
page,
seedForkWorkspace,
}) => {
const initialPrompt = "emit 1 coalesced agent stream updates for rewindable fork history.";
const session = await seedForkWorkspace({
repoPrefix: "fork-rewind-",
title: "Rewind source",
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 submitMessage(page, prompt);
const forkId = await getActiveForkAgentId(page);
await session.client.waitForFinish(forkId, 45_000);
await page.reload();
await openChatHistory(page, "sent", [initialPrompt]);
await closeChatHistory(page);
await rewindForkPrompt(page);
await expectRewoundHistoryIsPrivate(page, session.agentId, forkId);
await openChatHistory(page, "draft", [initialPrompt]);
await closeChatHistory(page);
await submitMessage(page, prompt);
await session.client.waitForFinish(forkId, 45_000);
await page.reload();
await openChatHistory(page, "sent", [initialPrompt]);
});
}
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 ({
page,
seedForkWorkspace,
@@ -112,21 +112,3 @@ export async function expectLiveAssistantText(page: Page, text: string): Promise
timeout: 60_000,
});
}
export async function getActiveForkAgentId(page: Page): Promise<string> {
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",
);
}
@@ -1,82 +0,0 @@
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";
const attachment = page.getByTestId(testId).filter({ visible: true }).last();
await expect(attachment).toBeVisible({ timeout: 10_000 });
await attachment.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" },
},
],
});
}
export async function rewindForkPrompt(page: Page) {
const prompt = page.getByTestId("user-message").filter({ visible: true }).last();
await prompt.hover();
const trigger = prompt.getByRole("button", { name: "Rewind to this message", exact: true });
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
const option = page.getByRole("menuitem", { name: "Rewind conversation", exact: true });
await expect(option).toBeInViewport({ ratio: 1 });
await option.click();
await expect(option).not.toBeVisible();
}
export async function expectRewoundHistoryIsPrivate(page: Page, sourceId: string, forkId: string) {
await page.getByTestId(`workspace-tab-agent_${sourceId}`).click();
const history = page
.getByTestId("composer-chat-history-attachment-pill")
.filter({ visible: true });
await expect(history).toHaveCount(0);
await page.getByTestId(`workspace-tab-agent_${forkId}`).click();
await expect(history).toBeVisible();
}
@@ -157,10 +157,7 @@ export interface SeedDaemonClient {
sendAgentMessage(
agentId: string,
text: string,
options?: {
sourceAgentId?: string;
attachments?: import("@getpaseo/protocol/messages").AgentAttachment[];
},
options?: { sourceAgentId?: string },
): Promise<void>;
waitForFinish(
agentId: string,
@@ -49,7 +49,7 @@ function getTextAttachmentSubtitle(
t: TFunction,
): string {
if (attachment.contextKind === "chat_history") {
return attachment.chatHistorySource?.title ?? t("message.chatHistory.previousConversation");
return "Previous conversation";
}
return t("message.attachments.text");
}
@@ -87,13 +87,6 @@ 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,
@@ -1,125 +0,0 @@
import { generateMessageId } from "@/types/stream";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import type { ChatHistoryAttachment as HistoryAttachment } from "@getpaseo/protocol/messages";
import { extractChatHistoryBody } from "@getpaseo/protocol/chat-history";
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(() => extractChatHistoryBody(attachment.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,
},
}));
export function restoreChatHistoryAttachment(scopeKey: string, attachment: HistoryAttachment) {
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
scopeKey,
attachment: {
kind: "chat_history",
id: generateMessageId(),
attachment,
source: attachment.chatHistorySource,
},
});
}
+3 -2
View File
@@ -1,6 +1,5 @@
import type {
AgentAttachment,
ChatHistorySource,
ForgeSearchItem,
UploadedFileAttachment,
} from "@getpaseo/protocol/messages";
@@ -83,7 +82,9 @@ export interface ChatHistoryContextAttachment {
kind: "chat_history";
id: string;
attachment: Extract<AgentAttachment, { type: "text" }>;
source?: ChatHistorySource & {
source: {
serverId: string;
agentId: string;
boundaryMessageId?: string | null;
boundaryCursor?: { epoch: string; seq: number } | null;
itemCount?: number;
@@ -21,29 +21,6 @@ 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,12 +62,7 @@ export function workspaceAttachmentToSubmitAttachment(
};
}
if (attachment.kind === "chat_history") {
if (!attachment.source) return attachment.attachment;
const { serverId, agentId, title } = attachment.source;
return {
...attachment.attachment,
chatHistorySource: { serverId, agentId, ...(title ? { title } : {}) },
};
return attachment.attachment;
}
return attachment.kind === "review" ? attachment.attachment : null;
}
@@ -178,19 +178,6 @@ export function useWorkspaceAttachmentScopeKey(input: WorkspaceAttachmentScope):
}, [cwd, draftId, isDraftScope, serverId, workspaceId]);
}
export function useAgentAttachmentScopeKey(input: { serverId: string; agentId: string }): string {
const { serverId, agentId } = input;
return useMemo(
() =>
[
"agent-attachments",
`server=${encodeScopePart(serverId)}`,
`agent=${encodeScopePart(agentId)}`,
].join(":"),
[serverId, agentId],
);
}
export function useDraftWorkspaceAttachmentScopeKey(draftId: string | null | undefined): string {
const normalizedDraftId = useMemo(() => draftId?.trim() ?? "", [draftId]);
return useMemo(
@@ -0,0 +1,42 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { within, waitFor } from "@testing-library/dom";
import { EnrichedMarkdownText } from "react-native-enriched-markdown";
import { expect, it } from "vitest";
import { normalizeMarkdownHtml } from "./html";
const FLAGS = { hardSoftBreaks: true, latexMath: false };
it.each([false, true])(
"renders table-cell breaks on separate lines (details: %s)",
async (details) => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const table =
"| A | B |\n|---|---|\n| 1 | first<br/>second |\n| 2 | first<br />second |\n| 3 | first<br>second |";
const source = details ? `<details><summary>Results</summary>\n${table}\n</details>` : table;
try {
act(() =>
root.render(
<EnrichedMarkdownText markdown={normalizeMarkdownHtml(source)} md4cFlags={FLAGS} />,
),
);
await waitFor(() => expect(within(container).getAllByRole("row")).toHaveLength(4));
for (const row of within(container).getAllByRole("row").slice(1)) {
const cell = within(row).getAllByRole("cell")[1];
expect(cell.innerText).toBe("first\nsecond");
const first = document.createRange();
first.selectNodeContents(cell.firstChild!);
const second = document.createRange();
second.selectNodeContents(cell.lastChild!);
expect(second.getBoundingClientRect().top).toBeGreaterThan(
first.getBoundingClientRect().top,
);
}
} finally {
act(() => root.unmount());
container.remove();
}
},
);
+25 -7
View File
@@ -1,5 +1,7 @@
import { Parser } from "htmlparser2";
import { fromMarkdown } from "mdast-util-from-markdown";
import { gfmTable } from "micromark-extension-gfm-table";
import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
import { decodeString } from "micromark-util-decode-string";
import type { Nodes } from "mdast";
@@ -34,25 +36,35 @@ const EMPHASIS: Readonly<Record<string, string>> = { b: "**", strong: "**", i: "
export function normalizeMarkdownHtml(source: string): string {
if (!source.includes("<")) return source;
const htmlRanges: { start: number; end: number }[] = [];
const tableRanges: { start: number; end: number }[] = [];
function findHtml(node: Nodes) {
if (node.type === "html" && node.position) {
htmlRanges.push({ start: node.position.start.offset!, end: node.position.end.offset! });
} else if ("children" in node) node.children.forEach(findHtml);
}
findHtml(fromMarkdown(source));
findHtml(
fromMarkdown(source, {
extensions: [gfmTable()],
mdastExtensions: [gfmTableFromMarkdown()],
}),
);
const codeRanges: { start: number; end: number }[] = [];
// Disable HTML parsing so code inside <details> remains visible to the Markdown parser.
// Disable HTML parsing so code and tables inside <details> remain visible.
const tree = fromMarkdown(source, {
extensions: [{ disable: { null: ["htmlFlow", "htmlText"] } }],
extensions: [{ disable: { null: ["htmlFlow", "htmlText"] } }, gfmTable()],
mdastExtensions: [gfmTableFromMarkdown()],
});
function protectCode(node: Nodes) {
function findMarkdownRanges(node: Nodes) {
if (node.type === "table" && node.position) {
tableRanges.push({ start: node.position.start.offset!, end: node.position.end.offset! });
}
if (node.type === "code" || node.type === "inlineCode") {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start !== undefined && end !== undefined) codeRanges.push({ start, end });
} else if ("children" in node) node.children.forEach(protectCode);
} else if ("children" in node) node.children.forEach(findMarkdownRanges);
}
protectCode(tree);
findMarkdownRanges(tree);
const links: (string | undefined)[] = [];
let previousTag: { name: string; end: number } | null = null;
const convert = (raw: string, offset: number): string => {
@@ -84,6 +96,13 @@ export function normalizeMarkdownHtml(source: string): string {
: /^\s*$/.test(source.slice(0, offset));
previousTag = { name, end: offset + raw.length };
if (EMPHASIS[name]) return EMPHASIS[name];
if (name === "br") {
if (closing) return "";
// A Unicode line separator keeps the row intact; its space ends GFM autolinks.
return tableRanges.some(({ start, end }) => offset >= start && offset < end)
? " \u2028"
: " \n";
}
const boundary = layoutBoundary(name, source, raw, offset, closing, startsAfterBoundary);
if (boundary !== null) return boundary;
if (name === "a") return markdownAnchor(raw, closing, links);
@@ -168,7 +187,6 @@ function layoutBoundary(
closing: boolean,
startsAfterBoundary: boolean,
): string | null {
if (name === "br") return closing ? "" : " \n";
if (name === "summary") return closing ? "**\n\n" : "**";
if (name === "details")
return (closing ? offset + raw.length === source.length : offset === 0) ? "" : "\n\n";
@@ -39,6 +39,88 @@ it("resolves authored root-relative links only when the target is a known comman
});
describe("shared Markdown source adaptation", () => {
it("keeps HTML line breaks inside their Markdown table cells", async () => {
const source =
"| A | B |\n|---|---|\n| 1 | first<br/>second |\n| 2 | first<br />second |\n| 3 | first<br>second |";
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
hardSoftBreaks: true,
latexMath: false,
});
const rows: ASTNode[] = [];
const collect = (node: ASTNode) => {
if (node.type === "TableRow") rows.push(node);
node.children?.forEach(collect);
};
collect(tree);
expect(rows).toHaveLength(4);
const text = (node: ASTNode): string =>
(node.content ?? "") + (node.children?.map(text).join("") ?? "");
expect(rows.map((row) => row.children?.map(text))).toEqual([
["A", "B"],
["1", "first \u2028second"],
["2", "first \u2028second"],
["3", "first \u2028second"],
]);
});
it("keeps table breaks inside a details wrapper without a blank line", async () => {
const source =
"<details><summary>Results</summary>\n| A | B |\n|---|---|\n| 1 | first<br>second |\n</details>";
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
hardSoftBreaks: true,
latexMath: false,
});
const tables: ASTNode[] = [];
const collect = (node: ASTNode) => {
if (node.type === "Table") tables.push(node);
node.children?.forEach(collect);
};
collect(tree);
expect(tables).toHaveLength(1);
expect(tables[0].attributes?.bodyRowCount).toBe("1");
expect(tables[0].children?.[1]?.children?.[0]?.children?.[1]?.children).toEqual([
{ type: "Text", content: "first \u2028second" },
]);
});
it.each([
["src/first.ts", "src/second.ts"],
["https://example.com/first", "src/second.ts"],
["https://example.com/first", "https://example.com/second"],
])("keeps %s and %s separate across a table break", async (first, second) => {
const prepared = prepareMarkdownSource(
`| A | B |\n|---|---|\n| 1 | ${first}<br>${second} |`,
"/repo",
);
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { hardSoftBreaks: true, latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
const links: string[] = [];
const collect = (node: ASTNode) => {
if (node.type === "Link") links.push(String(node.attributes?.url));
node.children?.forEach(collect);
};
collect(tree);
expect(links).toEqual([first, second]);
});
it.each([
["| A<br>B | C |\n|---|---|\n| x | y |", "| A \u2028B | C |\n|---|---|\n| x | y |"],
[
"> | A | B |\n> |---|---|\n> | x | first<br>second |",
"> | A | B |\n> |---|---|\n> | x | first \u2028second |",
],
[
"| A | B |\n|---|---|\n| `<br>` | **first<br><BR />second** |",
"| A | B |\n|---|---|\n| `<br>` | **first \u2028 \u2028second** |",
],
[
"| A | B |\n|---|---|\n| x | \\<br> &lt;br&gt; |",
"| A | B |\n|---|---|\n| x | \\<br> &lt;br&gt; |",
],
["Not a table | first<br>second", "Not a table | first \nsecond"],
])("normalizes table breaks without changing literal tags: %s", (source, expected) => {
expect(normalizeMarkdownHtml(source)).toBe(expected);
});
it("keeps paragraphs, fences and quotes together for the library", () => {
const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
expect(prepareMarkdownSource(source).render()).toBe(source);
@@ -10,9 +10,10 @@ import type { Link, Nodes, Paragraph, PhrasingContent, Root } from "mdast";
// Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex.
// The Unicode flag makes Metro expand it to escapes rejected by native engines.
const PATH_CHARACTER = String.raw`[a-zA-Z0-9\u0080-\uFFFF_@.%+-]`;
// U+2028 is an inline line break, including between table-cell file links.
const PATH_CHARACTER = String.raw`[a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@.%+-]`;
const PATH_LETTER = String.raw`[a-zA-Z]`;
const PATH_SEGMENT = String.raw`${PATH_CHARACTER}*[a-zA-Z0-9\u0080-\uFFFF_@%+-]`;
const PATH_SEGMENT = String.raw`${PATH_CHARACTER}*[a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@%+-]`;
const LINE_SUFFIX = String.raw`(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?`;
const FILE_EXTENSIONS = [...ASSISTANT_FILE_EXTENSIONS]
.map((extension) => extension.replace(/[a-z]/g, (letter) => `[${letter}${letter.toUpperCase()}]`))
@@ -29,7 +30,7 @@ function createTextPattern(path: string, forgeReferencePattern?: string): RegExp
const forgeReference = forgeReferencePattern ? `|${forgeReferencePattern}` : "";
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
return new RegExp(
String.raw`(?:${BARE_URL}${forgeReference}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
String.raw`(?:${BARE_URL}${forgeReference}|(?<![a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\u2027\u2029-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
"g",
);
}
+8 -29
View File
@@ -1,4 +1,3 @@
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";
@@ -34,10 +33,7 @@ import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { AgentCapabilityFlags, ToolCallDetail } from "@getpaseo/protocol/agent-types";
import type {
AgentAttachment,
ChatHistoryAttachment as ChatHistorySnapshot,
} from "@getpaseo/protocol/messages";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import MaskedView from "@react-native-masked-view/masked-view";
import {
Check,
@@ -423,12 +419,7 @@ export const UserMessage = memo(function UserMessage({
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
const hasAttachments = attachments.length > 0;
const chatHistory = attachments.find(
(attachment): attachment is ChatHistorySnapshot =>
attachment.type === "text" && attachment.contextKind === "chat_history",
);
const showTrailingRow =
!isPending && (hasText || Boolean(chatHistory)) && (isCompact || isNative || isHovered);
const showTrailingRow = !isPending && hasText && (isCompact || isNative || isHovered);
const formattedTimestamp = useMemo(
() => formatMessageTimestamp(new Date(timestamp)),
[timestamp],
@@ -437,12 +428,9 @@ export const UserMessage = memo(function UserMessage({
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const getMessageContent = useCallback(
() => message || chatHistory?.text || "",
[message, chatHistory],
);
const getMessageContent = useCallback(() => message, [message]);
const handleRewind = useCallback(
(input: { mode: RewindMode; rewoundText: string; chatHistory?: ChatHistorySnapshot }) => {
(input: { mode: RewindMode; rewoundText: string }) => {
return rewindMutation.rewindAgent(input);
},
[rewindMutation],
@@ -508,19 +496,11 @@ 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={key}>
<AttachmentFrame
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
>
<AttachmentLabel
icon={content.icon}
title={content.title}
@@ -538,7 +518,7 @@ export const UserMessage = memo(function UserMessage({
) : null}
</MessagePreview>
</View>
{hasText || chatHistory ? (
{hasText ? (
<View
style={trailingRowStyle}
pointerEvents={showTrailingRow ? "auto" : "none"}
@@ -552,7 +532,6 @@ export const UserMessage = memo(function UserMessage({
capabilities={capabilities}
isPending={rewindMutation.isPending}
rewoundText={message}
chatHistory={chatHistory}
onRewind={handleRewind}
/>
) : null}
@@ -1,45 +1,27 @@
import { describe, expect, test } from "vitest";
import { restoreComposerPromptIfEmpty } from "./composer-restore";
import { restoreComposerTextIfEmpty } from "./composer-restore";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
describe("restoreComposerPromptIfEmpty", () => {
describe("restoreComposerTextIfEmpty", () => {
test("restores the rewound message when the composer is empty", () => {
expect(
restoreComposerPromptIfEmpty({
restoreComposerTextIfEmpty({
currentText: "",
rewoundPrompt: { text: "message before rewind" },
rewoundText: "message before rewind",
}),
).toEqual({ text: "message before rewind" });
).toBe("message before rewind");
});
test("preserves an existing composer draft", () => {
expect(
restoreComposerPromptIfEmpty({
restoreComposerTextIfEmpty({
currentText: "keep this draft",
rewoundPrompt: { text: "message before rewind" },
rewoundText: "message before rewind",
}),
).toEqual({ text: "keep this draft" });
).toBe("keep this draft");
});
});
test.each(["", "Continue here"])("restores inherited history with rewound prompt %s", (text) => {
const chatHistory = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "Earlier context",
} as const;
expect(
restoreComposerPromptIfEmpty({ currentText: "", rewoundPrompt: { text, chatHistory } }),
).toEqual({ text, chatHistory });
expect(
restoreComposerPromptIfEmpty({
currentText: "Existing draft",
rewoundPrompt: { text, chatHistory },
}),
).toEqual({ text: "Existing draft" });
});
describe("shouldRestoreComposerForRewindMode", () => {
test("restores only conversation-mutating rewind modes", () => {
expect(shouldRestoreComposerForRewindMode("conversation")).toBe(true);
@@ -1,14 +1,11 @@
import type { ChatHistoryAttachment } from "@getpaseo/protocol/messages";
import { restoreChatHistoryAttachment } from "@/attachments/chat-history";
import type { ComposerTextSource } from "@/composer/text-source";
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
interface RewindComposerRestoreContextValue {
completeRewind: (prompt: { text: string; chatHistory?: ChatHistoryAttachment }) => void;
completeRewind: (text: string) => void;
}
interface RewindComposerRestoreProviderProps {
attachmentScopeKey: string;
textSource: ComposerTextSource;
setText: (text: string) => void;
onRewindComplete: () => void;
@@ -17,34 +14,35 @@ interface RewindComposerRestoreProviderProps {
const RewindComposerRestoreContext = createContext<RewindComposerRestoreContextValue | null>(null);
export function restoreComposerPromptIfEmpty(input: {
export function restoreComposerTextIfEmpty(input: {
currentText: string;
rewoundPrompt: { text: string; chatHistory?: ChatHistoryAttachment };
}): { text: string; chatHistory?: ChatHistoryAttachment } {
if (input.currentText.length > 0) return { text: input.currentText };
return input.rewoundPrompt;
rewoundText: string;
}): string {
if (input.currentText.length > 0) {
return input.currentText;
}
return input.rewoundText;
}
export function RewindComposerRestoreProvider({
attachmentScopeKey,
textSource,
setText,
onRewindComplete,
children,
}: RewindComposerRestoreProviderProps) {
const completeRewind = useCallback(
(prompt: { text: string; chatHistory?: ChatHistoryAttachment }) => {
(rewoundText: string) => {
const currentText = textSource.getSnapshot();
const restored = restoreComposerPromptIfEmpty({ currentText, rewoundPrompt: prompt });
if (restored.chatHistory) {
restoreChatHistoryAttachment(attachmentScopeKey, restored.chatHistory);
}
if (restored.text !== currentText) {
setText(restored.text);
const nextText = restoreComposerTextIfEmpty({
currentText: currentText,
rewoundText,
});
if (nextText !== currentText) {
setText(nextText);
}
onRewindComplete();
},
[attachmentScopeKey, onRewindComplete, setText, textSource],
[onRewindComplete, setText, textSource],
);
const value = useMemo(() => ({ completeRewind }), [completeRewind]);
@@ -1,4 +1,3 @@
import type { ChatHistoryAttachment } from "@getpaseo/protocol/messages";
import { memo, useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
@@ -20,12 +19,7 @@ export type { RewindMode };
interface RewindMenuProps {
capabilities: AgentCapabilityFlags;
rewoundText: string;
chatHistory?: ChatHistoryAttachment;
onRewind: (input: {
mode: RewindMode;
rewoundText: string;
chatHistory?: ChatHistoryAttachment;
}) => Promise<void> | void;
onRewind: (input: { mode: RewindMode; rewoundText: string }) => Promise<void> | void;
isPending?: boolean;
testID?: string;
}
@@ -44,7 +38,6 @@ function getIcon(mode: RewindMode, color: string): ReactElement {
export const RewindMenu = memo(function RewindMenu({
capabilities,
rewoundText,
chatHistory,
onRewind,
isPending: isPendingProp = false,
testID = "rewind-menu",
@@ -77,7 +70,7 @@ export const RewindMenu = memo(function RewindMenu({
if (isLocked) return;
setPendingMode(mode);
try {
await onRewind({ mode, rewoundText, chatHistory });
await onRewind({ mode, rewoundText });
} catch {
// useRewindAgentMutation owns the toast; the menu only owns flow state.
} finally {
@@ -85,7 +78,7 @@ export const RewindMenu = memo(function RewindMenu({
setIsOpen(false);
}
},
[isLocked, onRewind, rewoundText, chatHistory],
[isLocked, onRewind, rewoundText],
);
const triggerStyle = useCallback(
@@ -1,4 +1,3 @@
import type { ChatHistoryAttachment } from "@getpaseo/protocol/messages";
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
@@ -21,7 +20,6 @@ interface UseRewindAgentMutationInput {
interface RewindAgentInput {
mode: RewindMode;
rewoundText: string;
chatHistory?: ChatHistoryAttachment;
}
export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
@@ -54,10 +52,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
if (!shouldRestoreComposerForRewindMode(variables.mode)) {
return;
}
composerRestore?.completeRewind({
text: variables.rewoundText,
chatHistory: variables.chatHistory,
});
composerRestore?.completeRewind(variables.rewoundText);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t("rewind.errors.failed"));
@@ -1,4 +1,3 @@
import { ChatHistoryAttachment } from "@/attachments/chat-history";
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import type {
@@ -256,28 +255,12 @@ function WorkspaceAttachmentPill({
}: WorkspaceAttachmentPillProps) {
const { t } = useTranslation();
const content = getWorkspaceAttachmentPillContent(attachment, t);
const chatHistory = useMemo(() => {
if (attachment.kind !== "chat_history") return null;
if (!attachment.source) return attachment.attachment;
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)}
@@ -242,10 +242,10 @@ describe("useDraftAgentCreateFlow", () => {
expect(createRequest).toHaveBeenCalledWith({
attempt: expect.objectContaining({
text: "",
attachments: [{ ...attachment.attachment, chatHistorySource: attachment.source }],
attachments: [attachment.attachment],
}),
text: "",
attachments: [{ ...attachment.attachment, chatHistorySource: attachment.source }],
attachments: [attachment.attachment],
cwd: "/repo",
});
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
-3
View File
@@ -68,7 +68,6 @@ export interface UseForkAgentInput {
function buildChatHistoryAttachment(input: {
draftId: string;
title: string | null;
serverId: string;
agentId: string;
payload: Awaited<ReturnType<DaemonClient["buildAgentForkContext"]>>;
@@ -84,7 +83,6 @@ 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,
@@ -149,7 +147,6 @@ 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,14 +342,6 @@ export const ar: TranslationResources = {
openFile: "افتح الملف",
copied: "منقول",
},
chatHistory: {
title: "سجل المحادثة",
previousConversation: "المحادثة السابقة",
open: "فتح مرفق سجل المحادثة",
remove: "إزالة مرفق سجل المحادثة",
openSource: "فتح المحادثة الأصلية",
description: "السياق المنسوخ عند تفريع هذه المحادثة.",
},
attachments: {
dismissImage: "تجاهل الصورة",
closeImage: "إغلاق الصورة",
-8
View File
@@ -339,14 +339,6 @@ 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,14 +347,6 @@ 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,14 +347,6 @@ 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,14 +347,6 @@ export const ja: TranslationResources = {
openFile: "ファイルを開く",
copied: "コピーしました",
},
chatHistory: {
title: "チャット履歴",
previousConversation: "以前の会話",
open: "チャット履歴の添付を開く",
remove: "チャット履歴の添付を削除",
openSource: "元のチャットを開く",
description: "このチャットを分岐した時点でコピーされたコンテキストです。",
},
attachments: {
dismissImage: "画像を閉じる",
closeImage: "画像を閉じる",
-8
View File
@@ -343,14 +343,6 @@ export const ko: TranslationResources = {
openFile: "파일 열기",
copied: "복사됨",
},
chatHistory: {
title: "채팅 기록",
previousConversation: "이전 대화",
open: "채팅 기록 첨부 열기",
remove: "채팅 기록 첨부 제거",
openSource: "원본 채팅 열기",
description: "이 채팅을 분기할 때 복사된 컨텍스트입니다.",
},
attachments: {
dismissImage: "이미지 닫기",
closeImage: "이미지 닫기",
-8
View File
@@ -346,14 +346,6 @@ 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,14 +346,6 @@ export const ru: TranslationResources = {
openFile: "Открыть файл",
copied: "Скопировано",
},
chatHistory: {
title: "История чата",
previousConversation: "Предыдущий разговор",
open: "Открыть вложенную историю чата",
remove: "Удалить вложенную историю чата",
openSource: "Открыть исходный чат",
description: "Контекст, скопированный при создании этой ветки чата.",
},
attachments: {
dismissImage: "Закрыть изображение",
closeImage: "Закрыть изображение",
-8
View File
@@ -342,14 +342,6 @@ export const zhCN: TranslationResources = {
openFile: "打开文件",
copied: "已复制",
},
chatHistory: {
title: "聊天历史",
previousConversation: "之前的对话",
open: "打开聊天历史附件",
remove: "移除聊天历史附件",
openSource: "打开来源对话",
description: "创建此分叉时复制的上下文。",
},
attachments: {
dismissImage: "关闭图片",
closeImage: "关闭图片",
+3 -9
View File
@@ -43,10 +43,7 @@ import { RewindComposerRestoreProvider } from "@/components/rewind/composer-rest
import { useAgentDescriptor } from "@/agent-presentation";
import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import {
useWorkspaceAttachmentScopeKey,
useAgentAttachmentScopeKey,
} from "@/attachments/workspace-attachments-store";
import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { COMPACT_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
@@ -1115,7 +1112,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const parentAgentId = useParentAgentId(serverId, agentId);
// With a composer the parent is a pill above it; without one, the box takes the composer's place.
const parentPillAgentId = showsComposer ? parentAgentId : null;
const rewindAttachmentScopeKey = useAgentAttachmentScopeKey({ serverId, agentId });
const rawAgentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
serverId,
@@ -1249,7 +1245,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const content = (
<RewindComposerRestoreProvider
attachmentScopeKey={rewindAttachmentScopeKey}
textSource={agentInputDraft.textSource}
setText={agentInputDraft.replaceText}
onRewindComplete={handleRewindComplete}
@@ -1546,10 +1541,9 @@ function ActiveAgentComposer({
cwd,
workspaceId,
});
const agentAttachmentScopeKey = useAgentAttachmentScopeKey({ serverId, agentId });
const attachmentScopeKeys = useMemo(
() => [workspaceAttachmentScopeKey, agentAttachmentScopeKey],
[workspaceAttachmentScopeKey, agentAttachmentScopeKey],
() => [workspaceAttachmentScopeKey],
[workspaceAttachmentScopeKey],
);
const handleOpenWorkspaceAttachment = useCallback(
(attachment: WorkspaceComposerAttachment) => {
-99
View File
@@ -9,7 +9,6 @@ import {
hydrateStreamState,
mergeToolCallDetail,
reduceStreamUpdate,
replaceWithCanonicalStream,
streamTimelineItemIdentity,
type AgentToolCallItem,
type StreamItem,
@@ -159,104 +158,6 @@ describe("plugin timeline rows", () => {
});
describe("user message identity", () => {
it.each(["", "Continue here"])(
"restores fork history onto a cached user message (%s)",
(text) => {
const cached = createUserMessage({
clientMessageId: "fork-client",
messageId: "fork-provider",
text,
timestamp: new Date(0),
timelineCursor: { epoch: "timeline", seq: 1 },
});
const history = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "[User] Earlier question\n[Assistant] Earlier answer",
} as const;
const canonical = createUserMessage({
...cached,
attachments: [history],
});
const result = replaceWithCanonicalStream({
canonical: [canonical],
previousTail: [cached],
previousHead: [],
sendingClientMessageIds: [],
preserveContinuity: true,
canonicalCoverage: { epoch: "timeline", endSeq: 1 },
});
expect(result.tail).toHaveLength(1);
expect(result.tail[0]).toMatchObject({ text, attachments: [history] });
},
);
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.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",
+2 -19
View File
@@ -263,7 +263,6 @@ function produceUserMessage(
clientMessageId: incoming.clientMessageId ?? existing.clientMessageId,
messageId: incoming.messageId ?? existing.messageId,
timelineCursor: incoming.timelineCursor ?? existing.timelineCursor,
attachments: presentation.attachments ?? incoming.attachments,
});
if (
existing.id === merged.id &&
@@ -892,18 +891,6 @@ export function handoffCreatedAgentUserMessageToStream(params: {
});
}
function normalizeUserMessageContent(
text: string,
chatHistory?: Extract<AgentTimelineItem, { type: "user_message" }>["chatHistory"],
) {
const normalized = normalizeChunk(text);
return {
...normalized,
hasContent: normalized.hasContent || chatHistory !== undefined,
attachments: chatHistory ? [chatHistory] : undefined,
};
}
function appendUserMessage(
state: StreamItem[],
text: string,
@@ -913,9 +900,8 @@ function appendUserMessage(
clientMessageId?: string,
timelineCursor?: TimelinePosition,
turnId?: string,
chatHistory?: Extract<AgentTimelineItem, { type: "user_message" }>["chatHistory"],
): StreamItem[] {
const { chunk, hasContent, attachments } = normalizeUserMessageContent(text, chatHistory);
const { chunk, hasContent } = normalizeChunk(text);
if (!hasContent) {
return state;
}
@@ -928,7 +914,6 @@ function appendUserMessage(
timelineCursor,
turnId,
text: chunk,
attachments,
timestamp,
});
return upsertUserMessage(state, nextItem);
@@ -1538,7 +1523,6 @@ function reduceTimelineEvent(
item.clientMessageId,
timelineCursor,
event.turnId,
item.chatHistory,
);
case "assistant_message":
return appendAssistantMessage(
@@ -1890,7 +1874,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 = normalizeUserMessageContent(event.item.text, event.item.chatHistory);
const normalized = normalizeChunk(event.item.text);
const flushedTail = head.length > 0 ? flushHeadToTail(tail, head) : tail;
const flushedHead = head.length > 0 ? [] : head;
@@ -1903,7 +1887,6 @@ function applyCanonicalUserMessageEvent(params: {
turnId: event.turnId,
timelineCursor,
text: normalized.chunk,
attachments: normalized.attachments,
timestamp,
});
if (unmatchedInsert === "head") {
+2 -8
View File
@@ -1,5 +1,5 @@
import type { AgentMessage } from "./agent-message.js";
import type { AgentAttachment, ChatHistoryAttachment } from "./messages.js";
import type { AgentAttachment } from "./messages.js";
export type AgentProvider = string;
@@ -373,13 +373,7 @@ export interface AgentTaskItem {
}
export type AgentTimelineItem =
| {
type: "user_message";
text: string;
messageId?: string;
clientMessageId?: string;
chatHistory?: ChatHistoryAttachment;
}
| { type: "user_message"; text: string; messageId?: string; clientMessageId?: string }
| { type: "assistant_message"; text: string; messageId?: string }
| { type: "reasoning"; text: string }
| ToolCallTimelineItem
-9
View File
@@ -1,9 +0,0 @@
/** 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("<chat-history-summary>\n") &&
text.endsWith("\n</chat-history-summary>") &&
bodyStart !== -1
? text.slice(bodyStart + 2, -"\n</chat-history-summary>".length)
: text;
}
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
AgentTimelineItemPayloadSchema,
AgentForkContextRequestMessageSchema,
AgentForkContextResponseMessageSchema,
CreateAgentRequestMessageSchema,
@@ -10,24 +9,6 @@ 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({
@@ -288,29 +269,6 @@ 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,25 +712,6 @@ 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([
@@ -739,7 +720,6 @@ 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"),
@@ -1200,7 +1180,6 @@ export const TextAttachmentSchema = z
title: z.string().nullable().optional(),
text: z.string(),
externalResource: ExternalResourceAttachmentMetadataSchema.optional(),
chatHistorySource: ChatHistorySourceSchema.optional(),
})
.transform(({ contextKind, ...attachment }) => ({
...attachment,
@@ -572,57 +572,3 @@ it("preserves every message in fork history below the character budget", () => {
`<chat-history-summary>\nChat history from a previous Paseo agent.\n\n[User] ${text}\n</chat-history-summary>`,
);
});
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(/<chat-history-summary>/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");
});
@@ -1,4 +1,3 @@
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";
@@ -22,7 +21,6 @@ const FORK_CONTEXT_KINDS: readonly AgentTimelineItem["type"][] = [
];
interface ActivityCuratorOptions {
includeChatHistory?: boolean;
maxItems?: number;
labelAssistantMessages?: boolean;
includeKinds?: readonly AgentTimelineItem["type"][];
@@ -39,17 +37,6 @@ function activityEntry(text: string): ActivityEntry {
return { text };
}
function appendUserEntry(
entries: ActivityEntry[],
item: Extract<AgentTimelineItem, { type: "user_message" }>,
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<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>,
@@ -171,7 +158,7 @@ function curateProjectedActivityEntries(
switch (item.type) {
case "user_message":
appendUserEntry(entries, item, options);
entries.push(activityEntry(`[User] ${item.text.trim()}`));
break;
case "assistant_message":
case "reasoning":
@@ -374,7 +361,6 @@ export function buildAgentForkContextAttachment(input: {
});
const entries = curateProjectedActivityEntries(selected.items, {
maxItems: 0,
includeChatHistory: true,
labelAssistantMessages: true,
includeKinds: FORK_CONTEXT_KINDS,
includeExternalToolInput: false,
@@ -729,159 +729,6 @@ 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.each([undefined, "native-replay-id"])(
"restores fork history after a local submission echo and replay ID %s",
async (replayId) => {
const workdir = mkdtempSync(join(tmpdir(), "fork-local-echo-"));
const clientMessageId = "local-fork-prompt";
const attachment = {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
text: "[User] Original question",
} as const;
class LocalEchoSession extends TestAgentSession {
override async startTurn(): Promise<{ turnId: string }> {
this.pushEvent({
type: "timeline",
provider: "codex",
item: {
type: "user_message",
text: attachment.text,
messageId: clientMessageId,
clientMessageId,
},
});
return super.startTurn();
}
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield {
type: "timeline",
provider: "codex",
item: {
type: "user_message",
text: attachment.text,
...(replayId ? { messageId: replayId } : {}),
},
};
}
}
class LocalEchoClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new LocalEchoSession(config);
}
}
const manager = new AgentManager({ clients: { codex: new LocalEchoClient() }, logger });
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
try {
await drainAsyncGenerator(manager.streamAgent(agent.id, [attachment], { clientMessageId }));
await manager.hydrateTimelineFromProvider(agent.id, { force: true });
expect((await manager.getTimelineRows(agent.id))[0]?.item).toMatchObject({
type: "user_message",
text: "",
chatHistory: attachment,
});
} finally {
await manager.closeAgent(agent.id);
await manager.flush();
rmSync(workdir, { recursive: true, force: true });
}
},
);
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,7 +441,6 @@ interface HandleStreamEventOptions {
}
interface ManagedAgentBase {
forkContext?: StoredAgentRecord["forkContext"];
id: string;
provider: AgentProvider;
cwd: string;
@@ -2702,15 +2701,11 @@ export class AgentManager {
if (event.type === "timeline") {
this.touchUpdatedAt(agent);
const row = this.recordTimeline(agent.id, event.item);
this.dispatchStream(
agent.id,
{ ...event, item: row.item },
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
},
);
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
return;
}
this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
@@ -3924,8 +3919,6 @@ 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[] = [];
@@ -4498,15 +4491,11 @@ export class AgentManager {
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
if (broadcastTimeline) {
this.dispatchStream(
agent.id,
{ ...event, item: row.item },
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
},
);
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
}
}
this.touchUpdatedAt(agent);
@@ -4571,17 +4560,13 @@ export class AgentManager {
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
if (deferredBroadcast) {
timelineEvents.push({ event: { ...event, item: row.item }, row });
timelineEvents.push({ event, row });
} else if (broadcast) {
this.dispatchStream(
agent.id,
{ ...event, item: row.item },
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
},
);
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
}
}
agent.historyPrimed = true;
@@ -5116,7 +5101,7 @@ export class AgentManager {
const row = this.recordTimeline(agentId, item, { ...options, turnId });
const event: AgentStreamEvent = {
type: "timeline",
item: row.item,
item,
provider,
...(turnId !== undefined ? { turnId } : {}),
};
@@ -5148,25 +5133,9 @@ 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 !== clientMessageId ? options?.providerMessageId : undefined,
text,
attachment: { ...attachment, contextKind: "chat_history" },
};
}
}
const items = projectAgentMessages({
type: "user_message",
text,
text: submittedPromptText(prompt),
clientMessageId,
...(options?.messageId ? { messageId: options.messageId } : {}),
});
@@ -5214,10 +5183,6 @@ export class AgentManager {
}
if (!existing || existing.item.type !== "user_message") return null;
if (messageId) {
if (agent.forkContext?.clientMessageId === clientMessageId && messageId !== clientMessageId) {
agent.forkContext.providerMessageId = messageId;
this.enqueueBackgroundPersist(agent);
}
const enriched = this.timelineStore.enrichSubmittedUserMessage(
agent.id,
clientMessageId,
@@ -5282,10 +5247,6 @@ 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,
@@ -5295,29 +5256,6 @@ 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,7 +92,6 @@ 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, ChatHistoryAttachment } from "@getpaseo/protocol/messages";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { PaseoToolCatalog } from "./tools/types.js";
export type {
@@ -398,13 +398,7 @@ export interface PluginTimelineItem {
}
export type AgentTimelineItem =
| {
type: "user_message";
text: string;
messageId?: string;
clientMessageId?: string;
chatHistory?: ChatHistoryAttachment;
}
| { type: "user_message"; text: string; messageId?: string; clientMessageId?: string }
| { 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, ChatHistoryAttachmentSchema } from "../messages.js";
import { AgentFeatureSchema, AgentStatusSchema } 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,15 +53,6 @@ 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(),
File diff suppressed because one or more lines are too long