| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "reasonix/internal/provider" |
| 12 | "reasonix/internal/session" |
| 13 | ) |
| 14 | |
| 15 | func TestNativeHistoryPromptBoundsPreview(t *testing.T) { |
| 16 | text, err := nativeHistoryPrompt(t.Context(), "\n\t hello world "+strings.Repeat(" x", 100000)) |
| 17 | if err != nil || !strings.HasPrefix(text, "hello world x") || len(text) > 128 { |
| 18 | t.Fatalf("preview: %q %v", text, err) |
| 19 | } |
| 20 | ctx, cancel := context.WithCancel(t.Context()) |
| 21 | cancel() |
| 22 | if _, err := nativeHistoryPrompt(ctx, strings.Repeat("\u2003", 100000)); !errors.Is(err, context.Canceled) { |
| 23 | t.Fatalf("canceled normalization: %v", err) |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | func TestNativeHistoryColdOutlineAndAnchorsShareCut(t *testing.T) { |
| 28 | a := historySliceTestApp(t) |
| 29 | t.Cleanup(a.closeHistoryReaders) |
| 30 | tab := newColdHistoryTab(t, a) |
| 31 | var messages []provider.Message |
| 32 | for i := range 140 { |
| 33 | messages = append(messages, historySliceUser(i, fmt.Sprintf("question %d", i)), historySliceAssistant(i, fmt.Sprintf("answer %d", i))) |
| 34 | } |
| 35 | _, tab.SessionPath = saveHistorySliceSession(t, tabSessionDir(tab), "outline.jsonl", messages) |
| 36 | before, err := os.ReadFile(tab.SessionPath) |
| 37 | if err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | handle, err := a.BeginSessionHistoryReadForTab(tab.ID) |
| 41 | if err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | first, err := a.ReadSessionHistoryOutline(handle.ID, session.HistoryOutlineRequest{Limit: 128}) |
| 45 | if err != nil || first.Status != "ready" || len(first.Entries) != 128 || first.Done || first.NextTurn != 129 || first.TotalTurns != 140 { |
| 46 | t.Fatalf("first outline: %+v %v", first, err) |
| 47 | } |
| 48 | last, err := a.ReadSessionHistoryOutline(handle.ID, session.HistoryOutlineRequest{Generation: first.Generation, SnapshotSequence: &first.SnapshotSequence, StartTurn: first.NextTurn, Limit: 128}) |
| 49 | if err != nil || last.Status != "ready" || len(last.Entries) != 12 || !last.Done || last.Entries[0].Prompt != "question 128" { |
| 50 | t.Fatalf("last outline: %+v %v", last, err) |
| 51 | } |
| 52 | target := first.Entries[3] |
| 53 | location, err := a.LocateSessionHistoryMessage(handle.ID, target.MessageID, first.SnapshotSequence) |
| 54 | if err != nil || location.Status != "ready" || location.Position != target.Position || location.VisibleTurn != target.Turn || location.Generation != first.Generation { |
| 55 | t.Fatalf("locate: %+v %v", location, err) |
| 56 | } |
| 57 | for _, request := range []HistorySliceRequest{ |
| 58 | {Cursor: location.Cursor}, |
| 59 | {Anchor: "turn", Turn: target.Turn}, |
| 60 | {Anchor: "message", MessageID: target.MessageID}, |
| 61 | } { |
| 62 | request.Generation, request.SnapshotSequence, request.Entries = first.Generation, &first.SnapshotSequence, 2 |
| 63 | page, err := a.ReadSessionHistorySlice(handle.ID, request) |
| 64 | if err != nil || page.Status != "ready" || len(page.Page.Entries) == 0 || page.Page.Entries[len(page.Page.Entries)-1].EntryID != target.MessageID { |
| 65 | t.Fatalf("target page: %+v %v", page, err) |
| 66 | } |
| 67 | if !page.Page.HasNewer || page.Page.NewerCursor == "" { |
| 68 | t.Fatal("anchored window lost access to the newer suffix") |
| 69 | } |
| 70 | } |
| 71 | for _, id := range []string{target.MessageID + "junk", "sother:r0:m6:o0", "soutline:r999:m6:o0", "soutline:r0:m9999:o0"} { |
| 72 | location, err := a.LocateSessionHistoryMessage(handle.ID, id, first.SnapshotSequence) |
| 73 | if err != nil || location.Status != "not_found" { |
| 74 | t.Fatalf("invalid identity %q: %+v %v", id, location, err) |
| 75 | } |
| 76 | } |
| 77 | if tab.Ctrl != nil { |
| 78 | t.Fatal("navigation constructed an execution controller") |
| 79 | } |
| 80 | after, _ := os.ReadFile(tab.SessionPath) |
| 81 | if string(before) != string(after) { |
| 82 | t.Fatal("navigation changed authoritative history") |
| 83 | } |
| 84 | wrong := first.SnapshotSequence + 1 |
| 85 | for _, request := range []session.HistoryOutlineRequest{{Generation: "other"}, {SnapshotSequence: &wrong}} { |
| 86 | page, err := a.ReadSessionHistoryOutline(handle.ID, request) |
| 87 | if err != nil || page.Status != "stale_cursor" || len(page.Entries) != 0 { |
| 88 | t.Fatalf("outline accepted another cut: %+v %v", page, err) |
| 89 | } |
| 90 | } |
| 91 | stale, err := a.ReadSessionHistorySlice(handle.ID, HistorySliceRequest{Anchor: "turn", Turn: 4, Generation: "other"}) |
| 92 | if err != nil || stale.Status != "stale_cursor" || len(stale.Page.Entries) != 0 { |
| 93 | t.Fatalf("jump accepted another cut: %+v %v", stale, err) |
| 94 | } |
| 95 | missing, err := a.ReadSessionHistorySlice(handle.ID, HistorySliceRequest{Anchor: "turn", Turn: 999}) |
| 96 | if err != nil || missing.Status != "not_found" { |
| 97 | t.Fatalf("missing turn: %+v %v", missing, err) |
| 98 | } |
| 99 | // A retained binding must detect replacement even before a new Begin call. |
| 100 | if err := os.WriteFile(tab.SessionPath, []byte("{\"role\":\"user\",\"content\":\"replacement\"}\n"), 0600); err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | changed, err := a.ReadSessionHistoryOutline(handle.ID, session.HistoryOutlineRequest{}) |
| 104 | if err != nil || changed.Status != "stale_cursor" || len(changed.Entries) != 0 { |
| 105 | t.Fatalf("replaced outline: %+v %v", changed, err) |
| 106 | } |
| 107 | location, err = a.LocateSessionHistoryMessage(handle.ID, target.MessageID, first.SnapshotSequence) |
| 108 | if err != nil || location.Status != "stale_cursor" { |
| 109 | t.Fatalf("replaced locate: %+v %v", location, err) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | func TestNativeHistoryCanceledOutlineCannotPublish(t *testing.T) { |
| 114 | a := historySliceTestApp(t) |
| 115 | t.Cleanup(a.closeHistoryReaders) |
| 116 | tab := newColdHistoryTab(t, a) |
| 117 | _, tab.SessionPath = saveHistorySliceSession(t, tabSessionDir(tab), "cancel-outline.jsonl", []provider.Message{historySliceUser(0, "one")}) |
| 118 | resume, err := a.historyMaintenance.Foreground(t.Context()) |
| 119 | if err != nil { |
| 120 | t.Fatal(err) |
| 121 | } |
| 122 | defer resume() |
| 123 | handle, err := a.BeginSessionHistoryReadForTab(tab.ID) |
| 124 | if err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | reader, err := a.historyReader(handle.ID) |
| 128 | if err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | result := make(chan session.HistoryOutlinePage, 1) |
| 132 | go func() { |
| 133 | page, _ := a.readNativeHistoryOutline(reader, session.HistoryOutlineRequest{}) |
| 134 | result <- page |
| 135 | }() |
| 136 | a.ReleaseSessionHistoryRead(handle.ID) |
| 137 | select { |
| 138 | case page := <-result: |
| 139 | if page.Status != "stale_cursor" || len(page.Entries) != 0 { |
| 140 | t.Fatalf("canceled outline published: %+v", page) |
| 141 | } |
| 142 | case <-t.Context().Done(): |
| 143 | t.Fatal(context.Cause(t.Context())) |
| 144 | } |
| 145 | } |
| 146 |