Rewind chat or files from any user message (#1154)
* Add Pi extension-launch plumbing for in-process tree primitives
* Add Rewind controls for agent sessions
* Preserve SDK checkpoint env var so Claude file rewind works
The SDK injects CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING=true when file checkpointing is enabled. The earlier parent-env stripping from c4782ec71c was too broad and removed that child flag along with real parent-session markers.
Keep stripping parent markers, but preserve the checkpoint flag so the SDK rewind path can run directly. With that boundary fixed, the JSONL file-rewind fallback is no longer needed.
* Update OpenCode history replay tests for rewind state
* Show Rewind actions in an adaptive sheet
* Fix rewind semantics across providers
* Refactor Pi IDs through extension capture
* Restore user message to composer after rewind
* Fix Claude rewind tool result mapping
* Fix Claude rewind session switch roundtrip
* Fix rewind and optimistic message regressions
* Tighten rewind test discipline
* Fix OpenCode optimistic message reconciliation
* Reconcile optimistic user messages by marker
* Reorder user message actions
* Unify provider user message contract
* Add Claude rewind flow Playwright contract
* Add Codex rewind flow Playwright contract
* Add OpenCode rewind flow Playwright contract
* Add Pi rewind flow Playwright contract
* Remove real-provider rewind Playwright contracts
* Gate real-provider rewind specs from CI Playwright
This commit is contained in:
@@ -537,13 +537,22 @@ async function awaitRelayReady(
|
||||
}
|
||||
}
|
||||
|
||||
async function startRelay(): Promise<number> {
|
||||
async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<number> {
|
||||
for (;;) {
|
||||
const port = await getAvailablePort();
|
||||
if (!excludedPorts.has(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
||||
const relayDir = path.resolve(__dirname, "..", "..", "relay");
|
||||
const maxRelayStartupAttempts = 5;
|
||||
let lastRelayStartupError: unknown = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
|
||||
const relayPort = await getAvailablePort();
|
||||
const relayPort = await getAvailablePortExcluding(excludedPorts);
|
||||
const buffer = createLineBuffer();
|
||||
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
|
||||
|
||||
@@ -729,7 +738,7 @@ export default async function globalSetup() {
|
||||
const dictation = await resolveDictationConfig();
|
||||
|
||||
try {
|
||||
const relayPort = await startRelay();
|
||||
const relayPort = await startRelay(new Set([port, metroPort]));
|
||||
metroProcess = startMetro(metroPort, metroLineBuffer);
|
||||
daemonProcess = startDaemon({
|
||||
port,
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expectComposerEditable, submitMessage } from "./composer";
|
||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
|
||||
|
||||
export type RewindFlowProvider = "claude" | "codex" | "opencode" | "pi";
|
||||
export type RewindFlowMode = "conversation" | "files" | "both";
|
||||
|
||||
export interface AgentHandle {
|
||||
page: Page;
|
||||
client: TerminalPerfDaemonClient;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
provider: RewindFlowProvider;
|
||||
}
|
||||
|
||||
export interface TranscriptMessage {
|
||||
role: "user" | "assistant";
|
||||
text: string | RegExp;
|
||||
}
|
||||
|
||||
interface ProviderLaunchConfig {
|
||||
provider: RewindFlowProvider;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
modeId?: string;
|
||||
}
|
||||
|
||||
const SEND_TIMEOUT_MS = 240_000;
|
||||
const REWIND_TIMEOUT_MS = 120_000;
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
|
||||
switch (provider) {
|
||||
case "claude":
|
||||
return { provider, model: "haiku", modeId: "bypassPermissions" };
|
||||
case "codex":
|
||||
return {
|
||||
provider,
|
||||
model: "gpt-5.4-mini",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
};
|
||||
case "opencode":
|
||||
return { provider, model: "opencode/big-pickle", modeId: "full-access" };
|
||||
case "pi":
|
||||
return {
|
||||
provider,
|
||||
model: "openrouter/google/gemini-2.5-flash-lite",
|
||||
thinkingOptionId: "medium",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function agentRoute(cwd: string, agentId: string): string {
|
||||
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
|
||||
`agent:${agentId}`,
|
||||
)}`;
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
|
||||
await page.goto(agentRoute(input.cwd, input.agentId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await assertComposerIdle({ page });
|
||||
}
|
||||
|
||||
function visibleChatMessages(page: Page) {
|
||||
return page
|
||||
.locator('[data-testid="agent-chat-scroll"]:visible')
|
||||
.first()
|
||||
.locator('[data-testid="user-message"], [data-testid="assistant-message"]');
|
||||
}
|
||||
|
||||
async function transcript(
|
||||
page: Page,
|
||||
): Promise<Array<{ role: "user" | "assistant"; text: string }>> {
|
||||
const rawMessages = await visibleChatMessages(page).evaluateAll((elements) =>
|
||||
elements.map((element) => ({
|
||||
role: (element.getAttribute("data-testid") === "user-message" ? "user" : "assistant") as
|
||||
| "user"
|
||||
| "assistant",
|
||||
text: (element.textContent ?? "")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\d{1,2}:\d{2}\s?(?:AM|PM)$/u, "")
|
||||
.trim(),
|
||||
})),
|
||||
);
|
||||
|
||||
return coalesceAssistantTurnSegments(rawMessages);
|
||||
}
|
||||
|
||||
function coalesceAssistantTurnSegments(
|
||||
messages: Array<{ role: "user" | "assistant"; text: string }>,
|
||||
): Array<{ role: "user" | "assistant"; text: string }> {
|
||||
const transcriptMessages: Array<{ role: "user" | "assistant"; text: string }> = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const previous = transcriptMessages.at(-1);
|
||||
if (message.role === "assistant" && previous?.role === "assistant") {
|
||||
const joinedText =
|
||||
previous.text && message.text
|
||||
? `${previous.text}\n${message.text}`
|
||||
: previous.text || message.text;
|
||||
transcriptMessages[transcriptMessages.length - 1] = {
|
||||
role: "assistant",
|
||||
text: joinedText,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
transcriptMessages.push(message);
|
||||
}
|
||||
|
||||
return transcriptMessages.filter(
|
||||
(message) => message.role !== "assistant" || message.text.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function expectedTextMatches(actual: string, expected: string | RegExp): boolean {
|
||||
if (typeof expected === "string") {
|
||||
return actual === expected;
|
||||
}
|
||||
return expected.test(actual);
|
||||
}
|
||||
|
||||
function formatExpectedMessage(message: TranscriptMessage): string {
|
||||
const text = typeof message.text === "string" ? JSON.stringify(message.text) : message.text;
|
||||
return `${message.role}:${text}`;
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
}
|
||||
|
||||
export async function launchAgent(input: {
|
||||
page: Page;
|
||||
provider: RewindFlowProvider;
|
||||
cwd: string;
|
||||
mode: "full-access";
|
||||
}): Promise<AgentHandle> {
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "paseo-test@example.com"], {
|
||||
cwd: input.cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Paseo Test"], {
|
||||
cwd: input.cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||
cwd: input.cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
writeFileSync(`${input.cwd}/README.md`, "# Paseo rewind flow\n", "utf8");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
|
||||
const client = await connectTerminalClient();
|
||||
const opened = await client.openProject(input.cwd);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
|
||||
}
|
||||
const agent = await client.createAgent({
|
||||
...fullAccessConfig(input.provider),
|
||||
cwd: input.cwd,
|
||||
title: `rewind-flow-${input.provider}-${randomUUID()}`,
|
||||
});
|
||||
const handle = {
|
||||
page: input.page,
|
||||
client,
|
||||
agentId: agent.id,
|
||||
cwd: input.cwd,
|
||||
provider: input.provider,
|
||||
};
|
||||
await openAgent(input.page, { cwd: input.cwd, agentId: agent.id });
|
||||
return handle;
|
||||
}
|
||||
|
||||
export async function closeAgent(handle: AgentHandle): Promise<void> {
|
||||
await handle.client.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function sendMessage(handle: AgentHandle, text: string): Promise<void> {
|
||||
const before = await transcript(handle.page);
|
||||
await submitMessage(handle.page, text);
|
||||
const finish = await handle.client.waitForFinish(handle.agentId, SEND_TIMEOUT_MS);
|
||||
if (finish.status !== "idle") {
|
||||
const suffix = finish.final?.lastError ? `: ${finish.final.lastError}` : "";
|
||||
throw new Error(
|
||||
`Expected agent ${handle.agentId} to become idle, got ${finish.status}${suffix}`,
|
||||
);
|
||||
}
|
||||
if (finish.final?.lastError) {
|
||||
throw new Error(finish.final.lastError);
|
||||
}
|
||||
await expect
|
||||
.poll(async () => transcript(handle.page), { timeout: 30_000 })
|
||||
.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "user", text }),
|
||||
expect.objectContaining({ role: "assistant" }),
|
||||
]),
|
||||
);
|
||||
await expect
|
||||
.poll(async () => transcript(handle.page).then((messages) => messages.length), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(before.length + 2);
|
||||
await assertComposerIdle(handle);
|
||||
}
|
||||
|
||||
export async function assertChatTranscript(
|
||||
handle: Pick<AgentHandle, "page">,
|
||||
expected: TranscriptMessage[],
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const actual = await transcript(handle.page);
|
||||
if (actual.length !== expected.length) {
|
||||
return JSON.stringify(actual);
|
||||
}
|
||||
const matches = actual.every(
|
||||
(message, index) =>
|
||||
message.role === expected[index]?.role &&
|
||||
expectedTextMatches(message.text, expected[index]!.text),
|
||||
);
|
||||
return matches ? "match" : JSON.stringify(actual);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe("match");
|
||||
|
||||
const actual = await transcript(handle.page);
|
||||
if (actual.length !== expected.length) {
|
||||
throw new Error(
|
||||
`Expected ${expected.length} chat messages (${expected
|
||||
.map(formatExpectedMessage)
|
||||
.join(", ")}), found ${actual.length}: ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rewindMessage(
|
||||
handle: AgentHandle,
|
||||
userMessageIndex: number,
|
||||
mode: RewindFlowMode,
|
||||
): Promise<void> {
|
||||
const beforeEpoch = await fetchTimelineEpoch(handle);
|
||||
const userMessage = handle.page.getByTestId("user-message").nth(userMessageIndex);
|
||||
await expect(userMessage).toBeVisible({ timeout: 30_000 });
|
||||
const userMessages = (await transcript(handle.page)).filter((message) => message.role === "user");
|
||||
const userText = userMessages[userMessageIndex]?.text;
|
||||
if (!userText) {
|
||||
throw new Error(`No user message found at index ${userMessageIndex}`);
|
||||
}
|
||||
await userMessage
|
||||
.getByText(new RegExp(escapeRegExp(userText)))
|
||||
.first()
|
||||
.hover();
|
||||
const trigger = userMessage.getByTestId("rewind-menu-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
await expect(handle.page.getByTestId("rewind-menu-content")).toBeVisible({ timeout: 10_000 });
|
||||
const modeItem = handle.page.getByTestId(`rewind-menu-${mode}`);
|
||||
await expect(modeItem).toBeVisible({ timeout: 10_000 });
|
||||
await modeItem.click();
|
||||
await expect(handle.page.getByTestId("rewind-menu-content")).toHaveCount(0, { timeout: 10_000 });
|
||||
if (mode !== "files") {
|
||||
await waitForNextTimelineEpoch(handle, beforeEpoch);
|
||||
}
|
||||
await assertComposerIdle(handle);
|
||||
}
|
||||
|
||||
export async function assertFileExists(filePath: string): Promise<void> {
|
||||
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(true);
|
||||
}
|
||||
|
||||
export async function assertFileMissing(filePath: string): Promise<void> {
|
||||
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(false);
|
||||
}
|
||||
|
||||
export async function assertFileContains(filePath: string, text: string): Promise<void> {
|
||||
await assertFileExists(filePath);
|
||||
await expect.poll(() => readFileSync(filePath, "utf8"), { timeout: 10_000 }).toContain(text);
|
||||
}
|
||||
|
||||
export async function assertComposerIdle(handle: Pick<AgentHandle, "page">): Promise<void> {
|
||||
await expectComposerEditable(handle.page);
|
||||
await expect(handle.page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(handle.page.getByTestId("turn-working-indicator")).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cleanupRewindFlow(input: {
|
||||
handle?: AgentHandle;
|
||||
cwd: string;
|
||||
}): Promise<void> {
|
||||
if (input.handle) {
|
||||
await closeAgent(input.handle);
|
||||
}
|
||||
rmSync(input.cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function fetchTimelineEpoch(handle: AgentHandle): Promise<string | undefined> {
|
||||
const client = handle.client as TerminalPerfDaemonClient & {
|
||||
fetchAgentTimeline: (
|
||||
agentId: string,
|
||||
options?: { direction?: "head" | "tail"; projection?: "canonical"; limit?: number },
|
||||
) => Promise<{ epoch?: string }>;
|
||||
};
|
||||
const timeline = await client.fetchAgentTimeline(handle.agentId, {
|
||||
direction: "tail",
|
||||
projection: "canonical",
|
||||
limit: 0,
|
||||
});
|
||||
return timeline.epoch;
|
||||
}
|
||||
|
||||
async function waitForNextTimelineEpoch(
|
||||
handle: AgentHandle,
|
||||
beforeEpoch: string | undefined,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => fetchTimelineEpoch(handle), { timeout: REWIND_TIMEOUT_MS })
|
||||
.not.toBe(beforeEpoch);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
@@ -35,6 +36,10 @@ export interface TerminalPerfDaemonClient {
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
subscribeTerminal(
|
||||
terminalId: string,
|
||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||
@@ -68,6 +73,7 @@ interface TerminalPerfDaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
appVersion?: string;
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
@@ -91,12 +97,22 @@ export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient>
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `terminal-perf-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
appVersion: loadAppVersion(),
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
function loadAppVersion(): string {
|
||||
const packageJsonPath = path.resolve(__dirname, "../../package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
||||
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||
throw new Error(`Missing app version in ${packageJsonPath}`);
|
||||
}
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
|
||||
const serverId = getServerId();
|
||||
const route = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineRewindFlowSpec } from "./rewind-flow.shared";
|
||||
|
||||
defineRewindFlowSpec({
|
||||
provider: "claude",
|
||||
rewindMode: "both",
|
||||
fileReverted: true,
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineRewindFlowSpec } from "./rewind-flow.shared";
|
||||
|
||||
defineRewindFlowSpec({
|
||||
provider: "codex",
|
||||
rewindMode: "conversation",
|
||||
fileReverted: false,
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineRewindFlowSpec } from "./rewind-flow.shared";
|
||||
|
||||
defineRewindFlowSpec({
|
||||
provider: "opencode",
|
||||
initialRewindMode: "both",
|
||||
rewindMode: "both",
|
||||
fileReverted: true,
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineRewindFlowSpec } from "./rewind-flow.shared";
|
||||
|
||||
defineRewindFlowSpec({
|
||||
provider: "pi",
|
||||
rewindMode: "conversation",
|
||||
fileReverted: false,
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
assertChatTranscript,
|
||||
assertComposerIdle,
|
||||
assertFileContains,
|
||||
assertFileExists,
|
||||
assertFileMissing,
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
rewindMessage,
|
||||
sendMessage,
|
||||
type AgentHandle,
|
||||
type RewindFlowMode,
|
||||
type RewindFlowProvider,
|
||||
} from "./helpers/rewind-flow";
|
||||
|
||||
const FILE_PROMPT = "Use the Write tool to create ./qa.txt with the exact content: PASEO_QA_TOKEN";
|
||||
|
||||
interface RewindFlowCase {
|
||||
provider: RewindFlowProvider;
|
||||
initialRewindMode?: RewindFlowMode;
|
||||
rewindMode: RewindFlowMode;
|
||||
fileReverted: boolean;
|
||||
}
|
||||
|
||||
export function defineRewindFlowSpec(input: RewindFlowCase): void {
|
||||
test.describe(`rewind flow - ${input.provider}`, () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("rewinds conversation and file-write turns without transcript drift", async ({ page }) => {
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), `paseo-rewind-flow-${input.provider}-`)),
|
||||
);
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: input.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
});
|
||||
|
||||
await sendMessage(handle, "hello");
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: "hello" },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
]);
|
||||
|
||||
await rewindMessage(handle, 0, input.initialRewindMode ?? "conversation");
|
||||
await assertChatTranscript(handle, []);
|
||||
await assertComposerIdle(handle);
|
||||
|
||||
await sendMessage(handle, "hello");
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: "hello" },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
]);
|
||||
|
||||
await sendMessage(handle, FILE_PROMPT);
|
||||
await assertFileContains(path.join(cwd, "qa.txt"), "PASEO_QA_TOKEN");
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: "hello" },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
{ role: "user", text: /Use the Write tool/ },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
]);
|
||||
|
||||
await rewindMessage(handle, 1, input.rewindMode);
|
||||
if (input.fileReverted) {
|
||||
await assertFileMissing(path.join(cwd, "qa.txt"));
|
||||
} else {
|
||||
await assertFileExists(path.join(cwd, "qa.txt"));
|
||||
}
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: "hello" },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
]);
|
||||
await assertComposerIdle(handle);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
submitMessage,
|
||||
} from "./helpers/composer";
|
||||
|
||||
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
|
||||
const agentUrl = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
input.cwd,
|
||||
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(expected);
|
||||
}
|
||||
|
||||
test.describe("Rewind sheet", () => {
|
||||
test("rewinds from a user message sheet option", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("rewind-e2e-");
|
||||
const client = await connectTerminalClient();
|
||||
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
|
||||
const secondPrompt = "Prepare deleted rewind turn assistant content.";
|
||||
const replacementPrompt = "emit 1 coalesced agent stream updates for replacement rewind turn.";
|
||||
|
||||
try {
|
||||
await client.openProject(repo.path);
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "Rewind e2e",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: firstPrompt,
|
||||
});
|
||||
await openAgent(page, { cwd: repo.path, agentId: agent.id });
|
||||
|
||||
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
|
||||
await expectUserMessageCount(page, 1);
|
||||
await submitMessage(page, secondPrompt);
|
||||
await expect(page.getByText(secondPrompt, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
await page.getByText(firstPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").first().click();
|
||||
const rewindSheet = page.getByTestId("rewind-menu-content");
|
||||
await expect(rewindSheet).toBeVisible();
|
||||
await expect(rewindSheet.getByText("Rewind to this message", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
rewindSheet.getByText("This action cannot be undone", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-conversation").click();
|
||||
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
|
||||
await expectUserMessageCount(page, 1);
|
||||
await expectComposerDraft(page, firstPrompt);
|
||||
|
||||
await submitMessage(page, replacementPrompt);
|
||||
await expect(page.getByText(replacementPrompt, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
await fillComposerDraft(page, "");
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await page.getByText(replacementPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-files").click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expectComposerDraft(page, "");
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
const preservedDraft = "Keep this human draft after rewind.";
|
||||
await fillComposerDraft(page, preservedDraft);
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await page.getByText(replacementPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-files").click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expectComposerDraft(page, preservedDraft);
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
await fillComposerDraft(page, "");
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await page.getByText(replacementPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-both").click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expectComposerDraft(page, replacementPrompt);
|
||||
await expectUserMessageCount(page, 1);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("surfaces rewind failures without crashing the page", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("rewind-failure-e2e-");
|
||||
const client = await connectTerminalClient();
|
||||
const firstPrompt = "emit 1 coalesced agent stream updates for failed rewind turn.";
|
||||
const rewindError = "No file checkpoint found for message rewind-failure-e2e.";
|
||||
|
||||
try {
|
||||
await client.openProject(repo.path);
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "Rewind failure e2e",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
featureValues: {
|
||||
mockRewindError: rewindError,
|
||||
},
|
||||
initialPrompt: firstPrompt,
|
||||
});
|
||||
await openAgent(page, { cwd: repo.path, agentId: agent.id });
|
||||
|
||||
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByText(firstPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").first().click();
|
||||
const rewindSheet = page.getByTestId("rewind-menu-content");
|
||||
await expect(rewindSheet).toBeVisible();
|
||||
await expect(rewindSheet.getByText("Rewind to this message", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
rewindSheet.getByText("This action cannot be undone", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-conversation").click();
|
||||
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText(rewindError);
|
||||
await expect(page.getByText("Uncaught Error")).toHaveCount(0);
|
||||
|
||||
await page.getByText(firstPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").first().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerEditable,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
submitMessage,
|
||||
} from "./helpers/composer";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
|
||||
const agentUrl = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
input.cwd,
|
||||
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(expected, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function expectIdleComposer(page: Page): Promise<void> {
|
||||
await expectComposerEditable(page);
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectNoLoadingRegressionAfterIdle(page: Page): Promise<void> {
|
||||
await expectIdleComposer(page);
|
||||
await page.waitForTimeout(1_000);
|
||||
await expectIdleComposer(page);
|
||||
}
|
||||
|
||||
test.describe("User message UI contract", () => {
|
||||
test("dedupes mock provider user_message echoes across multi-turn sends", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("user-message-contract-e2e-");
|
||||
const client = await connectTerminalClient();
|
||||
const prompts = [
|
||||
"emit 1 coalesced agent stream updates for user message contract turn one.",
|
||||
"emit 1 coalesced agent stream updates for user message contract turn two.",
|
||||
"emit 1 coalesced agent stream updates for user message contract turn three.",
|
||||
];
|
||||
|
||||
try {
|
||||
await client.openProject(repo.path);
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "User message contract e2e",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
await openAgent(page, { cwd: repo.path, agentId: agent.id });
|
||||
|
||||
for (let index = 0; index < prompts.length; index += 1) {
|
||||
const prompt = prompts[index]!;
|
||||
await submitMessage(page, prompt);
|
||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText("stress-update-0", { exact: true }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expectUserMessageCount(page, index + 1);
|
||||
await expectNoLoadingRegressionAfterIdle(page);
|
||||
}
|
||||
|
||||
await fillComposerDraft(page, "append");
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await expectUserMessageCount(page, 3);
|
||||
await expectIdleComposer(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,8 @@
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:browser": "vitest run --project browser",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e": "playwright test --project='Desktop Chrome'",
|
||||
"test:e2e:real": "playwright test --project=real-provider",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
"build:web": "npm run build:workspace-deps && expo export --platform web",
|
||||
|
||||
@@ -27,6 +27,12 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "Desktop Chrome",
|
||||
testIgnore: ["**/*.real.spec.ts"],
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "real-provider",
|
||||
testMatch: ["**/*.real.spec.ts"],
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -412,16 +412,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
|
||||
return (
|
||||
<UserMessage
|
||||
serverId={resolvedServerId}
|
||||
agentId={agentId}
|
||||
messageId={item.id}
|
||||
message={item.text}
|
||||
images={item.images}
|
||||
attachments={item.attachments}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
capabilities={agent.capabilities}
|
||||
client={client}
|
||||
isFirstInGroup={layoutItem.isFirstInUserGroup}
|
||||
isLastInGroup={layoutItem.isLastInUserGroup}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[],
|
||||
[agent.capabilities, agentId, client, resolvedServerId],
|
||||
);
|
||||
|
||||
const renderAssistantMessageItem = useCallback(
|
||||
|
||||
@@ -353,6 +353,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
expect(userMessage.images).toEqual([image]);
|
||||
expect(userMessage.attachments).toEqual(call.options.attachments);
|
||||
expect(userMessage.id).toBe(call.options.messageId);
|
||||
expect(userMessage.optimistic).toBe(true);
|
||||
});
|
||||
|
||||
it("appends to the existing head when one is present", async () => {
|
||||
|
||||
@@ -135,6 +135,7 @@ export async function dispatchComposerAgentMessage(
|
||||
id: messageId,
|
||||
text: input.text,
|
||||
timestamp: new Date(),
|
||||
optimistic: true,
|
||||
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
||||
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
||||
};
|
||||
|
||||
@@ -102,15 +102,23 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
|
||||
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
|
||||
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
|
||||
export type { InlinePathTarget } from "@/assistant-file-links";
|
||||
|
||||
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
interface UserMessageProps {
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
messageId?: string;
|
||||
message: string;
|
||||
images?: UserMessageImageAttachment[];
|
||||
attachments?: AgentAttachment[];
|
||||
timestamp: number;
|
||||
capabilities?: AgentCapabilityFlags;
|
||||
client?: DaemonClient | null;
|
||||
isFirstInGroup?: boolean;
|
||||
isLastInGroup?: boolean;
|
||||
disableOuterSpacing?: boolean;
|
||||
@@ -450,10 +458,15 @@ function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
|
||||
}
|
||||
|
||||
export const UserMessage = memo(function UserMessage({
|
||||
serverId,
|
||||
agentId,
|
||||
messageId,
|
||||
message,
|
||||
images = [],
|
||||
attachments = [],
|
||||
timestamp,
|
||||
capabilities,
|
||||
client,
|
||||
isFirstInGroup = true,
|
||||
isLastInGroup = true,
|
||||
disableOuterSpacing,
|
||||
@@ -469,10 +482,17 @@ export const UserMessage = memo(function UserMessage({
|
||||
() => formatMessageTimestamp(new Date(timestamp)),
|
||||
[timestamp],
|
||||
);
|
||||
const rewindMutation = useRewindAgentMutation({ serverId, agentId, client, messageId });
|
||||
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
const getMessageContent = useCallback(() => message, [message]);
|
||||
const handleRewind = useCallback(
|
||||
(input: { mode: RewindMode; rewoundText: string }) => {
|
||||
return rewindMutation.rewindAgent(input);
|
||||
},
|
||||
[rewindMutation],
|
||||
);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
@@ -510,7 +530,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={containerStyle} testID="user-message">
|
||||
<View
|
||||
style={userMessageStylesheet.content}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
@@ -549,6 +569,14 @@ export const UserMessage = memo(function UserMessage({
|
||||
{hasText ? (
|
||||
<View style={trailingRowStyle} pointerEvents={showTrailingRow ? "auto" : "none"}>
|
||||
<Text style={userMessageStylesheet.timestampText}>{formattedTimestamp}</Text>
|
||||
{capabilities ? (
|
||||
<RewindMenu
|
||||
capabilities={capabilities}
|
||||
isPending={rewindMutation.isPending}
|
||||
rewoundText={message}
|
||||
onRewind={handleRewind}
|
||||
/>
|
||||
) : null}
|
||||
<TurnCopyButton
|
||||
getContent={getMessageContent}
|
||||
containerStyle={userMessageStylesheet.copyButton}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { restoreComposerTextIfEmpty } from "./composer-restore";
|
||||
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
|
||||
|
||||
describe("restoreComposerTextIfEmpty", () => {
|
||||
test("restores the rewound message when the composer is empty", () => {
|
||||
expect(
|
||||
restoreComposerTextIfEmpty({
|
||||
currentText: "",
|
||||
rewoundText: "message before rewind",
|
||||
}),
|
||||
).toBe("message before rewind");
|
||||
});
|
||||
|
||||
test("preserves an existing composer draft", () => {
|
||||
expect(
|
||||
restoreComposerTextIfEmpty({
|
||||
currentText: "keep this draft",
|
||||
rewoundText: "message before rewind",
|
||||
}),
|
||||
).toBe("keep this draft");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRestoreComposerForRewindMode", () => {
|
||||
test("restores only conversation-mutating rewind modes", () => {
|
||||
expect(shouldRestoreComposerForRewindMode("conversation")).toBe(true);
|
||||
expect(shouldRestoreComposerForRewindMode("files")).toBe(false);
|
||||
expect(shouldRestoreComposerForRewindMode("both")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
interface RewindComposerRestoreContextValue {
|
||||
restoreTextIfComposerEmpty: (text: string) => void;
|
||||
}
|
||||
|
||||
interface RewindComposerRestoreProviderProps {
|
||||
text: string;
|
||||
setText: (text: string) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const RewindComposerRestoreContext = createContext<RewindComposerRestoreContextValue | null>(null);
|
||||
|
||||
export function restoreComposerTextIfEmpty(input: {
|
||||
currentText: string;
|
||||
rewoundText: string;
|
||||
}): string {
|
||||
if (input.currentText.length > 0) {
|
||||
return input.currentText;
|
||||
}
|
||||
return input.rewoundText;
|
||||
}
|
||||
|
||||
export function RewindComposerRestoreProvider({
|
||||
text,
|
||||
setText,
|
||||
children,
|
||||
}: RewindComposerRestoreProviderProps) {
|
||||
const textRef = useRef(text);
|
||||
|
||||
useEffect(() => {
|
||||
textRef.current = text;
|
||||
}, [text]);
|
||||
|
||||
const restoreTextIfComposerEmpty = useCallback(
|
||||
(rewoundText: string) => {
|
||||
const nextText = restoreComposerTextIfEmpty({
|
||||
currentText: textRef.current,
|
||||
rewoundText,
|
||||
});
|
||||
if (nextText !== textRef.current) {
|
||||
setText(nextText);
|
||||
}
|
||||
},
|
||||
[setText],
|
||||
);
|
||||
|
||||
const value = useMemo(() => ({ restoreTextIfComposerEmpty }), [restoreTextIfComposerEmpty]);
|
||||
|
||||
return (
|
||||
<RewindComposerRestoreContext.Provider value={value}>
|
||||
{children}
|
||||
</RewindComposerRestoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRewindComposerRestore(): RewindComposerRestoreContextValue | null {
|
||||
return useContext(RewindComposerRestoreContext);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { memo, useCallback, useMemo, useState, type ComponentType } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { FileText, Layers, MessageSquare, Undo2 } from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
type RewindMenuItem,
|
||||
type RewindMode,
|
||||
useRewindCapabilities,
|
||||
} from "./use-rewind-capabilities";
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export type { RewindMode };
|
||||
|
||||
interface RewindMenuProps {
|
||||
capabilities: AgentCapabilityFlags;
|
||||
rewoundText: string;
|
||||
onRewind: (input: { mode: RewindMode; rewoundText: string }) => Promise<void> | void;
|
||||
isPending?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const REWIND_HEADER: SheetHeader = { title: "Rewind to this message" };
|
||||
const SNAP_POINTS: string[] = ["42%", "72%"];
|
||||
|
||||
function iconForItem(item: RewindMenuItem): ComponentType<{ color: string; size: number }> {
|
||||
switch (item.mode) {
|
||||
case "conversation":
|
||||
return MessageSquare;
|
||||
case "files":
|
||||
return FileText;
|
||||
case "both":
|
||||
return Layers;
|
||||
}
|
||||
}
|
||||
|
||||
export const RewindMenu = memo(function RewindMenu({
|
||||
capabilities,
|
||||
rewoundText,
|
||||
onRewind,
|
||||
isPending = false,
|
||||
testID = "rewind-menu",
|
||||
}: RewindMenuProps) {
|
||||
const items = useRewindCapabilities(capabilities);
|
||||
const [isSheetVisible, setIsSheetVisible] = useState(false);
|
||||
const [pendingMode, setPendingMode] = useState<RewindMode | null>(null);
|
||||
const handleOpen = useCallback(() => setIsSheetVisible(true), []);
|
||||
const handleClose = useCallback(() => {
|
||||
if (!isPending) {
|
||||
setIsSheetVisible(false);
|
||||
}
|
||||
}, [isPending]);
|
||||
const handleSelect = useCallback(
|
||||
(mode: RewindMode) => async () => {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
setPendingMode(mode);
|
||||
try {
|
||||
await onRewind({ mode, rewoundText });
|
||||
} catch {
|
||||
// useRewindAgentMutation owns the toast; the sheet only owns flow state.
|
||||
} finally {
|
||||
setPendingMode(null);
|
||||
setIsSheetVisible(false);
|
||||
}
|
||||
},
|
||||
[isPending, onRewind, rewoundText],
|
||||
);
|
||||
const triggerIcon = useCallback((color: string) => <Undo2 size={16} color={color} />, []);
|
||||
const tooltipContent = useMemo(
|
||||
() => (
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>Rewind to this message</Text>
|
||||
</TooltipContent>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
accessibilityLabel="Rewind to this message"
|
||||
disabled={isPending}
|
||||
leftIcon={triggerIcon}
|
||||
onPress={handleOpen}
|
||||
size="xs"
|
||||
style={styles.trigger}
|
||||
testID={`${testID}-trigger`}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
{tooltipContent}
|
||||
</Tooltip>
|
||||
<AdaptiveModalSheet
|
||||
desktopMaxWidth={420}
|
||||
header={REWIND_HEADER}
|
||||
onClose={handleClose}
|
||||
scrollable={false}
|
||||
snapPoints={SNAP_POINTS}
|
||||
testID={`${testID}-content`}
|
||||
visible={isSheetVisible}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.warningText}>This action cannot be undone</Text>
|
||||
<View style={styles.actions}>
|
||||
{items.map((item) => (
|
||||
<Button
|
||||
key={item.mode}
|
||||
leftIcon={iconForItem(item)}
|
||||
loading={pendingMode === item.mode}
|
||||
disabled={isPending}
|
||||
onPress={handleSelect(item.mode)}
|
||||
size="md"
|
||||
style={styles.actionButton}
|
||||
testID={item.testID}
|
||||
variant="secondary"
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
minHeight: 24,
|
||||
paddingHorizontal: 0,
|
||||
paddingVertical: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
content: {
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
warningText: {
|
||||
color: theme.colors.statusWarning,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actions: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
actionButton: {
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { RewindMode } from "./use-rewind-capabilities";
|
||||
|
||||
export function shouldRestoreComposerForRewindMode(mode: RewindMode): boolean {
|
||||
return mode === "conversation" || mode === "both";
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useCallback } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { RewindMode } from "./use-rewind-capabilities";
|
||||
import { useRewindComposerRestore } from "./composer-restore";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
|
||||
import { clearOptimisticUserMessages } from "@/types/stream";
|
||||
|
||||
interface UseRewindAgentMutationInput {
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
messageId?: string;
|
||||
client?: DaemonClient | null;
|
||||
}
|
||||
|
||||
interface RewindAgentInput {
|
||||
mode: RewindMode;
|
||||
rewoundText: string;
|
||||
}
|
||||
|
||||
export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
||||
rewindAgent: (input: RewindAgentInput) => Promise<void>;
|
||||
isPending: boolean;
|
||||
} {
|
||||
const toast = useToast();
|
||||
const composerRestore = useRewindComposerRestore();
|
||||
const { isPending, mutateAsync } = useMutation({
|
||||
mutationFn: async ({ mode }: RewindAgentInput) => {
|
||||
if (!input.client || !input.agentId || !input.messageId) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
await input.client.rewindAgent(input.agentId, input.messageId, mode);
|
||||
if (mode !== "files") {
|
||||
if (input.serverId) {
|
||||
const session = useSessionStore.getState().sessions[input.serverId];
|
||||
useSessionStore.getState().setAgentStreamState(input.serverId, input.agentId, {
|
||||
tail: clearOptimisticUserMessages(session?.agentStreamTail.get(input.agentId) ?? []),
|
||||
head: clearOptimisticUserMessages(session?.agentStreamHead.get(input.agentId) ?? []),
|
||||
});
|
||||
}
|
||||
const cursor = input.serverId
|
||||
? useSessionStore
|
||||
.getState()
|
||||
.sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
|
||||
: undefined;
|
||||
await input.client.fetchAgentTimeline(input.agentId, {
|
||||
direction: "tail",
|
||||
projection: "canonical",
|
||||
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
if (!shouldRestoreComposerForRewindMode(variables.mode)) {
|
||||
return;
|
||||
}
|
||||
composerRestore?.restoreTextIfComposerEmpty(variables.rewoundText);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to rewind agent");
|
||||
},
|
||||
});
|
||||
|
||||
const rewindAgent = useCallback(
|
||||
async (rewindInput: RewindAgentInput) => {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
await mutateAsync(rewindInput);
|
||||
},
|
||||
[isPending, mutateAsync],
|
||||
);
|
||||
|
||||
return {
|
||||
rewindAgent,
|
||||
isPending,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { resolveRewindMenuItems } from "./use-rewind-capabilities";
|
||||
|
||||
describe("resolveRewindMenuItems", () => {
|
||||
test("returns no items when the provider declares no rewind capability", () => {
|
||||
expect(
|
||||
resolveRewindMenuItems({
|
||||
supportsRewindConversation: false,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: false,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns only the capabilities declared by the provider", () => {
|
||||
expect(
|
||||
resolveRewindMenuItems({
|
||||
supportsRewindConversation: true,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: true,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
mode: "conversation",
|
||||
label: "Rewind conversation",
|
||||
testID: "rewind-menu-conversation",
|
||||
},
|
||||
{
|
||||
mode: "both",
|
||||
label: "Rewind conversation and files",
|
||||
testID: "rewind-menu-both",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from "react";
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export type RewindMode = "conversation" | "files" | "both";
|
||||
|
||||
export interface RewindMenuItem {
|
||||
mode: RewindMode;
|
||||
label: string;
|
||||
testID: string;
|
||||
}
|
||||
|
||||
export function resolveRewindMenuItems(
|
||||
capabilities:
|
||||
| Pick<
|
||||
AgentCapabilityFlags,
|
||||
"supportsRewindConversation" | "supportsRewindFiles" | "supportsRewindBoth"
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
): RewindMenuItem[] {
|
||||
if (!capabilities) {
|
||||
return [];
|
||||
}
|
||||
const items: RewindMenuItem[] = [];
|
||||
if (capabilities.supportsRewindConversation) {
|
||||
items.push({
|
||||
mode: "conversation",
|
||||
label: "Rewind conversation",
|
||||
testID: "rewind-menu-conversation",
|
||||
});
|
||||
}
|
||||
if (capabilities.supportsRewindFiles) {
|
||||
items.push({
|
||||
mode: "files",
|
||||
label: "Rewind files",
|
||||
testID: "rewind-menu-files",
|
||||
});
|
||||
}
|
||||
if (capabilities.supportsRewindBoth) {
|
||||
items.push({
|
||||
mode: "both",
|
||||
label: "Rewind conversation and files",
|
||||
testID: "rewind-menu-both",
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useRewindCapabilities(
|
||||
capabilities: Parameters<typeof resolveRewindMenuItems>[0],
|
||||
): RewindMenuItem[] {
|
||||
return useMemo(() => resolveRewindMenuItems(capabilities), [capabilities]);
|
||||
}
|
||||
@@ -1664,6 +1664,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
id: messageId,
|
||||
text: message,
|
||||
timestamp: new Date(),
|
||||
optimistic: true,
|
||||
...(images && images.length > 0 ? { images } : {}),
|
||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||
};
|
||||
|
||||
// Append to head if streaming (keeps the user message with the current
|
||||
|
||||
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
|
||||
commandDraftConfig: DraftCommandConfig | undefined;
|
||||
};
|
||||
|
||||
interface AgentInputDraft {
|
||||
export interface AgentInputDraft {
|
||||
text: string;
|
||||
setText: (text: string) => void;
|
||||
attachments: UserComposerAttachment[];
|
||||
|
||||
@@ -99,6 +99,16 @@ function expectSyncErrorSync(state: ReadyState): void {
|
||||
expect(state.sync.status).toBe("sync_error");
|
||||
}
|
||||
|
||||
function stateMachineLabel(state: AgentScreenViewState): "loading" | "resolving" | Agent["status"] {
|
||||
if (state.tag === "boot") {
|
||||
return state.reason;
|
||||
}
|
||||
if (state.tag === "ready") {
|
||||
return state.agent.status;
|
||||
}
|
||||
return "error";
|
||||
}
|
||||
|
||||
describe("deriveAgentScreenViewState", () => {
|
||||
it("returns boot loading before first interactive paint", () => {
|
||||
const memory = createBaseMemory();
|
||||
@@ -383,6 +393,39 @@ describe("deriveAgentScreenViewState", () => {
|
||||
expect(ready.agent.status).toBe("running");
|
||||
});
|
||||
|
||||
it("keeps send lifecycle transitions forward-only across optimistic and authoritative handoff", () => {
|
||||
let memory = createBaseMemory();
|
||||
const transitions: Array<"loading" | "resolving" | Agent["status"]> = [];
|
||||
|
||||
for (const input of [
|
||||
createBaseInput(),
|
||||
{
|
||||
...createBaseInput(),
|
||||
agent: createAgentWithStatus({ id: "agent-1", status: "idle" }),
|
||||
placeholderAgent: createAgent("agent-1"),
|
||||
shouldUseOptimisticStream: true,
|
||||
},
|
||||
{
|
||||
...createBaseInput(),
|
||||
agent: createAgentWithStatus({ id: "agent-1", status: "running" }),
|
||||
placeholderAgent: createAgent("agent-1"),
|
||||
shouldUseOptimisticStream: true,
|
||||
},
|
||||
{
|
||||
...createBaseInput(),
|
||||
agent: createAgentWithStatus({ id: "agent-1", status: "idle" }),
|
||||
},
|
||||
] satisfies AgentScreenMachineInput[]) {
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
memory = result.memory;
|
||||
transitions.push(stateMachineLabel(result.state));
|
||||
}
|
||||
|
||||
expect(transitions).toEqual(["loading", "running", "running", "idle"]);
|
||||
expect(transitions.join(" -> ")).not.toContain("running -> loading");
|
||||
expect(transitions.join(" -> ")).not.toContain("loading -> idle");
|
||||
});
|
||||
|
||||
it("keeps optimistic running status while authoritative agent is initializing", () => {
|
||||
const memory = createBaseMemory();
|
||||
const input: AgentScreenMachineInput = {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useRef } from "react";
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export interface AgentScreenAgent {
|
||||
serverId: string;
|
||||
id: string;
|
||||
status: "initializing" | "idle" | "running" | "error" | "closed";
|
||||
cwd: string;
|
||||
capabilities?: AgentCapabilityFlags;
|
||||
lastError?: string | null;
|
||||
projectPlacement?: {
|
||||
checkout?: {
|
||||
|
||||
@@ -133,6 +133,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
id: machine.attempt.clientMessageId,
|
||||
text: machine.attempt.text,
|
||||
timestamp: machine.attempt.timestamp,
|
||||
optimistic: true,
|
||||
...(machine.attempt.images && machine.attempt.images.length > 0
|
||||
? { images: machine.attempt.images }
|
||||
: {}),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view
|
||||
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
||||
@@ -23,7 +24,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import { useAgentInputDraft, type AgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import {
|
||||
type AgentScreenAgent,
|
||||
type AgentScreenMissingState,
|
||||
@@ -69,6 +70,7 @@ interface ChatAgentStateShape {
|
||||
id: string | null;
|
||||
status: Agent["status"] | null;
|
||||
cwd: string | null;
|
||||
capabilities?: Agent["capabilities"];
|
||||
lastError?: Agent["lastError"] | null;
|
||||
}
|
||||
|
||||
@@ -111,6 +113,7 @@ function selectChatAgentState(
|
||||
id: agent.id,
|
||||
status: agent.status,
|
||||
cwd: agent.cwd,
|
||||
capabilities: agent.capabilities,
|
||||
lastError: agent.lastError ?? null,
|
||||
archivedAt: agent.archivedAt ?? null,
|
||||
requiresAttention: agent.requiresAttention ?? false,
|
||||
@@ -130,6 +133,7 @@ function buildChatAgentFromState(
|
||||
id: state.id,
|
||||
status: state.status,
|
||||
cwd: state.cwd,
|
||||
capabilities: state.capabilities,
|
||||
lastError: state.lastError ?? null,
|
||||
projectPlacement,
|
||||
};
|
||||
@@ -945,70 +949,162 @@ function ChatAgentContent({
|
||||
effectiveAgent,
|
||||
});
|
||||
if (nonReadyView) return nonReadyView;
|
||||
invariant(agentId, "agent id is defined when agent content is ready");
|
||||
invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
|
||||
invariant(agentState.cwd, "agent cwd is defined when agent content is ready");
|
||||
const agentCwd = agentState.cwd;
|
||||
invariant(agentCwd, "agent cwd is defined when agent content is ready");
|
||||
const showHistorySyncOverlay =
|
||||
viewState.tag === "ready" &&
|
||||
viewState.sync.status === "catching_up" &&
|
||||
viewState.sync.ui === "overlay";
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isArchivingCurrentAgent}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.contentContainer}>
|
||||
<ReanimatedAnimated.View style={animatedContentStyle}>
|
||||
<AgentStreamSection
|
||||
streamViewRef={streamViewRef}
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
agent={effectiveAgent}
|
||||
pendingCreate={pendingCreate}
|
||||
shouldUseOptimisticStream={shouldUseOptimisticStream}
|
||||
canFinalizePendingCreate={canFinalizePendingCreate}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||
toast={panelToast.api}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
<ChatAgentReadyContent
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
||||
agentState={agentState}
|
||||
effectiveAgent={effectiveAgent}
|
||||
pendingCreate={pendingCreate}
|
||||
shouldUseOptimisticStream={shouldUseOptimisticStream}
|
||||
canFinalizePendingCreate={canFinalizePendingCreate}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||
panelToast={panelToast}
|
||||
streamViewRef={streamViewRef}
|
||||
animatedContentStyle={animatedContentStyle}
|
||||
handleFilesDropped={handleFilesDropped}
|
||||
handleAddImagesCallback={handleAddImagesCallback}
|
||||
handleComposerHeightChange={handleComposerHeightChange}
|
||||
handleMessageSent={handleMessageSent}
|
||||
showPendingCreateSubmitLoading={showPendingCreateSubmitLoading}
|
||||
showHistorySyncOverlay={showHistorySyncOverlay}
|
||||
cwd={agentCwd}
|
||||
attentionController={attentionController}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
<AgentComposerSection
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
||||
archivedAt={agentState.archivedAt}
|
||||
cwd={agentState.cwd}
|
||||
isSubmitLoading={showPendingCreateSubmitLoading}
|
||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
onComposerHeightChange={handleComposerHeightChange}
|
||||
onMessageSent={handleMessageSent}
|
||||
/>
|
||||
function ChatAgentReadyContent({
|
||||
serverId,
|
||||
agentId,
|
||||
isPaneFocused,
|
||||
isArchivingCurrentAgent,
|
||||
agentState,
|
||||
effectiveAgent,
|
||||
pendingCreate,
|
||||
shouldUseOptimisticStream,
|
||||
canFinalizePendingCreate,
|
||||
routeBottomAnchorRequest,
|
||||
hasAppliedAuthoritativeHistory,
|
||||
panelToast,
|
||||
streamViewRef,
|
||||
animatedContentStyle,
|
||||
handleFilesDropped,
|
||||
handleAddImagesCallback,
|
||||
handleComposerHeightChange,
|
||||
handleMessageSent,
|
||||
showPendingCreateSubmitLoading,
|
||||
showHistorySyncOverlay,
|
||||
cwd,
|
||||
attentionController,
|
||||
onOpenWorkspaceFile,
|
||||
}: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
isPaneFocused: boolean;
|
||||
isArchivingCurrentAgent: boolean;
|
||||
agentState: ChatAgentSelectedState;
|
||||
effectiveAgent: AgentScreenAgent;
|
||||
pendingCreate: PendingCreateAttempt | null;
|
||||
shouldUseOptimisticStream: boolean;
|
||||
canFinalizePendingCreate: boolean;
|
||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||
hasAppliedAuthoritativeHistory: boolean;
|
||||
panelToast: ReturnType<typeof useToastHost>;
|
||||
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
||||
animatedContentStyle: object[];
|
||||
handleFilesDropped: (files: ImageAttachment[]) => void;
|
||||
handleAddImagesCallback: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||
handleComposerHeightChange: (height: number) => void;
|
||||
handleMessageSent: () => void;
|
||||
showPendingCreateSubmitLoading: boolean;
|
||||
showHistorySyncOverlay: boolean;
|
||||
cwd: string;
|
||||
attentionController: ReturnType<typeof useAgentAttentionClear>;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const agentInputDraft = useAgentInputDraft({
|
||||
draftKey: buildDraftStoreKey({
|
||||
serverId,
|
||||
agentId,
|
||||
}),
|
||||
});
|
||||
|
||||
{viewState.tag === "ready" &&
|
||||
viewState.sync.status === "catching_up" &&
|
||||
viewState.sync.ui === "overlay" ? (
|
||||
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
|
||||
return (
|
||||
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}>
|
||||
<View style={styles.root}>
|
||||
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isArchivingCurrentAgent}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.contentContainer}>
|
||||
<ReanimatedAnimated.View style={animatedContentStyle}>
|
||||
<AgentStreamSection
|
||||
streamViewRef={streamViewRef}
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
agent={effectiveAgent}
|
||||
pendingCreate={pendingCreate}
|
||||
shouldUseOptimisticStream={shouldUseOptimisticStream}
|
||||
canFinalizePendingCreate={canFinalizePendingCreate}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||
toast={panelToast.api}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<ToastViewport
|
||||
toast={panelToast.toast}
|
||||
onDismiss={panelToast.dismiss}
|
||||
placement="panel"
|
||||
/>
|
||||
</View>
|
||||
</FileDropZone>
|
||||
<AgentComposerSection
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
||||
archivedAt={agentState.archivedAt}
|
||||
cwd={cwd}
|
||||
isSubmitLoading={showPendingCreateSubmitLoading}
|
||||
agentInputDraft={agentInputDraft}
|
||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
onComposerHeightChange={handleComposerHeightChange}
|
||||
onMessageSent={handleMessageSent}
|
||||
/>
|
||||
|
||||
{isArchivingCurrentAgent ? (
|
||||
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundColorMapping} />
|
||||
<Text style={styles.archivingTitle}>Archiving agent...</Text>
|
||||
<Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
{showHistorySyncOverlay ? (
|
||||
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<ToastViewport
|
||||
toast={panelToast.toast}
|
||||
onDismiss={panelToast.dismiss}
|
||||
placement="panel"
|
||||
/>
|
||||
</View>
|
||||
</FileDropZone>
|
||||
|
||||
{isArchivingCurrentAgent ? (
|
||||
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundColorMapping} />
|
||||
<Text style={styles.archivingTitle}>Archiving agent...</Text>
|
||||
<Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</RewindComposerRestoreProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1081,6 +1177,7 @@ function AgentStreamSection({
|
||||
id: pendingCreate.clientMessageId,
|
||||
text: pendingCreate.text,
|
||||
timestamp: new Date(pendingCreate.timestamp),
|
||||
optimistic: true,
|
||||
...(pendingCreate.images && pendingCreate.images.length > 0
|
||||
? { images: pendingCreate.images }
|
||||
: {}),
|
||||
@@ -1183,6 +1280,7 @@ function AgentComposerSection({
|
||||
archivedAt,
|
||||
cwd,
|
||||
isSubmitLoading,
|
||||
agentInputDraft,
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
onAddImages,
|
||||
@@ -1196,6 +1294,7 @@ function AgentComposerSection({
|
||||
archivedAt: Date | null;
|
||||
cwd: string;
|
||||
isSubmitLoading: boolean;
|
||||
agentInputDraft: AgentInputDraft;
|
||||
onAttentionInputFocus: () => void;
|
||||
onAttentionPromptSend: () => void;
|
||||
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||
@@ -1219,6 +1318,7 @@ function AgentComposerSection({
|
||||
isPaneFocused={isPaneFocused}
|
||||
cwd={cwd}
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
agentInputDraft={agentInputDraft}
|
||||
onAttentionInputFocus={onAttentionInputFocus}
|
||||
onAttentionPromptSend={onAttentionPromptSend}
|
||||
onAddImages={onAddImages}
|
||||
@@ -1234,6 +1334,7 @@ function ActiveAgentComposer({
|
||||
isPaneFocused,
|
||||
cwd,
|
||||
isSubmitLoading,
|
||||
agentInputDraft,
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
onAddImages,
|
||||
@@ -1245,6 +1346,7 @@ function ActiveAgentComposer({
|
||||
isPaneFocused: boolean;
|
||||
cwd: string;
|
||||
isSubmitLoading: boolean;
|
||||
agentInputDraft: AgentInputDraft;
|
||||
onAttentionInputFocus: () => void;
|
||||
onAttentionPromptSend: () => void;
|
||||
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||
@@ -1270,12 +1372,6 @@ function ActiveAgentComposer({
|
||||
[serverId],
|
||||
);
|
||||
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
||||
const agentInputDraft = useAgentInputDraft({
|
||||
draftKey: buildDraftStoreKey({
|
||||
serverId,
|
||||
agentId,
|
||||
}),
|
||||
});
|
||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||
serverId,
|
||||
cwd,
|
||||
|
||||
@@ -276,8 +276,17 @@ describe("processTimelineResponse", () => {
|
||||
});
|
||||
|
||||
it("sets cursor to null when reset=true but no cursors in payload", () => {
|
||||
const existingTail: StreamItem[] = [
|
||||
{
|
||||
kind: "user_message",
|
||||
id: "only-user",
|
||||
text: "first turn",
|
||||
timestamp: new Date(500),
|
||||
},
|
||||
];
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: existingTail,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
@@ -288,6 +297,8 @@ describe("processTimelineResponse", () => {
|
||||
|
||||
expect(result.cursor).toBe(null);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.tail).toEqual([]);
|
||||
expect(getUserTexts(result.tail)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("performs bootstrap tail init with catch-up side effect", () => {
|
||||
@@ -933,6 +944,34 @@ describe("processAgentStreamEvent", () => {
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("resets visible timeline when a new epoch starts at seq 1", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
};
|
||||
const currentTail = [makeAssistantItem("old timeline")];
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("rewound start", "user_message"),
|
||||
seq: 1,
|
||||
epoch: "epoch-2",
|
||||
currentCursor: existingCursor,
|
||||
currentTail,
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-2",
|
||||
startSeq: 1,
|
||||
endSeq: 1,
|
||||
});
|
||||
expect(getAssistantTexts(result.tail)).toEqual([]);
|
||||
expect(getUserTexts(result.tail)).toEqual(["rewound start"]);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("initializes cursor when none exists", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
|
||||
@@ -623,6 +623,14 @@ export interface AgentStreamReducerEvent {
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
interface TimelineSequencingGateResult {
|
||||
shouldApplyStreamEvent: boolean;
|
||||
nextTimelineCursor: TimelineCursor | null;
|
||||
cursorChanged: boolean;
|
||||
resetLiveTimeline: boolean;
|
||||
sideEffects: AgentStreamReducerSideEffect[];
|
||||
}
|
||||
|
||||
export interface AgentStreamReducerAgentSnapshot {
|
||||
status: AgentLifecycleStatus;
|
||||
updatedAt: Date;
|
||||
@@ -672,61 +680,91 @@ function applyAgentPatch(
|
||||
};
|
||||
}
|
||||
|
||||
function processTimelineSequencingGate(input: {
|
||||
event: AgentStreamEventPayload;
|
||||
seq: number | undefined;
|
||||
epoch: string | undefined;
|
||||
currentCursor: TimelineCursor | undefined;
|
||||
}): TimelineSequencingGateResult {
|
||||
const { event, seq, epoch, currentCursor } = input;
|
||||
const base: TimelineSequencingGateResult = {
|
||||
shouldApplyStreamEvent: true,
|
||||
nextTimelineCursor: null,
|
||||
cursorChanged: false,
|
||||
resetLiveTimeline: false,
|
||||
sideEffects: [],
|
||||
};
|
||||
if (event.type !== "timeline" || typeof seq !== "number" || typeof epoch !== "string") {
|
||||
return base;
|
||||
}
|
||||
|
||||
const decision = classifySessionTimelineSeq({
|
||||
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
|
||||
epoch,
|
||||
seq,
|
||||
});
|
||||
|
||||
if (decision === "init") {
|
||||
return {
|
||||
...base,
|
||||
nextTimelineCursor: { epoch, startSeq: seq, endSeq: seq },
|
||||
cursorChanged: true,
|
||||
};
|
||||
}
|
||||
if (decision === "accept") {
|
||||
return {
|
||||
...base,
|
||||
nextTimelineCursor: {
|
||||
...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }),
|
||||
epoch,
|
||||
endSeq: seq,
|
||||
},
|
||||
cursorChanged: true,
|
||||
};
|
||||
}
|
||||
if (decision === "gap") {
|
||||
return {
|
||||
...base,
|
||||
shouldApplyStreamEvent: false,
|
||||
sideEffects: currentCursor
|
||||
? [
|
||||
{
|
||||
type: "catch_up",
|
||||
cursor: { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
if (decision === "drop_epoch" && seq === 1) {
|
||||
return {
|
||||
...base,
|
||||
nextTimelineCursor: { epoch, startSeq: seq, endSeq: seq },
|
||||
cursorChanged: true,
|
||||
resetLiveTimeline: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
shouldApplyStreamEvent: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function processAgentStreamEvent(
|
||||
input: ProcessAgentStreamEventInput,
|
||||
): ProcessAgentStreamEventOutput {
|
||||
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
|
||||
input;
|
||||
|
||||
let shouldApplyStreamEvent = true;
|
||||
let nextTimelineCursor: TimelineCursor | null = null;
|
||||
let cursorChanged = false;
|
||||
const sideEffects: AgentStreamReducerSideEffect[] = [];
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Timeline sequencing gate
|
||||
// ------------------------------------------------------------------
|
||||
if (event.type === "timeline" && typeof seq === "number" && typeof epoch === "string") {
|
||||
const decision = classifySessionTimelineSeq({
|
||||
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
|
||||
epoch,
|
||||
seq,
|
||||
});
|
||||
|
||||
if (decision === "init") {
|
||||
nextTimelineCursor = { epoch, startSeq: seq, endSeq: seq };
|
||||
cursorChanged = true;
|
||||
} else if (decision === "accept") {
|
||||
nextTimelineCursor = {
|
||||
...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }),
|
||||
epoch,
|
||||
endSeq: seq,
|
||||
};
|
||||
cursorChanged = true;
|
||||
} else if (decision === "gap") {
|
||||
shouldApplyStreamEvent = false;
|
||||
if (currentCursor) {
|
||||
sideEffects.push({
|
||||
type: "catch_up",
|
||||
cursor: {
|
||||
epoch: currentCursor.epoch,
|
||||
endSeq: currentCursor.endSeq,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// drop_stale or drop_epoch
|
||||
shouldApplyStreamEvent = false;
|
||||
}
|
||||
}
|
||||
const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor });
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Apply stream event to tail/head
|
||||
// ------------------------------------------------------------------
|
||||
const { tail, head, changedTail, changedHead } = shouldApplyStreamEvent
|
||||
const { tail, head, changedTail, changedHead } = sequencing.shouldApplyStreamEvent
|
||||
? applyStreamEvent({
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
tail: sequencing.resetLiveTimeline ? [] : currentTail,
|
||||
head: sequencing.resetLiveTimeline ? [] : currentHead,
|
||||
event,
|
||||
timestamp,
|
||||
source: "live",
|
||||
@@ -771,11 +809,11 @@ export function processAgentStreamEvent(
|
||||
head,
|
||||
changedTail,
|
||||
changedHead,
|
||||
cursor: nextTimelineCursor,
|
||||
cursorChanged,
|
||||
cursor: sequencing.nextTimelineCursor,
|
||||
cursorChanged: sequencing.cursorChanged,
|
||||
agent: agentPatch,
|
||||
agentChanged,
|
||||
sideEffects,
|
||||
sideEffects: sequencing.sideEffects,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import invariant from "tiny-invariant";
|
||||
import { describe, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
applyStreamEvent,
|
||||
clearOptimisticUserMessages,
|
||||
hydrateStreamState,
|
||||
mergeToolCallDetail,
|
||||
reduceStreamUpdate,
|
||||
@@ -309,7 +311,7 @@ describe("stream reducer canonical tool calls", () => {
|
||||
const first = hydrateStreamState(updates);
|
||||
const second = hydrateStreamState(updates);
|
||||
|
||||
assert.strictEqual(JSON.stringify(first), JSON.stringify(second));
|
||||
expect(first).toEqual(second);
|
||||
const assistantMessage = first.find((item) => item.kind === "assistant_message");
|
||||
assert.strictEqual(assistantMessage?.text, "Hello world");
|
||||
});
|
||||
@@ -735,6 +737,7 @@ describe("stream reducer canonical tool calls", () => {
|
||||
id: messageId,
|
||||
text: "Analyze this image",
|
||||
timestamp: new Date("2025-01-01T11:10:00Z"),
|
||||
optimistic: true,
|
||||
images: optimisticImages,
|
||||
},
|
||||
];
|
||||
@@ -935,4 +938,270 @@ describe("turn lifecycle events", () => {
|
||||
["user_message", "user_message"],
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["codex", "opencode", "pi"] satisfies AgentProvider[])(
|
||||
"replaces an optimistic user message when a live %s provider-owned id echo arrives without text matching",
|
||||
(provider) => {
|
||||
const optimisticTimestamp = new Date("2025-01-01T15:02:00Z");
|
||||
const serverTimestamp = new Date("2025-01-01T15:02:01Z");
|
||||
const optimistic: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "msg_optimistic",
|
||||
text: "same user text",
|
||||
timestamp: optimisticTimestamp,
|
||||
optimistic: true,
|
||||
images: [
|
||||
{
|
||||
id: "image-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "image-1",
|
||||
createdAt: optimisticTimestamp.getTime(),
|
||||
},
|
||||
],
|
||||
attachments: [
|
||||
{
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
text: "attached context",
|
||||
title: "context.txt",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const state = reduceStreamUpdate(
|
||||
[optimistic],
|
||||
{
|
||||
type: "timeline",
|
||||
provider,
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "server-owned rendered text",
|
||||
messageId: "provider-owned-id",
|
||||
},
|
||||
},
|
||||
serverTimestamp,
|
||||
{ source: "live" },
|
||||
);
|
||||
|
||||
const userMessages = state.filter((item) => item.kind === "user_message");
|
||||
assert.strictEqual(userMessages.length, 1);
|
||||
const userMessage = userMessages[0];
|
||||
invariant(userMessage?.kind === "user_message");
|
||||
assert.strictEqual(userMessage.id, "provider-owned-id");
|
||||
assert.strictEqual(userMessage.text, "server-owned rendered text");
|
||||
assert.deepStrictEqual(userMessage.images, optimistic.images);
|
||||
assert.deepStrictEqual(userMessage.attachments, optimistic.attachments);
|
||||
},
|
||||
);
|
||||
|
||||
it("replaces one optimistic plain-text user message with the next live server user message", () => {
|
||||
const optimisticTimestamp = new Date("2025-01-01T15:03:00Z");
|
||||
const serverTimestamp = new Date("2025-01-01T15:03:01Z");
|
||||
const optimistic: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "msg_optimistic",
|
||||
text: "typed plain text",
|
||||
timestamp: optimisticTimestamp,
|
||||
optimistic: true,
|
||||
};
|
||||
|
||||
const state = reduceStreamUpdate(
|
||||
[optimistic],
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "typed plain text",
|
||||
messageId: "msg_opencode_provider_owned",
|
||||
},
|
||||
},
|
||||
serverTimestamp,
|
||||
{ source: "live" },
|
||||
);
|
||||
|
||||
const userMessages = state.filter((item) => item.kind === "user_message");
|
||||
assert.strictEqual(userMessages.length, 1);
|
||||
const userMessage = userMessages[0];
|
||||
invariant(userMessage?.kind === "user_message");
|
||||
assert.strictEqual(userMessage.id, "msg_opencode_provider_owned");
|
||||
assert.strictEqual(userMessage.text, "typed plain text");
|
||||
assert.strictEqual(userMessage.optimistic, undefined);
|
||||
});
|
||||
|
||||
it("reconciles an optimistic user message that was pending in the streaming head", () => {
|
||||
const optimistic: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "msg_head_optimistic",
|
||||
text: "plain text in head",
|
||||
timestamp: new Date("2025-01-01T15:03:02Z"),
|
||||
optimistic: true,
|
||||
};
|
||||
|
||||
const result = applyStreamEvent({
|
||||
tail: [],
|
||||
head: [optimistic],
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "plain text in head",
|
||||
messageId: "provider-owned-head",
|
||||
},
|
||||
},
|
||||
timestamp: new Date("2025-01-01T15:03:03Z"),
|
||||
source: "live",
|
||||
});
|
||||
|
||||
assert.strictEqual(result.head.length, 0);
|
||||
const userMessages = result.tail.filter((item) => item.kind === "user_message");
|
||||
assert.strictEqual(userMessages.length, 1);
|
||||
assert.strictEqual(userMessages[0]?.id, "provider-owned-head");
|
||||
assert.strictEqual(userMessages[0]?.optimistic, undefined);
|
||||
});
|
||||
|
||||
it("replaces multiple optimistic user messages in FIFO order", () => {
|
||||
const optimisticTimestamp = new Date("2025-01-01T15:04:00Z");
|
||||
const serverTimestamp = new Date("2025-01-01T15:04:01Z");
|
||||
const firstOptimistic: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "msg_optimistic_1",
|
||||
text: "first typed text",
|
||||
timestamp: optimisticTimestamp,
|
||||
optimistic: true,
|
||||
};
|
||||
const secondOptimistic: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "msg_optimistic_2",
|
||||
text: "second typed text",
|
||||
timestamp: new Date("2025-01-01T15:04:00.500Z"),
|
||||
optimistic: true,
|
||||
};
|
||||
|
||||
const afterFirstEcho = reduceStreamUpdate(
|
||||
[firstOptimistic, secondOptimistic],
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "first server text",
|
||||
messageId: "provider-owned-first",
|
||||
},
|
||||
},
|
||||
serverTimestamp,
|
||||
{ source: "live" },
|
||||
);
|
||||
const state = reduceStreamUpdate(
|
||||
afterFirstEcho,
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "second server text",
|
||||
messageId: "provider-owned-second",
|
||||
},
|
||||
},
|
||||
new Date("2025-01-01T15:04:02Z"),
|
||||
{ source: "live" },
|
||||
);
|
||||
|
||||
const userMessages = state.filter((item) => item.kind === "user_message");
|
||||
assert.strictEqual(userMessages.length, 2);
|
||||
assert.deepStrictEqual(
|
||||
userMessages.map((item) => [item.id, item.text, item.optimistic]),
|
||||
[
|
||||
["provider-owned-first", "first server text", undefined],
|
||||
["provider-owned-second", "second server text", undefined],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("appends a live server user message when no optimistic user message is pending", () => {
|
||||
const state = reduceStreamUpdate(
|
||||
[],
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "resumed session text",
|
||||
messageId: "provider-owned-resume",
|
||||
},
|
||||
},
|
||||
new Date("2025-01-01T15:04:03Z"),
|
||||
{ source: "live" },
|
||||
);
|
||||
|
||||
const userMessages = state.filter((item) => item.kind === "user_message");
|
||||
assert.strictEqual(userMessages.length, 1);
|
||||
assert.strictEqual(userMessages[0]?.id, "provider-owned-resume");
|
||||
assert.strictEqual(userMessages[0]?.optimistic, undefined);
|
||||
});
|
||||
|
||||
it("does not match a server user message to an optimistic from a rewound turn after pending optimistics are cleared", () => {
|
||||
const optimistic: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "msg_rewound_optimistic",
|
||||
text: "rewound text",
|
||||
timestamp: new Date("2025-01-01T15:04:04Z"),
|
||||
optimistic: true,
|
||||
};
|
||||
const cleared = clearOptimisticUserMessages([optimistic]);
|
||||
|
||||
const state = reduceStreamUpdate(
|
||||
cleared,
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "future server echo",
|
||||
messageId: "provider-owned-after-rewind",
|
||||
},
|
||||
},
|
||||
new Date("2025-01-01T15:04:05Z"),
|
||||
{ source: "live" },
|
||||
);
|
||||
|
||||
const userMessages = state.filter((item) => item.kind === "user_message");
|
||||
assert.strictEqual(userMessages.length, 1);
|
||||
assert.strictEqual(userMessages[0]?.id, "provider-owned-after-rewind");
|
||||
assert.strictEqual(userMessages[0]?.text, "future server echo");
|
||||
assert.strictEqual(userMessages[0]?.optimistic, undefined);
|
||||
});
|
||||
|
||||
it("keeps canonical repeated user messages distinct during hydration", () => {
|
||||
const state = hydrateStreamState(
|
||||
[
|
||||
{
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "user_message", text: "repeat", messageId: "native-1" },
|
||||
},
|
||||
timestamp: new Date("2025-01-01T15:03:00Z"),
|
||||
},
|
||||
{
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "user_message", text: "repeat", messageId: "native-2" },
|
||||
},
|
||||
timestamp: new Date("2025-01-01T15:03:01Z"),
|
||||
},
|
||||
],
|
||||
{ source: "canonical" },
|
||||
);
|
||||
|
||||
const userMessages = state.filter((item) => item.kind === "user_message");
|
||||
assert.strictEqual(userMessages.length, 2);
|
||||
assert.deepStrictEqual(
|
||||
userMessages.map((item) => item.id),
|
||||
["native-1", "native-2"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface UserMessageItem {
|
||||
id: string;
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
optimistic?: true;
|
||||
images?: UserMessageImageAttachment[];
|
||||
attachments?: AgentAttachment[];
|
||||
}
|
||||
@@ -184,10 +185,32 @@ function markThoughtReady(item: ThoughtItem): ThoughtItem {
|
||||
};
|
||||
}
|
||||
|
||||
function buildUserMessageItem(input: {
|
||||
id: string;
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
optimistic?: UserMessageItem | null;
|
||||
}): UserMessageItem {
|
||||
const preservedImages = input.optimistic?.images;
|
||||
const preservedAttachments = input.optimistic?.attachments;
|
||||
|
||||
return {
|
||||
kind: "user_message",
|
||||
id: input.id,
|
||||
text: input.text,
|
||||
timestamp: input.timestamp,
|
||||
...(preservedImages && preservedImages.length > 0 ? { images: preservedImages } : {}),
|
||||
...(preservedAttachments && preservedAttachments.length > 0
|
||||
? { attachments: preservedAttachments }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function appendUserMessage(
|
||||
state: StreamItem[],
|
||||
text: string,
|
||||
timestamp: Date,
|
||||
source: StreamUpdateSource,
|
||||
messageId?: string,
|
||||
): StreamItem[] {
|
||||
const { chunk, hasContent } = normalizeChunk(text);
|
||||
@@ -197,32 +220,33 @@ function appendUserMessage(
|
||||
|
||||
const chunkSeed = chunk.trim() || chunk;
|
||||
const entryId = messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp);
|
||||
const existingIndex = state.findIndex(
|
||||
(entry) => entry.kind === "user_message" && entry.id === entryId,
|
||||
);
|
||||
const existing =
|
||||
existingIndex >= 0 && state[existingIndex]?.kind === "user_message"
|
||||
? state[existingIndex]
|
||||
: null;
|
||||
const preservedImages = existing?.images;
|
||||
const optimisticIndex =
|
||||
source === "live"
|
||||
? state.findIndex((entry) => entry.kind === "user_message" && entry.optimistic)
|
||||
: -1;
|
||||
const optimistic = optimisticIndex >= 0 ? (state[optimisticIndex] as UserMessageItem) : null;
|
||||
|
||||
const nextItem: UserMessageItem = {
|
||||
kind: "user_message",
|
||||
const nextItem = buildUserMessageItem({
|
||||
id: entryId,
|
||||
text: chunk,
|
||||
timestamp,
|
||||
...(preservedImages && preservedImages.length > 0 ? { images: preservedImages } : {}),
|
||||
};
|
||||
optimistic,
|
||||
});
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
if (optimisticIndex >= 0) {
|
||||
const next = [...state];
|
||||
next[existingIndex] = nextItem;
|
||||
next[optimisticIndex] = nextItem;
|
||||
return next;
|
||||
}
|
||||
|
||||
return [...state, nextItem];
|
||||
}
|
||||
|
||||
export function clearOptimisticUserMessages(state: StreamItem[]): StreamItem[] {
|
||||
const next = state.filter((item) => item.kind !== "user_message" || !item.optimistic);
|
||||
return next.length === state.length ? state : next;
|
||||
}
|
||||
|
||||
function appendAssistantMessage(
|
||||
state: StreamItem[],
|
||||
text: string,
|
||||
@@ -664,7 +688,9 @@ function reduceTimelineEvent(
|
||||
const item = event.item;
|
||||
switch (item.type) {
|
||||
case "user_message":
|
||||
return finalizeActiveThoughts(appendUserMessage(state, item.text, timestamp, item.messageId));
|
||||
return finalizeActiveThoughts(
|
||||
appendUserMessage(state, item.text, timestamp, source, item.messageId),
|
||||
);
|
||||
case "assistant_message":
|
||||
return finalizeActiveThoughts(
|
||||
appendAssistantMessage(state, item.text, timestamp, source, item.messageId),
|
||||
|
||||
Reference in New Issue
Block a user