mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge commit 'refs/pr12814' into takeover/12814
This commit is contained in:
commit
c430ae5fe3
36 files changed
+907
-131
No files matched your search
@@ -2099,3 +2099,11 @@ test("a parked page is attached again when it is shown, and a page already on sc
|
||||
assert.deepEqual(calls, ["remove", "add", "bounds 10"], "a page put away and shown again is attached again");
|
||||
});
|
||||
|
||||
test("rewind notice dismissal survives the desktop preference disk round trip", (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rewind-notice-prefs-"));
|
||||
const file = path.join(dir, "prefs.json");
|
||||
t.after(() => { fs.rmSync(file, { force: true }); fs.rmdirSync(dir); });
|
||||
const record = { "rx-rewind-notice-dismissed:tx-saved": "1" };
|
||||
savePrefs(file, record);
|
||||
assert.deepEqual(loadPrefs(file), record);
|
||||
});
|
||||
@@ -768,6 +768,11 @@ export const EN: Record<string, string> = {
|
||||
"正在还原…": "Restoring…",
|
||||
"会话已切换,请重新选择还原位置": "The conversation changed. Choose a restore point again.",
|
||||
"撤销这次还原": "Undo this restore",
|
||||
"回退已完成": "Rewind completed",
|
||||
"关闭回退提示": "Dismiss rewind notice",
|
||||
"无法保存关闭状态:{error}": "Could not save dismissal: {error}",
|
||||
"撤销这次回退": "Undo this rewind",
|
||||
"正在撤销…": "Undoing…",
|
||||
"仍还原其余部分": "Restore the rest anyway",
|
||||
"还原失败": "Could not restore",
|
||||
"回退范围内未修改任何文件": "No files changed in this rewind range",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PlanAction } from "./session";
|
||||
import { HttpError } from "./port";
|
||||
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
|
||||
import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import { MockWorkspace } from "./mock_workspace";
|
||||
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
|
||||
@@ -17,7 +17,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
private log: WireEvent[] = [];
|
||||
// What the user has sent, so checkpoints() can mirror one per turn.
|
||||
private prompts: string[] = [];
|
||||
private undone: string[] | null = null;
|
||||
private undone: { prompts: string[]; offer: RewindUndo } | null = null;
|
||||
private at = 0;
|
||||
// The kernel numbers every frame a client cannot afford to miss, and a
|
||||
// bootstrap cut is read against those numbers.
|
||||
@@ -336,6 +336,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
}
|
||||
|
||||
async newSession() {
|
||||
this.clearUndo();
|
||||
this.log = [];
|
||||
this.at = 0;
|
||||
this.state.goal = "";
|
||||
@@ -430,13 +431,26 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
|
||||
async commitRewind(planId: string): Promise<RewindResult> {
|
||||
const turn = Number(planId.split("-")[2] ?? 0), files = !planId.endsWith("conversation") && this.prompts.length > 1;
|
||||
this.undone = this.prompts.slice();
|
||||
this.prompts = this.prompts.slice(0, turn);
|
||||
return { ok: true, transactionId: `mock-tx-${turn}`, undoAvailable: true, written: files ? ["main.ts", "style.css"] : [], deleted: files ? ["note.txt"] : [] };
|
||||
const scope = planId.split("-")[3];
|
||||
const offer = { transactionId: `mock-tx-${turn}-${crypto.randomUUID()}`, turn, files: files ? 3 : 0 };
|
||||
this.undone = { prompts: this.prompts.slice(), offer };
|
||||
this.emit({ kind: "rewind_changed" });
|
||||
if (scope !== "code") this.prompts = this.prompts.slice(0, turn);
|
||||
return { ok: true, transactionId: offer.transactionId, undoAvailable: true, conversationOk: scope !== "code",
|
||||
written: files ? ["main.ts", "style.css"] : [], deleted: files ? ["note.txt"] : [] };
|
||||
}
|
||||
|
||||
private clearUndo() {
|
||||
if (!this.undone) return;
|
||||
this.undone = null;
|
||||
this.emit({ kind: "rewind_changed" });
|
||||
}
|
||||
|
||||
async availableUndo(): Promise<RewindUndo | null> { return this.state.running ? null : this.undone?.offer ?? null; }
|
||||
|
||||
async undoRewind(_transactionId: string): Promise<void> {
|
||||
if (this.undone) this.prompts = this.undone;
|
||||
if (this.undone) this.prompts = this.undone.prompts;
|
||||
this.clearUndo();
|
||||
}
|
||||
|
||||
subscribe(onEvent: (ev: WireEvent) => void, _onGap?: () => void, bootstrap?: () => Promise<number>) {
|
||||
@@ -667,6 +681,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
this.emit({ kind: "steer", text });
|
||||
return;
|
||||
}
|
||||
this.clearUndo();
|
||||
this.prompts.push(text);
|
||||
// The first turn is what puts the session on disk. serve answers with a
|
||||
// truncated first message straight away and swaps in the generated title
|
||||
@@ -692,6 +707,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
}
|
||||
|
||||
async resume(_path: string) {
|
||||
this.clearUndo();
|
||||
this.log = [];
|
||||
this.at = 0;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export type { MemoryEdit } from "./memory";
|
||||
export type { Money, UsageDay, UsageModel, UsageProvider, UsageQuery, UsageReport } from "./usage";
|
||||
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments, RoleOverride } from "./model";
|
||||
import type { NetworkProbe, NetworkSettings } from "./network";
|
||||
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindScope, SessionEntry, SessionStatus, WalletLine, WalletReading, PlanAction } from "./session";
|
||||
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindUndo, RewindScope, SessionEntry, SessionStatus, WalletLine, WalletReading, PlanAction } from "./session";
|
||||
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
|
||||
import type { SkillCatalog, SkillEntry } from "./skill";
|
||||
import type { StudioCrash, StudioLogs, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
|
||||
@@ -33,7 +33,7 @@ export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, Approval
|
||||
HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource, JobEntry, McpCatalog, McpDraft,
|
||||
McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, MemoryCatalog,
|
||||
MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
|
||||
RewindResult, RewindScope, RoleAssignments, RoleOverride, ScopeLayer, SessionEntry, SessionStatus,
|
||||
RewindResult, RewindUndo, RewindScope, RoleAssignments, RoleOverride, ScopeLayer, SessionEntry, SessionStatus,
|
||||
ShellOption, ShellSettings, SkillCatalog, SkillEntry, UpdateProgress, VersionEntry,
|
||||
StudioCrash, StudioLogs, VersionHub, VersionNotes, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo };
|
||||
|
||||
@@ -462,6 +462,7 @@ export interface AgentPort {
|
||||
// Copies the conversation up to the end of a turn into a new session and
|
||||
// answers with its path; this session stays where it is.
|
||||
branchAt(turn: number, name: string): Promise<string>;
|
||||
availableUndo(): Promise<RewindUndo | null>;
|
||||
// Reverses a committed rewind. Only reachable while the caller still holds the
|
||||
// transaction id the commit returned.
|
||||
undoRewind(transactionId: string): Promise<void>;
|
||||
|
||||
@@ -102,6 +102,12 @@ export interface RewindFileStage {
|
||||
compensateError?: string;
|
||||
}
|
||||
|
||||
export interface RewindUndo {
|
||||
transactionId: string;
|
||||
turn: number;
|
||||
files: number;
|
||||
}
|
||||
|
||||
export interface RewindResult {
|
||||
ok?: boolean;
|
||||
transactionId?: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PLAN_ACTIONS, type PlanAction } from "./session";
|
||||
import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
|
||||
import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
|
||||
import { HttpError, type CommitProposal, type CommitRequest, type CommitResult, type WorkspaceFile, type WorkspaceFiles } from "./port";
|
||||
import { SseWorkspace } from "./sse_workspace";
|
||||
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
|
||||
@@ -511,6 +511,7 @@ export class SsePort extends SseWorkspace implements AgentPort {
|
||||
async branchAt(turn: number, name: string) {
|
||||
return (await this.post0<{ path: string }>("/branch/at", { turn, name })).path;
|
||||
}
|
||||
availableUndo() { return this.get<RewindUndo | null>("/rewind/undo"); }
|
||||
|
||||
undoRewind(transactionId: string) {
|
||||
return this.post("/rewind/undo", { transactionId });
|
||||
|
||||
@@ -32,6 +32,7 @@ export type Kind =
|
||||
| "workspace_changed"
|
||||
| "completion_summary"
|
||||
| "inbox_changed"
|
||||
| "rewind_changed"
|
||||
| "adjudications_changed"
|
||||
| "browser_tabs_changed"
|
||||
| "graph_delta"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const PREFIX = "rx-rewind-notice-dismissed:";
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function dismissed(transactionId: string | undefined): boolean {
|
||||
if (!transactionId) return false;
|
||||
try { return localStorage.getItem(PREFIX + transactionId) === "1"; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
function refresh(event: StorageEvent) {
|
||||
if (event.key === null || event.key.startsWith(PREFIX)) listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
function subscribe(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
if (listeners.size === 1) window.addEventListener("storage", refresh);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
if (listeners.size === 0) window.removeEventListener("storage", refresh);
|
||||
};
|
||||
}
|
||||
|
||||
export function dismissRewindNotice(transactionId: string): void {
|
||||
localStorage.setItem(PREFIX + transactionId, "1");
|
||||
listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
export function useRewindNoticeDismissed(transactionId: string | undefined): boolean {
|
||||
return useSyncExternalStore(subscribe, () => dismissed(transactionId), () => false);
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export const initialState: SessionState = {
|
||||
steerQueue: [], takenBack: [], subagentPhase: {},
|
||||
awaitingTurnStart: [],
|
||||
queueMoved: 0,
|
||||
rewindMoved: 0,
|
||||
browserTabsMoved: 0,
|
||||
panels: [],
|
||||
views: [],
|
||||
@@ -221,6 +222,7 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
|
||||
return left.length === s.entranceOwed.length ? s : { ...s, entranceOwed: left };
|
||||
}
|
||||
if (ev.kind === "__runtime_seen") return { ...s, runtime: s.runtime.filter((n) => n.id !== ev.id) };
|
||||
if (ev.kind === "rewind_changed") return { ...s, rewindMoved: s.rewindMoved + 1 };
|
||||
if (ev.kind === "inbox_changed") return { ...s, queueMoved: s.queueMoved + 1 };
|
||||
if (ev.kind === "browser_tabs_changed") return { ...s, browserTabsMoved: s.browserTabsMoved + 1 };
|
||||
// Both event.Message emitters carry assistant text, so nothing on the wire
|
||||
|
||||
@@ -243,6 +243,7 @@ export interface SessionState {
|
||||
// and this is what asks it again. Counting, not the kernel's own revision:
|
||||
// the point is that it changed, and a counter cannot arrive out of order.
|
||||
queueMoved: number;
|
||||
rewindMoved: number;
|
||||
// How many times the agent's browser tabs moved; the tabs are read back.
|
||||
browserTabsMoved: number;
|
||||
// Standing extension surfaces, keyed by plugin and surface id. They describe
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
.pbody.rewind-notice-host { position: relative; }
|
||||
.rewind-notice {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
flex: none;
|
||||
align-self: center;
|
||||
width: fit-content;
|
||||
max-width: min(420px, calc(100% - 24px));
|
||||
margin: 0;
|
||||
padding: 7px 36px 7px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--float);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-float);
|
||||
font: var(--w-med) var(--fs-ui)/1.45 var(--ui);
|
||||
}
|
||||
.rewind-notice > .studio-icon { flex: none; color: var(--muted); }
|
||||
.rewind-notice-copy { display: flex; flex: 0 1 auto; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.rewind-notice-error { color: var(--err-ink); overflow-wrap: anywhere; }
|
||||
.rewind-notice > .btn { flex: none; padding: 5px 9px; white-space: nowrap; }
|
||||
@container conversation (max-width: 460px) {
|
||||
.rewind-notice { max-width: calc(100% - 20px); flex-wrap: wrap; }
|
||||
}
|
||||
.rewind-notice-close {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.rewind-notice-close:hover:not(:disabled) { color: var(--text); }
|
||||
.rewind-notice-close:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||
.rewind-notice-close:disabled { opacity: .45; cursor: default; }
|
||||
|
||||
.rewind-notice-count { color: var(--muted); }
|
||||
@@ -14,7 +14,8 @@ import { useReplyActions } from "./reply";
|
||||
import { useGateActions } from "./gates";
|
||||
import { useQueueActions } from "./queueactions";
|
||||
import { useRewindActions } from "./rewind";
|
||||
import { ReloadNotice, RestoreNotice } from "./RestoreNotice";
|
||||
import { ReloadNotice } from "./RestoreNotice";
|
||||
import { RewindNotice } from "./RewindNotice";
|
||||
import { initialTraj, reduceTraj } from "../state/trajectory";
|
||||
import { Transcript } from "./Transcript";
|
||||
import { useBackgroundDeltas } from "./deltas";
|
||||
@@ -344,7 +345,8 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
sessionState: sessionRead.kind,
|
||||
sessionPath: status?.sessionPath,
|
||||
});
|
||||
const { reloadFailure, onReloadSession, restoreNotice, dismissRestore, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert } = useRewindActions(port, reloadSession, onRestoreText, JSON.stringify([rt.id, rt.root, status?.sessionPath ?? rt.sessionPath]), running);
|
||||
const { reloadFailure, onReloadSession, restoreNotice, onUndoAvailable, onPrepareRewind, onCommitRewind, onPrepareFileRevert, onCommitFileRevert } = useRewindActions(port, reloadSession, onRestoreText, JSON.stringify([rt.id, rt.root, status?.sessionPath ?? rt.sessionPath]), running);
|
||||
const onReadUndo = useCallback(() => port.availableUndo(), [port]);
|
||||
|
||||
const { onApprove, onFullAccess, onPlan, onForget, onExtInvoke, onExtSubmit, onAnswer } = useGateActions({
|
||||
port,
|
||||
@@ -412,7 +414,8 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
|
||||
<Find find={find} />
|
||||
|
||||
<div className="pbody" data-dock={tab === "flow" ? "" : undefined} data-full={tab === "browser" ? "" : undefined}>
|
||||
<div className="pbody rewind-notice-host" data-dock={tab === "flow" ? "" : undefined} data-full={tab === "browser" ? "" : undefined}>
|
||||
<RewindNotice key={JSON.stringify([rt.id, rt.root, status?.sessionPath ?? rt.sessionPath])} readUndo={onReadUndo} onUndo={onUndoAvailable} busy={!!restoreNotice?.working || !!reloadFailure?.working} refreshKey={checkpoints} moved={s.rewindMoved} running={running} shown={visible && tab === "flow" && !rt.readOnly} />
|
||||
<div className="pviews">
|
||||
|
||||
<ReceiptPrefContext.Provider value={receiptPref}>
|
||||
@@ -546,7 +549,6 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
onCancelJob={(id) => port.cancelJob(id).then(refreshStatus, fail)}
|
||||
/>
|
||||
{reloadFailure && <ReloadNotice failure={reloadFailure} onReload={onReloadSession} />}
|
||||
{restoreNotice && <RestoreNotice receipt={restoreNotice} refreshing={reloadFailure?.working} onUndo={onUndoRewind} onDismiss={dismissRestore} />}
|
||||
{/* Below the box, under a ceiling of their own. Both arrive unbidden and
|
||||
both are dismissed one at a time, so nothing else bounds how many can
|
||||
be on screen at once. */}
|
||||
|
||||
@@ -45,7 +45,7 @@ it("resets native paint with a resting click cue, themed focus and disabled stat
|
||||
expect(style(".restore-notice button:disabled").getPropertyValue("cursor")).toBe("default");
|
||||
});
|
||||
|
||||
it.each(["RestoreNotice", "ReloadNotice"])("reserves composer space for %s outside the runtime-notes ceiling", (component) => {
|
||||
it.each([{ component: "RewindNotice", parent: "pbody rewind-notice-host" }, { component: "ReloadNotice", parent: "compose" }])("places $component outside the runtime-notes ceiling", ({ component, parent: expectedParent }) => {
|
||||
const parents: string[] = [];
|
||||
walkStack(parse(pane, { sourceType: "module", plugins: ["typescript", "jsx"] }), (node, stack) => {
|
||||
if (node.type !== "JSXElement" || ((node.openingElement as Node).name as Node).name !== component) return;
|
||||
@@ -53,7 +53,7 @@ it.each(["RestoreNotice", "ReloadNotice"])("reserves composer space for %s outsi
|
||||
const attrs = (parent?.openingElement as Node).attributes as Node[];
|
||||
parents.push((attrs.find((a) => (a.name as Node)?.name === "className")?.value as Node)?.value as string);
|
||||
});
|
||||
expect(parents).toEqual(["compose"]);
|
||||
expect(parents).toEqual([expectedParent]);
|
||||
});
|
||||
|
||||
it("bounds only the error, with zoom-aware scrolling and a keyboard target", () => {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { RewindNotice } from "./RewindNotice";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { RewindUndo } from "../port/port";
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); localStorage.clear(); vi.useRealTimers(); });
|
||||
|
||||
const saved: RewindUndo = { transactionId: "tx-saved", turn: 2, files: 0 };
|
||||
function draw() {
|
||||
let available: RewindUndo | null = saved;
|
||||
const readUndo = vi.fn(async () => available);
|
||||
const onUndo = vi.fn(async (_id: string) => { available = null; });
|
||||
const props = { readUndo, onUndo, refreshKey: 0, running: false, shown: true };
|
||||
const view = render(<RewindNotice {...props} />);
|
||||
return { props, view, readUndo, onUndo, invalidate: () => { available = null; } };
|
||||
}
|
||||
|
||||
it("recovers a saved offer on a fresh mount without any message card", async () => {
|
||||
const { props, view } = draw();
|
||||
await screen.findByRole("button", { name: "撤销这次回退" });
|
||||
view.unmount();
|
||||
render(<RewindNotice {...props} />);
|
||||
await screen.findByRole("button", { name: "撤销这次回退" });
|
||||
expect(screen.getByRole("status").textContent).toContain("回退已完成");
|
||||
});
|
||||
|
||||
it("hides the notice when undo succeeds", async () => {
|
||||
const { onUndo } = draw();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "撤销这次回退" }));
|
||||
expect(onUndo).toHaveBeenCalledWith("tx-saved");
|
||||
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
|
||||
});
|
||||
|
||||
it("shows a failed undo without losing the backend offer", async () => {
|
||||
const { onUndo } = draw();
|
||||
onUndo.mockRejectedValueOnce(new Error("file changed"));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "撤销这次回退" }));
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("file changed");
|
||||
expect(screen.getByRole("button", { name: "撤销这次回退" }).hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("hides during a new turn and does not revive an invalidated offer afterwards", async () => {
|
||||
const { props, view, invalidate, readUndo } = draw();
|
||||
await screen.findByRole("button", { name: "撤销这次回退" });
|
||||
view.rerender(<RewindNotice {...props} running />);
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
invalidate();
|
||||
view.rerender(<RewindNotice {...props} running={false} />);
|
||||
await waitFor(() => expect(readUndo).toHaveBeenCalledTimes(2));
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not poll while a visible pane is idle", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { readUndo } = draw();
|
||||
await act(async () => {});
|
||||
await act(async () => { vi.advanceTimersByTime(30000); });
|
||||
expect(readUndo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reads once when an external undo-change event arrives", async () => {
|
||||
const { props, view, invalidate, readUndo } = draw();
|
||||
await screen.findByRole("button", { name: "撤销这次回退" });
|
||||
invalidate();
|
||||
view.rerender(<RewindNotice {...props} moved={1} />);
|
||||
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
|
||||
expect(readUndo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("ignores an old session's query after switching to another session", async () => {
|
||||
let finishOld!: (value: RewindUndo | null) => void;
|
||||
const old = new Promise<RewindUndo | null>((resolve) => { finishOld = resolve; });
|
||||
const next = { ...saved, transactionId: "tx-next" };
|
||||
const readUndo = vi.fn().mockReturnValueOnce(old).mockResolvedValue(next);
|
||||
const onUndo = vi.fn(async (_id: string) => { readUndo.mockResolvedValue(null); });
|
||||
const props = { readUndo, onUndo, refreshKey: 0, running: false, shown: true };
|
||||
const view = render(<RewindNotice key="old-session" {...props} />);
|
||||
view.rerender(<RewindNotice key="next-session" {...props} />);
|
||||
await screen.findByRole("button", { name: "撤销这次回退" });
|
||||
await act(async () => { finishOld(saved); });
|
||||
await userEvent.click(screen.getByRole("button", { name: "撤销这次回退" }));
|
||||
expect(onUndo).toHaveBeenCalledWith("tx-next");
|
||||
});
|
||||
|
||||
it("keeps a dismissed offer hidden after an event refresh and remount", async () => {
|
||||
const { props, view, readUndo, onUndo } = draw();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "关闭回退提示" }));
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
expect(onUndo).not.toHaveBeenCalled();
|
||||
view.rerender(<RewindNotice {...props} moved={1} />);
|
||||
await waitFor(() => expect(readUndo).toHaveBeenCalledTimes(2));
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
view.unmount();
|
||||
render(<RewindNotice {...props} />);
|
||||
await waitFor(() => expect(readUndo).toHaveBeenCalledTimes(3));
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a new transaction without reviving dismissed transactions", async () => {
|
||||
const { props, view, readUndo } = draw();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "关闭回退提示" }));
|
||||
const next = { ...saved, transactionId: "tx-new" };
|
||||
readUndo.mockResolvedValue(next);
|
||||
view.rerender(<RewindNotice {...props} moved={1} />);
|
||||
expect(await screen.findByRole("button", { name: "撤销这次回退" })).toBeTruthy();
|
||||
await userEvent.click(screen.getByRole("button", { name: "关闭回退提示" }));
|
||||
readUndo.mockResolvedValue(saved);
|
||||
view.rerender(<RewindNotice {...props} moved={2} />);
|
||||
await waitFor(() => expect(readUndo).toHaveBeenCalledTimes(3));
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
it("dismisses the same transaction in other mounted panes", async () => {
|
||||
const props = { readUndo: vi.fn(async () => saved), onUndo: vi.fn(), refreshKey: 0, running: false, shown: true };
|
||||
render(<><RewindNotice {...props} /><RewindNotice {...props} /></>);
|
||||
await waitFor(() => expect(screen.getAllByRole("button", { name: "关闭回退提示" })).toHaveLength(2));
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "关闭回退提示" })[0]!);
|
||||
expect(screen.queryAllByRole("status")).toHaveLength(0);
|
||||
expect(props.onUndo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a persistence failure rather than claiming the notice was dismissed", async () => {
|
||||
const { onUndo } = draw();
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("storage blocked"); });
|
||||
await userEvent.click(await screen.findByRole("button", { name: "关闭回退提示" }));
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("storage blocked");
|
||||
expect(screen.getByRole("button", { name: "撤销这次回退" })).toBeTruthy();
|
||||
expect(onUndo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gives repeated mock rewinds their own transaction identity", async () => {
|
||||
const port = new MockPort();
|
||||
const first = await port.commitRewind("mock-plan-0-code");
|
||||
const next = await port.commitRewind("mock-plan-0-code");
|
||||
expect(next.transactionId).not.toBe(first.transactionId);
|
||||
});
|
||||
|
||||
it("observes dismissal persisted by another browser window", async () => {
|
||||
draw();
|
||||
await screen.findByRole("button", { name: "关闭回退提示" });
|
||||
await act(async () => {
|
||||
const key = "rx-rewind-notice-dismissed:" + saved.transactionId;
|
||||
localStorage.setItem(key, "1");
|
||||
window.dispatchEvent(new StorageEvent("storage", { key }));
|
||||
});
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
it("disables actions while the session owner is completing a restore or refresh", async () => {
|
||||
const { props, view, onUndo } = draw();
|
||||
await screen.findByRole("button", { name: "撤销这次回退" });
|
||||
view.rerender(<RewindNotice {...props} busy />);
|
||||
expect(screen.getByRole("button", { name: "撤销这次回退" }).hasAttribute("disabled")).toBe(true);
|
||||
expect(screen.getByRole("button", { name: "关闭回退提示" }).hasAttribute("disabled")).toBe(true);
|
||||
expect(onUndo).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import type { RewindUndo } from "../port/port";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { dismissRewindNotice, useRewindNoticeDismissed } from "../state/rewind_notice";
|
||||
import "../styles/rewind-notice.css";
|
||||
|
||||
export function RewindNotice({ readUndo, onUndo, refreshKey, moved = 0, busy = false, running, shown }: {
|
||||
readUndo: () => Promise<RewindUndo | null>;
|
||||
onUndo: (transactionId: string) => Promise<void>;
|
||||
refreshKey: unknown;
|
||||
moved?: number;
|
||||
busy?: boolean;
|
||||
running: boolean;
|
||||
shown: boolean;
|
||||
}) {
|
||||
const [offer, setOffer] = useState<RewindUndo | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const reads = useRef(0);
|
||||
const dismissed = useRewindNoticeDismissed(offer?.transactionId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shown || running) {
|
||||
setOffer(null);
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
if (pending) return;
|
||||
let alive = true;
|
||||
const seq = ++reads.current;
|
||||
// The event signals a change; only the backend owns the current offer.
|
||||
readUndo().then((next) => {
|
||||
if (!alive || seq !== reads.current) return;
|
||||
setOffer((prev) => prev?.transactionId === next?.transactionId && prev?.turn === next?.turn && prev?.files === next?.files ? prev : next);
|
||||
if (!next) setError("");
|
||||
}).catch(() => {
|
||||
if (alive && seq === reads.current) setOffer(null);
|
||||
});
|
||||
return () => { alive = false; };
|
||||
}, [readUndo, refreshKey, moved, running, shown, pending]);
|
||||
|
||||
const undo = async () => {
|
||||
if (!offer || pending || busy) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
await onUndo(offer.transactionId);
|
||||
setOffer(null);
|
||||
} catch (e) {
|
||||
setError(reason(e));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dismiss = () => {
|
||||
if (!offer || pending || busy) return;
|
||||
try { dismissRewindNotice(offer.transactionId); }
|
||||
catch (e) { setError(t("无法保存关闭状态:{error}", { error: reason(e) })); }
|
||||
};
|
||||
|
||||
if (!shown || running || !offer || dismissed) return null;
|
||||
return (
|
||||
<div className="rewind-notice" role="status" aria-live="polite" aria-busy={pending || busy}>
|
||||
<button type="button" className="rewind-notice-close" data-action="rewind.notice-dismiss" data-target={offer.transactionId} aria-label={t("关闭回退提示")} title={t("关闭回退提示")} disabled={pending || busy} onClick={dismiss}>
|
||||
<StudioIcon name="close" />
|
||||
</button>
|
||||
<StudioIcon name="rewind" />
|
||||
<div className="rewind-notice-copy">
|
||||
<span>{t("回退已完成")}</span>
|
||||
<span className="rewind-notice-count">{t("已还原 {n} 个文件", { n: offer.files })}</span>
|
||||
{error && <span className="rewind-notice-error" role="alert">{error}</span>}
|
||||
</div>
|
||||
<button type="button" className="btn" data-action="rewind.undo" data-target={offer.transactionId} disabled={pending || busy} onClick={() => void undo()}>
|
||||
{t(pending ? "正在撤销…" : "撤销这次回退")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment jsdom
|
||||
import "./testkit";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { Pane } from "./Pane";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { HttpError, type HistoryMessage, type RewindScope, type RewindUndo } from "../port/port";
|
||||
import type { WireEvent } from "../port/wire";
|
||||
import type { RuntimeView } from "../port/hub";
|
||||
|
||||
afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
|
||||
|
||||
const PROMPT = "Create a sample file";
|
||||
const HISTORY: HistoryMessage[] = [
|
||||
{ role: "user", content: PROMPT, msgIndex: 0 },
|
||||
{ role: "assistant", content: "Created the file.", msgIndex: 1 },
|
||||
];
|
||||
const props = {
|
||||
rt: { id: "p1", root: "/sample", name: "Sample" } as RuntimeView,
|
||||
title: "Sample", active: true, visible: true, sideHost: null, side: false,
|
||||
onFocus() {}, onReport() {}, onSessionChanged() {}, pulse: 0, findPulse: 0,
|
||||
onSettings() {}, needsProject: false, onOpenProject() {}, onKeepHere() {},
|
||||
theme: "dark", dockW: 560, dockMax: 880, onDockW() {},
|
||||
};
|
||||
|
||||
function open(recovered = false) {
|
||||
const port = new MockPort();
|
||||
const listeners = new Set<(event: WireEvent) => void>();
|
||||
vi.spyOn(port, "subscribe").mockImplementation((listener) => {
|
||||
listeners.add(listener); return () => listeners.delete(listener);
|
||||
});
|
||||
let history = recovered ? [] : HISTORY;
|
||||
let offer: RewindUndo | null = recovered ? { transactionId: "tx-saved", turn: 1, files: 0 } : null;
|
||||
vi.spyOn(port, "history").mockImplementation(async () => history);
|
||||
vi.spyOn(port, "checkpoints").mockImplementation(async () => history.length ? [{
|
||||
turn: 1, prompt: PROMPT, files: 1, msgIndex: 0,
|
||||
}] : []);
|
||||
vi.spyOn(port, "prepareRewind").mockImplementation(async (turn, scope) => ({
|
||||
planId: scope, turn, coverage: "full", canFiles: true, canConversation: true,
|
||||
fileCount: 1, requiresConfirmation: false,
|
||||
}));
|
||||
vi.spyOn(port, "commitRewind").mockImplementation(async (scope) => {
|
||||
if (scope !== "code") history = [];
|
||||
offer = { transactionId: "tx-new", turn: 1, files: scope === "conversation" ? 0 : 1 };
|
||||
return { ok: true, conversationOk: scope !== "code", transactionId: offer.transactionId, undoAvailable: true };
|
||||
});
|
||||
vi.spyOn(port, "availableUndo").mockImplementation(async () => offer);
|
||||
vi.spyOn(port, "undoRewind").mockImplementation(async () => { history = HISTORY; offer = null; });
|
||||
return { port, mount: () => render(<Pane {...props} port={port} />), changeOffer(next: RewindUndo | null) {
|
||||
offer = next; listeners.forEach((listener) => listener({ kind: "rewind_changed" }));
|
||||
} };
|
||||
}
|
||||
|
||||
it.each([
|
||||
["code", "只还原代码"], ["conversation", "只回退对话"], ["both", "代码和对话"],
|
||||
] as [RewindScope, string][])("offers undo after a %s rewind with realistic history", async (scope, label) => {
|
||||
const { port, mount } = open();
|
||||
mount();
|
||||
fireEvent.click((await screen.findAllByRole("button", { name: "回到这里" })).at(-1)!);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: new RegExp(label) }));
|
||||
const undo = await screen.findByRole("button", { name: "撤销这次回退" }, { timeout: 5000 });
|
||||
expect(port.prepareRewind).toHaveBeenCalledWith(1, scope);
|
||||
if (scope === "code") expect(screen.getByText(PROMPT)).toBeTruthy();
|
||||
else await waitFor(() => expect(screen.queryByRole("button", { name: "回到这里" })).toBeNull());
|
||||
fireEvent.click(undo);
|
||||
await waitFor(() => expect(port.undoRewind).toHaveBeenCalledExactlyOnceWith("tx-new"));
|
||||
await screen.findByText(PROMPT, { selector: ".txt" });
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull());
|
||||
});
|
||||
|
||||
it("recovers a conversation undo on a fresh pane without the removed user row", async () => {
|
||||
const { port, mount } = open(true);
|
||||
mount();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "撤销这次回退" }));
|
||||
await waitFor(() => expect(port.undoRewind).toHaveBeenCalledExactlyOnceWith("tx-saved"));
|
||||
await screen.findByText(PROMPT, { selector: ".txt" });
|
||||
});
|
||||
|
||||
it("leaves the normal rewind menu usable when the host lacks the availability route", async () => {
|
||||
const { port, mount } = open();
|
||||
vi.mocked(port.availableUndo).mockRejectedValue(new HttpError(404, "not found"));
|
||||
mount();
|
||||
fireEvent.click((await screen.findAllByRole("button", { name: "回到这里" })).at(-1)!);
|
||||
expect(screen.getByRole("menuitem", { name: /只还原代码/ })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull();
|
||||
});
|
||||
|
||||
it("refreshes an idle pane from a content-free event without a local turn", async () => {
|
||||
const { mount, changeOffer } = open();
|
||||
mount();
|
||||
await screen.findAllByRole("button", { name: "回到这里" });
|
||||
changeOffer({ transactionId: "remote-tx", turn: 1, files: 0 });
|
||||
await screen.findByRole("button", { name: "撤销这次回退" });
|
||||
changeOffer(null);
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull());
|
||||
});
|
||||
|
||||
it("keeps a recovered offer dismissed when the entire pane is reopened", async () => {
|
||||
const { port, mount } = open(true);
|
||||
const view = mount();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "关闭回退提示" }));
|
||||
expect(port.undoRewind).not.toHaveBeenCalled();
|
||||
view.unmount();
|
||||
mount();
|
||||
await act(async () => {});
|
||||
expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a recovered undo failure and allows retry through the session owner", async () => {
|
||||
const { port, mount } = open(true);
|
||||
vi.mocked(port.undoRewind).mockRejectedValueOnce(new Error("files changed"));
|
||||
mount();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "撤销这次回退" }));
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("files changed");
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "撤销这次回退" }).hasAttribute("disabled")).toBe(false));
|
||||
fireEvent.click(screen.getByRole("button", { name: "撤销这次回退" }));
|
||||
await screen.findByText(PROMPT, { selector: ".txt" });
|
||||
expect(port.undoRewind).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-libra
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { Pane } from "./Pane";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { HistoryMessage, RewindResult } from "../port/port";
|
||||
import type { HistoryMessage, RewindResult, RewindUndo } from "../port/port";
|
||||
import type { WireEvent } from "../port/wire";
|
||||
import type { RuntimeView } from "../port/hub";
|
||||
|
||||
@@ -29,6 +29,9 @@ async function open(conversation = true, holdInitialStatus = false, runtimePath:
|
||||
if (display) await port.saveFoldWorkSettings(display.foldWork);
|
||||
let session = "session-a";
|
||||
let running = false;
|
||||
let offer: RewindUndo | null = null;
|
||||
let transaction = 0;
|
||||
vi.spyOn(port, "availableUndo").mockImplementation(async () => running ? null : offer);
|
||||
let emit: (event: WireEvent) => void = () => {};
|
||||
const subscribe = port.subscribe.bind(port);
|
||||
vi.spyOn(port, "subscribe").mockImplementation((onEvent, onGap, bootstrap) => { emit = onEvent; return subscribe(onEvent, onGap, bootstrap); });
|
||||
@@ -41,12 +44,14 @@ async function open(conversation = true, holdInitialStatus = false, runtimePath:
|
||||
vi.spyOn(port, "prepareRewind").mockResolvedValue({ planId: "plan-1", turn: 1, coverage: "full", canFiles: true, canConversation: true, fileCount: mutation ? 3 : 1, requiresConfirmation: false });
|
||||
vi.spyOn(port, "commitRewind").mockImplementation(async () => {
|
||||
if (conversation) history = [];
|
||||
return { ok: true, conversationOk: conversation, transactionId: "tx-1", undoAvailable: true, deleted: ["file.txt"], ...mutation };
|
||||
const result = { ok: true, conversationOk: conversation, transactionId: `tx-${++transaction}`, undoAvailable: true, deleted: ["file.txt"], ...mutation };
|
||||
offer = { transactionId: result.transactionId, turn: 1, files: (result.written?.length ?? 0) + (result.deleted?.length ?? 0) };
|
||||
return result;
|
||||
});
|
||||
const undo = vi.spyOn(port, "undoRewind").mockImplementation(async () => { history = messages; });
|
||||
const undo = vi.spyOn(port, "undoRewind").mockImplementation(async () => { history = messages; offer = null; });
|
||||
const view = render(<Pane {...props} rt={{ ...props.rt, sessionPath: runtimePath }} port={port} />);
|
||||
await screen.findByRole("button", { name: "回到这里" });
|
||||
return { port, reads, undo, view, resolveStatus, start: () => { running = true; emit({ kind: "turn_started" } as WireEvent); }, setSession: (next: string) => { session = next; } };
|
||||
return { port, reads, undo, view, resolveStatus, start: () => { running = true; emit({ kind: "turn_started" } as WireEvent); }, setSession: (next: string) => { session = next; offer = null; } };
|
||||
}
|
||||
async function restore(scope = "代码和对话") {
|
||||
fireEvent.click(screen.getByRole("button", { name: "回到这里" }));
|
||||
@@ -59,7 +64,7 @@ it("preserves canonical attachments through conversation restore and undo", asyn
|
||||
const src = port.workspaceImageURL(".reasonix/attachments/restore-picture.png");
|
||||
expect(image().getAttribute("src")).toBe(src);
|
||||
await restore();
|
||||
const button = await screen.findByRole<HTMLButtonElement>("button", { name: "撤销这次还原" });
|
||||
const button = await screen.findByRole<HTMLButtonElement>("button", { name: "撤销这次回退" });
|
||||
await waitFor(() => expect(button.disabled).toBe(false));
|
||||
expect(screen.queryByRole("img", { name: "restore-picture.png" })).toBeNull();
|
||||
fireEvent.click(button);
|
||||
@@ -88,13 +93,13 @@ it.each([false, true])("preserves speaker grouping and work folding through rest
|
||||
};
|
||||
await waitFor(assertTranscript);
|
||||
await restore();
|
||||
const button = await screen.findByRole<HTMLButtonElement>("button", { name: "撤销这次还原" });
|
||||
const button = await screen.findByRole<HTMLButtonElement>("button", { name: "撤销这次回退" });
|
||||
await waitFor(() => expect(button.disabled).toBe(false));
|
||||
expect(document.querySelectorAll('[data-k="say"]')).toHaveLength(0);
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(undo).toHaveBeenCalledExactlyOnceWith("tx-1"));
|
||||
await waitFor(assertTranscript);
|
||||
expect(screen.queryByRole("button", { name: "撤销这次还原" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull();
|
||||
});
|
||||
|
||||
it.each([true, false])("keeps undo through the real Pane reload with conversation removal %j", async (conversation) => {
|
||||
@@ -104,41 +109,41 @@ it.each([true, false])("keeps undo through the real Pane reload with conversatio
|
||||
await waitFor(() => expect(reads.mock.calls.length).toBeGreaterThan(before));
|
||||
await waitFor(() => expect(!!screen.queryByRole("button", { name: "回到这里" })).toBe(!conversation));
|
||||
expect(screen.getByText("已还原 1 个文件")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "撤销这次还原" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "撤销这次回退" }));
|
||||
await waitFor(() => expect(undo).toHaveBeenCalledExactlyOnceWith("tx-1"));
|
||||
await screen.findByRole("button", { name: "回到这里" });
|
||||
expect(screen.queryByRole("button", { name: "撤销这次还原" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps undo after unrelated rerender, supports a refused undo retry and repeated restore", async () => {
|
||||
const h = await open();
|
||||
await restore();
|
||||
await screen.findByRole("button", { name: "撤销这次还原" });
|
||||
await screen.findByRole("button", { name: "撤销这次回退" });
|
||||
h.view.rerender(<Pane {...props} port={h.port} title="Renamed conversation" />);
|
||||
h.undo.mockRejectedValueOnce(new Error("Tracked file changed"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "撤销这次还原" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "撤销这次回退" }));
|
||||
await screen.findByRole("alert", { name: "" });
|
||||
expect(screen.getByText("Tracked file changed")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "撤销这次还原" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "撤销这次回退" }));
|
||||
await screen.findByRole("button", { name: "回到这里" });
|
||||
await restore();
|
||||
await screen.findByRole("button", { name: "撤销这次还原" });
|
||||
await screen.findByRole("button", { name: "撤销这次回退" });
|
||||
expect(h.port.commitRewind).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("allows explicit dismissal without invoking undo", async () => {
|
||||
const h = await open(); await restore();
|
||||
await screen.findByRole("button", { name: "撤销这次还原" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "知道了" }));
|
||||
await screen.findByRole("button", { name: "撤销这次回退" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "关闭回退提示" }));
|
||||
expect(screen.queryByText("已还原 1 个文件")).toBeNull(); expect(h.undo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the receipt when host status changes even while runtime metadata is stale", async () => {
|
||||
const h = await open(); await restore();
|
||||
await screen.findByRole("button", { name: "撤销这次还原" });
|
||||
await screen.findByRole("button", { name: "撤销这次回退" });
|
||||
h.setSession("session-b");
|
||||
h.view.rerender(<Pane {...props} port={h.port} pulse={1} />);
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: "撤销这次还原" })).toBeNull());
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull());
|
||||
expect(h.undo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -151,7 +156,7 @@ it("does not publish a pending old-session restore or append its prompt to the n
|
||||
await screen.findByRole("button", { name: "回到这里" });
|
||||
const before = h.reads.mock.calls.length;
|
||||
await act(async () => resolve({ ok: true, conversationOk: true, transactionId: "tx-1", undoAvailable: true, deleted: ["file.txt"] }));
|
||||
expect(screen.queryByRole("button", { name: "撤销这次还原" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull();
|
||||
expect(h.reads.mock.calls).toHaveLength(before);
|
||||
expect((screen.getByRole("combobox", { name: "任务输入" }) as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
@@ -159,40 +164,42 @@ it("does not publish a pending old-session restore or append its prompt to the n
|
||||
it("does not mistake initial status hydration for leaving the known conversation", async () => {
|
||||
const h = await open(true, true);
|
||||
await restore();
|
||||
await screen.findByRole("button", { name: "撤销这次还原" });
|
||||
await screen.findByRole("button", { name: "撤销这次回退" });
|
||||
await act(async () => h.resolveStatus!());
|
||||
expect(screen.getByRole("button", { name: "撤销这次还原" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "撤销这次回退" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disables repeated undo clicks while the operation is in flight", async () => {
|
||||
const h = await open(); await restore();
|
||||
const button = await screen.findByRole("button", { name: "撤销这次还原" });
|
||||
const button = await screen.findByRole("button", { name: "撤销这次回退" });
|
||||
let resolve!: () => void;
|
||||
h.undo.mockReturnValue(new Promise((done) => { resolve = done; }));
|
||||
const applyUndo = h.undo.getMockImplementation()!;
|
||||
const pending = new Promise<void>((done) => { resolve = done; });
|
||||
h.undo.mockImplementation(async (tx) => { await pending; await applyUndo(tx); });
|
||||
fireEvent.click(button); fireEvent.click(button);
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(h.undo).toHaveBeenCalledExactlyOnceWith("tx-1");
|
||||
await act(async () => resolve());
|
||||
expect(screen.queryByRole("button", { name: "撤销这次还原" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a receipt when runtime metadata catches up to the known host session", async () => {
|
||||
const h = await open(true, false, "");
|
||||
await restore();
|
||||
await screen.findByRole("button", { name: "撤销这次还原" });
|
||||
await screen.findByRole("button", { name: "撤销这次回退" });
|
||||
h.view.rerender(<Pane {...props} port={h.port} />);
|
||||
expect(screen.getByRole("button", { name: "撤销这次还原" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "撤销这次回退" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("retries history after a successful undo without applying the transaction twice", async () => {
|
||||
const h = await open(); await restore();
|
||||
const undo = await screen.findByRole("button", { name: "撤销这次还原" });
|
||||
const undo = await screen.findByRole("button", { name: "撤销这次回退" });
|
||||
await waitFor(() => expect((undo as HTMLButtonElement).disabled).toBe(false));
|
||||
h.reads.mockRejectedValueOnce(new Error("History unavailable"));
|
||||
fireEvent.click(undo);
|
||||
const retry = await screen.findByRole("button", { name: "重试刷新会话" });
|
||||
expect(screen.getByRole("alert").textContent).toBe("History unavailable");
|
||||
expect(screen.queryByRole("button", { name: "撤销这次还原" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull();
|
||||
fireEvent.click(retry);
|
||||
await screen.findByRole("button", { name: "回到这里" });
|
||||
expect(screen.queryByRole("button", { name: "重试刷新会话" })).toBeNull();
|
||||
@@ -202,7 +209,7 @@ it("retries history after a successful undo without applying the transaction twi
|
||||
|
||||
it.each(["commit", "undo"])("reloads the actual Pane after %s completes behind a new-run event", async (operation) => {
|
||||
const h = await open();
|
||||
if (operation === "undo") { await restore(); await screen.findByRole("button", { name: "撤销这次还原" }); }
|
||||
if (operation === "undo") { await restore(); await screen.findByRole("button", { name: "撤销这次回退" }); }
|
||||
let release!: () => void;
|
||||
const response = new Promise<void>((resolve) => { release = resolve; });
|
||||
const commit = vi.mocked(h.port.commitRewind);
|
||||
@@ -210,14 +217,14 @@ it.each(["commit", "undo"])("reloads the actual Pane after %s completes behind a
|
||||
if (operation === "commit") commit.mockImplementation(async (plan) => { const result = await mutation(plan); await response; return result as RewindResult; });
|
||||
else h.undo.mockImplementation(async (tx) => { await mutation(tx); await response; });
|
||||
if (operation === "commit") await restore();
|
||||
else fireEvent.click(screen.getByRole("button", { name: "撤销这次还原" }));
|
||||
else fireEvent.click(screen.getByRole("button", { name: "撤销这次回退" }));
|
||||
await act(async () => h.start());
|
||||
const before = h.reads.mock.calls.length;
|
||||
await act(async () => release());
|
||||
await waitFor(() => expect(h.reads.mock.calls.length).toBeGreaterThan(before));
|
||||
const userCards = h.view.container.querySelectorAll('[data-k="me"] .txt');
|
||||
expect([...userCards].map((card) => card.textContent)).toEqual(operation === "undo" ? ["Restore this turn"] : []);
|
||||
expect(screen.queryByRole("button", { name: "撤销这次还原" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull();
|
||||
expect((screen.getByRole("combobox", { name: "任务输入" }) as HTMLTextAreaElement).value).toBe(operation === "undo" ? "Restore this turn" : "");
|
||||
});
|
||||
|
||||
@@ -226,10 +233,10 @@ it("does not replay a pending rewind history read into a switched conversation",
|
||||
let resolve!: (history: HistoryMessage[]) => void;
|
||||
h.reads.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
|
||||
await restore();
|
||||
await screen.findByRole("button", { name: "撤销这次还原" });
|
||||
await screen.findByRole("button", { name: "撤销这次回退" });
|
||||
h.setSession("session-b");
|
||||
h.view.rerender(<Pane {...props} port={h.port} pulse={1} />);
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: "撤销这次还原" })).toBeNull());
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: "撤销这次回退" })).toBeNull());
|
||||
await act(async () => resolve([{ role: "user", content: "Stale prior-session text", msgIndex: 0 }]));
|
||||
expect(screen.queryByText("Stale prior-session text")).toBeNull();
|
||||
});
|
||||
@@ -247,7 +254,7 @@ it.each([
|
||||
expect(screen.getAllByText("3 个文件")).toHaveLength(2);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: new RegExp(conversation ? "只回退对话" : "只还原代码") }));
|
||||
await screen.findByText(`已还原 ${count} 个文件`);
|
||||
const undo = screen.getByRole<HTMLButtonElement>("button", { name: "撤销这次还原" });
|
||||
const undo = screen.getByRole<HTMLButtonElement>("button", { name: "撤销这次回退" });
|
||||
await waitFor(() => expect(undo.disabled).toBe(false));
|
||||
fireEvent.click(undo);
|
||||
await waitFor(() => expect(h.undo).toHaveBeenCalledExactlyOnceWith("tx-1"));
|
||||
|
||||
@@ -102,6 +102,14 @@ export function useRewindActions(port: AgentPort, reloadSession: (current?: () =
|
||||
void operation.finally(() => { if (owner.undo === operation) owner.undo = null; });
|
||||
return operation;
|
||||
}, [port, owner, publish, onReloadSession]);
|
||||
const onUndoAvailable = useCallback(async (transactionId: string) => {
|
||||
if (!owner.active) throw new Error(t("会话已切换,请重新选择还原位置"));
|
||||
if (owner.commit || owner.reload || (owner.undo && owner.notice?.tx !== transactionId)) throw new Error(t("正在还原…"));
|
||||
// Backend offers survive reloads that retired the local restore receipt.
|
||||
if (owner.notice?.tx !== transactionId) publish({ tx: transactionId, files: 0, working: false, error: "" });
|
||||
await onUndoRewind(transactionId);
|
||||
if (owner.notice?.tx === transactionId && owner.notice.error) throw new Error(owner.notice.error);
|
||||
}, [owner, publish, onUndoRewind]);
|
||||
const dismissRestore = useCallback(() => { if (!owner.notice?.working) publish(null); }, [owner, publish]);
|
||||
const onPrepareFileRevert = useCallback((path: string) => port.prepareFileRevert(path), [port]);
|
||||
const onCommitFileRevert = useCallback(async (planId: string, resolution?: string) => {
|
||||
@@ -110,5 +118,5 @@ export function useRewindActions(port: AgentPort, reloadSession: (current?: () =
|
||||
if (owner.active && owner.epoch === epoch && result.transactionId) publish(null);
|
||||
return result;
|
||||
}, [port, owner, publish]);
|
||||
return { reloadFailure: reloadFailure?.owner === owner ? reloadFailure : null, onReloadSession, restoreNotice: notice?.owner === owner ? notice.value : null, dismissRestore, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert };
|
||||
return { onUndoAvailable, reloadFailure: reloadFailure?.owner === owner ? reloadFailure : null, onReloadSession, restoreNotice: notice?.owner === owner ? notice.value : null, dismissRestore, onPrepareRewind, onCommitRewind, onUndoRewind, onPrepareFileRevert, onCommitFileRevert };
|
||||
}
|
||||
@@ -155,6 +155,8 @@ const (
|
||||
// ProgressWatchEvent reports whether the run is still producing effects the
|
||||
// host can observe (ProgressWatch). Said to the person, never to the model.
|
||||
ProgressWatchEvent
|
||||
// RewindChanged signals that the undo offer must be read again.
|
||||
RewindChanged
|
||||
// KindCount is a sentinel one past the last real Kind. New event kinds must
|
||||
// be inserted above it so completeness tests cover them automatically.
|
||||
KindCount
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package eventwire
|
||||
|
||||
import "reasonix/internal/contract/event"
|
||||
|
||||
// KindNames returns every stable frontend event kind in event.Kind order. It is
|
||||
// the protocol-neutral source used by consumers such as the Remote schema
|
||||
// generator; callers receive a copy and may sort it without mutating eventwire.
|
||||
func KindNames() []string {
|
||||
names := make([]string, 0, int(event.KindCount))
|
||||
for kind := range event.KindCount {
|
||||
if name, ok := kindNames[kind]; ok {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// KindName returns the stable wire name of one event kind, or false for a
|
||||
// kind outside the known set.
|
||||
func KindName(kind event.Kind) (string, bool) {
|
||||
name, ok := kindNames[kind]
|
||||
return name, ok
|
||||
}
|
||||
|
||||
var kindNames = map[event.Kind]string{
|
||||
event.TurnStarted: "turn_started",
|
||||
event.Reasoning: "reasoning",
|
||||
event.Text: "text",
|
||||
event.Message: "message",
|
||||
event.ToolDispatch: "tool_dispatch",
|
||||
event.ToolResult: "tool_result",
|
||||
event.Usage: "usage",
|
||||
event.Notice: "notice",
|
||||
event.Phase: "phase",
|
||||
event.ApprovalRequest: "approval_request",
|
||||
event.AskRequest: "ask_request",
|
||||
event.TurnDone: "turn_done",
|
||||
event.CompactionStarted: "compaction_started",
|
||||
event.CompactionProgress: "compaction_progress",
|
||||
event.CompactionDone: "compaction_done",
|
||||
event.ToolProgress: "tool_progress",
|
||||
event.MCPSurfaceReady: "mcp_surface_ready",
|
||||
event.Retrying: "retrying",
|
||||
event.Steer: "steer",
|
||||
event.GuardianAssessment: "guardian_assessment",
|
||||
event.ExtensionSurface: "extension_surface",
|
||||
event.ExtensionStatus: "extension_status",
|
||||
event.StreamAttempt: "stream_attempt",
|
||||
event.ContextMaintenanceEvent: "context_maintenance",
|
||||
event.TodoProgressEvent: "todo_progress",
|
||||
event.WorkspaceLeaseEvent: "workspace_lease",
|
||||
event.WorkspaceChanged: "workspace_changed",
|
||||
event.TurnPhase: "turn_phase",
|
||||
event.CompletionSummary: "completion_summary",
|
||||
event.AdjudicationsChanged: "adjudications_changed",
|
||||
event.BrowserTabsChanged: "browser_tabs_changed",
|
||||
event.InboxChanged: "inbox_changed",
|
||||
event.RewindChanged: "rewind_changed",
|
||||
event.GraphDelta: "graph_delta",
|
||||
event.ProgressWatchEvent: "progress_watch",
|
||||
}
|
||||
@@ -701,63 +701,6 @@ func ToWireCacheDiagnostics(d *event.CacheDiagnostics) *CacheDiagnostics {
|
||||
}
|
||||
}
|
||||
|
||||
// KindNames returns every stable frontend event kind in event.Kind order. It is
|
||||
// the protocol-neutral source used by consumers such as the Remote schema
|
||||
// generator; callers receive a copy and may sort it without mutating eventwire.
|
||||
func KindNames() []string {
|
||||
names := make([]string, 0, int(event.KindCount))
|
||||
for kind := range event.KindCount {
|
||||
if name, ok := kindNames[kind]; ok {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// KindName returns the stable wire name of one event kind, or false for a
|
||||
// kind outside the known set.
|
||||
func KindName(kind event.Kind) (string, bool) {
|
||||
name, ok := kindNames[kind]
|
||||
return name, ok
|
||||
}
|
||||
|
||||
var kindNames = map[event.Kind]string{
|
||||
event.TurnStarted: "turn_started",
|
||||
event.Reasoning: "reasoning",
|
||||
event.Text: "text",
|
||||
event.Message: "message",
|
||||
event.ToolDispatch: "tool_dispatch",
|
||||
event.ToolResult: "tool_result",
|
||||
event.Usage: "usage",
|
||||
event.Notice: "notice",
|
||||
event.Phase: "phase",
|
||||
event.ApprovalRequest: "approval_request",
|
||||
event.AskRequest: "ask_request",
|
||||
event.TurnDone: "turn_done",
|
||||
event.CompactionStarted: "compaction_started",
|
||||
event.CompactionProgress: "compaction_progress",
|
||||
event.CompactionDone: "compaction_done",
|
||||
event.ToolProgress: "tool_progress",
|
||||
event.MCPSurfaceReady: "mcp_surface_ready",
|
||||
event.Retrying: "retrying",
|
||||
event.Steer: "steer",
|
||||
event.GuardianAssessment: "guardian_assessment",
|
||||
event.ExtensionSurface: "extension_surface",
|
||||
event.ExtensionStatus: "extension_status",
|
||||
event.StreamAttempt: "stream_attempt",
|
||||
event.ContextMaintenanceEvent: "context_maintenance",
|
||||
event.TodoProgressEvent: "todo_progress",
|
||||
event.WorkspaceLeaseEvent: "workspace_lease",
|
||||
event.WorkspaceChanged: "workspace_changed",
|
||||
event.TurnPhase: "turn_phase",
|
||||
event.CompletionSummary: "completion_summary",
|
||||
event.AdjudicationsChanged: "adjudications_changed",
|
||||
event.BrowserTabsChanged: "browser_tabs_changed",
|
||||
event.InboxChanged: "inbox_changed",
|
||||
event.GraphDelta: "graph_delta",
|
||||
event.ProgressWatchEvent: "progress_watch",
|
||||
}
|
||||
|
||||
func toWireTodoProgress(p *event.TodoProgress) *TodoProgress {
|
||||
if p == nil {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package serve
|
||||
|
||||
import "net/http"
|
||||
|
||||
// rewindAvailableUndo reads the session's currently retained undo offer.
|
||||
func (s *Server) rewindAvailableUndo(w http.ResponseWriter, r *http.Request) {
|
||||
offer, err := s.ctl().AvailableUndo()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, offer)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reasonix/internal/session/control"
|
||||
"reasonix/internal/state/checkpoint"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type undoReadPort struct {
|
||||
control.SessionAPI
|
||||
offer *checkpoint.RewindUndo
|
||||
}
|
||||
|
||||
func (p *undoReadPort) AvailableUndo() (*checkpoint.RewindUndo, error) { return p.offer, nil }
|
||||
|
||||
func TestRewindAvailableUndoRoute(t *testing.T) {
|
||||
for _, offer := range []*checkpoint.RewindUndo{nil, {TransactionID: "saved", Turn: 2, Files: 3}} {
|
||||
s := &Server{ctrl: &undoReadPort{offer: offer}}
|
||||
w := httptest.NewRecorder()
|
||||
s.routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/rewind/undo", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status: %d, %s", w.Code, w.Body.String())
|
||||
}
|
||||
var got *checkpoint.RewindUndo
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if offer == nil {
|
||||
if got != nil {
|
||||
t.Fatalf("empty offer: %+v", got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got == nil || *got != *offer {
|
||||
t.Fatalf("offer: %+v, want %+v", got, offer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ func (s *Server) routes() http.Handler {
|
||||
mux.HandleFunc("POST /rewind/prepare", s.rewindPrepare)
|
||||
mux.HandleFunc("POST /rewind/commit", s.rewindCommit)
|
||||
mux.HandleFunc("POST /rewind/undo", s.rewindUndo)
|
||||
mux.HandleFunc("GET /rewind/undo", s.rewindAvailableUndo)
|
||||
mux.HandleFunc("POST /rewind/file/prepare", s.fileRevertPrepare)
|
||||
mux.HandleFunc("POST /rewind/file/commit", s.fileRevertCommit)
|
||||
mux.HandleFunc("POST /summarize", s.summarize)
|
||||
|
||||
@@ -296,6 +296,9 @@ func ckptDir(sessionPath string) string {
|
||||
func (c *Controller) rebindCheckpoints(sessionPath string) {
|
||||
c.goals.setStatePath(goalStatePath(sessionPath))
|
||||
c.checkpoints.rebind(ckptDir(sessionPath), c.workspaceRoot)
|
||||
c.checkpoints.storeRef().OnUndoChange(func() {
|
||||
c.sink.Emit(event.Event{Kind: event.RewindChanged})
|
||||
})
|
||||
if c.executor != nil {
|
||||
c.wireMutationObserver()
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ type SessionHistory interface {
|
||||
Rewind(turn int, scope RewindScope) error
|
||||
PrepareRewind(turn int, scope RewindScope) (checkpoint.RewindPlan, error)
|
||||
CommitRewind(planID string) (checkpoint.RewindResult, error)
|
||||
AvailableUndo() (*checkpoint.RewindUndo, error)
|
||||
UndoRewind(transactionID string) (checkpoint.RewindResult, error)
|
||||
PrepareFileRevert(path string) (checkpoint.RewindPlan, error)
|
||||
CommitFileRevert(planID string, resolution checkpoint.ConflictResolution) (checkpoint.RewindResult, error)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package control
|
||||
|
||||
import "reasonix/internal/state/checkpoint"
|
||||
|
||||
// AvailableUndo is a read: it neither takes the rotation gate nor scans files.
|
||||
func (c *Controller) AvailableUndo() (*checkpoint.RewindUndo, error) {
|
||||
if c.Running() {
|
||||
return nil, nil
|
||||
}
|
||||
return c.checkpoints.storeRef().AvailableUndo()
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/runtime/agent"
|
||||
"reasonix/internal/state/checkpoint"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAvailableUndoAndChangeEvents(t *testing.T) {
|
||||
root, dir := testenv.TempDir(t), testenv.TempDir(t)
|
||||
path := filepath.Join(root, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("before"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sess := sessionstore.NewSession("sys")
|
||||
ag := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
||||
changes := make(chan event.Event, 8)
|
||||
c := New(Options{Runner: ag, Executor: ag, SessionDir: dir,
|
||||
SessionPath: filepath.Join(dir, "test.jsonl"), WorkspaceRoot: root,
|
||||
Sink: event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.RewindChanged {
|
||||
changes <- e
|
||||
}
|
||||
}),
|
||||
})
|
||||
t.Cleanup(c.Close)
|
||||
c.beginCheckpoint(context.Background(), "edit")
|
||||
c.mutationObserver.BeforeMutation("note.txt", "write_file", checkpoint.CaptureBeforeMutation)
|
||||
if err := os.WriteFile(path, []byte("after"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.mutationObserver.AfterMutation("note.txt", "write_file")
|
||||
plan, err := c.PrepareRewind(0, RewindCode)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := c.CommitRewind(plan.PlanID)
|
||||
if err != nil || !result.OK {
|
||||
t.Fatalf("commit: %+v, %v", result, err)
|
||||
}
|
||||
select {
|
||||
case e := <-changes:
|
||||
if e.Text != "" {
|
||||
t.Fatalf("event has content: %+v", e)
|
||||
}
|
||||
default:
|
||||
t.Fatal("commit did not emit change")
|
||||
}
|
||||
if err := c.beginRotation(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
offer, err := c.AvailableUndo()
|
||||
c.endRotation()
|
||||
if err != nil || offer == nil || offer.TransactionID != result.TransactionID {
|
||||
t.Fatalf("rotation read: %+v, %v", offer, err)
|
||||
}
|
||||
b := c.checkpoints.storeRef().Barrier()
|
||||
if err := b.EnterWrite(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
offer, err = c.AvailableUndo()
|
||||
b.ExitWrite()
|
||||
if err != nil || offer != nil {
|
||||
t.Fatalf("writer read: %+v, %v", offer, err)
|
||||
}
|
||||
c.beginCheckpoint(context.Background(), "next turn")
|
||||
select {
|
||||
case <-changes:
|
||||
default:
|
||||
t.Fatal("new turn did not emit invalidation")
|
||||
}
|
||||
offer, err = c.AvailableUndo()
|
||||
if err != nil || offer != nil {
|
||||
t.Fatalf("invalidated read: %+v, %v", offer, err)
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,16 @@ func (b *MutationBarrier) ExitExclusive() {
|
||||
b.cond.Broadcast()
|
||||
}
|
||||
|
||||
// HasWriters reads writer activity without taking exclusive mutation access.
|
||||
func (b *MutationBarrier) HasWriters() bool {
|
||||
if b == nil {
|
||||
return false
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.writers > 0
|
||||
}
|
||||
|
||||
// Busy reports whether exclusive is held or writers are active.
|
||||
func (b *MutationBarrier) Busy() bool {
|
||||
if b == nil {
|
||||
|
||||
@@ -140,6 +140,7 @@ type Store struct {
|
||||
activeWriters []ActiveWriter
|
||||
plans map[string]preparedPlan
|
||||
lastUndo *TransactionManifest
|
||||
undoChanged func()
|
||||
sessionID string
|
||||
mutationSeq int64
|
||||
retainN int
|
||||
@@ -224,27 +225,14 @@ func (s *Store) activeWriterConflicts() []RewindConflict {
|
||||
return conflicts
|
||||
}
|
||||
|
||||
// LastUndoTransactionID returns the committed transaction id available for undo.
|
||||
func (s *Store) LastUndoTransactionID() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.lastUndo == nil || s.lastUndo.State != TxCommitted {
|
||||
return ""
|
||||
}
|
||||
return s.lastUndo.ID
|
||||
}
|
||||
|
||||
// InvalidateUndo clears the last undo slot (new turn / new mutation / new rewind).
|
||||
func (s *Store) InvalidateUndo() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.unlockUndoChange(s.lastUndo)
|
||||
s.lastUndo = nil
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Store) load() {
|
||||
@@ -309,7 +297,7 @@ func (s *Store) load() {
|
||||
// prompt labels it in the picker; msgIndex is the conversation-rewind boundary.
|
||||
func (s *Store) Begin(turn int, prompt string, msgIndex int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
defer s.unlockUndoChange(s.lastUndo)
|
||||
if s.cur != nil {
|
||||
s.recomputeCoverageLocked(s.cur)
|
||||
s.done = append(s.done, s.cur)
|
||||
@@ -501,7 +489,7 @@ func (s *Store) CaptureAfter(path string, opts CaptureAfterOpts) {
|
||||
_ = err
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
defer s.unlockUndoChange(s.lastUndo)
|
||||
s.mutationSeq = opts.Seq
|
||||
if s.cur != nil {
|
||||
s.cur.LastMutationSeq = opts.Seq
|
||||
|
||||
@@ -95,16 +95,19 @@ func (s *Store) finishOwnedUndo(undo, original *TransactionManifest, applier Con
|
||||
s.cleanupCommittedBackups(undo.Targets)
|
||||
|
||||
// Mark original as undone; clear lastUndo.
|
||||
s.mu.Lock()
|
||||
original.State = TxUndone
|
||||
original.UpdatedAt = time.Now()
|
||||
s.mu.Unlock()
|
||||
if err := s.persistTransaction(original); err != nil {
|
||||
// The committed undo manifest durably names its parent, so startup will
|
||||
// suppress the stale parent even if this secondary write failed.
|
||||
slog.Warn("checkpoint: persist original transaction as undone", "err", err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
previous := s.lastUndo
|
||||
s.lastUndo = nil
|
||||
s.mu.Unlock()
|
||||
s.unlockUndoChange(previous)
|
||||
|
||||
result.OK = true
|
||||
result.UndoAvailable = false
|
||||
@@ -151,8 +154,9 @@ func (s *Store) finishOwnedRewind(tx *TransactionManifest, applier ConversationA
|
||||
}
|
||||
s.cleanupCommittedBackups(tx.Targets)
|
||||
s.mu.Lock()
|
||||
previous := s.lastUndo
|
||||
s.lastUndo = tx
|
||||
s.mu.Unlock()
|
||||
s.unlockUndoChange(previous)
|
||||
|
||||
result.OK = true
|
||||
result.UndoAvailable = true
|
||||
|
||||
@@ -1197,13 +1197,7 @@ func (s *Store) recoverTransactions(applier ConversationApplier) []string {
|
||||
if tx.Kind == "undo" || undoneParents[tx.ID] {
|
||||
continue
|
||||
}
|
||||
// Keep as last undo if newer.
|
||||
s.mu.Lock()
|
||||
if s.lastUndo == nil || s.lastUndo.UpdatedAt.Before(tx.UpdatedAt) {
|
||||
cp := tx
|
||||
s.lastUndo = &cp
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.restoreUndoOffer(tx)
|
||||
}
|
||||
}
|
||||
return notes
|
||||
|
||||
@@ -191,6 +191,13 @@ type RewindPlan struct {
|
||||
ConflictResolution string `json:"conflictResolution,omitempty"`
|
||||
}
|
||||
|
||||
// RewindUndo describes the currently retained undo offer.
|
||||
type RewindUndo struct {
|
||||
TransactionID string `json:"transactionId"`
|
||||
Turn int `json:"turn"`
|
||||
Files int `json:"files"`
|
||||
}
|
||||
|
||||
// RewindResult is returned after commit or undo.
|
||||
type RewindResult struct {
|
||||
OK bool `json:"ok"`
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package checkpoint
|
||||
|
||||
// OnUndoChange installs the owner's content-free notification callback.
|
||||
func (s *Store) OnUndoChange(notify func()) {
|
||||
s.mu.Lock()
|
||||
s.undoChanged = notify
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// AvailableUndo reads the saved offer without disk I/O or file verification.
|
||||
func (s *Store) AvailableUndo() (*RewindUndo, error) {
|
||||
if s == nil || s.barrier.HasWriters() {
|
||||
return nil, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
tx := s.lastUndo
|
||||
if tx == nil || tx.State != TxCommitted {
|
||||
return nil, nil
|
||||
}
|
||||
return &RewindUndo{TransactionID: tx.ID, Turn: tx.Turn, Files: len(tx.Targets)}, nil
|
||||
}
|
||||
|
||||
// Callbacks may read the offer, so release the lock before notifying.
|
||||
func (s *Store) unlockUndoChange(previous *TransactionManifest) {
|
||||
before, after := "", ""
|
||||
if previous != nil {
|
||||
before = previous.ID
|
||||
}
|
||||
if s.lastUndo != nil {
|
||||
after = s.lastUndo.ID
|
||||
}
|
||||
notify := s.undoChanged
|
||||
s.mu.Unlock()
|
||||
if before != after && notify != nil {
|
||||
notify()
|
||||
}
|
||||
}
|
||||
|
||||
// LastUndoTransactionID returns the committed transaction id available for undo.
|
||||
func (s *Store) LastUndoTransactionID() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.lastUndo == nil || s.lastUndo.State != TxCommitted {
|
||||
return ""
|
||||
}
|
||||
return s.lastUndo.ID
|
||||
}
|
||||
|
||||
func (s *Store) restoreUndoOffer(tx TransactionManifest) {
|
||||
s.mu.Lock()
|
||||
defer s.unlockUndoChange(s.lastUndo)
|
||||
if s.lastUndo == nil || s.lastUndo.UpdatedAt.Before(tx.UpdatedAt) {
|
||||
s.lastUndo = &tx
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package checkpoint
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUndoOfferChangeLifecycle(t *testing.T) {
|
||||
for _, action := range []string{"undo", "begin", "mutation", "invalidate"} {
|
||||
t.Run(action, func(t *testing.T) {
|
||||
s, _, _, path := twoOwnedTurns(t)
|
||||
var offers []*RewindUndo
|
||||
s.OnUndoChange(func() {
|
||||
offer, err := s.AvailableUndo()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
offers = append(offers, offer)
|
||||
})
|
||||
result := commitOwnedRewind(t, s, 1)
|
||||
if len(offers) != 1 || offers[0] == nil || offers[0].TransactionID != result.TransactionID {
|
||||
t.Fatalf("commit offers: %+v", offers)
|
||||
}
|
||||
switch action {
|
||||
case "undo":
|
||||
if _, err := s.UndoRewind(result.TransactionID, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "begin":
|
||||
s.Begin(2, "next turn", 2)
|
||||
case "mutation":
|
||||
write(t, path, "external writer")
|
||||
s.CaptureAfter(path, CaptureAfterOpts{Seq: 3})
|
||||
case "invalidate":
|
||||
s.InvalidateUndo()
|
||||
}
|
||||
s.InvalidateUndo()
|
||||
if len(offers) != 2 || offers[1] != nil {
|
||||
t.Fatalf("invalidation offers: %+v", offers)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedRewindDoesNotSignalUndoChange(t *testing.T) {
|
||||
s, _, _, _ := twoOwnedTurns(t)
|
||||
commitOwnedRewind(t, s, 1)
|
||||
changes := 0
|
||||
s.OnUndoChange(func() { changes++ })
|
||||
plan := prepareOwnedRewind(t, s, 0)
|
||||
if _, err := s.CommitRewindWithForward(plan.PlanID, nil, nil, &InjectFail{Phase: "finalize"}); err == nil {
|
||||
t.Fatal("expected injected failure")
|
||||
}
|
||||
if changes != 0 {
|
||||
t.Fatalf("failed rewind sent %d changes", changes)
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,7 @@ var mirroredWireTypes = []wireMirror{
|
||||
{"internal/frontend/serve/studio_logs.go", "studioLogs", tsVersionFile, "StudioLogs"},
|
||||
{"internal/frontend/serve/studio_logs.go", "studioCrashes", tsVersionFile, "StudioCrash"},
|
||||
{"internal/state/checkpoint/types.go", "RewindResult", tsSessionFile, "RewindResult"},
|
||||
{"internal/state/checkpoint/types.go", "RewindUndo", tsSessionFile, "RewindUndo"},
|
||||
{"internal/contract/eventwire/wire.go", "ShellExecution", tsWireFile, "Execution"},
|
||||
{"internal/contract/eventwire/workspace_lease.go", "WorkspaceLease", tsWireFile, "WorkspaceLease"},
|
||||
// A source and a model as the pickers name them. A label the page cannot
|
||||
|
||||
Reference in new issue
Block a user