mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
fix: preserve inherited fork context and live history attachments
This commit is contained in:
@@ -1,6 +1,16 @@
|
||||
import {
|
||||
captureChatHistory,
|
||||
closeChatHistory,
|
||||
followChatHistorySource,
|
||||
openChatHistory,
|
||||
sendChatHistoryFromAnotherClient,
|
||||
setChatHistoryViewport,
|
||||
} from "../support/helpers/chat-history";
|
||||
import { expect, test as base } from "../support/fixtures";
|
||||
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
|
||||
import {
|
||||
getActiveForkAgentId,
|
||||
expectActiveForkAgent,
|
||||
expectChatHistoryAttachment,
|
||||
expectInFlightForkAvailable,
|
||||
expectLiveAssistantText,
|
||||
@@ -54,62 +64,45 @@ test.describe("Assistant fork menu", () => {
|
||||
await awaitAssistantMessage(page);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
await forkMostRecentAssistantTurnToNewTab(page);
|
||||
await page.getByTestId("composer-chat-history-attachment-pill").click();
|
||||
const preview = page.getByTestId("chat-history-preview");
|
||||
await expect(page.getByTestId("chat-history-content")).toContainText(initialPrompt);
|
||||
await expect(page.getByRole("button", { name: "Open source chat" })).toBeVisible();
|
||||
// Settle the sheet's spring animation and hide Expo's development-only overlay for QA.
|
||||
await page.waitForTimeout(600);
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("history-wide.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await preview.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await openChatHistory(page, "draft", [initialPrompt]);
|
||||
await captureChatHistory(page, testInfo, "history-wide.png");
|
||||
await closeChatHistory(page);
|
||||
await submitMessage(page, "emit 1 coalesced agent stream updates for the fork.");
|
||||
await expect(page.getByTestId("message-chat-history-attachment")).toBeVisible();
|
||||
const selectedTab = page
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.getByRole("button")
|
||||
.and(page.locator('[aria-selected="true"]'));
|
||||
const tabId = await selectedTab.getAttribute("data-testid");
|
||||
expect(tabId).toMatch(/^workspace-tab-agent_/);
|
||||
const forkAgentId = tabId!.replace("workspace-tab-agent_", "");
|
||||
const forkAgentId = await getActiveForkAgentId(page);
|
||||
await session.client.waitForFinish(forkAgentId, 45_000);
|
||||
|
||||
await openAgentRoute(page, { workspaceId: session.workspaceId, agentId: forkAgentId });
|
||||
await page.reload();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.getByTestId("message-chat-history-attachment").click();
|
||||
await expect(page.getByTestId("chat-history-content")).toContainText(initialPrompt);
|
||||
// Settle the sheet's spring animation and hide Expo's development-only overlay for QA.
|
||||
await page.waitForTimeout(600);
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("history-compact.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await page.getByRole("button", { name: "Open source chat" }).click();
|
||||
await expect(preview).not.toBeVisible();
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${session.agentId}`)).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await setChatHistoryViewport(page, "compact");
|
||||
await openChatHistory(page, "sent", [initialPrompt]);
|
||||
await captureChatHistory(page, testInfo, "history-compact.png");
|
||||
await followChatHistorySource(page);
|
||||
await setChatHistoryViewport(page, "wide");
|
||||
await expectActiveForkAgent(page, session.agentId);
|
||||
|
||||
// Fork the fork, then follow its immediate source rather than guessing by title.
|
||||
await openAgentRoute(page, { workspaceId: session.workspaceId, agentId: forkAgentId });
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${forkAgentId}`)).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expectActiveForkAgent(page, forkAgentId);
|
||||
await forkMostRecentAssistantTurnToNewTab(page);
|
||||
await page.getByTestId("composer-chat-history-attachment-pill").click();
|
||||
await expect(page.getByTestId("chat-history-content")).toContainText("updates for the fork.");
|
||||
await page.getByRole("button", { name: "Open source chat" }).click();
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${forkAgentId}`)).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await openChatHistory(page, "draft", [initialPrompt, "updates for the fork."]);
|
||||
await followChatHistorySource(page);
|
||||
await expectActiveForkAgent(page, forkAgentId);
|
||||
});
|
||||
|
||||
test("shows history sent from another connected client without reloading", async ({
|
||||
page,
|
||||
seedForkWorkspace,
|
||||
}) => {
|
||||
const session = await seedForkWorkspace({
|
||||
repoPrefix: "assistant-fork-live-history-",
|
||||
title: "Live history recipient",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
const history = "[User] Context inherited on another device.";
|
||||
await sendChatHistoryFromAnotherClient(session, history);
|
||||
await openChatHistory(page, "sent", [history]);
|
||||
await session.client.waitForFinish(session.agentId, 45_000);
|
||||
});
|
||||
|
||||
test("forks a failed assistant turn that has no provider message id", async ({
|
||||
|
||||
@@ -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,58 @@
|
||||
import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import type { MockAgentWorkspace } from "./mock-agent";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export function setChatHistoryViewport(page: Page, layout: "wide" | "compact") {
|
||||
return page.setViewportSize(
|
||||
layout === "wide" ? { width: 1280, height: 720 } : { width: 390, height: 844 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function openChatHistory(page: Page, location: "draft" | "sent", contents: string[]) {
|
||||
const testId =
|
||||
location === "draft"
|
||||
? "composer-chat-history-attachment-pill"
|
||||
: "message-chat-history-attachment";
|
||||
await page.getByTestId(testId).click();
|
||||
for (const text of contents) {
|
||||
await expect(page.getByTestId("chat-history-content")).toContainText(text);
|
||||
}
|
||||
await expect(page.getByRole("button", { name: "Open source chat" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function closeChatHistory(page: Page) {
|
||||
await page
|
||||
.getByTestId("chat-history-preview")
|
||||
.getByRole("button", { name: "Close", exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
export async function followChatHistorySource(page: Page) {
|
||||
await page.getByRole("button", { name: "Open source chat" }).click();
|
||||
await expect(page.getByTestId("chat-history-preview")).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function captureChatHistory(page: Page, testInfo: TestInfo, name: string) {
|
||||
// Follow the existing QA helpers: settle spring animations and hide Expo-only chrome.
|
||||
await page.waitForTimeout(600);
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
await page.screenshot({ path: testInfo.outputPath(name), animations: "disabled" });
|
||||
}
|
||||
|
||||
export async function sendChatHistoryFromAnotherClient(
|
||||
session: MockAgentWorkspace,
|
||||
history: string,
|
||||
) {
|
||||
const serverId = getServerId();
|
||||
await session.client.sendAgentMessage(session.agentId, "", {
|
||||
attachments: [
|
||||
{
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
text: history,
|
||||
chatHistorySource: { serverId, agentId: "source-agent", title: "Original conversation" },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractChatHistoryBody } from "@getpaseo/protocol/chat-history";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "react-native";
|
||||
@@ -53,15 +54,7 @@ export function ChatHistoryAttachment({
|
||||
) : undefined,
|
||||
[source, openSource, t],
|
||||
);
|
||||
const history = useMemo(() => {
|
||||
const text = attachment.text;
|
||||
const bodyStart = text.indexOf("\n\n");
|
||||
return text.startsWith("<chat-history-summary>\n") &&
|
||||
text.endsWith("\n</chat-history-summary>") &&
|
||||
bodyStart !== -1
|
||||
? text.slice(bodyStart + 2, -"\n</chat-history-summary>".length)
|
||||
: text;
|
||||
}, [attachment.text]);
|
||||
const history = useMemo(() => extractChatHistoryBody(attachment.text), [attachment.text]);
|
||||
const content = getAgentAttachmentPillContent(attachment, t);
|
||||
const label = (
|
||||
<AttachmentLabel icon={content.icon} title={content.title} subtitle={content.subtitle} />
|
||||
|
||||
@@ -182,6 +182,47 @@ describe("user message identity", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ text: "", insert: "tail" },
|
||||
{ text: "Continue", insert: "tail" },
|
||||
{ text: "", insert: "head" },
|
||||
{ text: "Continue", insert: "head" },
|
||||
] as const)("delivers fork history to a live viewer ($insert, $text)", ({ text, insert }) => {
|
||||
const chatHistory = {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
text: "[User] Original question",
|
||||
chatHistorySource: { serverId: "source-host", agentId: "source-agent" },
|
||||
} as const;
|
||||
const event = {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "user_message", text, messageId: "fork-message", chatHistory },
|
||||
} as const;
|
||||
const first = applyStreamEvent({
|
||||
tail: [],
|
||||
head: [],
|
||||
event,
|
||||
timestamp: new Date(0),
|
||||
unmatchedUserMessageInsert: insert,
|
||||
});
|
||||
expect(first[insert]).toHaveLength(1);
|
||||
expect(first[insert][0]).toMatchObject({
|
||||
kind: "user_message",
|
||||
text,
|
||||
attachments: [chatHistory],
|
||||
});
|
||||
const repeated = applyStreamEvent({
|
||||
...first,
|
||||
event,
|
||||
timestamp: new Date(0),
|
||||
unmatchedUserMessageInsert: insert,
|
||||
});
|
||||
expect([...repeated.tail, ...repeated.head]).toHaveLength(1);
|
||||
expect([...repeated.tail, ...repeated.head][0]).toMatchObject({ attachments: [chatHistory] });
|
||||
});
|
||||
|
||||
it("replaces provisional optimistic turn membership with canonical membership", () => {
|
||||
const optimistic = createUserMessage({
|
||||
clientMessageId: "hello-client",
|
||||
|
||||
@@ -891,6 +891,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,
|
||||
@@ -902,8 +914,8 @@ function appendUserMessage(
|
||||
turnId?: string,
|
||||
chatHistory?: Extract<AgentTimelineItem, { type: "user_message" }>["chatHistory"],
|
||||
): StreamItem[] {
|
||||
const { chunk, hasContent } = normalizeChunk(text);
|
||||
if (!hasContent && !chatHistory) {
|
||||
const { chunk, hasContent, attachments } = normalizeUserMessageContent(text, chatHistory);
|
||||
if (!hasContent) {
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -915,7 +927,7 @@ function appendUserMessage(
|
||||
timelineCursor,
|
||||
turnId,
|
||||
text: chunk,
|
||||
...(chatHistory ? { attachments: [chatHistory] } : {}),
|
||||
attachments,
|
||||
timestamp,
|
||||
});
|
||||
return upsertUserMessage(state, nextItem);
|
||||
@@ -1877,7 +1889,7 @@ function applyCanonicalUserMessageEvent(params: {
|
||||
}): ApplyStreamEventResult | null {
|
||||
const { tail, head, event, timestamp, timelineCursor, unmatchedInsert = "tail" } = params;
|
||||
if (event.type !== "timeline" || event.item.type !== "user_message") return null;
|
||||
const normalized = normalizeChunk(event.item.text);
|
||||
const normalized = normalizeUserMessageContent(event.item.text, event.item.chatHistory);
|
||||
|
||||
const flushedTail = head.length > 0 ? flushHeadToTail(tail, head) : tail;
|
||||
const flushedHead = head.length > 0 ? [] : head;
|
||||
@@ -1890,6 +1902,7 @@ function applyCanonicalUserMessageEvent(params: {
|
||||
turnId: event.turnId,
|
||||
timelineCursor,
|
||||
text: normalized.chunk,
|
||||
attachments: normalized.attachments,
|
||||
timestamp,
|
||||
});
|
||||
if (unmatchedInsert === "head") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user