Files
wehub-resource-sync f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

623 lines
24 KiB
Go

package parser
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"go.kenn.io/agentsview/internal/testjsonl"
)
// newGeminiTestProvider builds a concrete geminiProvider for the given roots so
// package tests can exercise the folded parse, discovery, and source-lookup
// behavior directly through provider methods, replacing the removed
// package-level entrypoints.
func newGeminiTestProvider(t *testing.T, roots ...string) *geminiProvider {
t.Helper()
provider, ok := NewProvider(AgentGemini, ProviderConfig{
Roots: roots,
Machine: "local",
})
require.True(t, ok)
gp, ok := provider.(*geminiProvider)
require.True(t, ok)
return gp
}
// parseGeminiTestSession parses a Gemini session file at path through the
// provider-owned parse method, replacing the removed package-level
// ParseGeminiSession entrypoint.
func parseGeminiTestSession(
t *testing.T, path, project, machine string,
) (*ParsedSession, []ParsedMessage, error) {
t.Helper()
return newGeminiTestProvider(t).parseSession(path, project, machine)
}
// discoverGeminiTestSessions discovers Gemini sessions under root through the
// provider, returning the legacy DiscoveredFile shape (path + project) the
// tests assert against.
func discoverGeminiTestSessions(t *testing.T, root string) []DiscoveredFile {
t.Helper()
provider := newGeminiTestProvider(t, root)
sources, err := provider.Discover(context.Background())
require.NoError(t, err)
if len(sources) == 0 {
return nil
}
files := make([]DiscoveredFile, 0, len(sources))
for _, source := range sources {
files = append(files, DiscoveredFile{
Path: source.DisplayPath,
Project: source.ProjectHint,
Agent: AgentGemini,
})
}
return files
}
// findGeminiTestSourceFile resolves a Gemini session ID to a session file path
// through the provider, replacing the removed FindGeminiSourceFile.
func findGeminiTestSourceFile(t *testing.T, root, sessionID string) string {
t.Helper()
return newGeminiTestProvider(t, root).sources.findSourceFile(root, sessionID)
}
func runGeminiParserTest(t *testing.T, content string) (*ParsedSession, []ParsedMessage) {
t.Helper()
path := createTestFile(t, "session.json", content)
sess, msgs, err := parseGeminiTestSession(t, path, "my_project", "local")
require.NoError(t, err)
return sess, msgs
}
func TestParseGeminiSession_Basic(t *testing.T) {
content := loadFixture(t, "gemini/standard_session.json")
sess, msgs := runGeminiParserTest(t, content)
require.NotNil(t, sess)
assertMessageCount(t, len(msgs), 4)
assertMessageCount(t, sess.MessageCount, 4)
assertSessionMeta(t, sess, "gemini:sess-uuid-1", "my_project", AgentGemini)
assert.Equal(t, "Fix the login bug", sess.FirstMessage)
assertMessage(t, msgs[0], RoleUser, "Fix the login bug")
assertMessage(t, msgs[1], RoleAssistant, "Looking at")
assert.Equal(t, 0, msgs[0].Ordinal)
assert.Equal(t, 1, msgs[1].Ordinal)
}
func TestParseGeminiSession_JSONLStream(t *testing.T) {
content := strings.Join([]string{
`{"sessionId":"sess-jsonl-1","projectHash":"hash","startTime":"2026-04-23T16:12:42.783Z","lastUpdated":"2026-04-23T16:12:42.783Z","kind":"main"}`,
`{"id":"u1","timestamp":"2026-04-23T16:12:43.085Z","type":"user","content":[{"text":"Fix the import path"}]}`,
`{"$set":{"lastUpdated":"2026-04-23T16:12:43.085Z"}}`,
`{"id":"a1","timestamp":"2026-04-23T16:12:50.158Z","type":"gemini","content":"","thoughts":[{"subject":"Planning","description":"Looking for the failure.","timestamp":"2026-04-23T16:12:46.795Z"}],"tokens":{"input":9184,"output":26,"cached":0},"model":"gemini-3.1-pro-preview"}`,
`{"id":"a1","timestamp":"2026-04-23T16:12:50.158Z","type":"gemini","content":"I found the issue.","thoughts":[{"subject":"Planning","description":"Looking for the failure.","timestamp":"2026-04-23T16:12:46.795Z"}],"tokens":{"input":9184,"output":26,"cached":0},"model":"gemini-3.1-pro-preview","toolCalls":[{"id":"read_file_1","name":"read_file","args":{"file_path":"main.go"},"result":[{"functionResponse":{"id":"read_file_1","name":"read_file","response":{"output":"package main"}}}],"displayName":"ReadFile"}]}`,
`{"$set":{"lastUpdated":"2026-04-23T16:12:50.158Z"}}`,
}, "\n")
path := createTestFile(t, "session.jsonl", content)
sess, msgs, err := parseGeminiTestSession(t, path, "my_project", "local")
require.NoError(t, err)
require.NotNil(t, sess)
require.Equal(t, 2, len(msgs))
assertSessionMeta(t, sess, "gemini:sess-jsonl-1", "my_project", AgentGemini)
assert.Equal(t, "Fix the import path", sess.FirstMessage)
assertMessage(t, msgs[0], RoleUser, "Fix the import path")
assertMessage(t, msgs[1], RoleAssistant, "I found the issue.")
assert.True(t, msgs[1].HasThinking)
assert.True(t, msgs[1].HasToolUse)
require.Len(t, msgs[1].ToolCalls, 1)
assert.Equal(t, "read_file_1", msgs[1].ToolCalls[0].ToolUseID)
require.Len(t, msgs[1].ToolResults, 1)
assert.Equal(t, "package main", DecodeContent(msgs[1].ToolResults[0].ContentRaw))
assert.Equal(
t,
parseTimestamp("2026-04-23T16:12:50.158Z"),
sess.EndedAt,
)
}
func TestParseGeminiSession_JSONLStreamLargeRecord(t *testing.T) {
largeContent := strings.Repeat("x", 16*1024*1024+1)
content := strings.Join([]string{
`{"sessionId":"sess-jsonl-large","projectHash":"hash","startTime":"2026-04-23T16:12:42.783Z","lastUpdated":"2026-04-23T16:12:42.783Z","kind":"main"}`,
`{"id":"u1","timestamp":"2026-04-23T16:12:43.085Z","type":"user","content":[{"text":"` + largeContent + `"}]}`,
}, "\n")
path := createTestFile(t, "large-session.jsonl", content)
sess, msgs, err := parseGeminiTestSession(t, path, "my_project", "local")
require.NoError(t, err)
require.NotNil(t, sess)
require.Len(t, msgs, 1)
assertSessionMeta(t, sess, "gemini:sess-jsonl-large", "my_project", AgentGemini)
assert.Equal(t, len(largeContent), len(msgs[0].Content))
}
func TestParseGeminiSession_JSONLStreamTolerantOfPartialLines(t *testing.T) {
t.Run("partial trailing write", func(t *testing.T) {
content := strings.Join([]string{
`{"sessionId":"sess-jsonl-partial","projectHash":"hash","startTime":"2026-04-23T16:12:42.783Z","lastUpdated":"2026-04-23T16:12:42.783Z","kind":"main"}`,
`{"id":"u1","timestamp":"2026-04-23T16:12:43.085Z","type":"user","content":[{"text":"first"}]}`,
`{"id":"a1","timestamp":"2026-04-23T16:12:50.158Z","type":"gemini","content":"reply"`,
}, "\n")
path := createTestFile(t, "session.jsonl", content)
sess, msgs, err := parseGeminiTestSession(t, path, "my_project", "local")
require.NoError(t, err)
require.NotNil(t, sess)
require.Equal(t, 1, len(msgs))
assertMessage(t, msgs[0], RoleUser, "first")
})
t.Run("malformed line mid-stream", func(t *testing.T) {
content := strings.Join([]string{
`{"sessionId":"sess-jsonl-mid","projectHash":"hash","startTime":"2026-04-23T16:12:42.783Z","lastUpdated":"2026-04-23T16:12:42.783Z","kind":"main"}`,
`{"id":"u1","timestamp":"2026-04-23T16:12:43.085Z","type":"user","content":[{"text":"first"}]}`,
`{not valid json`,
`{"id":"a1","timestamp":"2026-04-23T16:12:50.158Z","type":"gemini","content":"reply"}`,
"",
}, "\n")
path := createTestFile(t, "session.jsonl", content)
sess, msgs, err := parseGeminiTestSession(t, path, "my_project", "local")
require.NoError(t, err)
require.NotNil(t, sess)
require.Equal(t, 2, len(msgs))
assertMessage(t, msgs[0], RoleUser, "first")
assertMessage(t, msgs[1], RoleAssistant, "reply")
})
}
func TestParseGeminiSession_ToolCalls(t *testing.T) {
t.Run("basic tool calls", func(t *testing.T) {
content := loadFixture(t, "gemini/tool_calls.json")
_, msgs := runGeminiParserTest(t, content)
assert.Equal(t, 2, len(msgs))
assert.True(t, msgs[1].HasToolUse)
assert.True(t, msgs[1].HasThinking)
assert.True(t, strings.Contains(msgs[1].Content, "[Thinking]\nPlanning\n"))
assert.True(t, strings.Contains(msgs[1].Content, "[/Thinking]"))
assert.True(t, strings.Contains(msgs[1].Content, "[Read: main.go]"))
// Chronological: thinking before content before tool calls
thinkIdx := strings.Index(msgs[1].Content, "[Thinking]")
contentIdx := strings.Index(msgs[1].Content, "Let me read it.")
toolIdx := strings.Index(msgs[1].Content, "[Read:")
assert.Less(t, thinkIdx, contentIdx)
assert.Less(t, contentIdx, toolIdx)
assertToolCalls(t, msgs[1].ToolCalls, []ParsedToolCall{{ToolName: "read_file", Category: "Read"}})
})
t.Run("tool calls with results", func(t *testing.T) {
content := loadFixture(t, "gemini/tool_calls_with_results.json")
_, msgs := runGeminiParserTest(t, content)
require.Equal(t, 2, len(msgs))
assistantMsg := msgs[1]
assert.True(t, assistantMsg.HasToolUse)
// Verify ToolUseID and InputJSON are extracted
require.Equal(t, 2, len(assistantMsg.ToolCalls))
assertToolCalls(t, assistantMsg.ToolCalls, []ParsedToolCall{
{
ToolName: "read_file",
Category: "Read",
ToolUseID: "read_file_1772747340739_0",
InputJSON: `{"file_path":".planning/ONE-PAGER.md"}`,
},
{
ToolName: "run_command",
Category: "Bash",
ToolUseID: "run_command_1772747340739_1",
InputJSON: `{"command":"ls -la"}`,
},
})
// Verify tool results are extracted
require.Equal(t, 2, len(assistantMsg.ToolResults))
assert.Equal(t, "read_file_1772747340739_0", assistantMsg.ToolResults[0].ToolUseID)
assert.Equal(t, len("# Agentstrove -- One-Pager\n\nDraft: 2026-03-04"), assistantMsg.ToolResults[0].ContentLength)
// Verify DecodeContent works on the raw content
assert.Equal(t, "# Agentstrove -- One-Pager\n\nDraft: 2026-03-04", DecodeContent(assistantMsg.ToolResults[0].ContentRaw))
assert.Equal(t, "run_command_1772747340739_1", assistantMsg.ToolResults[1].ToolUseID)
assert.Equal(t, "total 42\ndrwxr-xr-x 5 user user 160 Mar 4 10:00 .", DecodeContent(assistantMsg.ToolResults[1].ContentRaw))
})
t.Run("programmatic tool call with result", func(t *testing.T) {
content := testjsonl.GeminiSessionJSON("sess-tc-result", "hash", tsEarly, tsEarlyS5, []map[string]any{
testjsonl.GeminiUserMsg("u1", tsEarly, "list files"),
testjsonl.GeminiAssistantMsg("a1", tsEarlyS5, "Running command.", &testjsonl.GeminiMsgOpts{
ToolCalls: []testjsonl.GeminiToolCall{
{
ID: "run_cmd_1",
Name: "run_command",
DisplayName: "RunCommand",
Args: map[string]string{"command": "ls"},
ResultOutput: "file1.go\nfile2.go",
},
},
}),
})
_, msgs := runGeminiParserTest(t, content)
require.Equal(t, 2, len(msgs))
require.Equal(t, 1, len(msgs[1].ToolCalls))
assert.Equal(t, "run_cmd_1", msgs[1].ToolCalls[0].ToolUseID)
require.Equal(t, 1, len(msgs[1].ToolResults))
assert.Equal(t, "run_cmd_1", msgs[1].ToolResults[0].ToolUseID)
assert.Equal(t, len("file1.go\nfile2.go"), msgs[1].ToolResults[0].ContentLength)
assert.Equal(t, "file1.go\nfile2.go", DecodeContent(msgs[1].ToolResults[0].ContentRaw))
})
t.Run("tool call without result", func(t *testing.T) {
content := testjsonl.GeminiSessionJSON("sess-tc-no-result", "hash", tsEarly, tsEarlyS5, []map[string]any{
testjsonl.GeminiUserMsg("u1", tsEarly, "read it"),
testjsonl.GeminiAssistantMsg("a1", tsEarlyS5, "Reading.", &testjsonl.GeminiMsgOpts{
ToolCalls: []testjsonl.GeminiToolCall{
{
ID: "read_1",
Name: "read_file",
DisplayName: "ReadFile",
Args: map[string]string{"file_path": "main.go"},
},
},
}),
})
_, msgs := runGeminiParserTest(t, content)
require.Equal(t, 2, len(msgs))
require.Equal(t, 1, len(msgs[1].ToolCalls))
assert.Equal(t, "read_1", msgs[1].ToolCalls[0].ToolUseID)
assert.Equal(t, 0, len(msgs[1].ToolResults))
})
t.Run("empty tool name skipped", func(t *testing.T) {
content := testjsonl.GeminiSessionJSON("sess-uuid-empty-tc", "hash", tsEarly, tsEarlyS5, []map[string]any{
testjsonl.GeminiUserMsg("u1", tsEarly, "do it"),
testjsonl.GeminiAssistantMsg("a1", tsEarlyS5, "Using tool.", &testjsonl.GeminiMsgOpts{
ToolCalls: []testjsonl.GeminiToolCall{{Name: "", DisplayName: "", Args: nil}},
}),
})
_, msgs := runGeminiParserTest(t, content)
assert.Equal(t, 2, len(msgs))
assert.True(t, msgs[1].HasToolUse)
assertToolCalls(t, msgs[1].ToolCalls, nil)
})
}
func TestParseGeminiSession_ThinkingWithText(t *testing.T) {
content := loadFixture(t, "gemini/thinking_only.json")
_, msgs := runGeminiParserTest(t, content)
require.Equal(t, 2, len(msgs))
msg := msgs[1]
assert.True(t, msg.HasThinking)
assert.False(t, msg.HasToolUse)
// Thinking and content should be separated by blank lines
assert.Contains(t, msg.Content, "[Thinking]")
assert.Contains(t, msg.Content, "Here is how it works")
// Verify blank-line separation between thinking blocks
// and between thinking and content
thinkIdx := strings.LastIndex(
msg.Content, "[Thinking]",
)
contentIdx := strings.Index(
msg.Content,
"Here is how it works",
)
assert.Less(t, thinkIdx, contentIdx)
// The text between last thinking block and response
// should contain a blank line
between := msg.Content[thinkIdx:contentIdx]
assert.Contains(t, between, "\n\n")
}
func TestParseGeminiSession_TokenUsage(t *testing.T) {
t.Run("per-message tokens from fixture", func(t *testing.T) {
content := loadFixture(t, "gemini/standard_session.json")
sess, msgs := runGeminiParserTest(t, content)
require.Equal(t, 4, len(msgs))
// User messages have no tokens
assert.Equal(t, 0, msgs[0].ContextTokens)
assert.Equal(t, 0, msgs[0].OutputTokens)
assert.False(t, msgs[0].HasContextTokens)
assert.False(t, msgs[0].HasOutputTokens)
assert.Empty(t, msgs[0].TokenUsage)
// First assistant message (a1): input=1500, cached=100,
// output=200, thoughts=50; deltas equal cumulative (first turn).
assert.Equal(t, 1600, msgs[1].ContextTokens)
assert.Equal(t, 250, msgs[1].OutputTokens)
assert.True(t, msgs[1].HasContextTokens)
assert.True(t, msgs[1].HasOutputTokens)
assert.NotEmpty(t, msgs[1].TokenUsage)
assert.Equal(t, int64(1500),
gjson.GetBytes(msgs[1].TokenUsage, "input_tokens").Int())
assert.Equal(t, int64(250),
gjson.GetBytes(msgs[1].TokenUsage, "output_tokens").Int())
assert.Equal(t, int64(100),
gjson.GetBytes(msgs[1].TokenUsage, "cache_read_input_tokens").Int())
// Second user message has no tokens
assert.Equal(t, 0, msgs[2].ContextTokens)
assert.Equal(t, 0, msgs[2].OutputTokens)
assert.False(t, msgs[2].HasContextTokens)
assert.False(t, msgs[2].HasOutputTokens)
// Second assistant message (a2): input=2000, cached=50;
// cached went down from 100 so it resets, inputDelta=500,
// cachedDelta=50, ContextTokens=550.
assert.Equal(t, 550, msgs[3].ContextTokens)
assert.Equal(t, 400, msgs[3].OutputTokens)
assert.True(t, msgs[3].HasContextTokens)
assert.True(t, msgs[3].HasOutputTokens)
assert.NotEmpty(t, msgs[3].TokenUsage)
assert.Equal(t, int64(500),
gjson.GetBytes(msgs[3].TokenUsage, "input_tokens").Int())
assert.Equal(t, int64(400),
gjson.GetBytes(msgs[3].TokenUsage, "output_tokens").Int())
assert.Equal(t, int64(50),
gjson.GetBytes(msgs[3].TokenUsage, "cache_read_input_tokens").Int())
// Session totals
assert.Equal(t, 650, sess.TotalOutputTokens)
assert.Equal(t, 1600, sess.PeakContextTokens)
assert.True(t, sess.HasTotalOutputTokens)
assert.True(t, sess.HasPeakContextTokens)
})
t.Run("messages without tokens get zero values", func(t *testing.T) {
content := testjsonl.GeminiSessionJSON(
"sess-no-tokens", "hash", tsEarly, tsEarlyS5,
[]map[string]any{
testjsonl.GeminiUserMsg("u1", tsEarly, "hello"),
testjsonl.GeminiAssistantMsg("a1", tsEarlyS5, "hi there", nil),
},
)
sess, msgs := runGeminiParserTest(t, content)
require.Equal(t, 2, len(msgs))
assert.Equal(t, 0, msgs[0].ContextTokens)
assert.Equal(t, 0, msgs[1].ContextTokens)
assert.Equal(t, 0, msgs[1].OutputTokens)
assert.False(t, msgs[0].HasContextTokens)
assert.False(t, msgs[0].HasOutputTokens)
assert.False(t, msgs[1].HasContextTokens)
assert.False(t, msgs[1].HasOutputTokens)
assert.Equal(t, 0, sess.TotalOutputTokens)
assert.Equal(t, 0, sess.PeakContextTokens)
assert.False(t, sess.HasTotalOutputTokens)
assert.False(t, sess.HasPeakContextTokens)
})
t.Run("tokens with programmatic fixture", func(t *testing.T) {
content := testjsonl.GeminiSessionJSON(
"sess-tokens-prog", "hash", tsEarly, tsEarlyS5,
[]map[string]any{
testjsonl.GeminiUserMsg("u1", tsEarly, "explain"),
{
"id": "a1",
"timestamp": tsEarlyS5,
"type": "gemini",
"content": "Here is the explanation.",
"tokens": map[string]int{
"input": 5000,
"output": 800,
"cached": 200,
"thoughts": 100,
"tool": 0,
"total": 6100,
},
},
},
)
sess, msgs := runGeminiParserTest(t, content)
require.Equal(t, 2, len(msgs))
assert.Equal(t, 5200, msgs[1].ContextTokens)
assert.Equal(t, 900, msgs[1].OutputTokens)
assert.True(t, msgs[1].HasContextTokens)
assert.True(t, msgs[1].HasOutputTokens)
assert.NotEmpty(t, msgs[1].TokenUsage)
assert.Equal(t, int64(5000),
gjson.GetBytes(msgs[1].TokenUsage, "input_tokens").Int())
assert.Equal(t, int64(900),
gjson.GetBytes(msgs[1].TokenUsage, "output_tokens").Int())
assert.Equal(t, int64(200),
gjson.GetBytes(msgs[1].TokenUsage,
"cache_read_input_tokens").Int())
assert.Equal(t, 900, sess.TotalOutputTokens)
assert.Equal(t, 5200, sess.PeakContextTokens)
assert.True(t, sess.HasTotalOutputTokens)
assert.True(t, sess.HasPeakContextTokens)
})
t.Run("zero-valued token keys preserve coverage", func(t *testing.T) {
content := testjsonl.GeminiSessionJSON(
"sess-zero-explicit", "hash", tsEarly, tsEarlyS5,
[]map[string]any{
testjsonl.GeminiUserMsg("u1", tsEarly, "hello"),
{
"id": "a1",
"timestamp": tsEarlyS5,
"type": "gemini",
"content": "still counted",
"tokens": map[string]int{
"input": 0,
"output": 0,
"cached": 0,
},
},
},
)
sess, msgs := runGeminiParserTest(t, content)
require.Equal(t, 2, len(msgs))
assert.Equal(t, 0, msgs[1].ContextTokens)
assert.Equal(t, 0, msgs[1].OutputTokens)
assert.True(t, msgs[1].HasContextTokens)
assert.True(t, msgs[1].HasOutputTokens)
msgHasCtx, msgHasOut := msgs[1].TokenPresence()
assert.True(t, msgHasCtx)
assert.True(t, msgHasOut)
assert.Equal(t, 0, sess.TotalOutputTokens)
assert.Equal(t, 0, sess.PeakContextTokens)
assert.True(t, sess.HasTotalOutputTokens)
assert.True(t, sess.HasPeakContextTokens)
sessHasTotal, sessHasPeak := sess.AggregateTokenPresence()
assert.True(t, sessHasTotal)
assert.True(t, sessHasPeak)
coverageTotal, coveragePeak := sess.TokenCoverage(msgs)
assert.True(t, coverageTotal)
assert.True(t, coveragePeak)
})
}
func TestParseGeminiSession_EdgeCases(t *testing.T) {
t.Run("only system messages", func(t *testing.T) {
content := loadFixture(t, "gemini/system_messages.json")
sess, msgs := runGeminiParserTest(t, content)
require.NotNil(t, sess)
assert.Equal(t, 0, len(msgs))
})
t.Run("first message truncation", func(t *testing.T) {
content := testjsonl.GeminiSessionJSON(
"sess-uuid-6", "hash", tsEarly, tsEarlyS5,
[]map[string]any{
testjsonl.GeminiUserMsg("u1", tsEarly, generateLargeString(400)),
},
)
sess, _ := runGeminiParserTest(t, content)
require.NotNil(t, sess)
assert.Equal(t, 303, len(sess.FirstMessage))
})
t.Run("malformed JSON", func(t *testing.T) {
path := createTestFile(t, "session.json", "not valid json {{{")
_, _, err := parseGeminiTestSession(t, path, "my_project", "local")
assert.Error(t, err)
})
t.Run("missing file", func(t *testing.T) {
_, _, err := parseGeminiTestSession(t, "/nonexistent.json", "my_project", "local")
assert.Error(t, err)
})
t.Run("empty messages array", func(t *testing.T) {
content := testjsonl.GeminiSessionJSON("sess-uuid-4", "hash", tsEarly, tsEarlyS5, []map[string]any{})
sess, msgs := runGeminiParserTest(t, content)
assert.Equal(t, 0, sess.MessageCount)
assert.Equal(t, 0, len(msgs))
})
t.Run("content as Part array", func(t *testing.T) {
content := testjsonl.GeminiSessionJSON("sess-uuid-5", "hash", tsEarly, tsEarlyS5, []map[string]any{
{
"id": "u1",
"timestamp": tsEarly,
"type": "user",
"content": []map[string]string{
{"text": "part one"},
{"text": "part two"},
},
},
})
_, msgs := runGeminiParserTest(t, content)
assert.Equal(t, 1, len(msgs))
assert.True(t, strings.Contains(msgs[0].Content, "part one"))
assert.True(t, strings.Contains(msgs[0].Content, "part two"))
})
t.Run("timestamps from startTime and lastUpdated", func(t *testing.T) {
content := testjsonl.GeminiSessionJSON("sess-uuid-7", "hash", "2024-06-15T10:00:00Z", "2024-06-15T11:30:00Z", []map[string]any{
testjsonl.GeminiUserMsg("u1", "2024-06-15T10:00:00Z", "hello"),
})
sess, _ := runGeminiParserTest(t, content)
wantStart := time.Date(2024, 6, 15, 10, 0, 0, 0, time.UTC)
wantEnd := time.Date(2024, 6, 15, 11, 30, 0, 0, time.UTC)
assertTimestamp(t, sess.StartedAt, wantStart)
assertTimestamp(t, sess.EndedAt, wantEnd)
})
t.Run("missing sessionId", func(t *testing.T) {
content := `{"projectHash":"abc","startTime":"2024-01-01T00:00:00Z","lastUpdated":"2024-01-01T00:00:00Z","messages":[]}`
path := createTestFile(t, "session.json", content)
_, _, err := parseGeminiTestSession(t, path, "my_project", "local")
assert.Error(t, err)
})
}
func TestParseGeminiSession_ContextTokensDelta(t *testing.T) {
t.Run("multi-turn increasing cumulative", func(t *testing.T) {
content := `{"sessionId":"sess-ctx-delta","startTime":"2026-04-23T16:12:42.783Z","lastUpdated":"2026-04-23T16:13:42.783Z","messages":[
{"type":"user","timestamp":"2026-04-23T16:12:43Z","content":[{"text":"first question"}]},
{"type":"gemini","timestamp":"2026-04-23T16:12:45Z","content":"answer one","tokens":{"input":10000,"output":50,"cached":0}},
{"type":"user","timestamp":"2026-04-23T16:12:50Z","content":[{"text":"second question"}]},
{"type":"gemini","timestamp":"2026-04-23T16:12:55Z","content":"answer two","tokens":{"input":22000,"output":80,"cached":3000}},
{"type":"gemini","timestamp":"2026-04-23T16:13:05Z","content":"answer three","tokens":{"input":60000,"output":120,"cached":0}}
]}`
sess, msgs := runGeminiParserTest(t, content)
// user messages have no tokens
require.Len(t, msgs, 5)
assert.False(t, msgs[0].HasContextTokens)
assert.False(t, msgs[2].HasContextTokens)
// gemini msg 0 (answer one): input=10000, cached=0, deltas=10000+0
assert.Equal(t, 10000, msgs[1].ContextTokens)
// gemini msg 1 (answer two): input=22000, cached=3000,
// inputDelta=12000, cachedDelta=3000, total=15000
assert.Equal(t, 15000, msgs[3].ContextTokens)
// gemini msg 2 (answer three): input=60000, cached=0;
// cached reset (3000->0), inputDelta=38000, cachedDelta=0, total=38000
assert.Equal(t, 38000, msgs[4].ContextTokens)
// PeakContextTokens is the largest delta, not the final cumulative
assert.Equal(t, 38000, sess.PeakContextTokens)
})
t.Run("counter reset clamps to cumulative", func(t *testing.T) {
content := `{"sessionId":"sess-ctx-reset","startTime":"2026-04-23T16:12:42.783Z","lastUpdated":"2026-04-23T16:13:42.783Z","messages":[
{"type":"user","timestamp":"2026-04-23T16:12:43Z","content":[{"text":"first"}]},
{"type":"gemini","timestamp":"2026-04-23T16:12:45Z","content":"reply one","tokens":{"input":10000,"output":50,"cached":0}},
{"type":"user","timestamp":"2026-04-23T16:12:50Z","content":[{"text":"second"}]},
{"type":"gemini","timestamp":"2026-04-23T16:12:55Z","content":"reply two","tokens":{"input":25000,"output":80,"cached":0}},
{"type":"user","timestamp":"2026-04-23T16:13:00Z","content":[{"text":"third"}]},
{"type":"gemini","timestamp":"2026-04-23T16:13:05Z","content":"reply three","tokens":{"input":5000,"output":120,"cached":0}}
]}`
_, msgs := runGeminiParserTest(t, content)
require.Len(t, msgs, 6)
// deltas: 10000, 15000, 5000 (reset clamps to cumulative)
assert.Equal(t, 10000, msgs[1].ContextTokens)
assert.Equal(t, 15000, msgs[3].ContextTokens)
assert.Equal(t, 5000, msgs[5].ContextTokens)
})
t.Run("no token field", func(t *testing.T) {
content := `{"sessionId":"sess-ctx-no-tokens","startTime":"2026-04-23T16:12:42.783Z","lastUpdated":"2026-04-23T16:12:50.783Z","messages":[
{"type":"user","timestamp":"2026-04-23T16:12:43Z","content":[{"text":"hello"}]},
{"type":"gemini","timestamp":"2026-04-23T16:12:45Z","content":"hi there"}
]}`
_, msgs := runGeminiParserTest(t, content)
require.Len(t, msgs, 2)
assert.False(t, msgs[1].HasContextTokens)
assert.Equal(t, 0, msgs[1].ContextTokens)
})
}