package parser
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/tidwall/gjson"
"go.kenn.io/agentsview/internal/testjsonl"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetProjectName(t *testing.T) {
tests := []struct {
name string
dir string
want string
}{
{"simple name", "my-project", "my_project"},
{"encoded path with code",
"-Users-alice-code-my-app", "my_app"},
{"encoded path with projects",
"-Users-alice-projects-api-server", "api_server"},
{"encoded path with repos",
"-home-user-repos-frontend", "frontend"},
{"encoded path without marker",
"-Users-alice", "alice"},
{"empty", "", ""},
{"no prefix", "plain_name", "plain_name"},
{"with src marker",
"-Users-alice-src-my-lib", "my_lib"},
{"multi-word after marker",
"-Users-alice-code-my-cool-project", "my_cool_project"},
{"deeply nested",
"-Users-alice-code-org-team-repo", "org_team_repo"},
{"unicode components",
"-Users-alice-code-café-app", "café_app"},
{"trailing dash",
"-Users-alice-code-myapp-", "myapp_"},
{"double dashes",
"-Users-alice-code--my-app", "_my_app"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetProjectName(tt.dir)
assert.Equalf(t, tt.want, got, "GetProjectName(%q)", tt.dir)
})
}
}
func TestExtractProjectFromCwd(t *testing.T) {
tests := []struct {
cwd string
want string
}{
{cwd: "/Users/alice/code/my-app", want: "my_app"},
{cwd: "/home/user/projects/api-server", want: "api_server"},
{cwd: ""},
{cwd: "/"},
{cwd: "."},
{cwd: ".."},
}
// Windows drive-letter paths are normalized cross-platform.
tests = append(tests, []struct{ cwd, want string }{
{cwd: `C:\Users\me\my-app`, want: "my_app"},
{cwd: `D:\projects\frontend`, want: "frontend"},
}...)
// Mixed path: on Windows filepath treats \ as separator,
// on POSIX it is a literal filename character.
if runtime.GOOS == "windows" {
tests = append(tests, struct{ cwd, want string }{
cwd: `/mixed\path/to\project`, want: "project",
})
} else {
tests = append(tests, struct{ cwd, want string }{
cwd: `/mixed\path/to\project`,
})
}
for _, tt := range tests {
t.Run(tt.cwd, func(t *testing.T) {
got := ExtractProjectFromCwd(tt.cwd)
assert.Equalf(t, tt.want, got, "ExtractProjectFromCwd(%q)", tt.cwd)
})
}
}
func TestNeedsProjectReparse(t *testing.T) {
tests := []struct {
project string
want bool
}{
{"my_project", false},
{"_Users_alice_code_app", true},
{"_home_user_project", true},
{"_private_var_folders", true},
{"good_project_var_folders_ok", true},
{"good_project", false},
{"_var_folders_xx_temp", true},
{"_private_tmp_build", true},
{"_tmp_workspace", true},
{"roborev_ci_28293_3831737461", true},
{"normal_var_project", false},
}
for _, tt := range tests {
t.Run(tt.project, func(t *testing.T) {
got := NeedsProjectReparse(tt.project)
assert.Equalf(t, tt.want, got, "NeedsProjectReparse(%q)", tt.project)
})
}
}
func TestExtractTextContent(t *testing.T) {
tests := []struct {
name string
json string
wantText string
wantThink bool
wantToolUse bool
wantToolCalls []ParsedToolCall
}{
{
"plain string",
`"Hello world"`,
"Hello world", false, false, nil,
},
{
"text block array",
`[{"type":"text","text":"Hi"}]`,
"Hi", false, false, nil,
},
{
"thinking block",
`[{"type":"thinking","thinking":"Let me think..."}]`,
"[Thinking]\nLet me think...\n[/Thinking]", true, false, nil,
},
{
"tool_use block",
`[{"type":"tool_use","name":"Read","input":{"file_path":"test.go"}}]`,
"[Read: test.go]", false, true,
[]ParsedToolCall{{ToolName: "Read", Category: "Read"}},
},
{
"mixed blocks",
`[{"type":"text","text":"Looking at"},{"type":"tool_use","name":"Bash","input":{"command":"ls","description":"list files"}}]`,
"Looking at\n[Bash: list files]\n$ ls", false, true,
[]ParsedToolCall{{ToolName: "Bash", Category: "Bash"}},
},
{
"multiple tool_use blocks",
`[{"type":"tool_use","name":"Read","input":{"file_path":"a.go"}},{"type":"tool_use","name":"Grep","input":{"pattern":"TODO"}}]`,
"[Read: a.go]\n[Grep: TODO]", false, true,
[]ParsedToolCall{
{ToolName: "Read", Category: "Read"},
{ToolName: "Grep", Category: "Grep"},
},
},
{
"tool_use with id and input",
`[{"type":"tool_use","id":"toolu_123","name":"Read","input":{"file_path":"main.go"}}]`,
"[Read: main.go]", false, true,
[]ParsedToolCall{{ToolUseID: "toolu_123", ToolName: "Read", Category: "Read", InputJSON: `{"file_path":"main.go"}`}},
},
{
"Skill tool extracts skill_name",
`[{"type":"tool_use","id":"toolu_456","name":"Skill","input":{"skill":"superpowers:brainstorming"}}]`,
"[Skill: superpowers:brainstorming]", false, true,
[]ParsedToolCall{{ToolUseID: "toolu_456", ToolName: "Skill", Category: "Tool", InputJSON: `{"skill":"superpowers:brainstorming"}`, SkillName: "superpowers:brainstorming"}},
},
{
"Amp skill tool extracts skill_name",
`[{"type":"tool_use","id":"toolu_789","name":"skill","input":{"name":"frontend-design"}}]`,
"[Skill: frontend-design]", false, true,
[]ParsedToolCall{{ToolUseID: "toolu_789", ToolName: "skill", Category: "Tool", InputJSON: `{"name":"frontend-design"}`, SkillName: "frontend-design"}},
},
{
"Amp skill tool supports legacy input.skill",
`[{"type":"tool_use","id":"toolu_790","name":"skill","input":{"skill":"legacy-skill"}}]`,
"[Skill: legacy-skill]", false, true,
[]ParsedToolCall{{ToolUseID: "toolu_790", ToolName: "skill", Category: "Tool", InputJSON: `{"skill":"legacy-skill"}`, SkillName: "legacy-skill"}},
},
{
"Amp skill tool falls back to input.name when input.skill is empty",
`[{"type":"tool_use","id":"toolu_791","name":"skill","input":{"skill":"","name":"frontend-design"}}]`,
"[Skill: frontend-design]", false, true,
[]ParsedToolCall{{ToolUseID: "toolu_791", ToolName: "skill", Category: "Tool", InputJSON: `{"skill":"","name":"frontend-design"}`, SkillName: "frontend-design"}},
},
{
"tool_use with empty name",
`[{"type":"tool_use","name":"","input":{}}]`,
"[Tool: ]", false, true, nil,
},
{
"empty array",
`[]`,
"", false, false, nil,
},
{
"unknown and empty blocks ignored",
`[{"type":"unknown","value":"x"},{"type":"text","text":""},{"type":"thinking","thinking":""}]`,
"", false, false, nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := gjson.Parse(tt.json)
text, _, hasThinking, hasToolUse, tcs, _ :=
ExtractTextContent(result)
assert.Equal(t, tt.wantText, text, "text")
assert.Equal(t, tt.wantThink, hasThinking, "hasThinking")
assert.Equal(t, tt.wantToolUse, hasToolUse, "hasToolUse")
assertToolCalls(t, tcs, tt.wantToolCalls)
})
}
}
func TestExtractTextContent_AmpSkillNameExtraction(t *testing.T) {
result := gjson.Parse(
`[{"type":"tool_use","id":"toolu_amp_skill","name":"skill","input":{"name":"walkthrough"}}]`,
)
text, _, hasThinking, hasToolUse, toolCalls, toolResults :=
ExtractTextContent(result)
require.Equal(t, "[Skill: walkthrough]", text, "text")
require.False(t, hasThinking, "hasThinking")
require.True(t, hasToolUse, "hasToolUse")
require.Empty(t, toolResults, "toolResults")
require.Len(t, toolCalls, 1, "toolCalls")
got := toolCalls[0]
assert.Equal(t, "toolu_amp_skill", got.ToolUseID, "ToolUseID")
assert.Equal(t, "skill", got.ToolName, "ToolName")
assert.Equal(t, "Tool", got.Category, "Category")
assert.Equal(t, "walkthrough", got.SkillName, "SkillName")
assert.Equal(t, `{"name":"walkthrough"}`, got.InputJSON, "InputJSON")
}
func TestExtractToolResults(t *testing.T) {
tests := []struct {
name string
json string
wantResults []ParsedToolResult
}{
{
"no tool_result blocks",
`[{"type":"text","text":"hello"}]`,
nil,
},
{
"single tool_result",
`[{"type":"tool_result","tool_use_id":"toolu_123","content":"file contents here"}]`,
[]ParsedToolResult{{ToolUseID: "toolu_123", ContentLength: 18, ContentRaw: `"file contents here"`}},
},
{
"tool_result with array content",
`[{"type":"tool_result","tool_use_id":"toolu_456","content":[{"type":"text","text":"output data"}]}]`,
[]ParsedToolResult{{ToolUseID: "toolu_456", ContentLength: 11, ContentRaw: `[{"type":"text","text":"output data"}]`}},
},
{
"multiple tool_results",
`[{"type":"tool_result","tool_use_id":"toolu_1","content":"abc"},{"type":"tool_result","tool_use_id":"toolu_2","content":"defgh"}]`,
[]ParsedToolResult{
{ToolUseID: "toolu_1", ContentLength: 3, ContentRaw: `"abc"`},
{ToolUseID: "toolu_2", ContentLength: 5, ContentRaw: `"defgh"`},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := gjson.Parse(tt.json)
_, _, _, _, _, trs := ExtractTextContent(result)
require.Len(t, trs, len(tt.wantResults), "tool_results count")
for i := range tt.wantResults {
assert.Equalf(t, tt.wantResults[i].ToolUseID, trs[i].ToolUseID,
"[%d].ToolUseID", i)
assert.Equalf(t, tt.wantResults[i].ContentLength, trs[i].ContentLength,
"[%d].ContentLength", i)
assert.Equalf(t, tt.wantResults[i].ContentRaw, trs[i].ContentRaw,
"[%d].ContentRaw", i)
}
})
}
}
func TestDecodeContent(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{"string content", `"file contents here"`, "file contents here"},
{"array content", `[{"type":"text","text":"output data"}]`, "output data"},
{"multiple array blocks", `[{"type":"text","text":"foo"},{"type":"text","text":"bar"}]`, "foobar"},
{"empty raw", "", ""},
{"non-text array block ignored", `[{"type":"image"}]`, ""},
{
"iFlow object with nested output",
`{"responseParts":{"functionResponse":{"response":{"output":"hello world"}}}}`,
"hello world",
},
{
"iFlow object without nested output",
`{"other":"data"}`,
"",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := DecodeContent(tt.raw)
assert.Equalf(t, tt.want, got, "DecodeContent(%q)", tt.raw)
})
}
}
func TestExtractTextContent_IflowToolResult(t *testing.T) {
// iFlow tool results use an object with nested output.
content := `[{
"type":"tool_result",
"tool_use_id":"tu_123",
"content":{"responseParts":{"functionResponse":{"response":{"output":"result text"}}}}
}]`
_, _, _, _, _, trs := ExtractTextContent(gjson.Parse(content))
require.Len(t, trs, 1, "expected 1 tool result")
tr := trs[0]
assert.Equal(t, "tu_123", tr.ToolUseID, "ToolUseID")
assert.Equal(t, len("result text"), tr.ContentLength, "ContentLength")
decoded := DecodeContent(tr.ContentRaw)
assert.Equal(t, "result text", decoded, "DecodeContent")
// Object without nested output: both length and decode
// should be zero/empty.
noOutput := `[{
"type":"tool_result",
"tool_use_id":"tu_456",
"content":{"other":"data"}
}]`
_, _, _, _, _, trs2 := ExtractTextContent(gjson.Parse(noOutput))
require.Len(t, trs2, 1, "expected 1 tool result")
assert.Zero(t, trs2[0].ContentLength, "ContentLength")
assert.Empty(t, DecodeContent(trs2[0].ContentRaw), "DecodeContent")
}
func TestFormatToolUseVariants(t *testing.T) {
tests := []struct {
toolName string
json string
want string
}{
{
"Read",
`{"type":"tool_use","name":"Read","input":{"file_path":"main.go"}}`,
"[Read: main.go]",
},
{
"Glob",
`{"type":"tool_use","name":"Glob","input":{"pattern":"*.ts","path":"src"}}`,
"[Glob: *.ts in src]",
},
{
"Glob default path",
`{"type":"tool_use","name":"Glob","input":{"pattern":"*.ts"}}`,
"[Glob: *.ts in .]",
},
{
"Grep",
`{"type":"tool_use","name":"Grep","input":{"pattern":"TODO"}}`,
"[Grep: TODO]",
},
{
"Edit",
`{"type":"tool_use","name":"Edit","input":{"file_path":"config.go"}}`,
"[Edit: config.go]",
},
{
"Write",
`{"type":"tool_use","name":"Write","input":{"file_path":"new.go"}}`,
"[Write: new.go]",
},
{
"Bash with description",
`{"type":"tool_use","name":"Bash","input":{"command":"go test ./...","description":"run tests"}}`,
"[Bash: run tests]\n$ go test ./...",
},
{
"Bash without description",
`{"type":"tool_use","name":"Bash","input":{"command":"ls"}}`,
"[Bash]\n$ ls",
},
{
"Task",
`{"type":"tool_use","name":"Task","input":{"description":"explore","subagent_type":"Explore"}}`,
"[Task: explore (Explore)]",
},
{
"Agent",
`{"type":"tool_use","name":"Agent","input":{"description":"explore","subagent_type":"Explore"}}`,
"[Task: explore (Explore)]",
},
{
"EnterPlanMode",
`{"type":"tool_use","name":"EnterPlanMode","input":{}}`,
"[Entering Plan Mode]",
},
{
"ExitPlanMode",
`{"type":"tool_use","name":"ExitPlanMode","input":{}}`,
"[Exiting Plan Mode]",
},
{
"Unknown tool",
`{"type":"tool_use","name":"CustomTool","input":{}}`,
"[Tool: CustomTool]",
},
{
"AskUserQuestion",
`{"type":"tool_use","name":"AskUserQuestion","input":{"questions":[{"question":"Which approach?","options":[{"label":"A","description":"First option"},{"label":"B","description":"Second option"}]}]}}`,
"[Question: AskUserQuestion]\n Which approach?\n - A: First option\n - B: Second option",
},
{
"TodoWrite",
`{"type":"tool_use","name":"TodoWrite","input":{"todos":[{"content":"Fix bug","status":"completed"},{"content":"Write tests","status":"in_progress"},{"content":"Deploy","status":"pending"}]}}`,
"[Todo List]\n ✓ Fix bug\n → Write tests\n ○ Deploy",
},
{
"TodoWrite unknown status",
`{"type":"tool_use","name":"TodoWrite","input":{"todos":[{"content":"Something","status":"unknown"}]}}`,
"[Todo List]\n ○ Something",
},
{
"Skill",
`{"type":"tool_use","name":"Skill","input":{"skill":"superpowers:brainstorming"}}`,
"[Skill: superpowers:brainstorming]",
},
{
"Skill with args",
`{"type":"tool_use","name":"Skill","input":{"skill":"commit","args":"-m 'Fix bug'"}}`,
"[Skill: commit]",
},
{
"TaskCreate with subject",
`{"type":"tool_use","name":"TaskCreate","input":{"subject":"Fix authentication bug","description":"Debug the login flow"}}`,
"[TaskCreate: Fix authentication bug]",
},
{
"TaskUpdate with status",
`{"type":"tool_use","name":"TaskUpdate","input":{"taskId":"5","status":"completed"}}`,
"[TaskUpdate: #5 completed]",
},
{
"TaskGet",
`{"type":"tool_use","name":"TaskGet","input":{"taskId":"3"}}`,
"[TaskGet: #3]",
},
{
"TaskList",
`{"type":"tool_use","name":"TaskList","input":{}}`,
"[TaskList]",
},
{
"SendMessage",
`{"type":"tool_use","name":"SendMessage","input":{"type":"message","recipient":"researcher","content":"hello"}}`,
"[SendMessage: message to researcher]",
},
// Amp tools
{
"Read with path (Amp)",
`{"type":"tool_use","name":"Read","input":{"path":"/tmp/foo.go"}}`,
"[Read: /tmp/foo.go]",
},
{
"Bash with cmd (Amp)",
`{"type":"tool_use","name":"Bash","input":{"cmd":"ls -la"}}`,
"[Bash]\n$ ls -la",
},
{
"edit_file",
`{"type":"tool_use","name":"edit_file","input":{"path":"main.go"}}`,
"[Edit: main.go]",
},
{
"create_file",
`{"type":"tool_use","name":"create_file","input":{"path":"new.go"}}`,
"[Write: new.go]",
},
{
"shell_command",
`{"type":"tool_use","name":"shell_command","input":{"command":"echo hi"}}`,
"[Bash]\n$ echo hi",
},
{
"glob (Amp)",
`{"type":"tool_use","name":"glob","input":{"filePattern":"**/*.ts"}}`,
"[Glob: **/*.ts]",
},
{
"look_at",
`{"type":"tool_use","name":"look_at","input":{"path":"diagram.png"}}`,
"[Read: diagram.png]",
},
{
"apply_patch",
`{"type":"tool_use","name":"apply_patch","input":{"path":"fix.patch"}}`,
"[Patch: fix.patch]",
},
{
"undo_edit",
`{"type":"tool_use","name":"undo_edit","input":{"path":"main.go"}}`,
"[Undo: main.go]",
},
{
"finder",
`{"type":"tool_use","name":"finder","input":{"query":"JWT validation"}}`,
"[Find: JWT validation]",
},
{
"read_web_page",
`{"type":"tool_use","name":"read_web_page","input":{"url":"https://example.com"}}`,
"[Web: https://example.com]",
},
{
"skill (Amp)",
`{"type":"tool_use","name":"skill","input":{"name":"frontend-design"}}`,
"[Skill: frontend-design]",
},
{
json: `{"type":"tool_use","name":"empty_tool","input":{}}`,
want: "[Tool: empty_tool]",
},
// Pi tools (lowercase variants)
{
"read (Pi)",
`{"type":"tool_use","name":"read","input":{"file_path":"auth.go"}}`,
"[Read: auth.go]",
},
{
"read_file (Pi)",
`{"type":"tool_use","name":"read_file","input":{"file_path":"README.md"}}`,
"[Read: README.md]",
},
{
"write (Pi)",
`{"type":"tool_use","name":"write","input":{"file_path":"out.txt"}}`,
"[Write: out.txt]",
},
{
"edit (Pi)",
`{"type":"tool_use","name":"edit","input":{"file_path":"config.yaml"}}`,
"[Edit: config.yaml]",
},
{
"str_replace (Pi)",
`{"type":"tool_use","name":"str_replace","input":{"file_path":"server.go"}}`,
"[Edit: server.go]",
},
{
"bash (Pi)",
`{"type":"tool_use","name":"bash","input":{"command":"ls -la"}}`,
"[Bash]\n$ ls -la",
},
{
"run_command (Pi)",
`{"type":"tool_use","name":"run_command","input":{"command":"go test"}}`,
"[Bash]\n$ go test",
},
{
"find (Pi)",
`{"type":"tool_use","name":"find","input":{"pattern":"*.go"}}`,
"[Find: *.go]",
},
// Pi path fallback variants
{
"read with path fallback (Pi)",
`{"type":"tool_use","name":"read","input":{"path":"src/main.go"}}`,
"[Read: src/main.go]",
},
{
"write with path fallback (Pi)",
`{"type":"tool_use","name":"write","input":{"path":"out.txt"}}`,
"[Write: out.txt]",
},
{
"edit with filePath fallback (Pi)",
`{"type":"tool_use","name":"edit","input":{"filePath":"app.ts"}}`,
"[Edit: app.ts]",
},
{
"str_replace with path fallback (Pi)",
`{"type":"tool_use","name":"str_replace","input":{"path":"server.go"}}`,
"[Edit: server.go]",
},
{
"read_file with path fallback (Pi)",
`{"type":"tool_use","name":"read_file","input":{"path":"README.md"}}`,
"[Read: README.md]",
},
}
for _, tt := range tests {
testName := tt.toolName
if testName == "" {
testName = "empty_string"
}
t.Run(testName, func(t *testing.T) {
block := gjson.Parse(tt.json)
got := formatToolUse(block)
assert.Equal(t, tt.want, got, "formatToolUse")
})
}
}
func TestParseTimestamp(t *testing.T) {
tests := []struct {
name string
input string
wantUTC time.Time
wantOK bool
}{
{
"empty string",
"",
time.Time{},
false,
},
{
"RFC3339 UTC",
"2024-01-15T10:30:00Z",
testJan15_1030UTC,
true,
},
{
"RFC3339Nano UTC",
"2024-01-15T10:30:00.123456789Z",
time.Date(
2024, 1, 15, 10, 30, 0, 123456789,
time.UTC,
),
true,
},
{
"milliseconds with Z",
"2024-01-15T10:30:00.500Z",
time.Date(
2024, 1, 15, 10, 30, 0, 500000000,
time.UTC,
),
true,
},
{
"positive timezone offset",
"2024-01-15T15:30:00+05:00",
testJan15_1030UTC,
true,
},
{
"negative timezone offset",
"2024-01-15T03:30:00-07:00",
testJan15_1030UTC,
true,
},
{
"millis with offset",
"2024-01-15T15:30:00.500+05:00",
time.Date(
2024, 1, 15, 10, 30, 0, 500000000,
time.UTC,
),
true,
},
{
"space-separated datetime",
"2024-01-15 10:30:00",
testJan15_1030UTC,
true,
},
{
"unparseable value",
"not-a-timestamp",
time.Time{},
false,
},
{
"date only",
"2024-01-15",
time.Time{},
false,
},
{
"unix epoch number string",
"1705315800",
time.Time{},
false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseTimestamp(tt.input)
if tt.wantOK {
require.Falsef(t, got.IsZero(),
"parseTimestamp(%q) = zero, want %v", tt.input, tt.wantUTC)
assert.Truef(t, got.Equal(tt.wantUTC),
"parseTimestamp(%q) = %v, want %v", tt.input, got, tt.wantUTC)
assert.Equalf(t, time.UTC, got.Location(),
"parseTimestamp(%q) location", tt.input)
} else {
assert.Truef(t, got.IsZero(),
"parseTimestamp(%q) = %v, want zero", tt.input, got)
}
})
}
}
func TestClaudeSessionTimestampSemantics(t *testing.T) {
t.Run("snapshot.timestamp fallback", func(t *testing.T) {
content := testjsonl.ClaudeSnapshotJSON("2024-06-15T12:00:00Z")
sess, msgs := parseClaudeTestFile(
t, "ts-fallback.jsonl", content, "proj",
)
wantTS := time.Date(
2024, 6, 15, 12, 0, 0, 0, time.UTC,
)
assertTimestamp(t, sess.StartedAt, wantTS)
assertTimestamp(t, sess.EndedAt, wantTS)
require.Len(t, msgs, 1, "messages")
assertTimestamp(t, msgs[0].Timestamp, wantTS)
})
t.Run("offset timestamp normalized to UTC", func(t *testing.T) {
content := testjsonl.ClaudeUserJSON("hello", "2024-06-15T17:00:00+05:00")
sess, msgs := parseClaudeTestFile(
t, "ts-offset.jsonl", content, "proj",
)
wantUTC := time.Date(
2024, 6, 15, 12, 0, 0, 0, time.UTC,
)
assertTimestamp(t, sess.StartedAt, wantUTC)
require.Len(t, msgs, 1, "messages")
assertTimestamp(t, msgs[0].Timestamp, wantUTC)
})
t.Run("unparseable timestamp yields zero", func(t *testing.T) {
content := testjsonl.ClaudeUserJSON("hello", "garbage")
sess, msgs := parseClaudeTestFile(
t, "ts-bad.jsonl", content, "proj",
)
assertZeroTimestamp(t, sess.StartedAt, "StartedAt")
require.Len(t, msgs, 1, "messages")
assertZeroTimestamp(t, msgs[0].Timestamp, "msg timestamp")
})
t.Run("invalid primary but valid fallback logs no warning", func(t *testing.T) {
content := `{"type":"user","timestamp":"garbage","snapshot":{"timestamp":"2024-06-15T12:00:00Z"},"message":{"content":"hello"}}` + "\n"
buf := captureLog(t)
sess, msgs := parseClaudeTestFile(
t, "ts-mixed.jsonl", content, "proj",
)
wantTS := time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)
assertTimestamp(t, sess.StartedAt, wantTS)
require.Len(t, msgs, 1, "messages")
assertTimestamp(t, msgs[0].Timestamp, wantTS)
assertLogEmpty(t, buf)
})
t.Run("both timestamps invalid logs warning", func(t *testing.T) {
content := `{"type":"user","timestamp":"garbage1","snapshot":{"timestamp":"garbage2"},"message":{"content":"hello"}}` + "\n"
buf := captureLog(t)
sess, _ := parseClaudeTestFile(
t, "ts-invalid-both.jsonl", content, "proj",
)
assertZeroTimestamp(t, sess.StartedAt, "StartedAt")
assertLogContains(t, buf,
"unparseable timestamp", "garbage1",
)
})
t.Run("very long invalid timestamp is truncated in log", func(t *testing.T) {
longInvalid := strings.Repeat("x", 200)
content := `{"type":"user","timestamp":"` + longInvalid + `","message":{"content":"hello"}}` + "\n"
buf := captureLog(t)
path := createTestFile(t, "ts-long-invalid.jsonl", content)
_, err := parseClaudeSession(
path, "proj", "local",
)
require.NoError(t, err, "ParseClaudeSession")
assertLogContains(t, buf,
"unparseable timestamp", "x...",
)
assert.LessOrEqualf(t, buf.Len(), 1000,
"log output too long: %d bytes", buf.Len())
assertLogNotContains(t, buf, longInvalid)
})
}
func createTestFile(
t *testing.T, name, content string,
) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
require.NoErrorf(t, os.WriteFile(path, []byte(content), 0o644), "create %s", name)
return path
}
func TestIsClaudeSystemMessage(t *testing.T) {
tests := []struct {
name string
content string
want bool
}{
{"context continuation",
"This session is being continued from a previous conversation.",
true},
{"request interrupted",
"[Request interrupted by user]", true},
{"task-notification",
"some data",
true},
{"command-message is not system",
"foo", false},
{"command-name is not system",
"commit", false},
{"command-message with args is not system",
"roborev-fix\n/roborev-fix\n450",
false},
{"local-command tag",
"ok",
true},
{"stop hook feedback",
"Stop hook feedback: rejected by policy", true},
{"leading whitespace trimmed",
" \n This session is being continued...",
true},
{"leading tabs trimmed",
"\tdata",
true},
{"BOM prefix trimmed",
"\uFEFFThis session is being continued...",
true},
{"BOM plus whitespace trimmed",
"\uFEFF \tdata",
true},
{"whitespace before BOM trimmed",
" \uFEFFThis session is being continued...",
true},
{"normal user message",
"Fix the login bug", false},
{"implement plan is not filtered",
"Implement the following plan:\n## Steps",
false},
{"empty string", "", false},
{"partial prefix mismatch",
"This session was great", false},
{"assistant-like content not matched",
"Looking at the auth module...", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isClaudeSystemMessage(tt.content)
assert.Equalf(t, tt.want, got,
"isClaudeSystemMessage(%q)", tt.content)
})
}
}
func TestExtractCommandText(t *testing.T) {
tests := []struct {
name string
content string
want string
ok bool
}{
{
"skill with args",
"roborev-fix\n/roborev-fix\n450",
"/roborev-fix 450",
true,
},
{
"skill without args",
"superpowers:brainstorming\n/superpowers:brainstorming",
"/superpowers:brainstorming",
true,
},
{
"command-name first format",
"/model\n model\n ",
"/model",
true,
},
{
"command-name first with args",
"/roborev-fix\nroborev-fix\n491",
"/roborev-fix 491",
true,
},
{
"not a command message",
"Fix the login bug",
"",
false,
},
{
"empty string",
"",
"",
false,
},
{
"prose mentioning command-name tag not rewritten",
"The foo tag is used for...",
"",
false,
},
{
"tag followed by prose not rewritten",
"foo is the XML wrapper...",
"",
false,
},
{
"bare command-message without command-name",
"insights",
"/insights",
true,
},
{
"BOM-prefixed command envelope",
"\uFEFF/commit\ncommit",
"/commit",
true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := extractCommandText(tt.content)
assert.Equalf(t, tt.ok, ok,
"extractCommandText(%q) ok", tt.content)
assert.Equalf(t, tt.want, got,
"extractCommandText(%q)", tt.content)
})
}
}
func TestCodexUserMessageCount(t *testing.T) {
content := testjsonl.JoinJSONL(
testjsonl.CodexSessionMetaJSON(
"umc-test", "/Users/alice/code/app", "user", tsEarly,
),
testjsonl.CodexMsgJSON("user", "Fix the tests", tsEarlyS1),
testjsonl.CodexFunctionCallJSON(
"shell_command", "Running tests", tsEarlyS5,
),
testjsonl.CodexMsgJSON("assistant", "Tests pass now", tsLate),
testjsonl.CodexMsgJSON("user", "Great, thanks", tsLateS5),
)
path := createTestFile(t, "codex-umc.jsonl", content)
sess, msgs, err := parseCodexTestSession(t, path, "local", false)
require.NoError(t, err, "ParseCodexSession")
require.NotNil(t, sess, "session")
require.Len(t, msgs, 4, "messages")
// 2 user messages with real text content.
assert.Equal(t, 2, sess.UserMessageCount, "UserMessageCount")
}
func TestCodexSessionTimestampSemantics(t *testing.T) {
t.Run("invalid timestamp logs warning", func(t *testing.T) {
content := testjsonl.CodexMsgJSON("user", "hello", "garbage") + "\n"
path := createTestFile(t, "codex-ts-invalid.jsonl", content)
buf := captureLog(t)
sess, msgs, err := parseCodexTestSession(t, path, "local", false)
require.NoError(t, err, "ParseCodexSession")
assertZeroTimestamp(t, sess.StartedAt, "StartedAt")
require.Len(t, msgs, 1, "messages")
assertZeroTimestamp(t, msgs[0].Timestamp, "msg timestamp")
assertLogContains(t, buf,
"unparseable timestamp", "garbage",
)
})
t.Run("very long invalid timestamp is truncated in log", func(t *testing.T) {
longInvalid := strings.Repeat("x", 200)
content := testjsonl.CodexMsgJSON("user", "hello", longInvalid) + "\n"
path := createTestFile(t, "codex-ts-long-invalid.jsonl", content)
buf := captureLog(t)
_, _, err := parseCodexTestSession(t, path, "local", false)
require.NoError(t, err, "ParseCodexSession")
assertLogContains(t, buf,
"unparseable timestamp", "...",
)
assertLogNotContains(t, buf, longInvalid)
})
}
func TestParseCodexSessionOversizedLineSkipped(t *testing.T) {
if testing.Short() {
t.Skip("allocates large buffer")
}
meta := testjsonl.CodexSessionMetaJSON(
"huge", "/tmp", "user", tsEarly,
) + "\n"
prefix := `{"type":"response_item","timestamp":"` +
tsEarlyS1 +
`","payload":{"role":"user","content":` +
`[{"type":"input_text","text":"`
suffix := `"}]}}` + "\n"
firstLine := prefix + "hello" + suffix
secondLine := prefix + "goodbye" + suffix
oversizedLine := prefix +
strings.Repeat("x", maxLineSize+1) + suffix
// Place the oversized line between two normal lines. The two
// normal messages differ so the assertion isolates oversized-line
// skipping from the re-emitted-prompt dedup.
content := meta + firstLine + oversizedLine + secondLine
path := createTestFile(t, "oversized.jsonl", content)
sess, msgs, err := parseCodexTestSession(t, path, "local", false)
require.NoError(t, err, "unexpected error")
require.NotNil(t, sess, "session")
require.Len(t, msgs, 2, "messages (oversized skipped)")
}
func TestExtractCwdFromSession(t *testing.T) {
tests := []struct {
name string
content string // empty means use nonexistent file
want string
}{
{
"has cwd field",
`{"type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"content":"hi"},"cwd":"/Users/alice/code/my-app"}` + "\n",
"/Users/alice/code/my-app",
},
{
"no cwd field",
`{"type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"content":"hi"}}` + "\n",
"",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := createTestFile(t, "test.jsonl", tt.content)
got := ExtractCwdFromSession(path)
assert.Equal(t, tt.want, got, "ExtractCwdFromSession")
})
}
t.Run("missing file", func(t *testing.T) {
got := ExtractCwdFromSession("/nonexistent/path.jsonl")
assert.Empty(t, got, "ExtractCwdFromSession")
})
}
func TestParseCodexSession_WorktreeBranchFallback(t *testing.T) {
content := `{"type":"session_meta","timestamp":"2024-01-01T00:00:00Z","payload":{"id":"test-uuid","cwd":"/Users/wesm/code/agentsview-worktree-tool-call-arguments","originator":"user","git":{"branch":"worktree-tool-call-arguments"}}}` + "\n" +
`{"type":"response_item","timestamp":"2024-01-01T00:00:01Z","payload":{"role":"user","content":[{"type":"input_text","text":"hello"}]}}` + "\n"
path := createTestFile(t, "codex-worktree.jsonl", content)
sess, _, err := parseCodexTestSession(t, path, "local", false)
require.NoError(t, err, "ParseCodexSession")
require.NotNil(t, sess, "session")
assert.Equal(t, "agentsview", sess.Project, "project")
}
func TestExtractClaudeProjectHints(t *testing.T) {
t.Run("extracts cwd and gitBranch", func(t *testing.T) {
content := `{"type":"user","timestamp":"2024-01-01T00:00:00Z","cwd":"/Users/alice/code/my-app-worktree-fix","gitBranch":"worktree-fix","message":{"content":"hi"}}` + "\n"
path := createTestFile(t, "hints.jsonl", content)
cwd, branch := ExtractClaudeProjectHints(path)
require.Equal(t, "/Users/alice/code/my-app-worktree-fix", cwd, "cwd")
require.Equal(t, "worktree-fix", branch, "branch")
})
t.Run("missing branch still returns cwd", func(t *testing.T) {
content := `{"type":"user","timestamp":"2024-01-01T00:00:00Z","cwd":"/Users/alice/code/my-app","message":{"content":"hi"}}` + "\n"
path := createTestFile(t, "hints-nobranch.jsonl", content)
cwd, branch := ExtractClaudeProjectHints(path)
require.Equal(t, "/Users/alice/code/my-app", cwd, "cwd")
require.Empty(t, branch, "branch")
})
t.Run("missing file", func(t *testing.T) {
cwd, branch := ExtractClaudeProjectHints(
"/nonexistent/path.jsonl",
)
require.Empty(t, cwd, "cwd")
require.Empty(t, branch, "branch")
})
}
func TestFormatGeminiToolCall(t *testing.T) {
tests := []struct {
toolName string
json string
want string
}{
{
"read_file",
`{"name":"read_file","args":{"file_path":"main.go"},"displayName":"ReadFile"}`,
"[Read: main.go]",
},
{
"write_file",
`{"name":"write_file","args":{"file_path":"out.txt"},"displayName":"WriteFile"}`,
"[Write: out.txt]",
},
{
"edit_file",
`{"name":"edit_file","args":{"file_path":"fix.go"},"displayName":"EditFile"}`,
"[Edit: fix.go]",
},
{
"replace",
`{"name":"replace","args":{"file_path":"fix.go"},"displayName":"Replace"}`,
"[Edit: fix.go]",
},
{
"run_command",
`{"name":"run_command","args":{"command":"go test ./..."},"displayName":"RunCommand"}`,
"[Bash]\n$ go test ./...",
},
{
"execute_command",
`{"name":"execute_command","args":{"command":"ls -la"},"displayName":"Exec"}`,
"[Bash]\n$ ls -la",
},
{
"run_shell_command",
`{"name":"run_shell_command","args":{"command":"make build"},"displayName":"Shell"}`,
"[Bash]\n$ make build",
},
{
"list_directory",
`{"name":"list_directory","args":{"dir_path":"src"},"displayName":"ReadFolder"}`,
"[List: src]",
},
{
"search_files with query",
`{"name":"search_files","args":{"query":"TODO"},"displayName":"Search"}`,
"[Grep: TODO]",
},
{
"grep with pattern",
`{"name":"grep","args":{"pattern":"func main"},"displayName":"Grep"}`,
"[Grep: func main]",
},
{
"grep_search with query",
`{"name":"grep_search","args":{"query":"TODO"},"displayName":"GrepSearch"}`,
"[Grep: TODO]",
},
{
"glob",
`{"name":"glob","args":{"pattern":"**/*.go"},"displayName":"Glob"}`,
"[Glob: **/*.go]",
},
{
"unknown tool with displayName",
`{"name":"custom_tool","args":{},"displayName":"CustomTool"}`,
"[Tool: CustomTool]",
},
{
"unknown tool without displayName",
`{"name":"custom_tool","args":{}}`,
"[Tool: custom_tool]",
},
{
"",
`{"name":"empty_tool","args":{}}`,
"[Tool: empty_tool]",
},
}
for _, tt := range tests {
testName := tt.toolName
if testName == "" {
testName = "empty_string"
}
t.Run(testName, func(t *testing.T) {
tc := gjson.Parse(tt.json)
got := formatGeminiToolCall(tc)
assert.Equal(t, tt.want, got, "formatGeminiToolCall")
})
}
}
func TestGeminiUserMessageCount(t *testing.T) {
hash := "abc123def456"
content := testjsonl.GeminiSessionJSON(
"umc-gemini", hash, tsEarly, tsLateS5,
[]map[string]any{
testjsonl.GeminiUserMsg("u1", tsEarly, "Fix the bug"),
testjsonl.GeminiAssistantMsg(
"a1", tsEarlyS5, "Looking at it.", nil,
),
testjsonl.GeminiUserMsg("u2", tsLate, "Ship it"),
testjsonl.GeminiAssistantMsg(
"a2", tsLateS5, "Done.", nil,
),
},
)
path := createTestFile(t, "gemini-umc.json", content)
sess, msgs, err := parseGeminiTestSession(
t, path, "my_project", "local",
)
require.NoError(t, err, "parseGeminiTestSession")
require.NotNil(t, sess, "session")
require.Len(t, msgs, 4, "messages")
assert.Equal(t, 2, sess.UserMessageCount, "UserMessageCount")
}
func TestGeminiSessionID(t *testing.T) {
data := []byte(`{"sessionId":"abc-123","messages":[]}`)
got := GeminiSessionID(data)
assert.Equal(t, "abc-123", got, "GeminiSessionID")
got = GeminiSessionID([]byte("{\"sessionId\":\"jsonl-123\"}\n{\"type\":\"user\"}\n"))
assert.Equal(t, "jsonl-123", got, "GeminiSessionID JSONL")
got = GeminiSessionID([]byte(`{}`))
assert.Empty(t, got, "GeminiSessionID empty")
}
func TestClaudeUserMessageCount(t *testing.T) {
tests := []struct {
name string
content string
wantUserCount int
wantMsgCount int
}{
{
name: "counts real user prompts only",
content: testjsonl.JoinJSONL(
testjsonl.ClaudeUserJSON("Fix the bug", tsEarly),
testjsonl.ClaudeAssistantJSON([]map[string]any{
{"type": "tool_use", "id": "toolu_1", "name": "Read", "input": map[string]string{"file_path": "main.go"}},
}, tsEarlyS1),
// Tool-result user message: Content="" but has tool_result blocks.
// This should NOT count as a user prompt.
`{"type":"user","timestamp":"`+tsEarlyS5+`","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"package main"}]}}`,
testjsonl.ClaudeAssistantJSON([]map[string]any{
{"type": "text", "text": "Here is the fix."},
}, tsLate),
testjsonl.ClaudeUserJSON("Thanks!", tsLateS5),
),
wantUserCount: 2,
wantMsgCount: 5,
},
{
name: "no user prompts in tool-only session",
content: testjsonl.JoinJSONL(
testjsonl.ClaudeAssistantJSON([]map[string]any{
{"type": "tool_use", "id": "toolu_2", "name": "Bash", "input": map[string]string{"command": "ls"}},
}, tsEarly),
`{"type":"user","timestamp":"`+tsEarlyS1+`","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_2","content":"file.go"}]}}`,
),
wantUserCount: 0,
wantMsgCount: 2,
},
{
name: "single user prompt",
content: testjsonl.JoinJSONL(
testjsonl.ClaudeUserJSON("Hello", tsEarly),
testjsonl.ClaudeAssistantJSON([]map[string]any{
{"type": "text", "text": "Hi!"},
}, tsEarlyS5),
),
wantUserCount: 1,
wantMsgCount: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := createTestFile(t, "test.jsonl", tt.content)
results, err := parseClaudeSession(
path, "test-proj", "local",
)
require.NoError(t, err, "ParseClaudeSession")
require.NotEmpty(t, results, "ParseClaudeSession returned no results")
sess := results[0].Session
msgs := results[0].Messages
require.Len(t, msgs, tt.wantMsgCount, "message count")
assert.Equal(t, tt.wantUserCount, sess.UserMessageCount, "UserMessageCount")
})
}
}
func TestParseClaudeToolResults(t *testing.T) {
lines := []string{
`{"type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"content":[{"type":"tool_use","id":"toolu_abc","name":"Read","input":{"file_path":"main.go"}}]}}`,
`{"type":"user","timestamp":"2024-01-01T00:00:01Z","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_abc","content":"package main\nfunc main() {}"}]}}`,
}
content := strings.Join(lines, "\n") + "\n"
path := createTestFile(t, "tool-results.jsonl", content)
results, err := parseClaudeSession(path, "test-project", "local")
require.NoError(t, err, "ParseClaudeSession")
require.NotEmpty(t, results, "ParseClaudeSession returned no results")
msgs := results[0].Messages
// Should have 2 messages: assistant tool_use + user tool_result
require.Len(t, msgs, 2, "messages")
// User message should have ToolResults populated
userMsg := msgs[1]
require.Len(t, userMsg.ToolResults, 1, "ToolResults count")
assert.Equal(t, "toolu_abc", userMsg.ToolResults[0].ToolUseID, "ToolUseID")
assert.Equal(t, 27, userMsg.ToolResults[0].ContentLength, "ContentLength")
}