f99010fae1
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
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
909 lines
26 KiB
Go
909 lines
26 KiB
Go
package parser
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestParseVSCodeCopilotSession(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
json string
|
|
wantNil bool
|
|
wantMessages int
|
|
wantTitle string
|
|
wantAgent AgentType
|
|
wantToolUse bool
|
|
}{
|
|
{
|
|
name: "empty requests",
|
|
json: `{"version":3,"sessionId":"abc","requests":[]}`,
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "single user+assistant turn",
|
|
json: `{
|
|
"version": 3,
|
|
"sessionId": "test-123",
|
|
"creationDate": 1755347684754,
|
|
"lastMessageDate": 1755347728048,
|
|
"customTitle": "Test session",
|
|
"requests": [{
|
|
"requestId": "req1",
|
|
"message": {"text": "Hello world", "parts": []},
|
|
"response": [
|
|
{"value": "Hi there! ", "supportThemeIcons": false},
|
|
{"value": "How can I help?", "supportThemeIcons": false}
|
|
],
|
|
"timestamp": 1755347728047,
|
|
"modelId": "copilot/gpt-5"
|
|
}]
|
|
}`,
|
|
wantMessages: 2,
|
|
wantTitle: "Hello world",
|
|
wantAgent: AgentVSCodeCopilot,
|
|
},
|
|
{
|
|
name: "with tool invocations",
|
|
json: `{
|
|
"version": 3,
|
|
"sessionId": "tools-456",
|
|
"creationDate": 1755347684754,
|
|
"lastMessageDate": 1755347728048,
|
|
"customTitle": "Tool session",
|
|
"requests": [{
|
|
"requestId": "req1",
|
|
"message": {"text": "Read the file", "parts": []},
|
|
"response": [
|
|
{"value": "Reading the file... "},
|
|
{"kind": "prepareToolInvocation", "toolName": "copilot_readFile"},
|
|
{"kind": "toolInvocationSerialized", "toolId": "copilot_readFile", "toolCallId": "tc1", "isConfirmed": true, "isComplete": true},
|
|
{"value": "Done reading."}
|
|
],
|
|
"timestamp": 1755347728047,
|
|
"modelId": "copilot/gpt-5"
|
|
}]
|
|
}`,
|
|
wantMessages: 2,
|
|
wantToolUse: true,
|
|
},
|
|
{
|
|
name: "multiple requests",
|
|
json: `{
|
|
"version": 3,
|
|
"sessionId": "multi-789",
|
|
"creationDate": 1755340000000,
|
|
"lastMessageDate": 1755350000000,
|
|
"customTitle": "Multi turn",
|
|
"requests": [
|
|
{
|
|
"requestId": "req1",
|
|
"message": {"text": "First question"},
|
|
"response": [{"value": "First answer"}],
|
|
"timestamp": 1755340000000
|
|
},
|
|
{
|
|
"requestId": "req2",
|
|
"message": {"text": "Second question"},
|
|
"response": [{"value": "Second answer"}],
|
|
"timestamp": 1755350000000
|
|
}
|
|
]
|
|
}`,
|
|
wantMessages: 4,
|
|
wantTitle: "First question",
|
|
},
|
|
{
|
|
name: "no user text uses customTitle",
|
|
json: `{
|
|
"version": 3,
|
|
"sessionId": "notitle-000",
|
|
"creationDate": 1755340000000,
|
|
"lastMessageDate": 1755340000000,
|
|
"customTitle": "Fallback Title",
|
|
"requests": [{
|
|
"requestId": "req1",
|
|
"message": {"text": ""},
|
|
"response": [{"value": "Some response"}],
|
|
"timestamp": 1755340000000
|
|
}]
|
|
}`,
|
|
wantMessages: 1,
|
|
wantTitle: "Fallback Title",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test-session.json")
|
|
require.NoError(t, os.WriteFile(
|
|
path, []byte(tt.json), 0644,
|
|
))
|
|
|
|
sess, msgs, err := parseVSCodeCopilotTestSession(t,
|
|
path, "testproject", "local",
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
if tt.wantNil {
|
|
assert.Nil(t, sess, "expected nil session")
|
|
return
|
|
}
|
|
|
|
require.NotNil(t, sess, "expected non-nil session")
|
|
|
|
assert.Len(t, msgs, tt.wantMessages, "messages")
|
|
|
|
if tt.wantTitle != "" {
|
|
assert.Equal(t, tt.wantTitle, sess.FirstMessage, "first message")
|
|
}
|
|
|
|
if tt.wantAgent != "" {
|
|
assert.Equal(t, tt.wantAgent, sess.Agent, "agent")
|
|
}
|
|
|
|
assert.Equal(t, "testproject", sess.Project, "project")
|
|
|
|
if tt.wantToolUse {
|
|
found := false
|
|
for _, m := range msgs {
|
|
if m.HasToolUse {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, found, "expected tool use in messages")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseVSCodeCopilotSession_NonExistent(t *testing.T) {
|
|
sess, msgs, err := parseVSCodeCopilotTestSession(t,
|
|
"/nonexistent/path.json", "proj", "local",
|
|
)
|
|
require.NoError(t, err, "expected nil error")
|
|
assert.Nil(t, sess, "expected nil session for non-existent file")
|
|
assert.Nil(t, msgs, "expected nil messages for non-existent file")
|
|
}
|
|
|
|
func TestParseVSCodeCopilotSession_MixedTextAndTools(t *testing.T) {
|
|
data := `{
|
|
"version": 3,
|
|
"sessionId": "mixed-001",
|
|
"creationDate": 1755340000000,
|
|
"lastMessageDate": 1755340000000,
|
|
"customTitle": "Mixed content",
|
|
"requests": [{
|
|
"requestId": "req1",
|
|
"message": {"text": "Read the file"},
|
|
"response": [
|
|
{"kind": "toolInvocationSerialized", "toolId": "copilot_readFile", "toolCallId": "tc1", "isConfirmed": true, "isComplete": true, "pastTenseMessage": {"value": "Read main.go, lines 1 to 50"}},
|
|
{"value": "Here is the file content."}
|
|
],
|
|
"timestamp": 1755340000000
|
|
}]
|
|
}`
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.json")
|
|
require.NoError(t, os.WriteFile(path, []byte(data), 0644))
|
|
|
|
_, msgs, err := parseVSCodeCopilotTestSession(t, path, "proj", "local")
|
|
require.NoError(t, err)
|
|
|
|
// Find assistant message
|
|
var assistant *ParsedMessage
|
|
for i := range msgs {
|
|
if msgs[i].Role == RoleAssistant {
|
|
assistant = &msgs[i]
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, assistant, "no assistant message")
|
|
|
|
assert.True(t, assistant.HasToolUse, "expected HasToolUse=true")
|
|
|
|
// Content should include both tool markers and text
|
|
assert.NotEmpty(t, assistant.Content, "expected non-empty content")
|
|
|
|
// Tool calls should have InputJSON populated
|
|
require.Len(t, assistant.ToolCalls, 1)
|
|
tc := assistant.ToolCalls[0]
|
|
assert.NotEmpty(t, tc.InputJSON, "expected non-empty InputJSON")
|
|
assert.Equal(t, "Read", tc.Category, "category")
|
|
}
|
|
|
|
func TestParseVSCodeCopilotSession_TerminalToolData(t *testing.T) {
|
|
data := `{
|
|
"version": 3,
|
|
"sessionId": "term-001",
|
|
"creationDate": 1755340000000,
|
|
"lastMessageDate": 1755340000000,
|
|
"customTitle": "Terminal session",
|
|
"requests": [{
|
|
"requestId": "req1",
|
|
"message": {"text": "Run tests"},
|
|
"response": [
|
|
{"kind": "toolInvocationSerialized", "toolId": "copilot_runInTerminal", "toolCallId": "tc1", "isConfirmed": true, "isComplete": true, "invocationMessage": "Using \"Run In Terminal\"", "toolSpecificData": {"kind": "terminal", "language": "sh", "command": "npm test"}}
|
|
],
|
|
"timestamp": 1755340000000
|
|
}]
|
|
}`
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.json")
|
|
require.NoError(t, os.WriteFile(path, []byte(data), 0644))
|
|
|
|
_, msgs, err := parseVSCodeCopilotTestSession(t, path, "proj", "local")
|
|
require.NoError(t, err)
|
|
|
|
var assistant *ParsedMessage
|
|
for i := range msgs {
|
|
if msgs[i].Role == RoleAssistant {
|
|
assistant = &msgs[i]
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, assistant, "no assistant message")
|
|
|
|
require.Len(t, assistant.ToolCalls, 1)
|
|
tc := assistant.ToolCalls[0]
|
|
assert.Equal(t, "Bash", tc.Category, "category")
|
|
assert.NotEmpty(t, tc.InputJSON, "expected non-empty InputJSON")
|
|
|
|
// Content should include the command
|
|
assert.Contains(t, assistant.Content, "npm test",
|
|
"content should contain command, got: %s", assistant.Content)
|
|
}
|
|
|
|
func TestExtractProjectFromURI(t *testing.T) {
|
|
tests := []struct {
|
|
uri string
|
|
want string
|
|
}{
|
|
{"file:///Users/dev/projects/myapp", "myapp"},
|
|
{"file:///home/user/code/repo", "repo"},
|
|
{"file:///C:/Users/dev/projects/app", "app"},
|
|
{"some-name", "some-name"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.uri, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, extractProjectFromURI(tt.uri),
|
|
"extractProjectFromURI(%q)", tt.uri)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReadVSCodeWorkspaceManifest(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Valid workspace.json
|
|
content := `{"folder":"file:///Users/dev/projects/agentsview"}`
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(dir, "workspace.json"),
|
|
[]byte(content), 0644,
|
|
))
|
|
|
|
assert.Equal(t, "agentsview", ReadVSCodeWorkspaceManifest(dir))
|
|
|
|
// Non-existent dir
|
|
assert.Empty(t, ReadVSCodeWorkspaceManifest("/nonexistent"))
|
|
}
|
|
|
|
func TestDiscoverVSCodeCopilotSessions(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
// Create workspace structure
|
|
hash := "abc123def456"
|
|
chatDir := filepath.Join(
|
|
root, "workspaceStorage", hash, "chatSessions",
|
|
)
|
|
require.NoError(t, os.MkdirAll(chatDir, 0755))
|
|
|
|
// workspace.json
|
|
wsJSON := `{"folder":"file:///Users/dev/projects/myproject"}`
|
|
wsPath := filepath.Join(
|
|
root, "workspaceStorage", hash, "workspace.json",
|
|
)
|
|
require.NoError(t, os.WriteFile(wsPath, []byte(wsJSON), 0644))
|
|
|
|
// Chat session file
|
|
sessionJSON := `{"version":3,"sessionId":"sess1","requests":[{"requestId":"r1","message":{"text":"hi"},"response":[{"value":"hello"}],"timestamp":1755340000000}]}`
|
|
sessPath := filepath.Join(chatDir, "sess1.json")
|
|
require.NoError(t, os.WriteFile(sessPath, []byte(sessionJSON), 0644))
|
|
|
|
// globalStorage/emptyWindowChatSessions
|
|
globalDir := filepath.Join(
|
|
root, "globalStorage", "emptyWindowChatSessions",
|
|
)
|
|
require.NoError(t, os.MkdirAll(globalDir, 0755))
|
|
globalPath := filepath.Join(globalDir, "global-sess.json")
|
|
require.NoError(t, os.WriteFile(globalPath, []byte(sessionJSON), 0644))
|
|
|
|
files := discoverVSCodeCopilotTestSessions(t, root)
|
|
|
|
require.Len(t, files, 2)
|
|
|
|
// Check workspace session
|
|
var wsFile, globalFile DiscoveredFile
|
|
for _, f := range files {
|
|
switch f.Project {
|
|
case "myproject":
|
|
wsFile = f
|
|
case "empty-window":
|
|
globalFile = f
|
|
}
|
|
}
|
|
|
|
assert.NotEmpty(t, wsFile.Path, "missing workspace session file")
|
|
assert.Equal(t, AgentVSCodeCopilot, wsFile.Agent, "agent")
|
|
|
|
assert.NotEmpty(t, globalFile.Path, "missing global session file")
|
|
}
|
|
|
|
func TestNormalizeVSCodeToolName(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"copilot_readFile", "read_file"},
|
|
{"copilot_replaceString", "edit_file"},
|
|
{"copilot_runCommand", "shell"},
|
|
{"copilot_searchFiles", "grep"},
|
|
{"copilot_listDir", "glob"},
|
|
{"copilot_createFile", "create_file"},
|
|
{"copilot_runInTerminal", "shell"},
|
|
{"copilot_getTerminalOutput", "shell"},
|
|
{"copilot_findTextInFiles", "grep"},
|
|
{"copilot_findFiles", "glob"},
|
|
{"copilot_listDirectory", "glob"},
|
|
{"copilot_applyPatch", "edit_file"},
|
|
{"copilot_multiReplaceString", "edit_file"},
|
|
{"copilot_fetchWebPage", "read_web_page"},
|
|
{"copilot_think", "Tool"},
|
|
{"runSubagent", "Task"},
|
|
{"unknown_tool", "unknown_tool"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, normalizeVSCodeToolName(tt.input))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractVSCopilotInputJSON(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
invMsg string
|
|
pastMsg string
|
|
toolData string
|
|
wantKey string
|
|
wantVal string
|
|
}{
|
|
{
|
|
name: "string invocation message",
|
|
invMsg: `"Using Run In Terminal"`,
|
|
wantKey: "message",
|
|
wantVal: "Using Run In Terminal",
|
|
},
|
|
{
|
|
name: "object invocation message",
|
|
invMsg: `{"value": "Reading file.txt, lines 1 to 50"}`,
|
|
wantKey: "message",
|
|
wantVal: "Reading file.txt, lines 1 to 50",
|
|
},
|
|
{
|
|
name: "prefers pastTenseMessage",
|
|
invMsg: `"Reading file..."`,
|
|
pastMsg: `"Read file.txt, lines 1 to 50"`,
|
|
wantKey: "message",
|
|
wantVal: "Read file.txt, lines 1 to 50",
|
|
},
|
|
{
|
|
name: "terminal tool data",
|
|
invMsg: `"Using Run In Terminal"`,
|
|
toolData: `{"kind":"terminal","language":"sh","command":"ls -la"}`,
|
|
wantKey: "command",
|
|
wantVal: "ls -la",
|
|
},
|
|
{
|
|
name: "empty fields",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var inv, past, td json.RawMessage
|
|
if tt.invMsg != "" {
|
|
inv = json.RawMessage(tt.invMsg)
|
|
}
|
|
if tt.pastMsg != "" {
|
|
past = json.RawMessage(tt.pastMsg)
|
|
}
|
|
if tt.toolData != "" {
|
|
td = json.RawMessage(tt.toolData)
|
|
}
|
|
got := extractVSCopilotInputJSON(inv, past, td)
|
|
|
|
if tt.wantKey == "" {
|
|
assert.Empty(t, got, "expected empty")
|
|
return
|
|
}
|
|
|
|
var m map[string]any
|
|
err := json.Unmarshal([]byte(got), &m)
|
|
require.NoError(t, err, "invalid JSON")
|
|
val, ok := m[tt.wantKey].(string)
|
|
assert.True(t, ok, "value not a string")
|
|
assert.Equal(t, tt.wantVal, val, "value for key %q", tt.wantKey)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseVSCodeCopilotSession_JSONL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
lines []string
|
|
wantNil bool
|
|
wantMessages int
|
|
wantTitle string
|
|
wantToolUse bool
|
|
}{
|
|
{
|
|
name: "simple session with mutations",
|
|
lines: []string{
|
|
// kind=0: initial snapshot with empty requests
|
|
`{"kind":0,"v":{"version":3,"sessionId":"jsonl-001","creationDate":1770650022790,"customTitle":"","requests":[],"responderUsername":"GitHub Copilot"}}`,
|
|
// kind=1: set customTitle
|
|
`{"kind":1,"k":["customTitle"],"v":"Test JSONL Session"}`,
|
|
// kind=2: push a request
|
|
`{"kind":2,"k":["requests"],"v":[{"requestId":"req1","timestamp":1770650031889,"message":{"text":"Hello JSONL","parts":[]},"response":[{"value":"Hi from JSONL!"}],"modelId":"copilot/gpt-4o"}]}`,
|
|
},
|
|
wantMessages: 2,
|
|
wantTitle: "Hello JSONL",
|
|
},
|
|
{
|
|
name: "empty session no requests",
|
|
lines: []string{
|
|
`{"kind":0,"v":{"version":3,"sessionId":"jsonl-empty","creationDate":1770650022790,"requests":[]}}`,
|
|
},
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "session with tool calls",
|
|
lines: []string{
|
|
`{"kind":0,"v":{"version":3,"sessionId":"jsonl-tools","creationDate":1770650022790,"requests":[]}}`,
|
|
`{"kind":2,"k":["requests"],"v":[{"requestId":"req1","timestamp":1770650031889,"message":{"text":"Read file","parts":[]},"response":[{"kind":"toolInvocationSerialized","toolId":"copilot_readFile","toolCallId":"tc1","isConfirmed":true,"isComplete":true},{"value":"Done."}],"modelId":"copilot/gpt-4o"}]}`,
|
|
},
|
|
wantMessages: 2,
|
|
wantToolUse: true,
|
|
wantTitle: "Read file",
|
|
},
|
|
{
|
|
name: "multiple requests via push",
|
|
lines: []string{
|
|
`{"kind":0,"v":{"version":3,"sessionId":"jsonl-multi","creationDate":1770650022790,"requests":[]}}`,
|
|
`{"kind":2,"k":["requests"],"v":[{"requestId":"req1","timestamp":1770650031889,"message":{"text":"First","parts":[]},"response":[{"value":"Answer 1"}],"modelId":"copilot/gpt-4o"}]}`,
|
|
`{"kind":2,"k":["requests"],"v":[{"requestId":"req2","timestamp":1770650041889,"message":{"text":"Second","parts":[]},"response":[{"value":"Answer 2"}],"modelId":"copilot/gpt-4o"}]}`,
|
|
},
|
|
wantMessages: 4,
|
|
wantTitle: "First",
|
|
},
|
|
{
|
|
name: "set mutation on response",
|
|
lines: []string{
|
|
`{"kind":0,"v":{"version":3,"sessionId":"jsonl-set","creationDate":1770650022790,"requests":[{"requestId":"req1","timestamp":1770650031889,"message":{"text":"Q","parts":[]},"response":[{"value":"partial"}],"modelId":"copilot/gpt-4o"}]}}`,
|
|
// Update the first response item
|
|
`{"kind":1,"k":["requests",0,"response",0],"v":{"value":"Complete answer"}}`,
|
|
},
|
|
wantMessages: 2,
|
|
wantTitle: "Q",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test-session.jsonl")
|
|
|
|
content := strings.Join(tt.lines, "\n") + "\n"
|
|
require.NoError(t, os.WriteFile(
|
|
path, []byte(content), 0644,
|
|
))
|
|
|
|
sess, msgs, err := parseVSCodeCopilotTestSession(t,
|
|
path, "testproject", "local",
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
if tt.wantNil {
|
|
assert.Nil(t, sess, "expected nil session")
|
|
return
|
|
}
|
|
|
|
require.NotNil(t, sess, "expected non-nil session")
|
|
|
|
assert.Len(t, msgs, tt.wantMessages, "messages")
|
|
|
|
if tt.wantTitle != "" {
|
|
assert.Equal(t, tt.wantTitle, sess.FirstMessage, "first message")
|
|
}
|
|
|
|
assert.Equal(t, AgentVSCodeCopilot, sess.Agent, "agent")
|
|
|
|
if tt.wantToolUse {
|
|
found := false
|
|
for _, m := range msgs {
|
|
if m.HasToolUse {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, found, "expected tool use in messages")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReconstructJSONL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
lines []string
|
|
wantErr bool
|
|
check func(t *testing.T, data []byte)
|
|
}{
|
|
{
|
|
name: "initial only",
|
|
lines: []string{
|
|
`{"kind":0,"v":{"sessionId":"s1","version":3}}`,
|
|
},
|
|
check: func(t *testing.T, data []byte) {
|
|
var m map[string]any
|
|
require.NoError(t, json.Unmarshal(data, &m))
|
|
assert.Equal(t, "s1", m["sessionId"], "sessionId")
|
|
},
|
|
},
|
|
{
|
|
name: "set nested property",
|
|
lines: []string{
|
|
`{"kind":0,"v":{"a":{"b":"old"}}}`,
|
|
`{"kind":1,"k":["a","b"],"v":"new"}`,
|
|
},
|
|
check: func(t *testing.T, data []byte) {
|
|
var m map[string]any
|
|
require.NoError(t, json.Unmarshal(data, &m))
|
|
a := m["a"].(map[string]any)
|
|
assert.Equal(t, "new", a["b"])
|
|
},
|
|
},
|
|
{
|
|
name: "push to array",
|
|
lines: []string{
|
|
`{"kind":0,"v":{"items":["a"]}}`,
|
|
`{"kind":2,"k":["items"],"v":["b","c"]}`,
|
|
},
|
|
check: func(t *testing.T, data []byte) {
|
|
var m map[string]any
|
|
require.NoError(t, json.Unmarshal(data, &m))
|
|
items := m["items"].([]any)
|
|
require.Len(t, items, 3, "len")
|
|
assert.Equal(t, "c", items[2], "items[2]")
|
|
},
|
|
},
|
|
{
|
|
name: "push with splice index replaces existing items",
|
|
lines: []string{
|
|
`{"kind":0,"v":{"items":["a","old","c"]}}`,
|
|
`{"kind":2,"k":["items"],"v":["b"],"i":1}`,
|
|
},
|
|
check: func(t *testing.T, data []byte) {
|
|
var m map[string]any
|
|
require.NoError(t, json.Unmarshal(data, &m))
|
|
items := m["items"].([]any)
|
|
require.Len(t, items, 3, "len")
|
|
assert.Equal(t, []any{"a", "b", "c"}, items, "items")
|
|
},
|
|
},
|
|
{
|
|
name: "push with negative splice index replaces from front",
|
|
lines: []string{
|
|
`{"kind":0,"v":{"items":["a","b"]}}`,
|
|
`{"kind":2,"k":["items"],"v":["z"],"i":-1}`,
|
|
},
|
|
check: func(t *testing.T, data []byte) {
|
|
var m map[string]any
|
|
require.NoError(t, json.Unmarshal(data, &m))
|
|
items := m["items"].([]any)
|
|
require.Len(t, items, 2, "len")
|
|
// Negative index clamped to 0: replaced at front.
|
|
assert.Equal(t, "z", items[0], "items[0]")
|
|
},
|
|
},
|
|
{
|
|
name: "delete property",
|
|
lines: []string{
|
|
`{"kind":0,"v":{"a":"keep","b":"remove"}}`,
|
|
`{"kind":3,"k":["b"]}`,
|
|
},
|
|
check: func(t *testing.T, data []byte) {
|
|
var m map[string]any
|
|
require.NoError(t, json.Unmarshal(data, &m))
|
|
_, ok := m["b"]
|
|
assert.False(t, ok, "expected b to be deleted")
|
|
assert.Equal(t, "keep", m["a"], "a")
|
|
},
|
|
},
|
|
{
|
|
name: "set array element by index",
|
|
lines: []string{
|
|
`{"kind":0,"v":{"arr":["x","y","z"]}}`,
|
|
`{"kind":1,"k":["arr",1],"v":"Y"}`,
|
|
},
|
|
check: func(t *testing.T, data []byte) {
|
|
var m map[string]any
|
|
require.NoError(t, json.Unmarshal(data, &m))
|
|
arr := m["arr"].([]any)
|
|
assert.Equal(t, "Y", arr[1], "arr[1]")
|
|
},
|
|
},
|
|
{
|
|
name: "empty file returns nil",
|
|
lines: []string{},
|
|
check: func(t *testing.T, data []byte) {
|
|
assert.Nil(t, data, "expected nil")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.jsonl")
|
|
|
|
content := strings.Join(tt.lines, "\n") + "\n"
|
|
require.NoError(t, os.WriteFile(
|
|
path, []byte(content), 0644,
|
|
))
|
|
|
|
data, err := reconstructJSONL(path)
|
|
if tt.wantErr {
|
|
require.Error(t, err, "expected error")
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
tt.check(t, data)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDiscoverVSCodeCopilot_JSONLDedup(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
hash := "abc123def456"
|
|
chatDir := filepath.Join(
|
|
root, "workspaceStorage", hash, "chatSessions",
|
|
)
|
|
require.NoError(t, os.MkdirAll(chatDir, 0755))
|
|
|
|
wsJSON := `{"folder":"file:///Users/dev/projects/myproject"}`
|
|
wsPath := filepath.Join(
|
|
root, "workspaceStorage", hash, "workspace.json",
|
|
)
|
|
require.NoError(t, os.WriteFile(wsPath, []byte(wsJSON), 0644))
|
|
|
|
// Session with both .json and .jsonl - jsonl should win
|
|
sessionJSON := `{"version":3,"sessionId":"dup1","requests":[{"requestId":"r1","message":{"text":"hi"},"response":[{"value":"hello"}],"timestamp":1755340000000}]}`
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(chatDir, "dup1.json"),
|
|
[]byte(sessionJSON), 0644,
|
|
))
|
|
jsonlContent := `{"kind":0,"v":{"version":3,"sessionId":"dup1","creationDate":1755340000000,"requests":[{"requestId":"r1","timestamp":1755340000000,"message":{"text":"hi"},"response":[{"value":"hello"}]}]}}` + "\n"
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(chatDir, "dup1.jsonl"),
|
|
[]byte(jsonlContent), 0644,
|
|
))
|
|
|
|
// Session with only .jsonl
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(chatDir, "only-jsonl.jsonl"),
|
|
[]byte(jsonlContent), 0644,
|
|
))
|
|
|
|
// Session with only .json
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(chatDir, "only-json.json"),
|
|
[]byte(sessionJSON), 0644,
|
|
))
|
|
|
|
files := discoverVSCodeCopilotTestSessions(t, root)
|
|
|
|
// Should get 3 files: dup1.jsonl, only-jsonl.jsonl, only-json.json
|
|
if !assert.Len(t, files, 3, "expected 3 files") {
|
|
for _, f := range files {
|
|
t.Logf(" %s", f.Path)
|
|
}
|
|
t.FailNow()
|
|
}
|
|
|
|
// Verify dup1.json was excluded (dup1.jsonl present)
|
|
for _, f := range files {
|
|
assert.NotEqual(t, "dup1.json", filepath.Base(f.Path),
|
|
"dup1.json should be excluded when dup1.jsonl exists")
|
|
}
|
|
}
|
|
|
|
func TestFindVSCodeCopilotSourceFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
uuid := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
|
|
|
// Set up a workspace session file
|
|
chatDir := filepath.Join(
|
|
dir, "workspaceStorage", "hash1", "chatSessions",
|
|
)
|
|
sessionPath := filepath.Join(chatDir, uuid+".json")
|
|
require.NoError(t, os.MkdirAll(chatDir, 0o755))
|
|
require.NoError(t, os.WriteFile(sessionPath, []byte("{}"), 0o644))
|
|
|
|
tests := []struct {
|
|
name string
|
|
dir string
|
|
id string
|
|
want string
|
|
}{
|
|
{"valid UUID", dir, uuid, sessionPath},
|
|
{"empty dir", "", uuid, ""},
|
|
{"empty ID", dir, "", ""},
|
|
{"traversal slash", dir, "../etc/passwd", ""},
|
|
{"traversal dotdot", dir, "..", ""},
|
|
{"path separator", dir, "foo/bar", ""},
|
|
{"nonexistent UUID", dir, "00000000-0000-0000-0000-000000000000", ""},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := findVSCodeCopilotTestSourceFile(t,
|
|
tt.dir, tt.id,
|
|
)
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseVSCodeCopilotSession_TokenUsage(t *testing.T) {
|
|
// Two turns carry result.metadata token accounting (the
|
|
// post-credits VSCode Copilot format); a third turn has no
|
|
// metadata and must not produce a usage event.
|
|
sessionJSON := `{
|
|
"version": 3,
|
|
"sessionId": "usage-1",
|
|
"creationDate": 1755340000000,
|
|
"lastMessageDate": 1755350000000,
|
|
"requests": [
|
|
{
|
|
"requestId": "r1",
|
|
"message": {"text": "First"},
|
|
"response": [{"value": "answer one"}],
|
|
"timestamp": 1755340000000,
|
|
"modelId": "copilot/claude-opus-4.8",
|
|
"result": {"metadata": {
|
|
"promptTokens": 35875,
|
|
"outputTokens": 221,
|
|
"resolvedModel": "claude-opus-4-8"
|
|
}}
|
|
},
|
|
{
|
|
"requestId": "r2",
|
|
"message": {"text": "Second"},
|
|
"response": [{"value": "answer two"}],
|
|
"timestamp": 1755345000000,
|
|
"modelId": "copilot/claude-opus-4.8",
|
|
"result": {"metadata": {
|
|
"promptTokens": 41055,
|
|
"outputTokens": 69,
|
|
"resolvedModel": "claude-opus-4-8"
|
|
}}
|
|
},
|
|
{
|
|
"requestId": "r3",
|
|
"message": {"text": "Third"},
|
|
"response": [{"value": "answer three"}],
|
|
"timestamp": 1755350000000,
|
|
"modelId": "copilot/claude-opus-4.8"
|
|
}
|
|
]
|
|
}`
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "usage.json")
|
|
require.NoError(t, os.WriteFile(path, []byte(sessionJSON), 0644))
|
|
|
|
sess, _, err := parseVSCodeCopilotTestSession(t, path, "proj", "local")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
|
|
// Only the two turns with metadata yield usage events.
|
|
require.Len(t, sess.UsageEvents, 2, "usage events")
|
|
|
|
for i, ev := range sess.UsageEvents {
|
|
assert.Equal(t, "vscode-copilot", ev.Source, "event[%d] source", i)
|
|
assert.Equal(t, "claude-opus-4-8", ev.Model, "event[%d] model", i)
|
|
assert.NotEmpty(t, ev.OccurredAt, "event[%d] occurredAt", i)
|
|
}
|
|
|
|
assert.Equal(t, 35875, sess.UsageEvents[0].InputTokens)
|
|
assert.Equal(t, 221, sess.UsageEvents[0].OutputTokens)
|
|
assert.Equal(t, 41055, sess.UsageEvents[1].InputTokens)
|
|
assert.Equal(t, 69, sess.UsageEvents[1].OutputTokens)
|
|
|
|
// Session output total sums the per-turn output tokens.
|
|
assert.True(t, sess.HasTotalOutputTokens, "has total output")
|
|
assert.Equal(t, 290, sess.TotalOutputTokens, "total output")
|
|
|
|
// Peak context is the largest per-turn promptTokens.
|
|
assert.True(t, sess.HasPeakContextTokens, "has peak context")
|
|
assert.Equal(t, 41055, sess.PeakContextTokens, "peak context")
|
|
}
|
|
|
|
func TestParseVSCodeCopilotSession_TokenUsageModelFallback(t *testing.T) {
|
|
// No resolvedModel: fall back to the prefixed modelId and
|
|
// normalize the claude version dots to hyphens.
|
|
sessionJSON := `{
|
|
"version": 3,
|
|
"sessionId": "usage-2",
|
|
"creationDate": 1755340000000,
|
|
"requests": [{
|
|
"requestId": "r1",
|
|
"message": {"text": "Hi"},
|
|
"response": [{"value": "Hello"}],
|
|
"timestamp": 1755340000000,
|
|
"modelId": "copilot/claude-sonnet-4.6",
|
|
"result": {"metadata": {"promptTokens": 1000, "outputTokens": 50}}
|
|
}]
|
|
}`
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "usage2.json")
|
|
require.NoError(t, os.WriteFile(path, []byte(sessionJSON), 0644))
|
|
|
|
sess, _, err := parseVSCodeCopilotTestSession(t, path, "proj", "local")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
|
|
require.Len(t, sess.UsageEvents, 1)
|
|
assert.Equal(t, "claude-sonnet-4-6", sess.UsageEvents[0].Model)
|
|
}
|
|
|
|
func TestParseVSCodeCopilotSession_NoTokenUsage(t *testing.T) {
|
|
// A session whose requests carry no token metadata yields no
|
|
// usage events and leaves output totals unset (cost -> n/a).
|
|
sessionJSON := `{
|
|
"version": 3,
|
|
"sessionId": "no-usage",
|
|
"creationDate": 1755340000000,
|
|
"requests": [{
|
|
"requestId": "r1",
|
|
"message": {"text": "Hi"},
|
|
"response": [{"value": "Hello"}],
|
|
"timestamp": 1755340000000
|
|
}]
|
|
}`
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "nousage.json")
|
|
require.NoError(t, os.WriteFile(path, []byte(sessionJSON), 0644))
|
|
|
|
sess, _, err := parseVSCodeCopilotTestSession(t, path, "proj", "local")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
|
|
assert.Empty(t, sess.UsageEvents, "no usage events expected")
|
|
assert.False(t, sess.HasTotalOutputTokens, "no total output expected")
|
|
assert.False(t, sess.HasPeakContextTokens, "no peak context expected")
|
|
}
|