mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47691eee91 | ||
|
|
15c8795cc6 | ||
|
|
42cbec5bd8 | ||
|
|
16425915e5 | ||
|
|
5800a626b9 | ||
|
|
5d71206b72 | ||
|
|
0a0f443691 |
@@ -1,6 +1,18 @@
|
||||
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,
|
||||
@@ -38,6 +50,97 @@ 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,3 +112,21 @@ 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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
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,7 +157,10 @@ export interface SeedDaemonClient {
|
||||
sendAgentMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: { sourceAgentId?: string },
|
||||
options?: {
|
||||
sourceAgentId?: string;
|
||||
attachments?: import("@getpaseo/protocol/messages").AgentAttachment[];
|
||||
},
|
||||
): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
|
||||
@@ -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,125 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,12 @@ export function workspaceAttachmentToSubmitAttachment(
|
||||
};
|
||||
}
|
||||
if (attachment.kind === "chat_history") {
|
||||
return attachment.attachment;
|
||||
if (!attachment.source) return attachment.attachment;
|
||||
const { serverId, agentId, title } = attachment.source;
|
||||
return {
|
||||
...attachment.attachment,
|
||||
chatHistorySource: { serverId, agentId, ...(title ? { title } : {}) },
|
||||
};
|
||||
}
|
||||
return attachment.kind === "review" ? attachment.attachment : null;
|
||||
}
|
||||
|
||||
@@ -178,6 +178,19 @@ 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(
|
||||
|
||||
@@ -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";
|
||||
@@ -33,7 +34,10 @@ 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 } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
AgentAttachment,
|
||||
ChatHistoryAttachment as ChatHistorySnapshot,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import MaskedView from "@react-native-masked-view/masked-view";
|
||||
import {
|
||||
Check,
|
||||
@@ -419,7 +423,12 @@ export const UserMessage = memo(function UserMessage({
|
||||
const hasText = message.trim().length > 0;
|
||||
const hasImages = images.length > 0;
|
||||
const hasAttachments = attachments.length > 0;
|
||||
const showTrailingRow = !isPending && hasText && (isCompact || isNative || isHovered);
|
||||
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 formattedTimestamp = useMemo(
|
||||
() => formatMessageTimestamp(new Date(timestamp)),
|
||||
[timestamp],
|
||||
@@ -428,9 +437,12 @@ export const UserMessage = memo(function UserMessage({
|
||||
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
const getMessageContent = useCallback(() => message, [message]);
|
||||
const getMessageContent = useCallback(
|
||||
() => message || chatHistory?.text || "",
|
||||
[message, chatHistory],
|
||||
);
|
||||
const handleRewind = useCallback(
|
||||
(input: { mode: RewindMode; rewoundText: string }) => {
|
||||
(input: { mode: RewindMode; rewoundText: string; chatHistory?: ChatHistorySnapshot }) => {
|
||||
return rewindMutation.rewindAgent(input);
|
||||
},
|
||||
[rewindMutation],
|
||||
@@ -496,11 +508,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}
|
||||
@@ -518,7 +538,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
) : null}
|
||||
</MessagePreview>
|
||||
</View>
|
||||
{hasText ? (
|
||||
{hasText || chatHistory ? (
|
||||
<View
|
||||
style={trailingRowStyle}
|
||||
pointerEvents={showTrailingRow ? "auto" : "none"}
|
||||
@@ -532,6 +552,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
capabilities={capabilities}
|
||||
isPending={rewindMutation.isPending}
|
||||
rewoundText={message}
|
||||
chatHistory={chatHistory}
|
||||
onRewind={handleRewind}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1,27 +1,45 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { restoreComposerTextIfEmpty } from "./composer-restore";
|
||||
import { restoreComposerPromptIfEmpty } from "./composer-restore";
|
||||
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
|
||||
|
||||
describe("restoreComposerTextIfEmpty", () => {
|
||||
describe("restoreComposerPromptIfEmpty", () => {
|
||||
test("restores the rewound message when the composer is empty", () => {
|
||||
expect(
|
||||
restoreComposerTextIfEmpty({
|
||||
restoreComposerPromptIfEmpty({
|
||||
currentText: "",
|
||||
rewoundText: "message before rewind",
|
||||
rewoundPrompt: { text: "message before rewind" },
|
||||
}),
|
||||
).toBe("message before rewind");
|
||||
).toEqual({ text: "message before rewind" });
|
||||
});
|
||||
|
||||
test("preserves an existing composer draft", () => {
|
||||
expect(
|
||||
restoreComposerTextIfEmpty({
|
||||
restoreComposerPromptIfEmpty({
|
||||
currentText: "keep this draft",
|
||||
rewoundText: "message before rewind",
|
||||
rewoundPrompt: { text: "message before rewind" },
|
||||
}),
|
||||
).toBe("keep this draft");
|
||||
).toEqual({ text: "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,11 +1,14 @@
|
||||
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: (text: string) => void;
|
||||
completeRewind: (prompt: { text: string; chatHistory?: ChatHistoryAttachment }) => void;
|
||||
}
|
||||
|
||||
interface RewindComposerRestoreProviderProps {
|
||||
attachmentScopeKey: string;
|
||||
textSource: ComposerTextSource;
|
||||
setText: (text: string) => void;
|
||||
onRewindComplete: () => void;
|
||||
@@ -14,35 +17,34 @@ interface RewindComposerRestoreProviderProps {
|
||||
|
||||
const RewindComposerRestoreContext = createContext<RewindComposerRestoreContextValue | null>(null);
|
||||
|
||||
export function restoreComposerTextIfEmpty(input: {
|
||||
export function restoreComposerPromptIfEmpty(input: {
|
||||
currentText: string;
|
||||
rewoundText: string;
|
||||
}): string {
|
||||
if (input.currentText.length > 0) {
|
||||
return input.currentText;
|
||||
}
|
||||
return input.rewoundText;
|
||||
rewoundPrompt: { text: string; chatHistory?: ChatHistoryAttachment };
|
||||
}): { text: string; chatHistory?: ChatHistoryAttachment } {
|
||||
if (input.currentText.length > 0) return { text: input.currentText };
|
||||
return input.rewoundPrompt;
|
||||
}
|
||||
|
||||
export function RewindComposerRestoreProvider({
|
||||
attachmentScopeKey,
|
||||
textSource,
|
||||
setText,
|
||||
onRewindComplete,
|
||||
children,
|
||||
}: RewindComposerRestoreProviderProps) {
|
||||
const completeRewind = useCallback(
|
||||
(rewoundText: string) => {
|
||||
(prompt: { text: string; chatHistory?: ChatHistoryAttachment }) => {
|
||||
const currentText = textSource.getSnapshot();
|
||||
const nextText = restoreComposerTextIfEmpty({
|
||||
currentText: currentText,
|
||||
rewoundText,
|
||||
});
|
||||
if (nextText !== currentText) {
|
||||
setText(nextText);
|
||||
const restored = restoreComposerPromptIfEmpty({ currentText, rewoundPrompt: prompt });
|
||||
if (restored.chatHistory) {
|
||||
restoreChatHistoryAttachment(attachmentScopeKey, restored.chatHistory);
|
||||
}
|
||||
if (restored.text !== currentText) {
|
||||
setText(restored.text);
|
||||
}
|
||||
onRewindComplete();
|
||||
},
|
||||
[onRewindComplete, setText, textSource],
|
||||
[attachmentScopeKey, onRewindComplete, setText, textSource],
|
||||
);
|
||||
|
||||
const value = useMemo(() => ({ completeRewind }), [completeRewind]);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -19,7 +20,12 @@ export type { RewindMode };
|
||||
interface RewindMenuProps {
|
||||
capabilities: AgentCapabilityFlags;
|
||||
rewoundText: string;
|
||||
onRewind: (input: { mode: RewindMode; rewoundText: string }) => Promise<void> | void;
|
||||
chatHistory?: ChatHistoryAttachment;
|
||||
onRewind: (input: {
|
||||
mode: RewindMode;
|
||||
rewoundText: string;
|
||||
chatHistory?: ChatHistoryAttachment;
|
||||
}) => Promise<void> | void;
|
||||
isPending?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
@@ -38,6 +44,7 @@ function getIcon(mode: RewindMode, color: string): ReactElement {
|
||||
export const RewindMenu = memo(function RewindMenu({
|
||||
capabilities,
|
||||
rewoundText,
|
||||
chatHistory,
|
||||
onRewind,
|
||||
isPending: isPendingProp = false,
|
||||
testID = "rewind-menu",
|
||||
@@ -70,7 +77,7 @@ export const RewindMenu = memo(function RewindMenu({
|
||||
if (isLocked) return;
|
||||
setPendingMode(mode);
|
||||
try {
|
||||
await onRewind({ mode, rewoundText });
|
||||
await onRewind({ mode, rewoundText, chatHistory });
|
||||
} catch {
|
||||
// useRewindAgentMutation owns the toast; the menu only owns flow state.
|
||||
} finally {
|
||||
@@ -78,7 +85,7 @@ export const RewindMenu = memo(function RewindMenu({
|
||||
setIsOpen(false);
|
||||
}
|
||||
},
|
||||
[isLocked, onRewind, rewoundText],
|
||||
[isLocked, onRewind, rewoundText, chatHistory],
|
||||
);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ChatHistoryAttachment } from "@getpaseo/protocol/messages";
|
||||
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -20,6 +21,7 @@ interface UseRewindAgentMutationInput {
|
||||
interface RewindAgentInput {
|
||||
mode: RewindMode;
|
||||
rewoundText: string;
|
||||
chatHistory?: ChatHistoryAttachment;
|
||||
}
|
||||
|
||||
export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
||||
@@ -52,7 +54,10 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
||||
if (!shouldRestoreComposerForRewindMode(variables.mode)) {
|
||||
return;
|
||||
}
|
||||
composerRestore?.completeRewind(variables.rewoundText);
|
||||
composerRestore?.completeRewind({
|
||||
text: variables.rewoundText,
|
||||
chatHistory: variables.chatHistory,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t("rewind.errors.failed"));
|
||||
|
||||
@@ -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,28 @@ 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],
|
||||
attachments: [{ ...attachment.attachment, chatHistorySource: attachment.source }],
|
||||
}),
|
||||
text: "",
|
||||
attachments: [attachment.attachment],
|
||||
attachments: [{ ...attachment.attachment, chatHistorySource: attachment.source }],
|
||||
cwd: "/repo",
|
||||
});
|
||||
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -342,6 +342,14 @@ export const ar: TranslationResources = {
|
||||
openFile: "افتح الملف",
|
||||
copied: "منقول",
|
||||
},
|
||||
chatHistory: {
|
||||
title: "سجل المحادثة",
|
||||
previousConversation: "المحادثة السابقة",
|
||||
open: "فتح مرفق سجل المحادثة",
|
||||
remove: "إزالة مرفق سجل المحادثة",
|
||||
openSource: "فتح المحادثة الأصلية",
|
||||
description: "السياق المنسوخ عند تفريع هذه المحادثة.",
|
||||
},
|
||||
attachments: {
|
||||
dismissImage: "تجاهل الصورة",
|
||||
closeImage: "إغلاق الصورة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -347,6 +347,14 @@ export const ja: TranslationResources = {
|
||||
openFile: "ファイルを開く",
|
||||
copied: "コピーしました",
|
||||
},
|
||||
chatHistory: {
|
||||
title: "チャット履歴",
|
||||
previousConversation: "以前の会話",
|
||||
open: "チャット履歴の添付を開く",
|
||||
remove: "チャット履歴の添付を削除",
|
||||
openSource: "元のチャットを開く",
|
||||
description: "このチャットを分岐した時点でコピーされたコンテキストです。",
|
||||
},
|
||||
attachments: {
|
||||
dismissImage: "画像を閉じる",
|
||||
closeImage: "画像を閉じる",
|
||||
|
||||
@@ -343,6 +343,14 @@ export const ko: TranslationResources = {
|
||||
openFile: "파일 열기",
|
||||
copied: "복사됨",
|
||||
},
|
||||
chatHistory: {
|
||||
title: "채팅 기록",
|
||||
previousConversation: "이전 대화",
|
||||
open: "채팅 기록 첨부 열기",
|
||||
remove: "채팅 기록 첨부 제거",
|
||||
openSource: "원본 채팅 열기",
|
||||
description: "이 채팅을 분기할 때 복사된 컨텍스트입니다.",
|
||||
},
|
||||
attachments: {
|
||||
dismissImage: "이미지 닫기",
|
||||
closeImage: "이미지 닫기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -346,6 +346,14 @@ export const ru: TranslationResources = {
|
||||
openFile: "Открыть файл",
|
||||
copied: "Скопировано",
|
||||
},
|
||||
chatHistory: {
|
||||
title: "История чата",
|
||||
previousConversation: "Предыдущий разговор",
|
||||
open: "Открыть вложенную историю чата",
|
||||
remove: "Удалить вложенную историю чата",
|
||||
openSource: "Открыть исходный чат",
|
||||
description: "Контекст, скопированный при создании этой ветки чата.",
|
||||
},
|
||||
attachments: {
|
||||
dismissImage: "Закрыть изображение",
|
||||
closeImage: "Закрыть изображение",
|
||||
|
||||
@@ -342,6 +342,14 @@ export const zhCN: TranslationResources = {
|
||||
openFile: "打开文件",
|
||||
copied: "已复制",
|
||||
},
|
||||
chatHistory: {
|
||||
title: "聊天历史",
|
||||
previousConversation: "之前的对话",
|
||||
open: "打开聊天历史附件",
|
||||
remove: "移除聊天历史附件",
|
||||
openSource: "打开来源对话",
|
||||
description: "创建此分叉时复制的上下文。",
|
||||
},
|
||||
attachments: {
|
||||
dismissImage: "关闭图片",
|
||||
closeImage: "关闭图片",
|
||||
|
||||
@@ -43,7 +43,10 @@ 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 } from "@/attachments/workspace-attachments-store";
|
||||
import {
|
||||
useWorkspaceAttachmentScopeKey,
|
||||
useAgentAttachmentScopeKey,
|
||||
} 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";
|
||||
@@ -1112,6 +1115,7 @@ 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,
|
||||
@@ -1245,6 +1249,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
|
||||
const content = (
|
||||
<RewindComposerRestoreProvider
|
||||
attachmentScopeKey={rewindAttachmentScopeKey}
|
||||
textSource={agentInputDraft.textSource}
|
||||
setText={agentInputDraft.replaceText}
|
||||
onRewindComplete={handleRewindComplete}
|
||||
@@ -1541,9 +1546,10 @@ function ActiveAgentComposer({
|
||||
cwd,
|
||||
workspaceId,
|
||||
});
|
||||
const agentAttachmentScopeKey = useAgentAttachmentScopeKey({ serverId, agentId });
|
||||
const attachmentScopeKeys = useMemo(
|
||||
() => [workspaceAttachmentScopeKey],
|
||||
[workspaceAttachmentScopeKey],
|
||||
() => [workspaceAttachmentScopeKey, agentAttachmentScopeKey],
|
||||
[workspaceAttachmentScopeKey, agentAttachmentScopeKey],
|
||||
);
|
||||
const handleOpenWorkspaceAttachment = useCallback(
|
||||
(attachment: WorkspaceComposerAttachment) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
hydrateStreamState,
|
||||
mergeToolCallDetail,
|
||||
reduceStreamUpdate,
|
||||
replaceWithCanonicalStream,
|
||||
streamTimelineItemIdentity,
|
||||
type AgentToolCallItem,
|
||||
type StreamItem,
|
||||
@@ -158,6 +159,104 @@ 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",
|
||||
|
||||
@@ -263,6 +263,7 @@ 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 &&
|
||||
@@ -891,6 +892,18 @@ 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,
|
||||
@@ -900,8 +913,9 @@ function appendUserMessage(
|
||||
clientMessageId?: string,
|
||||
timelineCursor?: TimelinePosition,
|
||||
turnId?: string,
|
||||
chatHistory?: Extract<AgentTimelineItem, { type: "user_message" }>["chatHistory"],
|
||||
): StreamItem[] {
|
||||
const { chunk, hasContent } = normalizeChunk(text);
|
||||
const { chunk, hasContent, attachments } = normalizeUserMessageContent(text, chatHistory);
|
||||
if (!hasContent) {
|
||||
return state;
|
||||
}
|
||||
@@ -914,6 +928,7 @@ function appendUserMessage(
|
||||
timelineCursor,
|
||||
turnId,
|
||||
text: chunk,
|
||||
attachments,
|
||||
timestamp,
|
||||
});
|
||||
return upsertUserMessage(state, nextItem);
|
||||
@@ -1523,6 +1538,7 @@ function reduceTimelineEvent(
|
||||
item.clientMessageId,
|
||||
timelineCursor,
|
||||
event.turnId,
|
||||
item.chatHistory,
|
||||
);
|
||||
case "assistant_message":
|
||||
return appendAssistantMessage(
|
||||
@@ -1874,7 +1890,7 @@ function applyCanonicalUserMessageEvent(params: {
|
||||
}): ApplyStreamEventResult | null {
|
||||
const { tail, head, event, timestamp, timelineCursor, unmatchedInsert = "tail" } = params;
|
||||
if (event.type !== "timeline" || event.item.type !== "user_message") return null;
|
||||
const normalized = normalizeChunk(event.item.text);
|
||||
const normalized = normalizeUserMessageContent(event.item.text, event.item.chatHistory);
|
||||
|
||||
const flushedTail = head.length > 0 ? flushHeadToTail(tail, head) : tail;
|
||||
const flushedHead = head.length > 0 ? [] : head;
|
||||
@@ -1887,6 +1903,7 @@ function applyCanonicalUserMessageEvent(params: {
|
||||
turnId: event.turnId,
|
||||
timelineCursor,
|
||||
text: normalized.chunk,
|
||||
attachments: normalized.attachments,
|
||||
timestamp,
|
||||
});
|
||||
if (unmatchedInsert === "head") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Remove the fork prompt envelope while preserving the snapshot body verbatim. */
|
||||
export function extractChatHistoryBody(text: string): string {
|
||||
const bodyStart = text.indexOf("\n\n");
|
||||
return text.startsWith("<chat-history-summary>\n") &&
|
||||
text.endsWith("\n</chat-history-summary>") &&
|
||||
bodyStart !== -1
|
||||
? text.slice(bodyStart + 2, -"\n</chat-history-summary>".length)
|
||||
: text;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -572,3 +572,57 @@ 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,3 +1,4 @@
|
||||
import { extractChatHistoryBody } from "@getpaseo/protocol/chat-history";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
@@ -21,6 +22,7 @@ const FORK_CONTEXT_KINDS: readonly AgentTimelineItem["type"][] = [
|
||||
];
|
||||
|
||||
interface ActivityCuratorOptions {
|
||||
includeChatHistory?: boolean;
|
||||
maxItems?: number;
|
||||
labelAssistantMessages?: boolean;
|
||||
includeKinds?: readonly AgentTimelineItem["type"][];
|
||||
@@ -37,6 +39,17 @@ function activityEntry(text: string): ActivityEntry {
|
||||
return { text };
|
||||
}
|
||||
|
||||
function appendUserEntry(
|
||||
entries: ActivityEntry[],
|
||||
item: Extract<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" }>,
|
||||
@@ -158,7 +171,7 @@ function curateProjectedActivityEntries(
|
||||
|
||||
switch (item.type) {
|
||||
case "user_message":
|
||||
entries.push(activityEntry(`[User] ${item.text.trim()}`));
|
||||
appendUserEntry(entries, item, options);
|
||||
break;
|
||||
case "assistant_message":
|
||||
case "reasoning":
|
||||
@@ -361,6 +374,7 @@ export function buildAgentForkContextAttachment(input: {
|
||||
});
|
||||
const entries = curateProjectedActivityEntries(selected.items, {
|
||||
maxItems: 0,
|
||||
includeChatHistory: true,
|
||||
labelAssistantMessages: true,
|
||||
includeKinds: FORK_CONTEXT_KINDS,
|
||||
includeExternalToolInput: false,
|
||||
|
||||
@@ -729,6 +729,159 @@ 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,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,25 @@ 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: submittedPromptText(prompt),
|
||||
text,
|
||||
clientMessageId,
|
||||
...(options?.messageId ? { messageId: options.messageId } : {}),
|
||||
});
|
||||
@@ -5183,6 +5214,10 @@ 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,
|
||||
@@ -5247,6 +5282,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 +5295,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(),
|
||||
|
||||
Reference in New Issue
Block a user