@cueloop/daemon 0.1.0-alpha.64 → 0.1.0-alpha.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.64",
3
+ "version": "0.1.0-alpha.66",
4
4
  "description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -28,13 +28,15 @@
28
28
  "./retention": "./src/retention.ts",
29
29
  "./herdr-pane": "./src/herdr-pane.ts",
30
30
  "./herdr-split": "./src/herdr-split.ts",
31
- "./share-blob": "./src/share-blob.ts"
31
+ "./share-blob": "./src/share-blob.ts",
32
+ "./curate": "./src/curate.ts"
32
33
  },
33
34
  "publishConfig": {
34
35
  "access": "public"
35
36
  },
36
37
  "dependencies": {
37
- "@cueloop/schema": "0.1.0-alpha.64",
38
+ "@cueloop/schema": "0.1.0-alpha.66",
39
+ "@pierre/diffs": "^1.3.6",
38
40
  "valibot": "^1.4.2"
39
41
  },
40
42
  "engines": {
package/src/api.test.ts CHANGED
@@ -4,7 +4,8 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { DaemonCore, verdictResponse } from "./api";
6
6
  import { SessionStore } from "./store";
7
- import type { Artifact, WorkspaceKey } from "@cueloop/schema";
7
+ import { derivePath, tipOf, type Artifact, type WorkspaceKey } from "@cueloop/schema";
8
+ import { MAX_BLOB_BYTES, packSessionBlob, unpackSessionBlob } from "./share-blob";
8
9
 
9
10
  const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
10
11
  const PLAN: Artifact = {
@@ -226,6 +227,30 @@ describe("revision marks addressed annotations", () => {
226
227
  expect(second!.resolution).toBeUndefined();
227
228
  });
228
229
 
230
+ test("a reported root comment addresses its replies as well", () => {
231
+ // Arrange: a discussion of a root and one reply, plus an unrelated comment
232
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
233
+
234
+ annotate(session.id, "root", "carefully");
235
+ core.sessionAnnotate(session.id, {
236
+ id: "reply",
237
+ kind: "comment",
238
+ anchor: { quote: "carefully", prefix: "", suffix: "" },
239
+ body: "agreed",
240
+ replyTo: "root",
241
+ });
242
+ annotate(session.id, "other", "Context");
243
+
244
+ // Act: the agent reports only the root
245
+ const revised = core.sessionSubmitRevision(session.id, PLAN.content + "\nMore.\n", ["root"]);
246
+ const byId = new Map(revised.annotations.map((annotation) => [annotation.id, annotation]));
247
+
248
+ // Assert
249
+ expect(byId.get("root")!.resolution).toEqual({ revision: 2, source: "agent" });
250
+ expect(byId.get("reply")!.resolution).toEqual({ revision: 2, source: "agent" });
251
+ expect(byId.get("other")!.resolution).toBeUndefined();
252
+ });
253
+
229
254
  test("a plan annotation whose quoted text vanished is drift-addressed; a surviving quote stays open", () => {
230
255
  // Arrange
231
256
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
@@ -400,3 +425,456 @@ describe("events", () => {
400
425
  ]);
401
426
  });
402
427
  });
428
+
429
+ describe("the session history records every write as an entry", () => {
430
+ const annotate = (sessionId: string, id: string, quote: string, body = "note") =>
431
+ core.sessionAnnotate(sessionId, {
432
+ id,
433
+ kind: "comment",
434
+ anchor: { quote, prefix: "", suffix: "" },
435
+ body,
436
+ });
437
+
438
+ test("create, comment, remove, resolve, and revise append in order on main", () => {
439
+ // Arrange
440
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
441
+
442
+ // Assert: a fresh session is one root revision
443
+ expect(created.history?.entries.map((entry) => entry.type)).toEqual(["revision"]);
444
+ expect(created.history?.tips.main).toBe(created.history?.entries[0]!.id);
445
+
446
+ // Act
447
+ annotate(created.id, "a1", "carefully");
448
+ annotate(created.id, "a1", "carefully", "edited body");
449
+ annotate(created.id, "a2", "Context");
450
+ core.sessionRemoveAnnotation(created.id, "a2");
451
+ core.sessionRemoveAnnotation(created.id, "never-existed");
452
+ core.sessionResolve(created.id, "request_changes", "tighten");
453
+ const revised = core.sessionSubmitRevision(created.id, PLAN.content + "\nMore.\n", ["a1"]);
454
+
455
+ // Assert: an edit of an existing comment and a removal of an unknown id leave no entry
456
+ const history = revised.history!;
457
+
458
+ expect(history.entries.map((entry) => entry.type)).toEqual([
459
+ "revision",
460
+ "comment",
461
+ "comment",
462
+ "comment-removed",
463
+ "verdict",
464
+ "revision",
465
+ ]);
466
+ expect(history.branch).toBe("main");
467
+ expect(history.tips.main).toBe(history.entries.at(-1)!.id);
468
+ // every entry chains on the one before
469
+ history.entries.forEach((entry, index) => {
470
+ expect(entry.parentId).toBe(index === 0 ? null : history.entries[index - 1]!.id);
471
+ });
472
+ });
473
+
474
+ test("the head of the current path is the artifact, and the open comments are the path's", () => {
475
+ // Arrange
476
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
477
+
478
+ annotate(created.id, "a1", "carefully");
479
+ annotate(created.id, "a2", "Context");
480
+ core.sessionRemoveAnnotation(created.id, "a1");
481
+ const revised = core.sessionSubmitRevision(created.id, "# Plan v2\n");
482
+
483
+ // Act
484
+ const derived = derivePath(revised.history!);
485
+
486
+ // Assert
487
+ expect(derived.head.content).toBe(revised.artifact.content);
488
+ expect(derived.annotationIds).toEqual(["a2"]);
489
+ expect(derived.rounds).toBe(2);
490
+ });
491
+
492
+ test("a shared session's incoming comments become entries, and the blob round-trips with its history", () => {
493
+ // Arrange
494
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
495
+
496
+ // Act
497
+ const merged = core.sessionMergeShared(created.id, {
498
+ annotations: [
499
+ {
500
+ id: "ana_1",
501
+ kind: "comment",
502
+ anchor: { quote: "Context", prefix: "", suffix: "" },
503
+ body: "from ana",
504
+ author: "SHA256:ana",
505
+ createdAt: "2026-09-01T10:00:00.000Z",
506
+ },
507
+ ],
508
+ });
509
+ const roundTripped = unpackSessionBlob(packSessionBlob(merged));
510
+
511
+ // Assert
512
+ expect(merged.history?.entries.at(-1)).toMatchObject({
513
+ type: "comment",
514
+ annotationId: "ana_1",
515
+ });
516
+ expect(roundTripped.history).toEqual(merged.history);
517
+ expect(packSessionBlob(merged).byteLength).toBeLessThan(MAX_BLOB_BYTES);
518
+ });
519
+
520
+ test("a session written before histories existed gets one on the next boot", () => {
521
+ // Arrange: a record with no history on disk, as an earlier daemon left it
522
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
523
+ const legacy = { ...created };
524
+
525
+ delete legacy.history;
526
+ new SessionStore(home).upsert(legacy);
527
+
528
+ // Act
529
+ const rebooted = new DaemonCore(home);
530
+ const recovered = rebooted.sessionGet(created.id);
531
+
532
+ // Assert
533
+ expect(recovered.history?.entries.map((entry) => entry.type)).toEqual(["revision"]);
534
+ expect(derivePath(recovered.history!).head.content).toBe(PLAN.content);
535
+ });
536
+ });
537
+
538
+ describe("curation primitives", () => {
539
+ test("cutting a block records a reviewer revision; restoring it returns the copy to pristine", () => {
540
+ // Arrange: block 2 of the plan is the paragraph
541
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
542
+
543
+ // Act
544
+ const cut = core.sessionCutBlock(created.id, 2);
545
+
546
+ // Assert: the working copy lost the paragraph and the history gained the edit
547
+ expect(cut.workingCopy).not.toContain("Do the thing carefully.");
548
+ expect(cut.history!.entries.at(-1)).toMatchObject({ type: "revision", by: "reviewer" });
549
+ expect(derivePath(cut.history!).head.content).toBe(cut.workingCopy!);
550
+
551
+ // Act: put it back where it was (block 2 of the submitted revision, before the end)
552
+ const restored = core.sessionRestoreBlock(created.id, 2);
553
+
554
+ // Assert: pristine again, and that edit is on record too
555
+ expect(restored.workingCopy).toBeUndefined();
556
+ expect(derivePath(restored.history!).head.content).toBe(PLAN.content);
557
+ expect(restored.history!.entries.filter((entry) => entry.type === "revision")).toHaveLength(3);
558
+ });
559
+
560
+ test("a block index outside the text is refused", () => {
561
+ // Arrange
562
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
563
+
564
+ // Assert
565
+ expect(() => core.sessionCutBlock(created.id, 99)).toThrow(/no block 99/);
566
+ expect(() => core.sessionRestoreBlock(created.id, 99)).toThrow(/no block 99/);
567
+ });
568
+
569
+ test("restoring a block that is present is refused rather than duplicated", () => {
570
+ // Arrange: nothing cut, then block 2 cut once
571
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
572
+
573
+ // Assert: a pristine copy has every block
574
+ expect(() => core.sessionRestoreBlock(created.id, 2)).toThrow(/present in the working copy/);
575
+
576
+ // Act
577
+ core.sessionCutBlock(created.id, 2);
578
+ const restored = core.sessionRestoreBlock(created.id, 2);
579
+
580
+ // Assert: one restore puts it back; a second would duplicate it
581
+ expect(restored.workingCopy).toBeUndefined();
582
+ expect(() => core.sessionRestoreBlock(created.id, 2)).toThrow(/present in the working copy/);
583
+ });
584
+
585
+ test("curating a diff stores the decisions and the patch they leave; no decisions clear both", () => {
586
+ // Arrange: one file with two separated changes
587
+ const oldContents = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
588
+ const newContents = "a\nB\nc\nd\ne\nf\ng\nh\ni\nJ\n";
589
+ const diff: Artifact = {
590
+ type: "diff",
591
+ content: "",
592
+ meta: {},
593
+ files: [{ path: "src/x.txt", oldContents, newContents, status: "modified" }],
594
+ };
595
+ const created = core.sessionCreate({ workspace: WS, artifact: diff });
596
+
597
+ // Act: reject the first hunk
598
+ const curated = core.sessionCurate(created.id, [{ path: "src/x.txt", hunkIndex: 0 }]);
599
+
600
+ // Assert: only the second change survives in the working copy
601
+ expect(curated.curation).toEqual([{ path: "src/x.txt", hunkIndex: 0 }]);
602
+ expect(curated.workingCopy).toContain("+J");
603
+ expect(curated.workingCopy).not.toContain("+B");
604
+ expect(curated.history!.entries.at(-1)).toMatchObject({ type: "revision", by: "reviewer" });
605
+
606
+ // Act
607
+ const cleared = core.sessionCurate(created.id, []);
608
+
609
+ // Assert
610
+ expect(cleared.curation).toBeUndefined();
611
+ expect(cleared.workingCopy).toBeUndefined();
612
+ });
613
+
614
+ test("a plan cannot be curated by hunk", () => {
615
+ // Arrange
616
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
617
+
618
+ // Assert
619
+ expect(() => core.sessionCurate(created.id, [])).toThrow(/only a diff review/);
620
+ });
621
+ });
622
+
623
+ describe("tree primitives", () => {
624
+ const comment = (id: string, body: string) => ({
625
+ id,
626
+ kind: "comment" as const,
627
+ anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
628
+ body,
629
+ });
630
+
631
+ test("navigating main back hides later comments and edits, and the agent's next revision lands there", () => {
632
+ // Arrange: comment, reviewer edit, second comment
633
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
634
+
635
+ core.sessionAnnotate(created.id, comment("a1", "first"));
636
+ const checkpoint = derivePath(core.sessionGet(created.id).history!);
637
+ const checkpointEntry = tipOf(core.sessionGet(created.id).history!);
638
+
639
+ core.sessionLabel(created.id, "after a1");
640
+ core.sessionSetWorkingCopy(created.id, "# Plan\n\n## Context\n\nDo it.\n");
641
+ core.sessionAnnotate(created.id, comment("a2", "second"));
642
+
643
+ // Act
644
+ const moved = core.sessionNavigate(created.id, checkpointEntry, "dropped the edit");
645
+
646
+ // Assert: the view is the checkpoint's, the abandoned segment is on record
647
+ expect(moved.workingCopy).toBeUndefined();
648
+ expect(moved.annotations.map((annotation) => annotation.id)).toEqual(["a1"]);
649
+ expect(moved.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["a2"]);
650
+ expect(moved.history!.labels[checkpointEntry]).toBe("after a1");
651
+ expect(derivePath(moved.history!).summaries[0]!.abandoned).toHaveLength(2);
652
+ expect(checkpoint.annotationIds).toEqual(["a1"]);
653
+
654
+ // Act: the agent resubmits
655
+ const revised = core.sessionSubmitRevision(created.id, "# Plan\n\nRevised.\n");
656
+
657
+ // Assert: the revision chains after the summary on main
658
+ const path = derivePath(revised.history!);
659
+
660
+ expect(path.head.content).toBe("# Plan\n\nRevised.\n");
661
+ expect(revised.history!.entries.at(-1)!.parentId).toBe(
662
+ derivePath(moved.history!).summaries[0]!.id,
663
+ );
664
+ });
665
+
666
+ test("the feedback document renders from the tip's path", () => {
667
+ // Arrange: two comments, then main moved back between them
668
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
669
+
670
+ core.sessionAnnotate(created.id, comment("a1", "keep this one"));
671
+ const between = tipOf(core.sessionGet(created.id).history!);
672
+
673
+ core.sessionAnnotate(created.id, comment("a2", "not this one"));
674
+ core.sessionNavigate(created.id, between);
675
+
676
+ // Act
677
+ const resolved = core.sessionResolve(created.id, "request_changes", "one note");
678
+
679
+ // Assert
680
+ expect(resolved.verdict!.feedback).toContain("keep this one");
681
+ expect(resolved.verdict!.feedback).not.toContain("not this one");
682
+ });
683
+
684
+ test("a navigate names the branch to stand on, so a move on another branch is one request", () => {
685
+ // Arrange: alt has a comment; the owner stands on main
686
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
687
+ const revision = tipOf(created.history!);
688
+
689
+ core.sessionBranch(created.id, "alt");
690
+ core.sessionAnnotate(created.id, comment("a1", "on alt"));
691
+ core.sessionSwitch(created.id, "main");
692
+
693
+ // Act
694
+ const moved = core.sessionNavigate(created.id, revision, undefined, "alt");
695
+
696
+ // Assert: on alt, back at the revision, the comment shelved
697
+ expect(moved.history!.branch).toBe("alt");
698
+ expect(tipOf(moved.history!)).toBe(revision);
699
+ expect(moved.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["a1"]);
700
+ expect(() => core.sessionNavigate(created.id, revision, undefined, "nope")).toThrow(
701
+ /no branch/,
702
+ );
703
+ });
704
+
705
+ test("a navigate to the tip or off the path is refused", () => {
706
+ // Arrange
707
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
708
+ const tip = tipOf(created.history!);
709
+
710
+ core.sessionBranch(created.id, "alt");
711
+ core.sessionAnnotate(created.id, comment("a1", "on alt"));
712
+ const onAlt = tipOf(core.sessionGet(created.id).history!, "alt");
713
+
714
+ core.sessionSwitch(created.id, "main");
715
+
716
+ // Assert
717
+ expect(() => core.sessionNavigate(created.id, tip)).toThrow(/already at/);
718
+ expect(() => core.sessionNavigate(created.id, onAlt)).toThrow(/not on branch "main"/);
719
+ expect(() => core.sessionNavigate(created.id, "e_nope")).toThrow(/no entry/);
720
+ });
721
+
722
+ test("branching keeps main where it is; switching shows each branch's own comments", () => {
723
+ // Arrange
724
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
725
+
726
+ core.sessionAnnotate(created.id, comment("shared", "on both"));
727
+
728
+ // Act
729
+ core.sessionBranch(created.id, "alt");
730
+ core.sessionAnnotate(created.id, comment("alt-only", "on alt"));
731
+ const onAlt = core
732
+ .sessionSwitch(created.id, "alt")
733
+ .annotations.map((annotation) => annotation.id);
734
+ const onMain = core.sessionSwitch(created.id, "main");
735
+
736
+ // Assert
737
+ expect(onAlt).toEqual(["shared", "alt-only"]);
738
+ expect(onMain.annotations.map((annotation) => annotation.id)).toEqual(["shared"]);
739
+ expect(onMain.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["alt-only"]);
740
+ expect(() => core.sessionBranch(created.id, "alt")).toThrow(/exists/);
741
+ expect(() => core.sessionSwitch(created.id, "nope")).toThrow(/no branch/);
742
+ });
743
+
744
+ test("a removed comment is shelved, and a share merge does not bring it back", () => {
745
+ // Arrange
746
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
747
+
748
+ core.sessionAnnotate(created.id, comment("a1", "first"));
749
+ const removed = core.sessionRemoveAnnotation(created.id, "a1");
750
+
751
+ // Act
752
+ const merged = core.sessionMergeShared(created.id, {
753
+ annotations: [{ ...comment("a1", "first"), createdAt: "2026-01-01T00:00:00.000Z" }],
754
+ });
755
+
756
+ // Assert
757
+ expect(removed.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["a1"]);
758
+ expect(merged.annotations).toEqual([]);
759
+ });
760
+
761
+ test("a share's removal entry hides the comment for the owner, once, by entry id", () => {
762
+ // Arrange: a collaborator's note pulled in earlier
763
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
764
+ const note = {
765
+ ...comment("c1", "from a teammate"),
766
+ author: "SHA256:collab",
767
+ createdAt: "2026-01-01T00:00:00.000Z",
768
+ };
769
+
770
+ core.sessionMergeShared(created.id, { annotations: [note] });
771
+ const removal = { id: "e_share_rm", annotationId: "c1", createdAt: "2026-01-02T00:00:00.000Z" };
772
+
773
+ // Act: the share hands the removal back twice, as two pulls would
774
+ core.sessionMergeShared(created.id, { annotations: [], removals: [removal] });
775
+ const merged = core.sessionMergeShared(created.id, {
776
+ annotations: [note],
777
+ removals: [removal],
778
+ });
779
+
780
+ // Assert: gone from view, kept on the shelf, one removal entry with the share's id
781
+ expect(merged.annotations).toEqual([]);
782
+ expect(merged.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["c1"]);
783
+ expect(merged.history!.entries.filter((entry) => entry.type === "comment-removed")).toEqual([
784
+ expect.objectContaining({ id: "e_share_rm", annotationId: "c1" }),
785
+ ]);
786
+ });
787
+
788
+ test("a merge lands on the branch the share follows, wherever the owner stands", () => {
789
+ // Arrange: the owner works on a side branch while the share follows main
790
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
791
+
792
+ core.sessionBranch(created.id, "alt");
793
+ const note = {
794
+ ...comment("c1", "from a teammate"),
795
+ author: "SHA256:collab",
796
+ createdAt: "2026-01-01T00:00:00.000Z",
797
+ };
798
+
799
+ // Act
800
+ const merged = core.sessionMergeShared(created.id, { annotations: [note] });
801
+
802
+ // Assert: main's path has the note, alt's does not show it
803
+ expect(merged.history!.branch).toBe("alt");
804
+ expect(merged.annotations).toEqual([]);
805
+ expect(merged.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["c1"]);
806
+ expect(derivePath(merged.history!, tipOf(merged.history!, "main")).annotationIds).toEqual([
807
+ "c1",
808
+ ]);
809
+ expect(
810
+ core.sessionSwitch(created.id, "main").annotations.map((annotation) => annotation.id),
811
+ ).toEqual(["c1"]);
812
+ });
813
+
814
+ test("a fork is a new pending session on the copied path, without verdict, edits, or share", () => {
815
+ // Arrange: comment, edit, resolve, then a second round
816
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
817
+
818
+ core.sessionAnnotate(created.id, comment("a1", "first"), "Ana");
819
+ core.sessionSetShareId(created.id, "share-1");
820
+ core.sessionResolve(created.id, "request_changes", "again");
821
+ core.sessionSubmitRevision(created.id, "# Plan\n\nRound two.\n", ["a1"]);
822
+ core.sessionLabel(created.id, "round two");
823
+ core.sessionSetWorkingCopy(created.id, "# Plan\n\nRound two, edited.\n");
824
+
825
+ // Act
826
+ const fork = core.sessionFork(created.id);
827
+
828
+ // Assert
829
+ expect(fork.id).not.toBe(created.id);
830
+ expect(fork.parentSessionId).toBe(created.id);
831
+ expect(fork.status).toBe("pending");
832
+ expect(fork.verdict).toBeNull();
833
+ expect(fork.shareId).toBeUndefined();
834
+ expect(fork.workingCopy).toBeUndefined();
835
+ expect(fork.artifact.content).toBe("# Plan\n\nRound two.\n");
836
+ expect(fork.revisions.map((revision) => revision.revision)).toEqual([1, 2]);
837
+ expect(fork.annotations.map((annotation) => annotation.id)).toEqual(["a1"]);
838
+ expect(fork.participants).toEqual(
839
+ created.participants ?? core.sessionGet(created.id).participants,
840
+ );
841
+ expect(Object.values(fork.history!.labels)).toEqual(["round two"]);
842
+ expect(fork.history!.entries.map((entry) => entry.type)).toEqual([
843
+ "revision",
844
+ "comment",
845
+ "revision",
846
+ ]);
847
+ expect(core.sessionList().map((session) => session.id)).toContain(fork.id);
848
+ });
849
+
850
+ test("a fork's addressed marks follow its own numbering; one addressed off the path reopens", () => {
851
+ // Arrange: a1 addressed by round two, main navigated back before round two, round three submitted
852
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
853
+
854
+ core.sessionAnnotate(created.id, comment("a1", "first"));
855
+ const beforeRoundTwo = tipOf(core.sessionGet(created.id).history!);
856
+
857
+ core.sessionSubmitRevision(created.id, "# Plan\n\nRound two.\n", ["a1"]);
858
+ core.sessionAnnotate(created.id, comment("a2", "second"));
859
+ core.sessionSubmitRevision(created.id, "# Plan\n\nRound three.\n", ["a2"]);
860
+
861
+ // Act: fork with both rounds on the path
862
+ const straight = core.sessionFork(created.id);
863
+
864
+ core.sessionNavigate(created.id, beforeRoundTwo);
865
+ core.sessionSubmitRevision(created.id, "# Plan\n\nRound four.\n");
866
+ // the source numbers this 4; on the fork's path it is the second revision
867
+ const rerouted = core.sessionFork(created.id);
868
+
869
+ // Assert
870
+ expect(straight.annotations.map((annotation) => annotation.resolution?.revision)).toEqual([
871
+ 2, 3,
872
+ ]);
873
+ expect(rerouted.revisions.map((revision) => revision.content)).toEqual([
874
+ PLAN.content,
875
+ "# Plan\n\nRound four.\n",
876
+ ]);
877
+ expect(rerouted.annotations.map((annotation) => annotation.id)).toEqual(["a1"]);
878
+ expect(rerouted.annotations[0]!.resolution).toBeUndefined();
879
+ });
880
+ });