Skip to content

Commit d428dd2

Browse files
aksOpsclaude
andcommitted
feat(serve,ui): V9 inline Edit/Write diff viewer
Backend: GET /api/sessions/{name}/tool_calls/{id}/detail — reads the tool_call row by hub event ID from the session's JSONL log and returns a rendered unified diff when the tool is Edit/MultiEdit/Write. Non-diff tools return detail without the Diff field. Frontend: - ToolCallRow gets an expand chevron for Edit/MultiEdit/Write rows with a known hub event id. - useToolCallDetail hook fetches detail on demand (TanStack Query, 5 min staleTime, enabled only when expanded). - Classified diff rendering shared via ui/src/lib/diff.ts so the existing DiffSheet can reuse the same line classifier. - SseProvider now stamps ev.id onto ToolCallRow cache entries so the expand button has an id to query. REST-seeded rows (no id) refresh from SSE once the stream opens. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7e4acd7 commit d428dd2

11 files changed

Lines changed: 1329 additions & 8 deletions

File tree

internal/serve/api/tool_call_detail.go

Lines changed: 497 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"testing"
11+
"time"
12+
)
13+
14+
// fakeLogReader is the testing seam for ToolCallDetail. Returns canned
15+
// Details keyed on (session, id); any key not present reports
16+
// ErrDetailNotFound so the handler's 404 path can be exercised.
17+
type fakeLogReader struct {
18+
items map[string]Detail
19+
err error
20+
}
21+
22+
func (f fakeLogReader) ReadDetail(session, id string) (Detail, error) {
23+
if f.err != nil {
24+
return Detail{}, f.err
25+
}
26+
if d, ok := f.items[session+"|"+id]; ok {
27+
return d, nil
28+
}
29+
return Detail{}, ErrDetailNotFound
30+
}
31+
32+
func newDetailRequest(t *testing.T, session, id string) *http.Request {
33+
t.Helper()
34+
req := httptest.NewRequest(
35+
http.MethodGet,
36+
"/api/sessions/"+session+"/tool_calls/"+id+"/detail",
37+
nil,
38+
)
39+
req.SetPathValue("name", session)
40+
req.SetPathValue("id", id)
41+
return req
42+
}
43+
44+
func TestToolCallDetail_HappyPathReturnsJSON(t *testing.T) {
45+
want := Detail{
46+
Tool: "Edit",
47+
InputJSON: `{"file_path":"/tmp/a.go","old_string":"foo","new_string":"bar"}`,
48+
OutputExcerpt: "ok",
49+
TS: "2026-04-21T16:28:00Z",
50+
IsError: false,
51+
Diff: "--- a/tmp/a.go\n+++ b/tmp/a.go\n@@ -1,1 +1,1 @@\n-foo\n+bar\n",
52+
}
53+
h := ToolCallDetail(fakeLogReader{
54+
items: map[string]Detail{"alpha|17771234-0": want},
55+
})
56+
rec := httptest.NewRecorder()
57+
h(rec, newDetailRequest(t, "alpha", "17771234-0"))
58+
59+
if rec.Code != http.StatusOK {
60+
t.Fatalf("status = %d, want 200", rec.Code)
61+
}
62+
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
63+
t.Errorf("Content-Type = %q, want application/json", ct)
64+
}
65+
var got Detail
66+
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
67+
t.Fatalf("decode: %v", err)
68+
}
69+
if got != want {
70+
t.Errorf("body mismatch\n got=%+v\nwant=%+v", got, want)
71+
}
72+
}
73+
74+
func TestToolCallDetail_NotFoundReturns404(t *testing.T) {
75+
h := ToolCallDetail(fakeLogReader{items: map[string]Detail{}})
76+
rec := httptest.NewRecorder()
77+
h(rec, newDetailRequest(t, "alpha", "doesnotexist-0"))
78+
if rec.Code != http.StatusNotFound {
79+
t.Errorf("status = %d, want 404", rec.Code)
80+
}
81+
}
82+
83+
func TestToolCallDetail_MethodNotGet(t *testing.T) {
84+
h := ToolCallDetail(fakeLogReader{})
85+
for _, method := range []string{
86+
http.MethodPost,
87+
http.MethodPut,
88+
http.MethodDelete,
89+
http.MethodPatch,
90+
} {
91+
rec := httptest.NewRecorder()
92+
req := httptest.NewRequest(
93+
method,
94+
"/api/sessions/alpha/tool_calls/1-0/detail",
95+
nil,
96+
)
97+
req.SetPathValue("name", "alpha")
98+
req.SetPathValue("id", "1-0")
99+
h(rec, req)
100+
if rec.Code != http.StatusMethodNotAllowed {
101+
t.Errorf("%s: status = %d, want 405", method, rec.Code)
102+
}
103+
if got := rec.Header().Get("Allow"); got != http.MethodGet {
104+
t.Errorf("%s: Allow = %q, want GET", method, got)
105+
}
106+
}
107+
}
108+
109+
func TestToolCallDetail_EmptyPathVarsReturn400(t *testing.T) {
110+
h := ToolCallDetail(fakeLogReader{})
111+
rec := httptest.NewRecorder()
112+
req := httptest.NewRequest(http.MethodGet, "/api/sessions//tool_calls//detail", nil)
113+
req.SetPathValue("name", "")
114+
req.SetPathValue("id", "")
115+
h(rec, req)
116+
if rec.Code != http.StatusBadRequest {
117+
t.Errorf("status = %d, want 400", rec.Code)
118+
}
119+
}
120+
121+
// renderDiff unit tests — exercise the three tool shapes. These hit
122+
// the rendering directly rather than going through the HTTP layer so
123+
// failures pinpoint the diff builder.
124+
125+
func TestRenderDiff_EditEmitsReplaceHunk(t *testing.T) {
126+
raw := map[string]any{
127+
"tool_name": "Edit",
128+
"tool_input": map[string]any{
129+
"file_path": "/tmp/a.go",
130+
"old_string": "package foo\n\nfunc Bar() {}",
131+
"new_string": "package foo\n\nfunc Baz() {}",
132+
},
133+
}
134+
got := renderDiff("Edit", raw)
135+
mustContain(t, got, "--- a//tmp/a.go")
136+
mustContain(t, got, "+++ b//tmp/a.go")
137+
mustContain(t, got, "@@ -1,3 +1,3 @@")
138+
mustContain(t, got, "-func Bar() {}")
139+
mustContain(t, got, "+func Baz() {}")
140+
}
141+
142+
func TestRenderDiff_WriteEmitsAllAddedHunk(t *testing.T) {
143+
raw := map[string]any{
144+
"tool_name": "Write",
145+
"tool_input": map[string]any{
146+
"file_path": "/tmp/new.txt",
147+
"content": "hello\nworld\n",
148+
},
149+
}
150+
got := renderDiff("Write", raw)
151+
mustContain(t, got, "@@ -0,0 +1,2 @@")
152+
mustContain(t, got, "+hello")
153+
mustContain(t, got, "+world")
154+
if strings.Contains(got, "-") {
155+
// "-" would indicate a removed-line leak into an all-added
156+
// hunk. Check a position-aware pattern: "\n-" at the start
157+
// of a line is the problem shape.
158+
if strings.Contains(got, "\n-") {
159+
t.Errorf("Write diff must contain no removed lines:\n%s", got)
160+
}
161+
}
162+
}
163+
164+
func TestRenderDiff_MultiEditEmitsMultipleHunks(t *testing.T) {
165+
raw := map[string]any{
166+
"tool_name": "MultiEdit",
167+
"tool_input": map[string]any{
168+
"file_path": "/tmp/multi.go",
169+
"edits": []any{
170+
map[string]any{
171+
"old_string": "alpha",
172+
"new_string": "ALPHA",
173+
},
174+
map[string]any{
175+
"old_string": "beta",
176+
"new_string": "BETA",
177+
},
178+
},
179+
},
180+
}
181+
got := renderDiff("MultiEdit", raw)
182+
hunks := strings.Count(got, "@@ -")
183+
if hunks != 2 {
184+
t.Errorf("hunk count = %d, want 2\n%s", hunks, got)
185+
}
186+
for _, token := range []string{"-alpha", "+ALPHA", "-beta", "+BETA"} {
187+
mustContain(t, got, token)
188+
}
189+
}
190+
191+
func TestRenderDiff_NonDiffToolReturnsEmpty(t *testing.T) {
192+
raw := map[string]any{
193+
"tool_name": "Bash",
194+
"tool_input": map[string]any{"command": "ls"},
195+
}
196+
if got := renderDiff("Bash", raw); got != "" {
197+
t.Errorf("Bash diff should be empty, got:\n%s", got)
198+
}
199+
if got := renderDiff("Read", raw); got != "" {
200+
t.Errorf("Read diff should be empty, got:\n%s", got)
201+
}
202+
}
203+
204+
// JSONLLogReader integration — drives the real scan path against a
205+
// hand-authored tailer-shaped JSONL file. Kept lean: one session, one
206+
// matching line, one distractor line.
207+
208+
func TestJSONLLogReader_FindsMatchByTS(t *testing.T) {
209+
dir := t.TempDir()
210+
uuid := "11111111-2222-3333-4444-555555555555"
211+
path := filepath.Join(dir, uuid+".jsonl")
212+
213+
ts := time.Date(2026, 4, 21, 16, 28, 0, 0, time.UTC)
214+
// Distractor: older line that should NOT match because its ts is
215+
// >1 s away from the target.
216+
older := map[string]any{
217+
"tool_name": "Bash",
218+
"tool_input": map[string]any{"command": "ls"},
219+
"ctm_timestamp": ts.Add(-10 * time.Second).Format(time.RFC3339),
220+
}
221+
// Target: Edit call right at the target second.
222+
target := map[string]any{
223+
"tool_name": "Edit",
224+
"tool_input": map[string]any{
225+
"file_path": "/tmp/x.go",
226+
"old_string": "foo",
227+
"new_string": "bar",
228+
},
229+
"tool_response": map[string]any{
230+
"output": "ok",
231+
"is_error": false,
232+
},
233+
"ctm_timestamp": ts.Format(time.RFC3339),
234+
}
235+
writeJSONLLine(t, path, older)
236+
writeJSONLLine(t, path, target)
237+
238+
reader := &JSONLLogReader{
239+
LogDir: dir,
240+
Resolver: staticResolver{"alpha": uuid},
241+
}
242+
// id = "<unix-nano>-<seq>"; nanos ÷ 1e9 == ts.Unix().
243+
id := toID(ts)
244+
d, err := reader.ReadDetail("alpha", id)
245+
if err != nil {
246+
t.Fatalf("ReadDetail: %v", err)
247+
}
248+
if d.Tool != "Edit" {
249+
t.Errorf("Tool = %q, want Edit", d.Tool)
250+
}
251+
if !strings.Contains(d.Diff, "-foo") || !strings.Contains(d.Diff, "+bar") {
252+
t.Errorf("Diff missing replace content:\n%s", d.Diff)
253+
}
254+
if d.TS != ts.Format(time.RFC3339) {
255+
t.Errorf("TS = %q, want %q", d.TS, ts.Format(time.RFC3339))
256+
}
257+
}
258+
259+
func TestJSONLLogReader_MissingFileIsNotFound(t *testing.T) {
260+
dir := t.TempDir()
261+
reader := &JSONLLogReader{
262+
LogDir: dir,
263+
Resolver: staticResolver{
264+
"alpha": "99999999-9999-9999-9999-999999999999",
265+
},
266+
}
267+
_, err := reader.ReadDetail("alpha", toID(time.Now()))
268+
if err != ErrDetailNotFound {
269+
t.Errorf("err = %v, want ErrDetailNotFound", err)
270+
}
271+
}
272+
273+
func TestJSONLLogReader_UnknownSessionIsNotFound(t *testing.T) {
274+
reader := &JSONLLogReader{
275+
LogDir: t.TempDir(),
276+
Resolver: staticResolver{},
277+
}
278+
_, err := reader.ReadDetail("nope", toID(time.Now()))
279+
if err != ErrDetailNotFound {
280+
t.Errorf("err = %v, want ErrDetailNotFound", err)
281+
}
282+
}
283+
284+
// --- helpers ---------------------------------------------------------
285+
286+
type staticResolver map[string]string
287+
288+
func (s staticResolver) ResolveName(name string) (string, bool) {
289+
u, ok := s[name]
290+
return u, ok
291+
}
292+
293+
func writeJSONLLine(t *testing.T, path string, payload map[string]any) {
294+
t.Helper()
295+
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
296+
if err != nil {
297+
t.Fatalf("open: %v", err)
298+
}
299+
defer f.Close()
300+
body, err := json.Marshal(payload)
301+
if err != nil {
302+
t.Fatalf("marshal: %v", err)
303+
}
304+
if _, err := f.Write(append(body, '\n')); err != nil {
305+
t.Fatalf("write: %v", err)
306+
}
307+
}
308+
309+
// toID mirrors the hub's ID format "<unix-nano>-<seq>" for a given
310+
// wall clock. Seq is always 0 here — we only match on the nanosecond
311+
// prefix.
312+
func toID(t time.Time) string {
313+
return timeNanoString(t) + "-0"
314+
}
315+
316+
func timeNanoString(t time.Time) string {
317+
return strings0Pad(t.UnixNano())
318+
}
319+
320+
// strings0Pad formats an int64 as decimal without thousand separators.
321+
// Named instead of calling strconv directly so the helper file stays
322+
// import-lean — the production path already imports strconv.
323+
func strings0Pad(n int64) string {
324+
// Local FormatInt avoids a second strconv import line here.
325+
return fmtInt(n)
326+
}
327+
328+
func fmtInt(n int64) string {
329+
// Simple wrapper around strconv.FormatInt via json.
330+
b, _ := json.Marshal(n)
331+
return string(b)
332+
}
333+
334+
func mustContain(t *testing.T, haystack, needle string) {
335+
t.Helper()
336+
if !strings.Contains(haystack, needle) {
337+
t.Errorf("missing %q in:\n%s", needle, haystack)
338+
}
339+
}

internal/serve/server.go

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

469+
// V9 inline Edit/Write diff viewer — looks up a single tool_call
470+
// row by its hub event ID and returns a rendered unified diff when
471+
// the tool is Edit/MultiEdit/Write.
472+
mux.Handle(
473+
"GET /api/sessions/{name}/tool_calls/{id}/detail",
474+
authHF(api.ToolCallDetail(api.NewJSONLLogReader(s.logDir, s.proj))),
475+
)
476+
469477
// Debug: hub counters + subscriber count. Gated on auth; useful
470478
// from curl to check whether publishes are flowing and whether
471479
// the browser is actually subscribed.

0 commit comments

Comments
 (0)