|
| 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 | +} |
0 commit comments