Skip to content

Commit ba77cc3

Browse files
committed
fix(studio-server): an agent turn cut by a claim commits its part before the claim
1 parent db69665 commit ba77cc3

4 files changed

Lines changed: 145 additions & 21 deletions

File tree

‎packages/studio-server/src/history/projectHistory.test.ts‎

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ describe("claim: a writer that records after writing", () => {
364364
expect(read("index.html")).toBe("A");
365365
});
366366

367-
it("an agent's turn that ends after Studio's edit to the same file does not jam Cmd+Z", async () => {
367+
it("an agent's turn cut by Studio's edit becomes an entry before it and one after, so Cmd+Z walks back in order", async () => {
368368
const { history, write, read } = await project({ "index.html": "A" });
369369
const window = await history.beginWindow(agent, "Agent turn");
370370
write("index.html", "B");
@@ -373,15 +373,87 @@ describe("claim: a writer that records after writing", () => {
373373
await history.claim(you, "Moved Title", ["index.html"], {
374374
overwrote: { "index.html": fileContentVersion("B") },
375375
});
376-
await window.close();
377-
expect(history.list().map((entry) => entry.label)).toEqual(["Moved Title", "Agent turn"]);
378-
expect(history.next("back")?.label, "the button names the step Cmd+Z takes").toBe(
376+
write("index.html", "D");
377+
expect(await window.close(), "the window still returns the entry it became").toMatchObject({
378+
id: window.id,
379+
});
380+
expect(history.list().map((entry) => entry.label)).toEqual([
381+
"Agent turn",
379382
"Moved Title",
380-
);
381-
expect(await history.step("back", you)).toMatchObject({ ok: true });
382-
expect(read("index.html")).toBe("B");
383-
expect(await history.step("back", you)).toMatchObject({ ok: true });
384-
expect(read("index.html")).toBe("A");
383+
"Agent turn",
384+
]);
385+
for (const expected of ["C", "B", "A"]) {
386+
expect(await history.step("back", you)).toMatchObject({ ok: true });
387+
expect(read("index.html")).toBe(expected);
388+
}
389+
});
390+
391+
it("a cut agent turn with nothing after the cut ends as the entry it became at the cut", async () => {
392+
const { history, write, read } = await project({ "index.html": "A", "b.js": "1" });
393+
const window = await history.beginWindow(agent, "Agent turn");
394+
write("index.html", "B");
395+
await history.claim(you, "sweep", []);
396+
write("index.html", "C");
397+
write("b.js", "2");
398+
await history.claim(you, "Moved Title", ["index.html", "b.js"], {
399+
overwrote: { "index.html": fileContentVersion("B"), "b.js": fileContentVersion("1") },
400+
});
401+
write("b.js", "3");
402+
await history.claim(you, "Recolored", ["b.js"], {
403+
overwrote: { "b.js": fileContentVersion("2") },
404+
});
405+
expect(await window.close()).toMatchObject({ label: "Agent turn" });
406+
for (const [index, b] of [
407+
["C", "2"],
408+
["B", "1"],
409+
["A", "1"],
410+
]) {
411+
expect(await history.step("back", you)).toMatchObject({ ok: true });
412+
expect([read("index.html"), read("b.js")]).toEqual([index, b]);
413+
}
414+
});
415+
416+
it("Cmd+Z undoes an agent's later write first when a held drag claim commits after the agent's turn", async () => {
417+
const { history, write, read } = await project({ "index.html": "A" });
418+
const window = await history.beginWindow(agent, "Agent turn");
419+
write("index.html", "B");
420+
await history.claim(you, "sweep", []);
421+
write("index.html", "C");
422+
await history.claim(you, "Dragged Title", ["index.html"], {
423+
coalesceKey: "drag",
424+
idleMs: 60_000,
425+
overwrote: { "index.html": fileContentVersion("B") },
426+
});
427+
write("index.html", "D");
428+
await window.close();
429+
await history.flush();
430+
expect(history.list().map((entry) => entry.label)).toEqual([
431+
"Agent turn",
432+
"Agent turn",
433+
"Dragged Title",
434+
]);
435+
expect(history.next("back")?.label, "the button names the step Cmd+Z takes").toBe("Agent turn");
436+
for (const expected of ["C", "B", "A"]) {
437+
expect(await history.step("back", you)).toMatchObject({ ok: true });
438+
expect(read("index.html")).toBe(expected);
439+
}
440+
});
441+
442+
it("logs an entry with only its own fields, not its window's timer", async () => {
443+
const { history, write } = await project({ "index.html": "A" });
444+
const window = await history.beginWindow(agent, "Agent turn");
445+
write("index.html", "B");
446+
await window.close();
447+
expect(Object.keys(history.list()[0]!).sort()).toEqual([
448+
"endedAt",
449+
"files",
450+
"id",
451+
"label",
452+
"pinned",
453+
"startedAt",
454+
"undone",
455+
"who",
456+
]);
385457
});
386458

387459
it("takes Studio's write out of an agent's open window, and leaves the agent's own writes there", async () => {

‎packages/studio-server/src/history/projectHistory.ts‎

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -335,14 +335,15 @@ class Engine {
335335
{ coalesceKey, idleMs, overwrote = {} }: ClaimOptions,
336336
): Promise<{ id: string } | null> {
337337
await this.sweep();
338-
const taken = this.takeClaimed(who, paths, overwrote);
338+
const { taken, split } = this.takeClaimed(who, paths, overwrote);
339339
if (!taken.length) {
340340
// A claim under another key still ends the held one.
341341
if (coalesceKey !== this.claimed?.key) await this.commitClaim();
342342
return null;
343343
}
344-
// What happened outside before this write is older than it, so it is logged first.
344+
// What happened outside, or in a window cut by this claim, before this write is older than it: logged first.
345345
await this.commitOutside();
346+
for (const window of split) await this.commitSoFar(window);
346347
const group = await this.claimGroup(who, label, coalesceKey);
347348
for (const change of taken) addChange(group, change.path, change.before, change.after);
348349
if (coalesceKey) return this.holdClaim(group, coalesceKey, idleMs);
@@ -364,13 +365,13 @@ class Engine {
364365

365366
/**
366367
* Removes and returns the uncommitted changes to `paths` (project-relative or absolute) filed outside or in another
367-
* writer's open window, each cut at the version `who` overwrote.
368+
* writer's open window, each cut at the version `who` overwrote; `split` names the windows that keep a part.
368369
*/
369370
takeClaimed(
370371
who: HistoryWho,
371372
paths: readonly string[],
372373
overwrote: Readonly<Record<string, string>>,
373-
): HistoryFileChange[] {
374+
): { taken: HistoryFileChange[]; split: Group[] } {
374375
const wanted = new Set(paths.map((path) => this.logPath(path)));
375376
const at = new Map(
376377
Object.entries(overwrote).map(([path, version]) => [
@@ -381,17 +382,34 @@ class Engine {
381382
const others = this.windows.filter((open) => !sameWho(open.who, who));
382383
const groups = this.outside ? [this.outside, ...others] : others;
383384
const taken: HistoryFileChange[] = [];
385+
const split = new Set<Group>();
384386
for (const group of groups) {
385387
for (const change of [...group.changes.values()]) {
386388
if (!wanted.has(change.path)) continue;
387-
const cut = at.get(change.path);
388-
const [kept, claimed] = splitAt(change, cut, cut !== undefined && this.blobs.has(cut));
389-
group.changes.delete(change.path);
390-
if (kept) group.changes.set(kept.path, kept);
389+
const [kept, claimed] = this.cutOut(group, change, at.get(change.path));
391390
if (claimed) taken.push(claimed);
391+
if (kept && claimed && group !== this.outside) split.add(group);
392392
}
393393
}
394-
return taken;
394+
return { taken, split: [...split] };
395+
}
396+
397+
/** Cuts `change` in `group` at `cut`: the part before stays in the group; both parts are returned. */
398+
cutOut(group: Group, change: HistoryFileChange, cut: string | undefined) {
399+
const [kept, claimed] = splitAt(change, cut, cut !== undefined && this.blobs.has(cut));
400+
group.changes.delete(change.path);
401+
if (kept) group.changes.set(kept.path, kept);
402+
return [kept, claimed] as const;
403+
}
404+
405+
/**
406+
* Commits a window's writes so far as an entry of its own and keeps the window open. A window holds one change per
407+
* file, so without this the writer's next write to a file cut by a claim would span the claimer's edit.
408+
*/
409+
async commitSoFar(window: Group): Promise<void> {
410+
window.entry = await this.commit({ ...window, id: randomUUID() });
411+
window.changes = new Map();
412+
window.startedAt = this.now();
395413
}
396414

397415
holdClaim(
@@ -444,9 +462,9 @@ class Engine {
444462

445463
async commit(group: Group, extra: Partial<HistoryEntry> = {}): Promise<HistoryEntry | null> {
446464
if (!group.changes.size) return null;
447-
const { changes, ...rest } = group;
465+
const { id, who, label, startedAt, changes } = group;
448466
const files = [...changes.values()].sort((a, b) => a.path.localeCompare(b.path));
449-
const entry: HistoryEntry = { ...rest, endedAt: this.now(), files, ...extra };
467+
const entry: HistoryEntry = { id, who, label, startedAt, endedAt: this.now(), files, ...extra };
450468
this.log.entries.push(entry);
451469
try {
452470
saveRecord(this.logFile, this.log, { type: "entry", entry });
@@ -530,7 +548,8 @@ class Engine {
530548
if (!this.windows.includes(window)) return window.entry ?? null;
531549
clearTimeout(window.idleTimer);
532550
this.windows = this.windows.filter((open) => open !== window);
533-
window.entry = await this.commit(window);
551+
// A window cut by a claim may have nothing after the cut: it became the entry committed then.
552+
window.entry = (await this.commit(window)) ?? window.entry ?? null;
534553
return window.entry;
535554
}
536555

‎packages/studio-server/src/routes/history.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,26 @@ describe("history routes", () => {
8080
});
8181
});
8282

83+
it("name the step the engine takes when it steps past an entry whose file moved on", async () => {
84+
const { projectDir, history, call } = await demoProject();
85+
const write = (text: string) => writeFileSync(join(projectDir, "index.html"), text);
86+
const agent = { kind: "agent" as const, name: "Agent" };
87+
const you = { kind: "person" as const, name: "You" };
88+
const window = await history.beginWindow(agent, "Agent turn");
89+
write("B");
90+
await history.claim(you, "sweep", []);
91+
write("C");
92+
await history.claim(you, "Dragged Title", ["index.html"], {
93+
coalesceKey: "drag",
94+
idleMs: 60_000,
95+
overwrote: { "index.html": fileContentVersion("B") },
96+
});
97+
write("D");
98+
await window.close();
99+
await history.flush();
100+
expect((await (await call("")).json()).back).toMatchObject({ label: "Agent turn" });
101+
});
102+
83103
it("label an undo's writes with Studio's write token, so their echo reads as Studio's own", async () => {
84104
const { projectDir, call } = await demoProject();
85105
writeFileSync(join(projectDir, "index.html"), "B");

‎packages/studio/src/hooks/usePersistentEditHistory.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,16 @@ it("a step whose reply cannot be read says so, instead of throwing", async () =>
235235
message: "The history's reply was unreadable.",
236236
});
237237
});
238+
239+
it("a step that cannot reach the server says so", async () => {
240+
const { hook, readFile } = await studio();
241+
const real = globalThis.fetch;
242+
vi.stubGlobal("fetch", (url: string, init?: RequestInit) =>
243+
url.endsWith("/history/step") ? Promise.reject(new TypeError("fetch failed")) : real(url, init),
244+
);
245+
expect(await act(() => hook().undo({ readFile }))).toEqual({
246+
ok: false,
247+
reason: "failed",
248+
message: "Studio could not reach its server.",
249+
});
250+
});

0 commit comments

Comments
 (0)