// SPDX-Licence-Identifier: EUPL-1.2 package session import ( "bytes" "path" "syscall" "testing" "time" core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // --- helpers to build synthetic JSONL --- // ts returns a stable timestamp offset by the given seconds from a fixed epoch. func ts(offsetSec int) string { base := time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC) return base.Add(time.Duration(offsetSec) * time.Second).Format(time.RFC3339Nano) } // jsonlLine marshals an arbitrary map to a single JSONL line. func jsonlLine(m map[string]any) string { marshalResult := core.JSONMarshal(m) if !marshalResult.OK { panic(resultError(marshalResult)) } return string(marshalResult.Value.([]byte)) } // userTextEntry creates a JSONL line for a user text message. func userTextEntry(timestamp string, text string) string { return jsonlLine(map[string]any{ "type": "user", "timestamp": timestamp, "sessionId": "test-session", "message": map[string]any{ "role": "user", "content": []map[string]any{ {"type": "text", "text": text}, }, }, }) } // assistantTextEntry creates a JSONL line for an assistant text message. func assistantTextEntry(timestamp string, text string) string { return jsonlLine(map[string]any{ "type": "assistant", "timestamp": timestamp, "sessionId": "test-session", "message": map[string]any{ "role": "assistant", "content": []map[string]any{ {"type": "text", "text": text}, }, }, }) } // toolUseEntry creates a JSONL line for an assistant message containing a tool_use block. func toolUseEntry(timestamp, toolName, toolID string, input map[string]any) string { return jsonlLine(map[string]any{ "type": "assistant", "timestamp": timestamp, "sessionId": "test-session", "message": map[string]any{ "role": "assistant", "content": []map[string]any{ { "type": "tool_use", "name": toolName, "id": toolID, "input": input, }, }, }, }) } // toolResultEntry creates a JSONL line for a user message containing a tool_result block. func toolResultEntry(timestamp, toolUseID string, content any, isError bool) string { entry := map[string]any{ "type": "user", "timestamp": timestamp, "sessionId": "test-session", "message": map[string]any{ "role": "user", "content": []map[string]any{ { "type": "tool_result", "tool_use_id": toolUseID, "content": content, "is_error": isError, }, }, }, } return jsonlLine(entry) } // writeJSONL writes lines to a temp .jsonl file and returns its path. func writeJSONL(t *testing.T, dir string, name string, lines ...string) string { t.Helper() filePath := path.Join(dir, name) writeResult := hostFS.Write(filePath, core.Concat(core.Join("\n", lines...), "\n")) require.True(t, writeResult.OK) return filePath } func setFileTimes(filePath string, atime, mtime time.Time) error { return syscall.UtimesNano(filePath, []syscall.Timespec{ syscall.NsecToTimespec(atime.UnixNano()), syscall.NsecToTimespec(mtime.UnixNano()), }) } // --- ParseTranscript tests --- func TestParser_ParseTranscriptMinimalValid_Good(t *testing.T) { dir := t.TempDir() path := writeJSONL(t, dir, "minimal.jsonl", userTextEntry(ts(0), "Hello"), assistantTextEntry(ts(1), "Hi there!"), ) sess, _, err := ParseTranscript(path) require.NoError(t, err) require.NotNil(t, sess) assert.Equal(t, "minimal", sess.ID) assert.Equal(t, path, sess.Path) assert.False(t, sess.StartTime.IsZero(), "StartTime should be set") assert.False(t, sess.EndTime.IsZero(), "EndTime should be set") assert.True(t, sess.EndTime.After(sess.StartTime) || sess.EndTime.Equal(sess.StartTime)) // Should have a user event and an assistant event require.Len(t, sess.Events, 2) assert.Equal(t, "user", sess.Events[0].Type) assert.Equal(t, "Hello", sess.Events[0].Input) assert.Equal(t, "assistant", sess.Events[1].Type) assert.Equal(t, "Hi there!", sess.Events[1].Input) } func TestParser_ParseTranscriptToolCalls_Good(t *testing.T) { dir := t.TempDir() lines := []string{ userTextEntry(ts(0), "Run a command"), // Bash tool_use toolUseEntry(ts(1), "Bash", "tool-bash-1", map[string]any{ "command": "ls -la", "description": "list files", }), toolResultEntry(ts(2), "tool-bash-1", "total 42\ndrwxr-xr-x 5 user staff 160 Feb 20 10:00 .", false), // Read tool_use toolUseEntry(ts(3), "Read", "tool-read-1", map[string]any{ "file_path": "/tmp/test.go", }), toolResultEntry(ts(4), "tool-read-1", "package main\n\nfunc main() {}", false), // Edit tool_use toolUseEntry(ts(5), "Edit", "tool-edit-1", map[string]any{ "file_path": "/tmp/test.go", "old_string": "main", "new_string": "app", }), toolResultEntry(ts(6), "tool-edit-1", "ok", false), // Write tool_use toolUseEntry(ts(7), "Write", "tool-write-1", map[string]any{ "file_path": "/tmp/new.go", "content": "package new\n", }), toolResultEntry(ts(8), "tool-write-1", "ok", false), // Grep tool_use toolUseEntry(ts(9), "Grep", "tool-grep-1", map[string]any{ "pattern": "TODO", "path": "/tmp", }), toolResultEntry(ts(10), "tool-grep-1", "/tmp/test.go:3:// TODO fix this", false), // Glob tool_use toolUseEntry(ts(11), "Glob", "tool-glob-1", map[string]any{ "pattern": "**/*.go", }), toolResultEntry(ts(12), "tool-glob-1", "/tmp/test.go\n/tmp/new.go", false), // Task tool_use toolUseEntry(ts(13), "Task", "tool-task-1", map[string]any{ "prompt": "Analyse the code", "description": "Code analysis", "subagent_type": "research", }), toolResultEntry(ts(14), "tool-task-1", "Analysis complete", false), assistantTextEntry(ts(15), "All done."), } path := writeJSONL(t, dir, "tools.jsonl", lines...) sess, _, err := ParseTranscript(path) require.NoError(t, err) // Count tool_use events var toolEvents []Event for _, e := range sess.Events { if e.Type == "tool_use" { toolEvents = append(toolEvents, e) } } require.Len(t, toolEvents, 7, "should have 7 tool_use events") // Verify each tool was parsed correctly assert.Equal(t, "Bash", toolEvents[0].Tool) assert.Contains(t, toolEvents[0].Input, "ls -la") assert.Contains(t, toolEvents[0].Input, "# list files") assert.True(t, toolEvents[0].Success) assert.Equal(t, time.Second, toolEvents[0].Duration) assert.Equal(t, "Read", toolEvents[1].Tool) assert.Equal(t, "/tmp/test.go", toolEvents[1].Input) assert.Equal(t, "Edit", toolEvents[2].Tool) assert.Equal(t, "/tmp/test.go (edit)", toolEvents[2].Input) assert.Equal(t, "Write", toolEvents[3].Tool) assert.Equal(t, "/tmp/new.go (12 bytes)", toolEvents[3].Input) assert.Equal(t, "Grep", toolEvents[4].Tool) assert.Equal(t, "/TODO/ in /tmp", toolEvents[4].Input) assert.Equal(t, "Glob", toolEvents[5].Tool) assert.Equal(t, "**/*.go", toolEvents[5].Input) assert.Equal(t, "Task", toolEvents[6].Tool) assert.Equal(t, "[research] Code analysis", toolEvents[6].Input) } func TestParser_ParseTranscriptToolError_Good(t *testing.T) { dir := t.TempDir() path := writeJSONL(t, dir, "error.jsonl", toolUseEntry(ts(0), "Bash", "tool-err-1", map[string]any{ "command": "cat /nonexistent", }), toolResultEntry(ts(1), "tool-err-1", "cat: /nonexistent: No such file or directory", true), ) sess, _, err := ParseTranscript(path) require.NoError(t, err) var toolEvents []Event for _, e := range sess.Events { if e.Type == "tool_use" { toolEvents = append(toolEvents, e) } } require.Len(t, toolEvents, 1) assert.False(t, toolEvents[0].Success) assert.Contains(t, toolEvents[0].ErrorMsg, "No such file or directory") } func TestParser_ParseTranscriptEmptyFile_Bad(t *testing.T) { dir := t.TempDir() path := writeJSONL(t, dir, "empty.jsonl") // Write a truly empty file writeResult := hostFS.Write(path, "") require.True(t, writeResult.OK) sess, _, err := ParseTranscript(path) require.NoError(t, err) require.NotNil(t, sess) assert.Empty(t, sess.Events) assert.True(t, sess.StartTime.IsZero()) } func TestParser_ParseTranscriptMalformedJSON_Bad(t *testing.T) { dir := t.TempDir() path := writeJSONL(t, dir, "malformed.jsonl", `{invalid json`, `{"type": "user", "timestamp": "`+ts(0)+`", not valid`, userTextEntry(ts(1), "This line is valid"), `}}}`, assistantTextEntry(ts(2), "This is also valid"), ) sess, _, err := ParseTranscript(path) require.NoError(t, err, "malformed lines should be skipped, not cause an error") require.NotNil(t, sess) // Only the valid lines should produce events assert.Len(t, sess.Events, 2) assert.Equal(t, "user", sess.Events[0].Type) assert.Equal(t, "assistant", sess.Events[1].Type) } func TestParser_ParseTranscriptTruncatedJSONL_Bad(t *testing.T) { dir := t.TempDir() validLine := userTextEntry(ts(0), "Hello") // Truncated line: cut a valid JSON line in half truncated := assistantTextEntry(ts(1), "World") truncated = truncated[:len(truncated)/2] path := writeJSONL(t, dir, "truncated.jsonl", validLine, truncated) sess, _, err := ParseTranscript(path) require.NoError(t, err, "truncated last line should be skipped gracefully") require.NotNil(t, sess) // Only the first valid line should produce an event assert.Len(t, sess.Events, 1) assert.Equal(t, "user", sess.Events[0].Type) } func TestParser_ParseTranscriptLargeSession_Good(t *testing.T) { dir := t.TempDir() var lines []string lines = append(lines, userTextEntry(ts(0), "Start large session")) // Generate 1000+ tool call pairs for i := range 1100 { toolID := core.Sprintf("tool-%d", i) offset := (i * 2) + 1 lines = append(lines, toolUseEntry(ts(offset), "Bash", toolID, map[string]any{ "command": core.Sprintf("echo %d", i), }), toolResultEntry(ts(offset+1), toolID, core.Sprintf("output %d", i), false), ) } lines = append(lines, assistantTextEntry(ts(2202), "Done")) path := writeJSONL(t, dir, "large.jsonl", lines...) sess, _, err := ParseTranscript(path) require.NoError(t, err) var toolCount int for _, e := range sess.Events { if e.Type == "tool_use" { toolCount++ } } assert.Equal(t, 1100, toolCount, "all 1100 tool events should be parsed") } func TestParser_ParseTranscriptNestedToolResults_Good(t *testing.T) { dir := t.TempDir() // Tool result with array content (multiple text blocks) arrayContent := []map[string]any{ {"type": "text", "text": "First block"}, {"type": "text", "text": "Second block"}, } lines := []string{ toolUseEntry(ts(0), "Bash", "tool-nested-1", map[string]any{ "command": "complex output", }), // Build the tool result with array content manually jsonlLine(map[string]any{ "type": "user", "timestamp": ts(1), "sessionId": "test-session", "message": map[string]any{ "role": "user", "content": []map[string]any{ { "type": "tool_result", "tool_use_id": "tool-nested-1", "content": arrayContent, "is_error": false, }, }, }, }), } path := writeJSONL(t, dir, "nested.jsonl", lines...) sess, _, err := ParseTranscript(path) require.NoError(t, err) var toolEvents []Event for _, e := range sess.Events { if e.Type == "tool_use" { toolEvents = append(toolEvents, e) } } require.Len(t, toolEvents, 1) assert.Contains(t, toolEvents[0].Output, "First block") assert.Contains(t, toolEvents[0].Output, "Second block") } func TestParser_ParseTranscriptNestedMapResult_Good(t *testing.T) { dir := t.TempDir() lines := []string{ toolUseEntry(ts(0), "Read", "tool-map-1", map[string]any{ "file_path": "/tmp/data.json", }), // Build a tool result with map content jsonlLine(map[string]any{ "type": "user", "timestamp": ts(1), "sessionId": "test-session", "message": map[string]any{ "role": "user", "content": []map[string]any{ { "type": "tool_result", "tool_use_id": "tool-map-1", "content": map[string]any{ "text": "file contents here", }, "is_error": false, }, }, }, }), } path := writeJSONL(t, dir, "map-result.jsonl", lines...) sess, _, err := ParseTranscript(path) require.NoError(t, err) var toolEvents []Event for _, e := range sess.Events { if e.Type == "tool_use" { toolEvents = append(toolEvents, e) } } require.Len(t, toolEvents, 1) assert.Contains(t, toolEvents[0].Output, "file contents here") } func TestParser_ParseTranscriptFileNotFound_Ugly(t *testing.T) { _, _, err := ParseTranscript("/nonexistent/path/session.jsonl") require.Error(t, err) assert.Contains(t, err.Error(), "open transcript") } func TestParser_ParseTranscriptSessionIDFromFilename_Good(t *testing.T) { dir := t.TempDir() path := writeJSONL(t, dir, "abc123def456.jsonl", userTextEntry(ts(0), "test"), ) sess, _, err := ParseTranscript(path) require.NoError(t, err) assert.Equal(t, "abc123def456", sess.ID) } func TestParser_ParseTranscriptTimestampsTracked_Good(t *testing.T) { dir := t.TempDir() path := writeJSONL(t, dir, "timestamps.jsonl", userTextEntry(ts(0), "start"), assistantTextEntry(ts(5), "middle"), userTextEntry(ts(10), "end"), ) sess, _, err := ParseTranscript(path) require.NoError(t, err) expectedStart, _ := time.Parse(time.RFC3339Nano, ts(0)) expectedEnd, _ := time.Parse(time.RFC3339Nano, ts(10)) assert.Equal(t, expectedStart, sess.StartTime) assert.Equal(t, expectedEnd, sess.EndTime) } func TestParser_ParseTranscriptTextTruncation_Good(t *testing.T) { dir := t.TempDir() longText := repeatString("x", 600) path := writeJSONL(t, dir, "truncation.jsonl", userTextEntry(ts(0), longText), ) sess, _, err := ParseTranscript(path) require.NoError(t, err) require.Len(t, sess.Events, 1) // Input should be truncated to 500 + "..." assert.True(t, len(sess.Events[0].Input) <= 504, "input should be truncated") assert.True(t, core.HasSuffix(sess.Events[0].Input, "..."), "truncated text should end with ...") } func TestParser_SessionEventsSeq_Good(t *testing.T) { sess := &Session{ Events: []Event{ {Type: "user", Input: "one"}, {Type: "assistant", Input: "two"}, {Type: "tool_use", Tool: "Bash", Input: "three"}, }, } var events []Event for e := range sess.EventsSeq() { events = append(events, e) } assert.Equal(t, sess.Events, events) } func TestParser_ParseTranscriptMixedContentBlocks_Good(t *testing.T) { // Assistant message with both text and tool_use in the same message dir := t.TempDir() lines := []string{ // An assistant message with text + tool_use in the same content array jsonlLine(map[string]any{ "type": "assistant", "timestamp": ts(0), "sessionId": "test-session", "message": map[string]any{ "role": "assistant", "content": []map[string]any{ {"type": "text", "text": "Let me check that file."}, { "type": "tool_use", "name": "Read", "id": "tool-mixed-1", "input": map[string]any{"file_path": "/tmp/mix.go"}, }, }, }, }), toolResultEntry(ts(1), "tool-mixed-1", "package mix", false), } path := writeJSONL(t, dir, "mixed.jsonl", lines...) sess, _, err := ParseTranscript(path) require.NoError(t, err) // Should have an assistant text event + a tool_use event require.Len(t, sess.Events, 2) assert.Equal(t, "assistant", sess.Events[0].Type) assert.Equal(t, "tool_use", sess.Events[1].Type) assert.Equal(t, "Read", sess.Events[1].Tool) } func TestParser_ParseTranscriptUnmatchedToolResult_Bad(t *testing.T) { // A tool_result with no matching tool_use should be silently ignored dir := t.TempDir() path := writeJSONL(t, dir, "unmatched.jsonl", toolResultEntry(ts(0), "nonexistent-tool-id", "orphan result", false), userTextEntry(ts(1), "Normal message"), ) sess, _, err := ParseTranscript(path) require.NoError(t, err) // Only the user text event should appear; the orphan tool result is ignored require.Len(t, sess.Events, 1) assert.Equal(t, "user", sess.Events[0].Type) } func TestParser_ParseTranscriptEmptyTimestamp_Bad(t *testing.T) { dir := t.TempDir() // Entry with empty timestamp line := jsonlLine(map[string]any{ "type": "user", "timestamp": "", "sessionId": "test-session", "message": map[string]any{ "role": "user", "content": []map[string]any{ {"type": "text", "text": "No timestamp"}, }, }, }) path := writeJSONL(t, dir, "no-ts.jsonl", line) sess, _, err := ParseTranscript(path) require.NoError(t, err) // The event should still be parsed, but StartTime remains zero assert.True(t, sess.StartTime.IsZero()) } // --- ListSessions tests --- func TestParser_ListSessionsEmptyDir_Good(t *testing.T) { dir := t.TempDir() sessions, err := ListSessions(dir) require.NoError(t, err) assert.Empty(t, sessions) } func TestParser_ListSessionsSingleSession_Good(t *testing.T) { dir := t.TempDir() writeJSONL(t, dir, "session-abc.jsonl", userTextEntry(ts(0), "Hello"), assistantTextEntry(ts(5), "World"), ) sessions, err := ListSessions(dir) require.NoError(t, err) require.Len(t, sessions, 1) assert.Equal(t, "session-abc", sessions[0].ID) assert.False(t, sessions[0].StartTime.IsZero()) assert.False(t, sessions[0].EndTime.IsZero()) } func TestParser_ListSessionsMultipleSorted_Good(t *testing.T) { dir := t.TempDir() // Create three sessions with different timestamps. // Session "old" starts at ts(0), "mid" at ts(100), "new" at ts(200). writeJSONL(t, dir, "old.jsonl", userTextEntry(ts(0), "old session"), ) writeJSONL(t, dir, "mid.jsonl", userTextEntry(ts(100), "mid session"), ) writeJSONL(t, dir, "new.jsonl", userTextEntry(ts(200), "new session"), ) sessions, err := ListSessions(dir) require.NoError(t, err) require.Len(t, sessions, 3) // Should be sorted newest first assert.Equal(t, "new", sessions[0].ID) assert.Equal(t, "mid", sessions[1].ID) assert.Equal(t, "old", sessions[2].ID) } func TestParser_ListSessionsNonJSONLIgnored_Good(t *testing.T) { dir := t.TempDir() writeJSONL(t, dir, "real-session.jsonl", userTextEntry(ts(0), "real"), ) // Write non-JSONL files require.True(t, hostFS.Write(path.Join(dir, "readme.md"), "# Hello").OK) require.True(t, hostFS.Write(path.Join(dir, "notes.txt"), "notes").OK) require.True(t, hostFS.Write(path.Join(dir, "data.json"), "{}").OK) sessions, err := ListSessions(dir) require.NoError(t, err) require.Len(t, sessions, 1) assert.Equal(t, "real-session", sessions[0].ID) } func TestParser_ListSessionsSeqMultipleSorted_Good(t *testing.T) { dir := t.TempDir() // Create three sessions with different timestamps. writeJSONL(t, dir, "old.jsonl", userTextEntry(ts(0), "old")) writeJSONL(t, dir, "mid.jsonl", userTextEntry(ts(100), "mid")) writeJSONL(t, dir, "new.jsonl", userTextEntry(ts(200), "new")) var sessions []Session for s := range ListSessionsSeq(dir) { sessions = append(sessions, s) } require.Len(t, sessions, 3) // Should be sorted newest first assert.Equal(t, "new", sessions[0].ID) assert.Equal(t, "mid", sessions[1].ID) assert.Equal(t, "old", sessions[2].ID) } func TestParser_ListSessionsMalformedJSONLStillListed_Bad(t *testing.T) { dir := t.TempDir() // A .jsonl file with no valid timestamps — should still list with zero time or modtime writeJSONL(t, dir, "broken.jsonl", `{invalid json}`, `also not valid`, ) sessions, err := ListSessions(dir) require.NoError(t, err) require.Len(t, sessions, 1) assert.Equal(t, "broken", sessions[0].ID) // StartTime should fall back to file modtime since no valid timestamps assert.False(t, sessions[0].StartTime.IsZero(), "should fall back to file modtime") } // --- extractToolInput tests --- func TestParser_ExtractToolInputBash_Good(t *testing.T) { input := rawJSON([]byte(`{"command":"go test ./...","description":"run tests","timeout":120}`)) result := extractToolInput("Bash", input) assert.Equal(t, "go test ./... # run tests", result) } func TestParser_ExtractToolInputBashNoDescription_Good(t *testing.T) { input := rawJSON([]byte(`{"command":"ls -la"}`)) result := extractToolInput("Bash", input) assert.Equal(t, "ls -la", result) } func TestParser_ExtractToolInputRead_Good(t *testing.T) { input := rawJSON([]byte(`{"file_path":"/Users/test/main.go","offset":10,"limit":50}`)) result := extractToolInput("Read", input) assert.Equal(t, "/Users/test/main.go", result) } func TestParser_ExtractToolInputEdit_Good(t *testing.T) { input := rawJSON([]byte(`{"file_path":"/tmp/app.go","old_string":"foo","new_string":"bar"}`)) result := extractToolInput("Edit", input) assert.Equal(t, "/tmp/app.go (edit)", result) } func TestParser_ExtractToolInputWrite_Good(t *testing.T) { input := rawJSON([]byte(`{"file_path":"/tmp/out.txt","content":"hello world"}`)) result := extractToolInput("Write", input) assert.Equal(t, "/tmp/out.txt (11 bytes)", result) } func TestParser_ExtractToolInputGrep_Good(t *testing.T) { input := rawJSON([]byte(`{"pattern":"TODO","path":"/src"}`)) result := extractToolInput("Grep", input) assert.Equal(t, "/TODO/ in /src", result) } func TestParser_ExtractToolInputGrepNoPath_Good(t *testing.T) { input := rawJSON([]byte(`{"pattern":"FIXME"}`)) result := extractToolInput("Grep", input) assert.Equal(t, "/FIXME/ in .", result) } func TestParser_ExtractToolInputGlob_Good(t *testing.T) { input := rawJSON([]byte(`{"pattern":"**/*.go","path":"/src"}`)) result := extractToolInput("Glob", input) assert.Equal(t, "**/*.go", result) } func TestParser_ExtractToolInputTask_Good(t *testing.T) { input := rawJSON([]byte(`{"prompt":"Analyse the codebase","description":"Code review","subagent_type":"research"}`)) result := extractToolInput("Task", input) assert.Equal(t, "[research] Code review", result) } func TestParser_ExtractToolInputTaskNoDescription_Good(t *testing.T) { input := rawJSON([]byte(`{"prompt":"Short prompt","subagent_type":"codegen"}`)) result := extractToolInput("Task", input) assert.Equal(t, "[codegen] Short prompt", result) } func TestParser_ExtractToolInputUnknownTool_Good(t *testing.T) { input := rawJSON([]byte(`{"alpha":"one","beta":"two"}`)) result := extractToolInput("CustomTool", input) // Fallback: sorted keys assert.Equal(t, "alpha, beta", result) } func TestParser_ExtractToolInputNilInput_Bad(t *testing.T) { result := extractToolInput("Bash", nil) assert.Equal(t, "", result) } func TestParser_ExtractToolInputInvalidJSON_Bad(t *testing.T) { input := rawJSON([]byte(`{broken`)) result := extractToolInput("Bash", input) // All unmarshals fail, including the fallback map unmarshal assert.Equal(t, "", result) } // --- extractResultContent tests --- func TestParser_ExtractResultContentString_Good(t *testing.T) { result := extractResultContent("simple string") assert.Equal(t, "simple string", result) } func TestParser_ExtractResultContentArray_Good(t *testing.T) { content := []any{ map[string]any{"type": "text", "text": "line one"}, map[string]any{"type": "text", "text": "line two"}, } result := extractResultContent(content) assert.Equal(t, "line one\nline two", result) } func TestParser_ExtractResultContentMap_Good(t *testing.T) { content := map[string]any{"text": "from map"} result := extractResultContent(content) assert.Equal(t, "from map", result) } func TestParser_ExtractResultContentOther_Bad(t *testing.T) { result := extractResultContent(42) assert.Equal(t, "42", result) } // --- truncate tests --- func TestParser_TruncateShort_Good(t *testing.T) { assert.Equal(t, "hello", truncate("hello", 10)) } func TestParser_TruncateExact_Good(t *testing.T) { assert.Equal(t, "hello", truncate("hello", 5)) } func TestParser_TruncateLong_Good(t *testing.T) { result := truncate("hello world", 5) assert.Equal(t, "hello...", result) } func TestParser_TruncateEmpty_Good(t *testing.T) { assert.Equal(t, "", truncate("", 10)) } // --- helper function tests --- func TestParser_ShortIDTruncatesAndPreservesLength_Good(t *testing.T) { assert.Equal(t, "abcdefgh", shortID("abcdefghijklmnop")) assert.Equal(t, "short", shortID("short")) assert.Equal(t, "12345678", shortID("12345678")) } func TestParser_FormatDurationCommonDurations_Good(t *testing.T) { assert.Equal(t, "500ms", formatDuration(500*time.Millisecond)) assert.Equal(t, "1.5s", formatDuration(1500*time.Millisecond)) assert.Equal(t, "2m30s", formatDuration(2*time.Minute+30*time.Second)) assert.Equal(t, "1h5m", formatDuration(1*time.Hour+5*time.Minute)) } // --- ParseStats tests --- func TestParser_ParseStatsCleanJSONL_Good(t *testing.T) { dir := t.TempDir() path := writeJSONL(t, dir, "clean.jsonl", userTextEntry(ts(0), "Hello"), toolUseEntry(ts(1), "Bash", "tool-1", map[string]any{ "command": "ls", }), toolResultEntry(ts(2), "tool-1", "ok", false), assistantTextEntry(ts(3), "Done"), ) _, stats, err := ParseTranscript(path) require.NoError(t, err) require.NotNil(t, stats) assert.Equal(t, 4, stats.TotalLines) assert.Equal(t, 0, stats.SkippedLines) assert.Equal(t, 0, stats.OrphanedToolCalls) assert.Empty(t, stats.Warnings) } func TestParser_ParseStatsMalformedLines_Good(t *testing.T) { dir := t.TempDir() path := writeJSONL(t, dir, "malformed-stats.jsonl", `{bad json line one`, userTextEntry(ts(0), "Valid line"), `{another bad line}}}`, `not even close to json`, assistantTextEntry(ts(1), "Also valid"), ) _, stats, err := ParseTranscript(path) require.NoError(t, err) require.NotNil(t, stats) assert.Equal(t, 5, stats.TotalLines) assert.Equal(t, 3, stats.SkippedLines) assert.Len(t, stats.Warnings, 3) // Each warning should contain line number and preview for _, w := range stats.Warnings { assert.Contains(t, w, "skipped (bad JSON)") } } func TestParser_ParseStatsOrphanedToolCalls_Good(t *testing.T) { dir := t.TempDir() // Two tool_use entries with no matching tool_result path := writeJSONL(t, dir, "orphaned.jsonl", toolUseEntry(ts(0), "Bash", "orphan-1", map[string]any{ "command": "ls", }), toolUseEntry(ts(1), "Read", "orphan-2", map[string]any{ "file_path": "/tmp/file.go", }), assistantTextEntry(ts(2), "Never got results"), ) _, stats, err := ParseTranscript(path) require.NoError(t, err) require.NotNil(t, stats) assert.Equal(t, 2, stats.OrphanedToolCalls) // Warnings should mention orphaned tool IDs var orphanWarnings int for _, w := range stats.Warnings { if core.Contains(w, "orphaned tool call") { orphanWarnings++ } } assert.Equal(t, 2, orphanWarnings) } func TestParser_ParseStatsTruncatedFinalLine_Good(t *testing.T) { dir := t.TempDir() validLine := userTextEntry(ts(0), "Hello") truncatedLine := `{"type":"assi` // Write without trailing newline after truncated line path := path.Join(dir, "truncfinal.jsonl") require.True(t, hostFS.Write(path, validLine+"\n"+truncatedLine+"\n").OK) _, stats, err := ParseTranscript(path) require.NoError(t, err) require.NotNil(t, stats) assert.Equal(t, 1, stats.SkippedLines) // Should detect truncated final line var foundTruncated bool for _, w := range stats.Warnings { if core.Contains(w, "truncated final line") { foundTruncated = true } } assert.True(t, foundTruncated, "should detect truncated final line") } func TestParser_ParseStatsFileEndingMidJSON_Good(t *testing.T) { dir := t.TempDir() validLine := userTextEntry(ts(0), "Hello") midJSON := `{"type":"assistant","timestamp":"2026-02-20T10:00:01Z","sessionId":"test","message":{"role":"assi` path := path.Join(dir, "midjson.jsonl") require.True(t, hostFS.Write(path, validLine+"\n"+midJSON+"\n").OK) sess, stats, err := ParseTranscript(path) require.NoError(t, err) require.NotNil(t, sess) require.NotNil(t, stats) assert.Equal(t, 1, stats.SkippedLines) var foundTruncated bool for _, w := range stats.Warnings { if core.Contains(w, "truncated final line") { foundTruncated = true } } assert.True(t, foundTruncated) } func TestParser_ParseStatsCompleteFileNoTrailingNewline_Good(t *testing.T) { dir := t.TempDir() line := userTextEntry(ts(0), "Hello") // Write without trailing newline — should still parse fine path := path.Join(dir, "nonewline.jsonl") require.True(t, hostFS.Write(path, line).OK) sess, stats, err := ParseTranscript(path) require.NoError(t, err) require.NotNil(t, sess) require.NotNil(t, stats) assert.Equal(t, 0, stats.SkippedLines) assert.Equal(t, 0, stats.OrphanedToolCalls) assert.Len(t, sess.Events, 1) // No truncation warning since the line parsed successfully var foundTruncated bool for _, w := range stats.Warnings { if core.Contains(w, "truncated final line") { foundTruncated = true } } assert.False(t, foundTruncated) } func TestParser_ParseStatsWarningPreviewTruncated_Good(t *testing.T) { dir := t.TempDir() // A malformed line longer than 100 chars longBadLine := `{` + repeatString("x", 200) path := writeJSONL(t, dir, "longbad.jsonl", longBadLine, userTextEntry(ts(0), "Valid"), ) _, stats, err := ParseTranscript(path) require.NoError(t, err) require.Len(t, stats.Warnings, 1) // 1 skipped line (last line is valid, no truncation) // The preview in the warning should be at most ~100 chars of the bad line assert.True(t, len(stats.Warnings[0]) < 200, "warning preview should be truncated for long lines") assert.Contains(t, stats.Warnings[0], "line 1:") } // --- ParseTranscriptReader (streaming) tests --- func TestParser_ParseTranscriptReaderMinimalValid_Good(t *testing.T) { // Parse directly from an in-memory reader. data := core.Join("\n", []string{ userTextEntry(ts(0), "hello"), assistantTextEntry(ts(1), "world"), }...) + "\n" reader := core.NewReader(data) sess, stats, err := ParseTranscriptReader(reader, "stream-session") require.NoError(t, err) require.NotNil(t, sess) require.NotNil(t, stats) assert.Equal(t, "stream-session", sess.ID) assert.Empty(t, sess.Path, "reader-based parse should have empty path") assert.Len(t, sess.Events, 2) assert.Equal(t, "hello", sess.Events[0].Input) assert.Equal(t, "world", sess.Events[1].Input) assert.Equal(t, 2, stats.TotalLines) assert.Equal(t, 0, stats.SkippedLines) } func TestParser_ParseTranscriptReaderBytesBuffer_Good(t *testing.T) { // Parse from a bytes.Buffer (common streaming use case). data := core.Join("\n", []string{ toolUseEntry(ts(0), "Bash", "tu-buf-1", map[string]any{ "command": "echo ok", "description": "test", }), toolResultEntry(ts(1), "tu-buf-1", "ok", false), }...) + "\n" buf := bytes.NewBufferString(data) sess, _, err := ParseTranscriptReader(buf, "buf-session") require.NoError(t, err) require.Len(t, sess.Events, 1) assert.Equal(t, "Bash", sess.Events[0].Tool) assert.True(t, sess.Events[0].Success) } func TestParser_ParseTranscriptReaderEmptyReader_Good(t *testing.T) { reader := core.NewReader("") sess, stats, err := ParseTranscriptReader(reader, "empty") require.NoError(t, err) require.NotNil(t, sess) assert.Empty(t, sess.Events) assert.Equal(t, 0, stats.TotalLines) } func TestParser_ParseTranscriptReaderLargeLines_Good(t *testing.T) { // Verify the scanner handles very long lines (> 64KB). longText := repeatString("x", 128*1024) // 128KB of text data := userTextEntry(ts(0), longText) + "\n" reader := core.NewReader(data) sess, _, err := ParseTranscriptReader(reader, "big-session") require.NoError(t, err) require.Len(t, sess.Events, 1) // Input gets truncated to 500 chars by the truncate function. assert.Len(t, sess.Events[0].Input, 503) // 500 + "..." } func TestParser_ParseTranscriptReaderMalformedWithStats_Good(t *testing.T) { // Malformed lines in a reader should still produce correct stats. data := core.Join("\n", []string{ `{bad json`, userTextEntry(ts(0), "valid"), `also bad`, }...) + "\n" reader := core.NewReader(data) sess, stats, err := ParseTranscriptReader(reader, "mixed") require.NoError(t, err) assert.Len(t, sess.Events, 1) assert.Equal(t, 3, stats.TotalLines) assert.Equal(t, 2, stats.SkippedLines) } func TestParser_ParseTranscriptReaderOrphanedTools_Good(t *testing.T) { // Tool calls without results should be tracked in stats. data := core.Join("\n", []string{ toolUseEntry(ts(0), "Bash", "orphan-r1", map[string]any{ "command": "ls", }), assistantTextEntry(ts(1), "No result arrived"), }...) + "\n" reader := core.NewReader(data) _, stats, err := ParseTranscriptReader(reader, "orphan-reader") require.NoError(t, err) assert.Equal(t, 1, stats.OrphanedToolCalls) } // --- Custom MCP tool tests --- func TestParser_ParseTranscriptCustomMCPTool_Good(t *testing.T) { // A tool_use with a non-standard MCP tool name (e.g. mcp__server__tool). dir := t.TempDir() lines := []string{ toolUseEntry(ts(0), "mcp__forge__create_issue", "tu-mcp-1", map[string]any{ "title": "bug report", "body": "something broke", "repo": "core/go", }), toolResultEntry(ts(1), "tu-mcp-1", "Issue #42 created", false), } path := writeJSONL(t, dir, "mcp_tool.jsonl", lines...) sess, _, err := ParseTranscript(path) require.NoError(t, err) var toolEvents []Event for _, e := range sess.Events { if e.Type == "tool_use" { toolEvents = append(toolEvents, e) } } require.Len(t, toolEvents, 1) assert.Equal(t, "mcp__forge__create_issue", toolEvents[0].Tool) // Fallback should show sorted keys. assert.Contains(t, toolEvents[0].Input, "body") assert.Contains(t, toolEvents[0].Input, "repo") assert.Contains(t, toolEvents[0].Input, "title") assert.True(t, toolEvents[0].Success) } func TestParser_ParseTranscriptCustomMCPToolNestedInput_Good(t *testing.T) { // MCP tool with nested JSON input — should show top-level keys. dir := t.TempDir() lines := []string{ toolUseEntry(ts(0), "mcp__db__query", "tu-nested-1", map[string]any{ "query": "SELECT *", "params": map[string]any{"limit": 10, "offset": 0}, }), toolResultEntry(ts(1), "tu-nested-1", "3 rows returned", false), } path := writeJSONL(t, dir, "mcp_nested.jsonl", lines...) sess, _, err := ParseTranscript(path) require.NoError(t, err) var toolEvents []Event for _, e := range sess.Events { if e.Type == "tool_use" { toolEvents = append(toolEvents, e) } } require.Len(t, toolEvents, 1) assert.Contains(t, toolEvents[0].Input, "params") assert.Contains(t, toolEvents[0].Input, "query") } func TestParser_ParseTranscriptUnknownToolEmptyInput_Good(t *testing.T) { // A tool_use with an empty input object. dir := t.TempDir() lines := []string{ toolUseEntry(ts(0), "SomeTool", "tu-empty-1", map[string]any{}), toolResultEntry(ts(1), "tu-empty-1", "done", false), } path := writeJSONL(t, dir, "empty_input.jsonl", lines...) sess, _, err := ParseTranscript(path) require.NoError(t, err) var toolEvents []Event for _, e := range sess.Events { if e.Type == "tool_use" { toolEvents = append(toolEvents, e) } } require.Len(t, toolEvents, 1) // Empty object should produce empty string from fallback. assert.Equal(t, "", toolEvents[0].Input) } // --- Edge case error recovery tests --- func TestParser_ParseTranscriptBinaryGarbage_Ugly(t *testing.T) { // Binary garbage interspersed with valid lines — must not panic. dir := t.TempDir() garbage := string([]byte{0x00, 0x01, 0x02, 0xff, 0xfe, 0xfd}) lines := []string{ garbage, userTextEntry(ts(0), "survived"), garbage + garbage, } path := writeJSONL(t, dir, "binary.jsonl", lines...) sess, stats, err := ParseTranscript(path) require.NoError(t, err) // Only the valid line should produce an event. var userEvents []Event for _, e := range sess.Events { if e.Type == "user" { userEvents = append(userEvents, e) } } require.Len(t, userEvents, 1) assert.Equal(t, "survived", userEvents[0].Input) assert.Equal(t, 2, stats.SkippedLines) } func TestParser_ParseTranscriptNullBytes_Ugly(t *testing.T) { // Lines with embedded null bytes. dir := t.TempDir() lines := []string{ `{"type":"user","timestamp":"` + ts(0) + `","sessionId":"n","message":` + string([]byte{0x00}) + `}`, userTextEntry(ts(1), "ok"), } path := writeJSONL(t, dir, "null_bytes.jsonl", lines...) sess, _, err := ParseTranscript(path) require.NoError(t, err) assert.Len(t, sess.Events, 1) } func TestParser_ParseTranscriptVeryLongLine_Ugly(t *testing.T) { // A single line that exceeds the default bufio.Scanner buffer. // The parser should handle this without error thanks to the enlarged buffer. dir := t.TempDir() huge := repeatString("a", 5*1024*1024) // 5MB text path := writeJSONL(t, dir, "huge_line.jsonl", userTextEntry(ts(0), huge), ) sess, _, err := ParseTranscript(path) require.NoError(t, err) require.Len(t, sess.Events, 1) } func TestParser_ParseTranscriptMalformedMessageJSON_Bad(t *testing.T) { // Valid outer JSON but the message field is not valid message structure. dir := t.TempDir() lines := []string{ `{"type":"assistant","timestamp":"` + ts(0) + `","sessionId":"b","message":"not an object"}`, userTextEntry(ts(1), "ok"), } path := writeJSONL(t, dir, "bad_msg.jsonl", lines...) sess, _, err := ParseTranscript(path) require.NoError(t, err) // First line's message is a string, not object — should be skipped. assert.Len(t, sess.Events, 1) assert.Equal(t, "ok", sess.Events[0].Input) } func TestParser_ParseTranscriptMalformedContentBlock_Bad(t *testing.T) { // Valid message structure but content blocks are malformed. dir := t.TempDir() lines := []string{ `{"type":"assistant","timestamp":"` + ts(0) + `","sessionId":"c","message":{"role":"assistant","content":["not a block object"]}}`, userTextEntry(ts(1), "still ok"), } path := writeJSONL(t, dir, "bad_block.jsonl", lines...) sess, _, err := ParseTranscript(path) require.NoError(t, err) assert.Len(t, sess.Events, 1) assert.Equal(t, "still ok", sess.Events[0].Input) } func TestParser_ParseTranscriptTruncatedMissingBrace_Good(t *testing.T) { // Final line is missing its closing brace — should be skipped gracefully. dir := t.TempDir() lines := []string{ userTextEntry(ts(0), "valid"), assistantTextEntry(ts(1), "also valid"), `{"type":"user","timestamp":"` + ts(2) + `","sessionId":"t","message":{"role":"user","content":[{"type":"text","text":"truncated"`, } path := writeJSONL(t, dir, "trunc_brace.jsonl", lines...) sess, _, err := ParseTranscript(path) require.NoError(t, err) // Only the two complete lines should produce events. assert.Len(t, sess.Events, 2) assert.Equal(t, "valid", sess.Events[0].Input) assert.Equal(t, "also valid", sess.Events[1].Input) } func TestParser_ParseTranscriptTruncatedMidKey_Good(t *testing.T) { // Line truncated in the middle of a JSON key. dir := t.TempDir() lines := []string{ userTextEntry(ts(0), "first"), `{"type":"assis`, } path := writeJSONL(t, dir, "trunc_midkey.jsonl", lines...) sess, _, err := ParseTranscript(path) require.NoError(t, err) assert.Len(t, sess.Events, 1) assert.Equal(t, "first", sess.Events[0].Input) } func TestParser_ParseTranscriptAllBadLines_Good(t *testing.T) { // Every line is truncated/malformed — result should be empty, no error. dir := t.TempDir() lines := []string{ `{"type":"user","timestamp`, `{"broken`, `not even json`, } path := writeJSONL(t, dir, "all_bad.jsonl", lines...) sess, stats, err := ParseTranscript(path) require.NoError(t, err) assert.Empty(t, sess.Events) assert.True(t, sess.StartTime.IsZero()) assert.Equal(t, 3, stats.SkippedLines) } // --- ListSessions with truncated files --- // --- PruneSessions tests --- func TestParser_PruneSessionsDeletesOldFiles_Good(t *testing.T) { dir := t.TempDir() // Create a session file with an old modification time. path := writeJSONL(t, dir, "old-session.jsonl", userTextEntry(ts(0), "old"), ) // Backdate the file's mtime by 2 hours. oldTime := time.Now().Add(-2 * time.Hour) require.NoError(t, setFileTimes(path, oldTime, oldTime)) // Create a recent session file. writeJSONL(t, dir, "new-session.jsonl", userTextEntry(ts(0), "new"), ) // Prune sessions older than 1 hour. deleted, err := PruneSessions(dir, 1*time.Hour) require.NoError(t, err) assert.Equal(t, 1, deleted) // Verify only the new file remains. sessions, err := ListSessions(dir) require.NoError(t, err) require.Len(t, sessions, 1) assert.Equal(t, "new-session", sessions[0].ID) } func TestParser_PruneSessionsNothingToDelete_Good(t *testing.T) { dir := t.TempDir() writeJSONL(t, dir, "recent.jsonl", userTextEntry(ts(0), "fresh"), ) deleted, err := PruneSessions(dir, 24*time.Hour) require.NoError(t, err) assert.Equal(t, 0, deleted) } func TestParser_PruneSessionsEmptyDir_Good(t *testing.T) { dir := t.TempDir() deleted, err := PruneSessions(dir, 1*time.Hour) require.NoError(t, err) assert.Equal(t, 0, deleted) } // --- IsExpired tests --- func TestParser_IsExpiredRecentSession_Good(t *testing.T) { sess := &Session{ EndTime: time.Now().Add(-5 * time.Minute), } assert.False(t, sess.IsExpired(1*time.Hour)) } func TestParser_IsExpiredOldSession_Good(t *testing.T) { sess := &Session{ EndTime: time.Now().Add(-2 * time.Hour), } assert.True(t, sess.IsExpired(1*time.Hour)) } func TestParser_IsExpiredZeroEndTime_Bad(t *testing.T) { sess := &Session{} assert.False(t, sess.IsExpired(1*time.Hour)) } // --- FetchSession tests --- func TestParser_FetchSessionValidID_Good(t *testing.T) { dir := t.TempDir() writeJSONL(t, dir, "abc123.jsonl", userTextEntry(ts(0), "hello"), ) sess, stats, err := FetchSession(dir, "abc123") require.NoError(t, err) require.NotNil(t, sess) require.NotNil(t, stats) assert.Equal(t, "abc123", sess.ID) assert.Len(t, sess.Events, 1) } func TestParser_FetchSessionPathTraversal_Ugly(t *testing.T) { dir := t.TempDir() _, _, err := FetchSession(dir, "../etc/passwd") require.Error(t, err) assert.Contains(t, err.Error(), "invalid session id") } func TestParser_FetchSessionBackslashTraversal_Ugly(t *testing.T) { dir := t.TempDir() _, _, err := FetchSession(dir, `foo\bar`) require.Error(t, err) assert.Contains(t, err.Error(), "invalid session id") } func TestParser_FetchSessionForwardSlash_Ugly(t *testing.T) { dir := t.TempDir() _, _, err := FetchSession(dir, "foo/bar") require.Error(t, err) assert.Contains(t, err.Error(), "invalid session id") } func TestParser_FetchSessionNotFound_Bad(t *testing.T) { dir := t.TempDir() _, _, err := FetchSession(dir, "nonexistent") require.Error(t, err) assert.Contains(t, err.Error(), "open transcript") } // --- ListSessions with truncated files --- func TestParser_ListSessionsTruncatedFile_Good(t *testing.T) { dir := t.TempDir() // A .jsonl file where some lines are truncated — ListSessions should // still extract timestamps from valid lines. lines := []string{ userTextEntry(ts(0), "start"), `{"type":"assistant","truncated`, userTextEntry(ts(5), "end"), } writeJSONL(t, dir, "partial.jsonl", lines...) sessions, err := ListSessions(dir) require.NoError(t, err) require.Len(t, sessions, 1) assert.False(t, sessions[0].StartTime.IsZero()) assert.False(t, sessions[0].EndTime.IsZero()) // End time should reflect the last valid timestamp. assert.True(t, sessions[0].EndTime.After(sessions[0].StartTime)) } // --- PruneSessions tests ---