From 8ec4e135e318c7799b2576a8729493c063da4a97 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 30 Jul 2026 17:05:12 -0300 Subject: --- vrsite/transcript_test.go | 333 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 vrsite/transcript_test.go (limited to 'vrsite/transcript_test.go') diff --git a/vrsite/transcript_test.go b/vrsite/transcript_test.go new file mode 100644 index 0000000..5e83ef4 --- /dev/null +++ b/vrsite/transcript_test.go @@ -0,0 +1,333 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Both fixtures are the shapes the real files use: Claude Code writes one +// record per message with content blocks, codex wraps everything in a payload +// and keeps the model's history in response_items. +const claudeJSONL = `{"type":"user","timestamp":"2026-07-20T09:57:00.000Z","sessionId":"s1","cwd":"/repo","message":{"role":"user","content":"read lib.rs and tell me about the cache"}} +{"type":"assistant","timestamp":"2026-07-20T09:57:30.000Z","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"thinking","thinking":"start with the struct","signature":"CAIS"},{"type":"text","text":"Reading it now."},{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"vr read src/lib.rs:1-30","description":"read the file"}}]}} +{"type":"user","timestamp":"2026-07-20T09:59:10.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"1\tuse std::collections::HashMap;\n2\t\n3\tstruct Cache;\n"}]}} +{"type":"mode","timestamp":"2026-07-20T09:59:11.000Z","mode":"default"} +{"type":"assistant","timestamp":"2026-07-20T09:59:40.000Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_2","name":"Read","input":{"file_path":"/repo/src/main.rs"}}]}} +{"type":"user","timestamp":"2026-07-20T09:59:45.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_2","is_error":true,"content":[{"type":"text","text":"no such file"}]}]}} +{"type":"assistant","timestamp":"2026-07-20T10:02:30.000Z","message":{"role":"assistant","content":[{"type":"text","text":"It is a two-level map."}]}} +` + +const codexJSONL = `{"timestamp":"2026-07-20T12:00:00.000Z","type":"session_meta","payload":{"id":"019f0000-0000-7000-8000-00000000abcd","cwd":"/repo"}} +{"timestamp":"2026-07-20T12:00:01.000Z","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"You are Codex, an agent."}]}} +{"timestamp":"2026-07-20T12:00:02.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"\n /repo\n"}]}} +{"timestamp":"2026-07-20T12:00:03.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"review the cache"}]}} +{"timestamp":"2026-07-20T12:00:04.000Z","type":"response_item","payload":{"type":"reasoning","summary":[{"type":"summary_text","text":"start with lib.rs"}],"encrypted_content":"gAAAA"}} +{"timestamp":"2026-07-20T12:00:05.000Z","type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"call_1","arguments":"{\"cmd\":\"vr read src/lib.rs\",\"workdir\":\"/repo\"}"}} +{"timestamp":"2026-07-20T12:00:06.000Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_1","output":"Chunk ID: abc123\nOutput:\n1\tstruct Cache;\n"}} +{"timestamp":"2026-07-20T12:00:07.000Z","type":"event_msg","payload":{"type":"agent_message","message":"the cache is two-level"}} +{"timestamp":"2026-07-20T12:00:08.000Z","type":"response_item","payload":{"type":"custom_tool_call","name":"apply_patch","call_id":"call_2","input":"*** Begin Patch\n*** Update File: src/lib.rs\n@@\n-old\n+new\n"}} +{"timestamp":"2026-07-20T12:00:09.000Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_2","output":"Exit code: 0\n"}} +{"timestamp":"2026-07-20T12:00:10.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"The cache is two-level."}]}} +` + +func writeFile(t *testing.T, path, body string) string { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func turnLine(tn Turn) string { + s := tn.Role + if tn.Tool != "" { + s += " " + tn.Tool + } + if tn.Text != "" { + s += " " + tn.Text + } + if tn.Detail != "" { + s += " " + tn.Detail + } + return s +} + +func TestParseClaudeTranscript(t *testing.T) { + path := writeFile(t, filepath.Join(t.TempDir(), "s.jsonl"), claudeJSONL) + tr := readTranscript(path, int64(len(claudeJSONL))) + if tr.Harness != "claude-code" || tr.Err != "" { + t.Fatalf("harness = %q err = %q", tr.Harness, tr.Err) + } + want := []string{ + "user read lib.rs and tell me about the cache", + "thinking start with the struct", + "assistant Reading it now.", + "tool Bash vr read src/lib.rs:1-30", + "result Bash 1\tuse std::collections::HashMap; … +2 lines", + "tool Read /repo/src/main.rs", + "result Read no such file", + "assistant It is a two-level map.", + } + var got []string + for _, tn := range tr.Turns { + got = append(got, turnLine(tn)) + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("turns:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + if tr.Turns[6].Kind != "error" { + t.Error("a failed tool result is not marked") + } + if at := tr.Turns[0].When; at.Format(time.RFC3339) != "2026-07-20T09:57:00Z" { + t.Errorf("first turn at %v", at) + } +} + +func TestParseCodexTranscript(t *testing.T) { + path := writeFile(t, filepath.Join(t.TempDir(), "r.jsonl"), codexJSONL) + tr := readTranscript(path, int64(len(codexJSONL))) + if tr.Harness != "codex" || tr.Err != "" { + t.Fatalf("harness = %q err = %q", tr.Harness, tr.Err) + } + want := []string{ + "user review the cache", + "thinking start with lib.rs", + "tool exec_command vr read src/lib.rs", + "result exec_command Chunk ID: abc123 … +2 lines", + "tool apply_patch *** Begin Patch … +4 lines", + "result apply_patch Exit code: 0", + "assistant The cache is two-level.", + } + var got []string + for _, tn := range tr.Turns { + got = append(got, turnLine(tn)) + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("turns:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +// Tool output is what makes these files megabytes; a page gets one line of it. +func TestToolOutputIsSummarized(t *testing.T) { + huge := strings.Repeat("x", 400) + "\n" + strings.Repeat("line\n", 50000) + line := fmt.Sprintf(`{"type":"user","timestamp":"2026-07-20T10:00:00.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t","content":%q}]}}`, huge) + path := writeFile(t, filepath.Join(t.TempDir(), "big.jsonl"), line+"\n") + tr := readTranscript(path, int64(len(line)+1)) + if len(tr.Turns) != 1 { + t.Fatalf("turns = %d", len(tr.Turns)) + } + if n := len(tr.Turns[0].Detail); n > 4*briefLine { + t.Errorf("summary of a 250 KB result is %d bytes", n) + } + if !strings.Contains(tr.Turns[0].Detail, "+50000 lines") { + t.Errorf("summary does not say what was left out: %q", tr.Turns[0].Detail) + } +} + +func TestTranscriptTooLargeIsRefused(t *testing.T) { + path := writeFile(t, filepath.Join(t.TempDir(), "x.jsonl"), "{}\n") + if tr := readTranscript(path, maxTranscript+1); !strings.Contains(tr.Err, "too large") { + t.Errorf("err = %q", tr.Err) + } +} + +// ── locating ───────────────────────────────────────────────────────────── + +func TestSafeSession(t *testing.T) { + for _, ok := range []string{"s1", "web:goblin", "24983541-2e59-4d47-94bb-837acf8147c3", + "019fa0f1-49de-7e33-af62-6db956589080"} { + if err := safeSession(ok); err != nil { + t.Errorf("safeSession(%q) = %v", ok, err) + } + } + for _, bad := range []string{"", "..", "a/b", `a\b`, "../../etc/passwd", "a..b", "a b", + "a*b", "a\x00b", strings.Repeat("x", 129)} { + if err := safeSession(bad); err == nil { + t.Errorf("safeSession(%q) was accepted", bad) + } + } +} + +func TestFindTranscript(t *testing.T) { + claudeRoot, codexRoot := t.TempDir(), t.TempDir() + const ( + cid = "24983541-2e59-4d47-94bb-837acf8147c3" + xid = "019fa0f1-49de-7e33-af62-6db956589080" + ) + want := writeFile(t, filepath.Join(claudeRoot, "-home-u-0x4200-cafe-proj", cid+".jsonl"), claudeJSONL) + wantX := writeFile(t, filepath.Join(codexRoot, "2026", "07", "26", + "rollout-2026-07-26T21-19-55-"+xid+".jsonl"), codexJSONL) + // a file outside every root must stay invisible + outside := writeFile(t, filepath.Join(t.TempDir(), cid+".jsonl"), claudeJSONL) + + tx := newTranscripts([]string{claudeRoot, codexRoot}) + if got := tx.find(cid, "/home/u/0x4200.cafe/proj"); got != want { + t.Errorf("with the logged cwd: %q, want %q", got, want) + } + if got := tx.find(cid, "/somewhere/else"); got != want { + t.Errorf("with a stale cwd: %q, want %q", got, want) + } + if got := tx.find(xid, ""); got != wantX { + t.Errorf("codex thread: %q, want %q", got, wantX) + } + if got := tx.find("nosuchsession", ""); got != "" { + t.Errorf("unknown session found %q", got) + } + if got := newTranscripts(nil).find(cid, filepath.Dir(outside)); got != "" { + t.Errorf("no roots configured, yet found %q", got) + } + if under(claudeRoot, outside) { + t.Error("a path outside a root passed the check") + } +} + +// A 5 MB transcript is parsed once and then only re-read when it changes. +func TestTranscriptCacheKeyedByFile(t *testing.T) { + root := t.TempDir() + path := writeFile(t, filepath.Join(root, "-repo", "s1.jsonl"), claudeJSONL) + tx := newTranscripts([]string{root}) + + first := tx.load("s1", "/repo") + if first == nil || len(first.Turns) == 0 { + t.Fatal("nothing parsed") + } + if again := tx.load("s1", "/repo"); again != first { + t.Error("an unchanged transcript was parsed twice") + } + writeFile(t, path, claudeJSONL+`{"type":"assistant","timestamp":"2026-07-20T11:00:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"one more thing"}]}}`+"\n") + future := time.Now().Add(time.Second) + os.Chtimes(path, future, future) + after := tx.load("s1", "/repo") + if after == first || len(after.Turns) != len(first.Turns)+1 { + t.Errorf("a changed transcript was not re-parsed: %d -> %d turns", len(first.Turns), len(after.Turns)) + } + if tx.load("nope", "/repo") != nil { + t.Error("a missing transcript should be nil, not empty") + } +} + +// ── windowing ──────────────────────────────────────────────────────────── + +func TestConvWindowIsBounded(t *testing.T) { + turns := make([]Turn, 500) + for i := range turns { + turns[i] = Turn{Role: "assistant", Text: fmt.Sprint(i)} + } + from, got := convWindowAt(turns, -1, false) + if len(got) != convWindow || from != 500-convWindow || got[len(got)-1].Text != "499" { + t.Errorf("unanchored window = %d turns from %d", len(got), from) + } + from, got = convWindowAt(turns, 200, false) + if len(got) != convWindow || from > 200 || from+len(got) <= 200 { + t.Errorf("window %d..%d does not hold turn 200", from, from+len(got)) + } + from, got = convWindowAt(turns, 498, false) + if len(got) != convWindow || from+len(got) != 500 { + t.Errorf("window near the end = %d..%d", from, from+len(got)) + } + if _, got = convWindowAt(turns, 200, true); len(got) != 500 { + t.Errorf("show all gave %d of 500 turns", len(got)) + } + if _, got = convWindowAt(turns[:10], -1, false); len(got) != 10 { + t.Errorf("short conversation was cut to %d", len(got)) + } +} + +// ── the session page ───────────────────────────────────────────────────── + +// sessionServer is testServer with a Claude Code transcript for session s1, +// filed under the repo path the log entries carry. +func sessionServer(t *testing.T) (*server, string) { + t.Helper() + sv, _, _ := testServer(t) + root := t.TempDir() + writeFile(t, filepath.Join(root, mangleDir(sv.repo), "s1.jsonl"), claudeJSONL) + sv.tx = newTranscripts([]string{root}) + return sv, root +} + +func TestSessionPageInterleavesLogAndTranscript(t *testing.T) { + sv, _ := sessionServer(t) + body := get(t, sv.handler(), "/session/s1?note=1", false).Body.String() + for _, want := range []string{ + "read lib.rs and tell me about the cache", // the conversation + "It is a two-level map.", + "vr read src/lib.rs:1-30", // a tool call, summarized + "read hello.txt:1-2", // the vr log, woven in + "second line matters", // the note itself + `data-anchor="note-1"`, // and it is what the page lands on + `class="turn t-note here"`, // marked as the anchored moment + } { + if !strings.Contains(body, want) { + t.Errorf("session page is missing %q", want) + } + } + // the whole file is never in the page + if strings.Contains(body, "use std::collections::HashMap;\n2") { + t.Error("a tool result was dumped in full") + } + // ordering: the note was logged after the first message, before the last + iFirst := strings.Index(body, "read lib.rs and tell me") + iNote := strings.Index(body, "second line matters") + iLast := strings.Index(body, "It is a two-level map.") + if !(iFirst < iNote && iNote < iLast) { + t.Errorf("turns are out of order: %d %d %d", iFirst, iNote, iLast) + } +} + +func TestSessionPageWithoutATranscript(t *testing.T) { + sv, _, _ := testServer(t) + sv.tx = newTranscripts([]string{t.TempDir()}) + body := get(t, sv.handler(), "/session/s1", false).Body.String() + if !strings.Contains(body, "no transcript found") { + t.Error("the page does not say the transcript is missing") + } + if !strings.Contains(body, sv.tx.roots[0]) { + t.Error("the page does not say where it looked") + } + if !strings.Contains(body, "read hello.txt:1-2") || !strings.Contains(body, "second line matters") { + t.Error("the vr timeline is missing from the fallback") + } +} + +func TestSessionPageRejectsUnsafeIDs(t *testing.T) { + sv, _ := sessionServer(t) + h := sv.handler() + if w := get(t, h, "/session/a%20b", false); w.Code != 400 { + t.Errorf("GET /session/a b = %d, want 400", w.Code) + } + if w := get(t, h, "/session/unknown-session", false); w.Code != 200 || + !strings.Contains(w.Body.String(), "no transcript found") { + t.Errorf("unknown session = %d", w.Code) + } +} + +// Live pages link a note to its conversation; the static export has no server +// to serve one from. +func TestConversationLinksAreLiveOnly(t *testing.T) { + sv, _ := sessionServer(t) + h := sv.handler() + if b := get(t, h, "/notes", false).Body.String(); !strings.Contains(b, `href="/session/s1?note=1#note-1"`) { + t.Error("no conversation link on a note card") + } + if b := get(t, h, "/", false).Body.String(); !strings.Contains(b, `href="/session/s1"`) { + t.Error("the agents table does not link the session") + } + if b := get(t, h, "/activity", true).Body.String(); !strings.Contains(b, `href="/session/s1"`) { + t.Error("the activity table does not link the session") + } + if b := get(t, h, "/session/s1?note=1", false).Body.String(); strings.Contains(b, ">conversation<") { + t.Error("the session page links back to itself") + } + static := &page{Root: "../"} + if got := static.ConvHref(&Note{ID: 1, Session: "s1"}); got != "" { + t.Errorf("static conversation href = %q, want none", got) + } +} -- cgit v1.3