Skip to content

Commit 6a3d5e0

Browse files
aksOpsclaude
andcommitted
feat(serve,ui): V6 historical scroll past the hub ring
GET /api/sessions/{name}/feed/history?before=<id>&limit=<n?> - Reads the session's JSONL log backwards in 64 KB chunks. - Returns tool_call rows strictly older than the cursor id, newest first, with a has_more flag so the UI knows when to hide the Load-older button. - 400 on missing/invalid before, 404 on unknown session, 405 on non-GET. UI: - FeedStream adds an onLoadOlder prop; when provided, renders a "Load older" button below the live tail. Click appends the batch to local state (kept out of the TanStack cache so live SSE ticks don't have to resort / de-dup against history). - SessionDetail's FeedTab wires fetchFeedHistory for the current session; cross-session feed and dashboard mini-feed are unchanged. Cursor shape matches the hub Event.ID: <unix-nano>-<seq>. The handler tolerates ±1 s drift between hub-publish stamp and JSONL ctm_timestamp so adjacent events never get stitched together. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e8f47b0 commit 6a3d5e0

8 files changed

Lines changed: 1206 additions & 4 deletions

File tree

internal/serve/api/feed_history.go

Lines changed: 490 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"os"
9+
"path/filepath"
10+
"strconv"
11+
"strings"
12+
"testing"
13+
"time"
14+
)
15+
16+
// newHistoryRequest constructs a GET /api/sessions/{name}/feed/history
17+
// test request with the Go 1.22 ServeMux path-value populated so the
18+
// handler's r.PathValue("name") call resolves.
19+
func newHistoryRequest(name, query string) *http.Request {
20+
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+name+"/feed/history?"+query, nil)
21+
req.SetPathValue("name", name)
22+
return req
23+
}
24+
25+
// writeJSONLFixture writes n tool_call hook-payload lines to <dir>/<uuid>.jsonl
26+
// with monotonically increasing ctm_timestamps starting at `base`.
27+
// Returns the slice of timestamps (nanos) written so tests can derive
28+
// the expected ids.
29+
func writeJSONLFixture(t *testing.T, dir, uuid string, n int, base time.Time) []int64 {
30+
t.Helper()
31+
path := filepath.Join(dir, uuid+".jsonl")
32+
fh, err := os.Create(path)
33+
if err != nil {
34+
t.Fatalf("create: %v", err)
35+
}
36+
defer fh.Close()
37+
nanos := make([]int64, 0, n)
38+
for i := 0; i < n; i++ {
39+
ts := base.Add(time.Duration(i) * time.Second)
40+
// Force UTC + seconds precision for cursor determinism.
41+
tsStr := ts.UTC().Format(time.RFC3339)
42+
parsed, _ := time.Parse(time.RFC3339, tsStr)
43+
nanos = append(nanos, parsed.UnixNano())
44+
line := map[string]any{
45+
"tool_name": "Bash",
46+
"tool_input": map[string]any{
47+
"command": fmt.Sprintf("echo %d", i),
48+
},
49+
"tool_response": map[string]any{
50+
"output": fmt.Sprintf("output-%d", i),
51+
"is_error": false,
52+
},
53+
"ctm_timestamp": tsStr,
54+
}
55+
enc, _ := json.Marshal(line)
56+
if _, err := fh.Write(append(enc, '\n')); err != nil {
57+
t.Fatalf("write: %v", err)
58+
}
59+
}
60+
return nanos
61+
}
62+
63+
// historyResolver is a fake UUIDNameResolver pinned to a single mapping
64+
// so tests can drive name→uuid resolution deterministically.
65+
type historyResolver struct{ uuid, name string }
66+
67+
func (h historyResolver) ResolveUUID(u string) (string, bool) {
68+
if u == h.uuid {
69+
return h.name, true
70+
}
71+
return "", false
72+
}
73+
74+
func TestFeedHistory_BeforeInMiddleReturnsOlder(t *testing.T) {
75+
dir := t.TempDir()
76+
const uuid = "aaaaaaaa-0000-0000-0000-000000000001"
77+
base := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC)
78+
nanos := writeJSONLFixture(t, dir, uuid, 50, base)
79+
80+
h := FeedHistory(dir, historyResolver{uuid: uuid, name: "alpha"})
81+
82+
// Cursor = id of the 30th event (0-indexed → index 30). Expect
83+
// events 0..29 returned, newest-first (29 down to 0).
84+
cursor := strconv.FormatInt(nanos[30], 10) + "-0"
85+
rec := httptest.NewRecorder()
86+
req := newHistoryRequest("alpha", "before="+cursor+"&limit=100")
87+
h(rec, req)
88+
89+
if rec.Code != http.StatusOK {
90+
t.Fatalf("status = %d, want 200 (body=%s)", rec.Code, rec.Body.String())
91+
}
92+
var body feedHistoryResponse
93+
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
94+
t.Fatalf("decode: %v", err)
95+
}
96+
if len(body.Events) != 30 {
97+
t.Fatalf("events = %d, want 30", len(body.Events))
98+
}
99+
// Newest-first: first returned must be event 29.
100+
wantFirst := strconv.FormatInt(nanos[29], 10) + "-0"
101+
if body.Events[0].ID != wantFirst {
102+
t.Errorf("events[0].id = %q, want %q", body.Events[0].ID, wantFirst)
103+
}
104+
wantLast := strconv.FormatInt(nanos[0], 10) + "-0"
105+
if body.Events[len(body.Events)-1].ID != wantLast {
106+
t.Errorf("events[last].id = %q, want %q", body.Events[len(body.Events)-1].ID, wantLast)
107+
}
108+
if body.HasMore {
109+
t.Errorf("has_more = true, want false (30 < limit 100)")
110+
}
111+
// Shape sanity: payload is a tool_call envelope with a command field.
112+
var payload map[string]any
113+
if err := json.Unmarshal(body.Events[0].Payload, &payload); err != nil {
114+
t.Fatalf("payload decode: %v", err)
115+
}
116+
if payload["tool"] != "Bash" {
117+
t.Errorf("payload.tool = %v, want Bash", payload["tool"])
118+
}
119+
if _, ok := payload["input"].(string); !ok {
120+
t.Errorf("payload.input missing or wrong type")
121+
}
122+
}
123+
124+
func TestFeedHistory_BeforeOlderThanAllReturnsEmpty(t *testing.T) {
125+
dir := t.TempDir()
126+
const uuid = "bbbbbbbb-0000-0000-0000-000000000002"
127+
base := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC)
128+
_ = writeJSONLFixture(t, dir, uuid, 10, base)
129+
130+
h := FeedHistory(dir, historyResolver{uuid: uuid, name: "beta"})
131+
132+
// Cursor older than any fixture timestamp → nothing to return.
133+
old := base.Add(-1 * time.Hour).UnixNano()
134+
cursor := strconv.FormatInt(old, 10) + "-0"
135+
rec := httptest.NewRecorder()
136+
req := newHistoryRequest("beta", "before="+cursor)
137+
h(rec, req)
138+
139+
if rec.Code != http.StatusOK {
140+
t.Fatalf("status = %d, want 200", rec.Code)
141+
}
142+
var body feedHistoryResponse
143+
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
144+
t.Fatalf("decode: %v", err)
145+
}
146+
if len(body.Events) != 0 {
147+
t.Errorf("events = %d, want 0", len(body.Events))
148+
}
149+
if body.HasMore {
150+
t.Errorf("has_more = true, want false")
151+
}
152+
}
153+
154+
func TestFeedHistory_LimitAppliedAndHasMoreTrue(t *testing.T) {
155+
dir := t.TempDir()
156+
const uuid = "cccccccc-0000-0000-0000-000000000003"
157+
base := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC)
158+
nanos := writeJSONLFixture(t, dir, uuid, 50, base)
159+
160+
h := FeedHistory(dir, historyResolver{uuid: uuid, name: "gamma"})
161+
162+
// before = id of the newest event so EVERYTHING older is in play;
163+
// limit=10 forces the scan to stop early.
164+
cursor := strconv.FormatInt(nanos[49], 10) + "-0"
165+
rec := httptest.NewRecorder()
166+
req := newHistoryRequest("gamma", "before="+cursor+"&limit=10")
167+
h(rec, req)
168+
169+
if rec.Code != http.StatusOK {
170+
t.Fatalf("status = %d, want 200", rec.Code)
171+
}
172+
var body feedHistoryResponse
173+
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
174+
t.Fatalf("decode: %v", err)
175+
}
176+
if len(body.Events) != 10 {
177+
t.Fatalf("events = %d, want 10", len(body.Events))
178+
}
179+
if !body.HasMore {
180+
t.Errorf("has_more = false, want true (39 older rows remain)")
181+
}
182+
// Events newest-first: first == event 48 (one below the cursor).
183+
wantFirst := strconv.FormatInt(nanos[48], 10) + "-0"
184+
if body.Events[0].ID != wantFirst {
185+
t.Errorf("events[0].id = %q, want %q", body.Events[0].ID, wantFirst)
186+
}
187+
}
188+
189+
func TestFeedHistory_MissingBefore400(t *testing.T) {
190+
dir := t.TempDir()
191+
const uuid = "dddddddd-0000-0000-0000-000000000004"
192+
writeJSONLFixture(t, dir, uuid, 3, time.Now())
193+
194+
h := FeedHistory(dir, historyResolver{uuid: uuid, name: "delta"})
195+
rec := httptest.NewRecorder()
196+
req := newHistoryRequest("delta", "")
197+
h(rec, req)
198+
199+
if rec.Code != http.StatusBadRequest {
200+
t.Fatalf("status = %d, want 400", rec.Code)
201+
}
202+
if !strings.Contains(rec.Body.String(), "before") {
203+
t.Errorf("body = %q, want mention of before cursor", rec.Body.String())
204+
}
205+
}
206+
207+
func TestFeedHistory_UnknownSession404(t *testing.T) {
208+
dir := t.TempDir()
209+
// No fixture file → resolver never matches.
210+
h := FeedHistory(dir, historyResolver{uuid: "x", name: "exists"})
211+
rec := httptest.NewRecorder()
212+
req := newHistoryRequest("ghost", "before=1-0")
213+
h(rec, req)
214+
215+
if rec.Code != http.StatusNotFound {
216+
t.Fatalf("status = %d, want 404", rec.Code)
217+
}
218+
}
219+
220+
func TestFeedHistory_NonGET405(t *testing.T) {
221+
dir := t.TempDir()
222+
h := FeedHistory(dir, historyResolver{uuid: "x", name: "exists"})
223+
rec := httptest.NewRecorder()
224+
req := httptest.NewRequest(http.MethodPost, "/api/sessions/exists/feed/history?before=1-0", nil)
225+
req.SetPathValue("name", "exists")
226+
h(rec, req)
227+
if rec.Code != http.StatusMethodNotAllowed {
228+
t.Fatalf("status = %d, want 405", rec.Code)
229+
}
230+
if !strings.Contains(rec.Header().Get("Allow"), "GET") {
231+
t.Errorf("Allow = %q, want GET", rec.Header().Get("Allow"))
232+
}
233+
}
234+
235+
// TestFeedHistory_SpansReverseChunkBoundary ensures the reverse reader
236+
// correctly stitches lines that straddle a 64 KB chunk boundary. We do
237+
// this by padding each line's command field so the total file is well
238+
// over reverseChunkSize.
239+
func TestFeedHistory_SpansReverseChunkBoundary(t *testing.T) {
240+
dir := t.TempDir()
241+
const uuid = "eeeeeeee-0000-0000-0000-000000000005"
242+
path := filepath.Join(dir, uuid+".jsonl")
243+
fh, err := os.Create(path)
244+
if err != nil {
245+
t.Fatalf("create: %v", err)
246+
}
247+
base := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC)
248+
pad := strings.Repeat("x", 2000)
249+
nanos := make([]int64, 0, 100)
250+
for i := 0; i < 100; i++ {
251+
ts := base.Add(time.Duration(i) * time.Second)
252+
tsStr := ts.UTC().Format(time.RFC3339)
253+
parsed, _ := time.Parse(time.RFC3339, tsStr)
254+
nanos = append(nanos, parsed.UnixNano())
255+
line := map[string]any{
256+
"tool_name": "Bash",
257+
"tool_input": map[string]any{"command": pad + "-" + strconv.Itoa(i)},
258+
"ctm_timestamp": tsStr,
259+
}
260+
enc, _ := json.Marshal(line)
261+
fh.Write(append(enc, '\n'))
262+
}
263+
fh.Close()
264+
265+
h := FeedHistory(dir, historyResolver{uuid: uuid, name: "epsilon"})
266+
cursor := strconv.FormatInt(nanos[99], 10) + "-0"
267+
rec := httptest.NewRecorder()
268+
req := newHistoryRequest("epsilon", "before="+cursor+"&limit=500")
269+
h(rec, req)
270+
271+
if rec.Code != http.StatusOK {
272+
t.Fatalf("status = %d, want 200", rec.Code)
273+
}
274+
var body feedHistoryResponse
275+
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
276+
t.Fatalf("decode: %v", err)
277+
}
278+
// Expect events 0..98 = 99 rows.
279+
if len(body.Events) != 99 {
280+
t.Fatalf("events = %d, want 99 (chunk-boundary stitching broken?)", len(body.Events))
281+
}
282+
wantFirst := strconv.FormatInt(nanos[98], 10) + "-0"
283+
if body.Events[0].ID != wantFirst {
284+
t.Errorf("events[0].id = %q, want %q", body.Events[0].ID, wantFirst)
285+
}
286+
wantLast := strconv.FormatInt(nanos[0], 10) + "-0"
287+
if body.Events[len(body.Events)-1].ID != wantLast {
288+
t.Errorf("events[last].id = %q, want %q", body.Events[len(body.Events)-1].ID, wantLast)
289+
}
290+
}

internal/serve/server.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,14 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
492492
// Slice 2 (UI bar) consumes this; slice 3 (FTS5) is deferred to v0.3.
493493
mux.Handle("GET /api/search", authHF(api.Search(s.logDir, logsUUIDResolver{proj: s.proj})))
494494

495+
// V6 historical feed scroll. Returns tool_call rows older than a
496+
// cursor by reading backwards over the session's JSONL log, so the
497+
// UI's Load-older button can walk past the 500-slot hub ring.
498+
mux.Handle(
499+
"GET /api/sessions/{name}/feed/history",
500+
authHF(api.FeedHistory(s.logDir, logsUUIDResolver{proj: s.proj})),
501+
)
502+
495503
// V9 inline Edit/Write diff viewer — looks up a single tool_call
496504
// row by its hub event ID and returns a rendered unified diff when
497505
// the tool is Edit/MultiEdit/Write.

0 commit comments

Comments
 (0)