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

754 lines
32 KiB
Go

package parser
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newCopilotTestProvider builds a concrete copilotProvider 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 newCopilotTestProvider(t *testing.T, roots ...string) *copilotProvider {
t.Helper()
provider, ok := NewProvider(AgentCopilot, ProviderConfig{
Roots: roots,
Machine: "local",
})
require.True(t, ok)
cp, ok := provider.(*copilotProvider)
require.True(t, ok)
return cp
}
// parseCopilotTestSession parses a Copilot JSONL session file at path through
// the provider-owned parse method, replacing the removed package-level
// ParseCopilotSession entrypoint.
func parseCopilotTestSession(
t *testing.T, path, machine string,
) (*ParsedSession, []ParsedMessage, []ParsedUsageEvent, error) {
t.Helper()
return newCopilotTestProvider(t).parseSession(path, machine)
}
// discoverCopilotTestSessions discovers Copilot sessions under root through the
// provider, returning the legacy DiscoveredFile shape (path) the tests assert
// against.
func discoverCopilotTestSessions(t *testing.T, root string) []DiscoveredFile {
t.Helper()
provider := newCopilotTestProvider(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,
Agent: AgentCopilot,
})
}
return files
}
// findCopilotTestSourceFile resolves a Copilot session ID to a session file
// path through the provider, replacing the removed FindCopilotSourceFile.
func findCopilotTestSourceFile(t *testing.T, root, rawID string) string {
t.Helper()
return newCopilotTestProvider(t, root).sources.findSourceFile(root, rawID)
}
// writeCopilotJSONL writes JSONL lines to a temp file and
// returns the file path.
func writeCopilotJSONL(
t *testing.T, lines ...string,
) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "test-session.jsonl")
content := strings.Join(lines, "\n") + "\n"
require.NoError(t, os.WriteFile(
path, []byte(content), 0o644,
))
return path
}
// parseAndValidateHelper parses the session and fails the test on basic errors.
func parseAndValidateHelper(t *testing.T, path string, machine string, wantMsgs int) (*ParsedSession, []ParsedMessage) {
t.Helper()
sess, msgs, _, err := parseCopilotTestSession(t, path, machine)
require.NoError(t, err)
require.NotNil(t, sess, "expected non-nil session")
require.Len(t, msgs, wantMsgs)
return sess, msgs
}
func assertEqual[T comparable](t *testing.T, want, got T, name string) {
t.Helper()
assert.Equal(t, want, got, name)
}
func TestParseCopilotSession_Basic(t *testing.T) {
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"abc-123","context":{"cwd":"/home/alice/code/myproject","branch":"main"}},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"Fix the login bug"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"I'll fix the login bug."},"timestamp":"2025-01-15T10:00:02Z"}`,
)
sess, msgs := parseAndValidateHelper(t, path, "test-machine", 2)
assertEqual(t, "copilot:abc-123", sess.ID, "session ID")
assertEqual(t, AgentCopilot, sess.Agent, "agent")
assertEqual(t, "test-machine", sess.Machine, "machine")
assertEqual(t, "myproject", sess.Project, "project")
assertEqual(t, "Fix the login bug", sess.FirstMessage, "first_message")
assertEqual(t, 2, sess.MessageCount, "message_count")
assertEqual(t, RoleUser, msgs[0].Role, "msgs[0].Role")
assertEqual(t, RoleAssistant, msgs[1].Role, "msgs[1].Role")
assertEqual(t, "Fix the login bug", msgs[0].Content, "msgs[0].Content")
}
func TestParseCopilotSession_ToolCalls(t *testing.T) {
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"tool-test"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"Read the config file"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"","toolRequests":[{"toolCallId":"tc-1","name":"view","arguments":"{\"path\":\"config.json\"}"}]},"timestamp":"2025-01-15T10:00:02Z"}`,
`{"type":"tool.execution_complete","data":{"toolCallId":"tc-1","success":true,"result":"{\"key\":\"value\"}"},"timestamp":"2025-01-15T10:00:03Z"}`,
`{"type":"assistant.message","data":{"content":"The config file contains a key-value pair."},"timestamp":"2025-01-15T10:00:04Z"}`,
)
_, msgs := parseAndValidateHelper(t, path, "m", 4)
// Check tool call message.
tcMsg := msgs[1]
assert.True(t, tcMsg.HasToolUse, "expected HasToolUse on tool call message")
assertToolCalls(t, tcMsg.ToolCalls, []ParsedToolCall{{
ToolName: "view",
Category: "Read",
ToolUseID: "tc-1",
InputJSON: `{"path":"config.json"}`,
}})
// Check tool result message.
trMsg := msgs[2]
assertEqual(t, 1, len(trMsg.ToolResults), "len(trMsg.ToolResults)")
assertEqual(t, "tc-1", trMsg.ToolResults[0].ToolUseID, "tool result ID")
assertEqual(t, 15, trMsg.ToolResults[0].ContentLength, "tool result ContentLength")
wantTS := parseTimestamp("2025-01-15T10:00:03Z")
assertEqual(t, wantTS, trMsg.Timestamp, "tool result timestamp")
}
func TestParseCopilotSession_ToolResultTypes(t *testing.T) {
tests := []struct {
name string
resultJSON string
expectedLen int
}{
{"Object", `{"files":["a.go","b.go"]}`, 25},
{"Array", `["one","two","three"]`, 21},
{"EmptyString", `""`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"test"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"cmd"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"","toolRequests":[{"toolCallId":"tc","name":"ls","arguments":"{}"}]},"timestamp":"2025-01-15T10:00:02Z"}`,
`{"type":"tool.execution_complete","data":{"toolCallId":"tc","success":true,"result":`+tt.resultJSON+`},"timestamp":"2025-01-15T10:00:03Z"}`,
`{"type":"assistant.message","data":{"content":"Done."},"timestamp":"2025-01-15T10:00:04Z"}`,
)
_, msgs := parseAndValidateHelper(t, path, "m", 4)
trMsg := msgs[2]
assertEqual(t, tt.expectedLen, trMsg.ContentLength, "ContentLength")
assertEqual(t, tt.expectedLen, trMsg.ToolResults[0].ContentLength, "tool result ContentLength")
})
}
}
func TestParseCopilotSession_Reasoning(t *testing.T) {
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"reason-test"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"Explain the bug"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"Here is my analysis.","reasoningText":"Let me think about this carefully..."},"timestamp":"2025-01-15T10:00:02Z"}`,
)
_, msgs := parseAndValidateHelper(t, path, "m", 2)
ast := msgs[1]
assert.True(t, ast.HasThinking, "expected HasThinking on assistant message with reasoningText")
assert.Contains(t, ast.Content, "[Thinking]\nLet me think about this carefully...\n[/Thinking]")
assert.Contains(t, ast.Content, "Here is my analysis.")
// Thinking block must precede the visible content.
thinkIdx := strings.Index(ast.Content, "[Thinking]")
visibleIdx := strings.Index(ast.Content, "Here is my analysis.")
assert.Less(t, thinkIdx, visibleIdx, "thinking block should appear before visible content")
}
func TestParseCopilotSession_ReasoningOnly(t *testing.T) {
// A message with only reasoningText and no visible content or tool calls
// should still be emitted with thinking content.
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"reason-only"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"What do you think?"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"","reasoningText":"Pondering the question..."},"timestamp":"2025-01-15T10:00:02Z"}`,
)
_, msgs := parseAndValidateHelper(t, path, "m", 2)
ast := msgs[1]
assert.True(t, ast.HasThinking, "expected HasThinking")
assert.Contains(t, ast.Content, "[Thinking]\nPondering the question...\n[/Thinking]")
}
func TestParseCopilotSession_AssistantReasoningEvent(t *testing.T) {
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"reason-event"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"Hello"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"Hi there."},"timestamp":"2025-01-15T10:00:02Z"}`,
`{"type":"assistant.reasoning","data":{},"timestamp":"2025-01-15T10:00:03Z"}`,
)
_, msgs := parseAndValidateHelper(t, path, "m", 2)
assert.True(t, msgs[1].HasThinking, "expected HasThinking set by assistant.reasoning event")
}
func TestParseCopilotSession_DirectoryFormat(t *testing.T) {
dir := t.TempDir()
sessDir := filepath.Join(dir, "abc-456")
require.NoError(t, os.MkdirAll(sessDir, 0o755))
content := strings.Join([]string{
`{"type":"session.start","data":{"sessionId":"abc-456"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"hello"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"hi"},"timestamp":"2025-01-15T10:00:02Z"}`,
}, "\n") + "\n"
path := filepath.Join(sessDir, "events.jsonl")
require.NoError(t, os.WriteFile(
path, []byte(content), 0o644,
))
sess, _ := parseAndValidateHelper(t, path, "m", 2)
assertEqual(t, "copilot:abc-456", sess.ID, "session ID")
// No workspace.yaml, so first user message is used.
assertEqual(t, "hello", sess.FirstMessage, "FirstMessage")
}
// writeDirSession writes events.jsonl (and optionally
// workspace.yaml) into a temporary session directory and
// returns the path to events.jsonl.
func writeDirSession(
t *testing.T,
sessID string,
events []string,
workspaceYAML string,
) string {
t.Helper()
dir := t.TempDir()
sessDir := filepath.Join(dir, sessID)
require.NoError(t, os.MkdirAll(sessDir, 0o755))
eventsPath := filepath.Join(sessDir, "events.jsonl")
require.NoError(t, os.WriteFile(
eventsPath,
[]byte(strings.Join(events, "\n")+"\n"),
0o644,
))
if workspaceYAML != "" {
yamlPath := filepath.Join(sessDir, "workspace.yaml")
require.NoError(t, os.WriteFile(
yamlPath, []byte(workspaceYAML), 0o644,
))
}
return eventsPath
}
func TestParseCopilotSession_WorkspaceName(t *testing.T) {
events := []string{
`{"type":"session.start","data":{"sessionId":"ws-name"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"Fix the login bug"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"Done."},"timestamp":"2025-01-15T10:00:02Z"}`,
}
yaml := "id: ws-name\nname: Fix Login Authentication Bug\nuser_named: false\nsummary_count: 1\n"
path := writeDirSession(t, "ws-name", events, yaml)
sess, _ := parseAndValidateHelper(t, path, "m", 2)
// workspace.yaml name takes precedence over first user message.
assertEqual(t, "Fix Login Authentication Bug", sess.FirstMessage, "FirstMessage")
}
func TestParseCopilotSession_WorkspaceNameUserNamed(t *testing.T) {
events := []string{
`{"type":"session.start","data":{"sessionId":"ws-user-named"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"Original prompt"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"Done."},"timestamp":"2025-01-15T10:00:02Z"}`,
}
yaml := "id: ws-user-named\nname: My Custom Session Name\nuser_named: true\nsummary_count: 0\n"
path := writeDirSession(t, "ws-user-named", events, yaml)
sess, _ := parseAndValidateHelper(t, path, "m", 2)
// user_named: true sessions also use name as FirstMessage.
assertEqual(t, "My Custom Session Name", sess.FirstMessage, "FirstMessage")
}
func TestParseCopilotSession_WorkspaceNameMissing(t *testing.T) {
// workspace.yaml exists but has no name field (older sessions).
events := []string{
`{"type":"session.start","data":{"sessionId":"ws-no-name"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"First user message"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"Done."},"timestamp":"2025-01-15T10:00:02Z"}`,
}
yaml := "id: ws-no-name\nsummary_count: 0\ncreated_at: 2026-03-08T12:38:01.203Z\n"
path := writeDirSession(t, "ws-no-name", events, yaml)
sess, _ := parseAndValidateHelper(t, path, "m", 2)
// Falls back to first user message.
assertEqual(t, "First user message", sess.FirstMessage, "FirstMessage")
}
func TestParseCopilotSession_WorkspaceNameWhitespaceOnly(t *testing.T) {
events := []string{
`{"type":"session.start","data":{"sessionId":"ws-blank"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"Do something"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"Done."},"timestamp":"2025-01-15T10:00:02Z"}`,
}
yaml := "id: ws-blank\nname: \nsummary_count: 0\n"
path := writeDirSession(t, "ws-blank", events, yaml)
sess, _ := parseAndValidateHelper(t, path, "m", 2)
// Whitespace-only name falls back to first user message.
assertEqual(t, "Do something", sess.FirstMessage, "FirstMessage")
}
func TestParseCopilotSession_WorkspaceNoYAMLFile(t *testing.T) {
// Directory format session with no workspace.yaml at all.
events := []string{
`{"type":"session.start","data":{"sessionId":"ws-noyaml"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"Hello there"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"Hi."},"timestamp":"2025-01-15T10:00:02Z"}`,
}
path := writeDirSession(t, "ws-noyaml", events, "")
sess, _ := parseAndValidateHelper(t, path, "m", 2)
assertEqual(t, "Hello there", sess.FirstMessage, "FirstMessage")
}
func TestParseCopilotSession_FlatFileNoWorkspaceYAML(t *testing.T) {
// Flat .jsonl format never looks for workspace.yaml.
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"flat-sess"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"Flat file prompt"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"OK."},"timestamp":"2025-01-15T10:00:02Z"}`,
)
sess, _ := parseAndValidateHelper(t, path, "m", 2)
assertEqual(t, "Flat file prompt", sess.FirstMessage, "FirstMessage")
}
func TestParseCopilotSession_WorkspaceNameTruncated(t *testing.T) {
longName := strings.Repeat("a", 350)
events := []string{
`{"type":"session.start","data":{"sessionId":"ws-long"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"original"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"Done."},"timestamp":"2025-01-15T10:00:02Z"}`,
}
yaml := "id: ws-long\nname: " + longName + "\nsummary_count: 1\n"
path := writeDirSession(t, "ws-long", events, yaml)
sess, _ := parseAndValidateHelper(t, path, "m", 2)
// truncate(s, 300) returns at most 303 bytes (300 runes + "...").
assert.LessOrEqual(t, len(sess.FirstMessage), 303, "FirstMessage not truncated")
assert.NotEqual(t, len(longName), len(sess.FirstMessage), "FirstMessage was not truncated at all")
}
func TestParseCopilotSession_DirectoryFormatFallbackID(t *testing.T) {
dir := t.TempDir()
sessDir := filepath.Join(dir, "def-789")
require.NoError(t, os.MkdirAll(sessDir, 0o755))
// No session.start event, so ID comes from dir name.
content := strings.Join([]string{
`{"type":"user.message","data":{"content":"test"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"ok"},"timestamp":"2025-01-15T10:00:02Z"}`,
}, "\n") + "\n"
path := filepath.Join(sessDir, "events.jsonl")
require.NoError(t, os.WriteFile(
path, []byte(content), 0o644,
))
sess, _ := parseAndValidateHelper(t, path, "m", 2)
assertEqual(t, "copilot:def-789", sess.ID, "session ID")
}
func TestParseCopilotSession_EmptySession(t *testing.T) {
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"empty"},"timestamp":"2025-01-15T10:00:00Z"}`,
)
sess, msgs, _, err := parseCopilotTestSession(t, path, "m")
require.NoError(t, err)
assert.Nil(t, sess, "expected nil session for empty")
assert.Nil(t, msgs, "expected nil messages for empty")
}
func TestParseCopilotSession_NonexistentFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "nonexistent.jsonl")
sess, msgs, _, err := parseCopilotTestSession(t, path, "m")
require.NoError(t, err, "expected nil error")
assert.Nil(t, sess, "expected nil session for nonexistent file")
assert.Nil(t, msgs, "expected nil messages for nonexistent file")
}
func TestParseCopilotSession_ObjectArguments(t *testing.T) {
// arguments is a native JSON object, not a string.
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"obj-args"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"list"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"","toolRequests":[{"toolCallId":"tc-5","name":"glob","arguments":{"pattern":"*.go"}}]},"timestamp":"2025-01-15T10:00:02Z"}`,
`{"type":"assistant.message","data":{"content":"done"},"timestamp":"2025-01-15T10:00:03Z"}`,
)
_, msgs := parseAndValidateHelper(t, path, "m", 3)
assertToolCalls(t, msgs[1].ToolCalls, []ParsedToolCall{{
ToolName: "glob",
Category: "Glob",
ToolUseID: "tc-5",
InputJSON: `{"pattern":"*.go"}`,
}})
}
func TestCopilotUserMessageCount(t *testing.T) {
// Tool-result user messages (Content == "") should not count
// as user prompts. This was the exact bug: Copilot emits
// user-role messages for tool results with empty Content,
// inflating UserMessageCount.
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"umc-test"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"Fix the bug"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"","toolRequests":[{"toolCallId":"tc-1","name":"view","arguments":"{}"}]},"timestamp":"2025-01-15T10:00:02Z"}`,
`{"type":"tool.execution_complete","data":{"toolCallId":"tc-1","success":true,"result":"file contents"},"timestamp":"2025-01-15T10:00:03Z"}`,
`{"type":"assistant.message","data":{"content":"I see the issue."},"timestamp":"2025-01-15T10:00:04Z"}`,
`{"type":"user.message","data":{"content":"Ship it"},"timestamp":"2025-01-15T10:00:05Z"}`,
`{"type":"assistant.message","data":{"content":"Done."},"timestamp":"2025-01-15T10:00:06Z"}`,
)
sess, _ := parseAndValidateHelper(t, path, "m", 6)
// Only 2 real user prompts: "Fix the bug" and "Ship it".
// The tool-result message at index 2 has empty Content.
assertEqual(t, 2, sess.UserMessageCount, "UserMessageCount")
}
func TestParseCopilotSession_SkipsSyntheticSkillMessages(t *testing.T) {
tests := []struct {
name string
dataJSON string
}{
{
name: "SourceAndContent",
dataJSON: `{"content":"<skill-context name=\"gh-cli\">\nbody\n</skill-context>","source":"skill-gh-cli"}`,
},
{
name: "SourceOnly",
dataJSON: `{"content":"skill payload without wrapper","source":"skill-prd"}`,
},
{
name: "ContentOnly",
dataJSON: `{"content":"<skill-context name=\"daily-summary\">\nbody\n</skill-context>"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"skill-filter"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":`+tt.dataJSON+`,"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"user.message","data":{"content":"Fix the parser"},"timestamp":"2025-01-15T10:00:02Z"}`,
`{"type":"assistant.message","data":{"content":"Working on it."},"timestamp":"2025-01-15T10:00:03Z"}`,
)
sess, msgs := parseAndValidateHelper(t, path, "m", 2)
assertEqual(t, "Fix the parser", sess.FirstMessage, "FirstMessage")
assertEqual(t, 1, sess.UserMessageCount, "UserMessageCount")
assertEqual(t, RoleUser, msgs[0].Role, "msgs[0].Role")
assertEqual(t, "Fix the parser", msgs[0].Content, "msgs[0].Content")
assertEqual(t, 0, msgs[0].Ordinal, "msgs[0].Ordinal")
assertEqual(t, 1, msgs[1].Ordinal, "msgs[1].Ordinal")
})
}
}
func TestParseCopilotSession_ModelChange(t *testing.T) {
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"model-test"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"session.model_change","data":{"newModel":"claude-sonnet-4.6"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"user.message","data":{"content":"Hello"},"timestamp":"2025-01-15T10:00:02Z"}`,
`{"type":"assistant.message","data":{"content":"Hi there"},"timestamp":"2025-01-15T10:00:03Z"}`,
)
_, msgs := parseAndValidateHelper(t, path, "m", 2)
assertEqual(t, "claude-sonnet-4-6", msgs[1].Model, "msgs[1].Model")
assertEqual(t, "", msgs[0].Model, "msgs[0].Model")
}
func TestParseCopilotSession_NoModel(t *testing.T) {
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"no-model"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"Hello"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"Hi"},"timestamp":"2025-01-15T10:00:02Z"}`,
)
_, msgs := parseAndValidateHelper(t, path, "m", 2)
assertEqual(t, "", msgs[1].Model, "msgs[1].Model")
}
func TestParseCopilotSession_ModelMidSessionChange(t *testing.T) {
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"switch-test"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"session.model_change","data":{"newModel":"claude-sonnet-4.6"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"user.message","data":{"content":"First"},"timestamp":"2025-01-15T10:00:02Z"}`,
`{"type":"assistant.message","data":{"content":"Reply one"},"timestamp":"2025-01-15T10:00:03Z"}`,
`{"type":"session.model_change","data":{"newModel":"claude-haiku-4.5"},"timestamp":"2025-01-15T10:00:04Z"}`,
`{"type":"user.message","data":{"content":"Second"},"timestamp":"2025-01-15T10:00:05Z"}`,
`{"type":"assistant.message","data":{"content":"Reply two"},"timestamp":"2025-01-15T10:00:06Z"}`,
)
_, msgs := parseAndValidateHelper(t, path, "m", 4)
assertEqual(t, "claude-sonnet-4-6", msgs[1].Model, "msgs[1].Model")
assertEqual(t, "claude-haiku-4-5", msgs[3].Model, "msgs[3].Model")
}
func TestParseCopilotSession_ModelReset(t *testing.T) {
// An empty newModel clears the active model so
// subsequent assistant messages have no model.
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"reset-test"},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"session.model_change","data":{"newModel":"claude-sonnet-4.6"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"user.message","data":{"content":"First"},"timestamp":"2025-01-15T10:00:02Z"}`,
`{"type":"assistant.message","data":{"content":"Reply one"},"timestamp":"2025-01-15T10:00:03Z"}`,
`{"type":"session.model_change","data":{"newModel":""},"timestamp":"2025-01-15T10:00:04Z"}`,
`{"type":"user.message","data":{"content":"Second"},"timestamp":"2025-01-15T10:00:05Z"}`,
`{"type":"assistant.message","data":{"content":"Reply two"},"timestamp":"2025-01-15T10:00:06Z"}`,
)
_, msgs := parseAndValidateHelper(t, path, "m", 4)
assertEqual(t, "claude-sonnet-4-6", msgs[1].Model, "msgs[1].Model")
assertEqual(t, "", msgs[3].Model, "msgs[3].Model (reset)")
}
func TestNormalizeCopilotModel(t *testing.T) {
tests := []struct {
input string
want string
}{
{"claude-sonnet-4.6", "claude-sonnet-4-6"},
{"claude-haiku-4.5", "claude-haiku-4-5"},
{"claude-opus-4.7", "claude-opus-4-7"},
// GPT models use dots in the pricing catalog and must not be changed.
{"gpt-5.4", "gpt-5.4"},
{"gpt-5.5", "gpt-5.5"},
{"gpt-4o", "gpt-4o"},
{"o3-mini", "o3-mini"},
{"", ""},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
assert.Equal(t, tc.want, normalizeCopilotModel(tc.input))
})
}
}
func TestSessionIDFromPath(t *testing.T) {
tests := []struct {
path string
want string
}{
{"/tmp/abc-123.jsonl", "abc-123"},
{"/tmp/abc-123/events.jsonl", "abc-123"},
{"/tmp/foo/bar.jsonl", "bar"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
got := sessionIDFromPath(tt.path)
assertEqual(t, tt.want, got, "sessionIDFromPath")
})
}
}
func TestParseCopilotSession_OutputTokens(t *testing.T) {
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"tok-test","context":{"cwd":"/home/alice/proj","branch":"main"}},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"Hello"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"Hi there.","outputTokens":120},"timestamp":"2025-01-15T10:00:02Z"}`,
`{"type":"user.message","data":{"content":"How are you?"},"timestamp":"2025-01-15T10:00:03Z"}`,
`{"type":"assistant.message","data":{"content":"I am fine.","outputTokens":85},"timestamp":"2025-01-15T10:00:04Z"}`,
)
sess, msgs := parseAndValidateHelper(t, path, "m", 4)
// Session total should be sum of both assistant messages.
assert.True(t, sess.HasTotalOutputTokens, "HasTotalOutputTokens")
assert.Equal(t, 205, sess.TotalOutputTokens, "TotalOutputTokens")
// Per-message token presence.
assert.True(t, msgs[1].HasOutputTokens, "msgs[1].HasOutputTokens")
assert.Equal(t, 120, msgs[1].OutputTokens, "msgs[1].OutputTokens")
assert.True(t, msgs[3].HasOutputTokens, "msgs[3].HasOutputTokens")
assert.Equal(t, 85, msgs[3].OutputTokens, "msgs[3].OutputTokens")
}
func TestParseCopilotSession_OutputTokens_Missing(t *testing.T) {
// When outputTokens is absent, HasOutputTokens must be false.
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"no-tok","context":{"cwd":"/home/alice/proj","branch":"main"}},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"Hello"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"Hi."},"timestamp":"2025-01-15T10:00:02Z"}`,
)
sess, msgs := parseAndValidateHelper(t, path, "m", 2)
assert.False(t, sess.HasTotalOutputTokens, "HasTotalOutputTokens should be false when field absent")
assert.Equal(t, 0, sess.TotalOutputTokens, "TotalOutputTokens should be zero")
assert.False(t, msgs[1].HasOutputTokens, "msgs[1].HasOutputTokens should be false")
}
// parseCopilotFull calls ParseCopilotSession and returns all four values.
func parseCopilotFull(
t *testing.T, path, machine string,
) (*ParsedSession, []ParsedMessage, []ParsedUsageEvent) {
t.Helper()
sess, msgs, usage, err := parseCopilotTestSession(t, path, machine)
require.NoError(t, err)
return sess, msgs, usage
}
func TestParseCopilotSession_ShutdownUsageEvents(t *testing.T) {
shutdownLine := `{"type":"session.shutdown","data":{"modelMetrics":{"claude-sonnet-4.6":{"usage":{"inputTokens":931647,"outputTokens":7150,"cacheReadTokens":873267,"cacheWriteTokens":51438,"reasoningTokens":432}}}},"timestamp":"2025-01-15T10:01:00Z"}`
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"shut-test","context":{"cwd":"/proj","branch":"main"}},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"Hello"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"Hi."},"timestamp":"2025-01-15T10:00:02Z"}`,
shutdownLine,
)
sess, _, usage := parseCopilotFull(t, path, "m")
require.NotNil(t, sess)
require.Len(t, usage, 1)
u := usage[0]
assert.Equal(t, "copilot:shut-test", u.SessionID)
assert.Equal(t, "shutdown", u.Source)
assert.Equal(t, "claude-sonnet-4-6", u.Model)
// Fresh input = 931647 - 873267 - 51438 = 6942
assert.Equal(t, 6942, u.InputTokens, "InputTokens should be fresh only")
assert.Equal(t, 7150, u.OutputTokens)
assert.Equal(t, 873267, u.CacheReadInputTokens)
assert.Equal(t, 51438, u.CacheCreationInputTokens)
assert.Equal(t, 432, u.ReasoningTokens)
assert.Equal(t, "shutdown:copilot:shut-test:claude-sonnet-4-6:0", u.DedupKey)
}
func TestParseCopilotSession_ShutdownMultiModel(t *testing.T) {
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"multi-model","context":{"cwd":"/proj","branch":"main"}},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"Hello"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"Hi."},"timestamp":"2025-01-15T10:00:02Z"}`,
`{"type":"session.shutdown","data":{"modelMetrics":{"claude-sonnet-4.6":{"usage":{"inputTokens":100,"outputTokens":50,"cacheReadTokens":60,"cacheWriteTokens":10}},"claude-haiku-4.5":{"usage":{"inputTokens":200,"outputTokens":80,"cacheReadTokens":120,"cacheWriteTokens":20}}}},"timestamp":"2025-01-15T10:01:00Z"}`,
)
_, _, usage := parseCopilotFull(t, path, "m")
require.Len(t, usage, 2)
byModel := make(map[string]ParsedUsageEvent)
for _, u := range usage {
byModel[u.Model] = u
}
sonnet := byModel["claude-sonnet-4-6"]
// fresh = 100 - 60 - 10 = 30
assert.Equal(t, 30, sonnet.InputTokens)
assert.Equal(t, 50, sonnet.OutputTokens)
haiku := byModel["claude-haiku-4-5"]
// fresh = 200 - 120 - 20 = 60
assert.Equal(t, 60, haiku.InputTokens)
assert.Equal(t, 80, haiku.OutputTokens)
}
func TestParseCopilotSession_MultiShutdown_SameModel(t *testing.T) {
// Sessions with compaction have multiple shutdown events for the
// same model. All segments must be captured with distinct DedupKeys.
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"multi-shut","context":{"cwd":"/proj","branch":"main"}},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"Hello"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"Hi."},"timestamp":"2025-01-15T10:00:02Z"}`,
`{"type":"session.shutdown","data":{"modelMetrics":{"claude-sonnet-4.6":{"usage":{"inputTokens":100,"outputTokens":50,"cacheReadTokens":60,"cacheWriteTokens":10}}}},"timestamp":"2025-01-15T10:01:00Z"}`,
`{"type":"user.message","data":{"content":"Continue"},"timestamp":"2025-01-15T10:02:00Z"}`,
`{"type":"assistant.message","data":{"content":"Sure."},"timestamp":"2025-01-15T10:02:01Z"}`,
`{"type":"session.shutdown","data":{"modelMetrics":{"claude-sonnet-4.6":{"usage":{"inputTokens":300,"outputTokens":80,"cacheReadTokens":250,"cacheWriteTokens":20}}}},"timestamp":"2025-01-15T10:03:00Z"}`,
)
_, _, usage := parseCopilotFull(t, path, "m")
require.Len(t, usage, 2, "both shutdown segments must be captured")
assert.Equal(t, "shutdown:copilot:multi-shut:claude-sonnet-4-6:0", usage[0].DedupKey)
assert.Equal(t, "shutdown:copilot:multi-shut:claude-sonnet-4-6:1", usage[1].DedupKey)
// First segment: fresh = 100 - 60 - 10 = 30
assert.Equal(t, 30, usage[0].InputTokens)
assert.Equal(t, 50, usage[0].OutputTokens)
// Second segment: fresh = 300 - 250 - 20 = 30
assert.Equal(t, 30, usage[1].InputTokens)
assert.Equal(t, 80, usage[1].OutputTokens)
}
func TestParseCopilotSession_ShutdownZeroUsage_Skipped(t *testing.T) {
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"zero-use","context":{"cwd":"/proj","branch":"main"}},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"Hello"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"Hi."},"timestamp":"2025-01-15T10:00:02Z"}`,
`{"type":"session.shutdown","data":{"modelMetrics":{"claude-sonnet-4.6":{"usage":{"inputTokens":0,"outputTokens":0,"cacheReadTokens":0,"cacheWriteTokens":0,"reasoningTokens":0}}}},"timestamp":"2025-01-15T10:01:00Z"}`,
)
_, _, usage := parseCopilotFull(t, path, "m")
assert.Empty(t, usage, "zero-usage model entry should be skipped")
}
func TestParseCopilotSession_NoShutdown_NoUsageEvents(t *testing.T) {
path := writeCopilotJSONL(t,
`{"type":"session.start","data":{"sessionId":"no-shut","context":{"cwd":"/proj","branch":"main"}},"timestamp":"2025-01-15T10:00:00Z"}`,
`{"type":"user.message","data":{"content":"Hello"},"timestamp":"2025-01-15T10:00:01Z"}`,
`{"type":"assistant.message","data":{"content":"Hi."},"timestamp":"2025-01-15T10:00:02Z"}`,
)
_, _, usage := parseCopilotFull(t, path, "m")
assert.Empty(t, usage, "no shutdown event should produce no usage events")
}