fix(app): preserve inherited context through rewind and replay

Restore fork history alongside the editable prompt after conversation rewind, including history-only prompts. Keep locally assigned submission IDs distinct from provider IDs so replay without that local ID can recover the snapshot.
This commit is contained in:
paseo-bot[bot]
2026-11-06 06:18:06 +00:00
parent 16425915e5
commit 42cbec5bd8
14 changed files with 211 additions and 37 deletions
@@ -1,5 +1,6 @@
import {
captureChatHistory,
rewindForkPrompt,
closeChatHistory,
followChatHistorySource,
openChatHistory,
@@ -88,6 +89,39 @@ test.describe("Assistant fork menu", () => {
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 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,
@@ -13,7 +13,9 @@ export async function openChatHistory(page: Page, location: "draft" | "sent", co
location === "draft"
? "composer-chat-history-attachment-pill"
: "message-chat-history-attachment";
await page.getByTestId(testId).click();
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);
}
@@ -56,3 +58,15 @@ export async function sendChatHistoryFromAnotherClient(
],
});
}
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();
}
@@ -1,3 +1,6 @@
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";
@@ -108,3 +111,15 @@ const styles = StyleSheet.create((theme) => ({
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 -1
View File
@@ -83,7 +83,7 @@ export interface ChatHistoryContextAttachment {
kind: "chat_history";
id: string;
attachment: Extract<AgentAttachment, { type: "text" }>;
source: ChatHistorySource & {
source?: ChatHistorySource & {
boundaryMessageId?: string | null;
boundaryCursor?: { epoch: string; seq: number } | null;
itemCount?: number;
@@ -62,6 +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,
+17 -5
View File
@@ -34,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,
@@ -420,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],
@@ -429,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],
@@ -527,7 +538,7 @@ export const UserMessage = memo(function UserMessage({
) : null}
</MessagePreview>
</View>
{hasText ? (
{hasText || chatHistory ? (
<View
style={trailingRowStyle}
pointerEvents={showTrailingRow ? "auto" : "none"}
@@ -541,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"));
@@ -258,6 +258,7 @@ function WorkspaceAttachmentPill({
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]);
+2
View File
@@ -1112,6 +1112,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 = useWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd });
const rawAgentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
serverId,
@@ -1245,6 +1246,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const content = (
<RewindComposerRestoreProvider
attachmentScopeKey={rewindAttachmentScopeKey}
textSource={agentInputDraft.textSource}
setText={agentInputDraft.replaceText}
onRewindComplete={handleRewindComplete}
@@ -820,6 +820,68 @@ test.each([true, false])(
},
);
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();
@@ -5157,7 +5157,8 @@ export class AgentManager {
if (attachment?.type === "text" && "mimeType" in attachment) {
agent.forkContext = {
clientMessageId,
providerMessageId: options?.providerMessageId,
providerMessageId:
options?.providerMessageId !== clientMessageId ? options?.providerMessageId : undefined,
text,
attachment: { ...attachment, contextKind: "chat_history" },
};
@@ -5213,7 +5214,7 @@ export class AgentManager {
}
if (!existing || existing.item.type !== "user_message") return null;
if (messageId) {
if (agent.forkContext?.clientMessageId === clientMessageId) {
if (agent.forkContext?.clientMessageId === clientMessageId && messageId !== clientMessageId) {
agent.forkContext.providerMessageId = messageId;
this.enqueueBackgroundPersist(agent);
}