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
714 lines
24 KiB
Go
714 lines
24 KiB
Go
package parser
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// newVibeTestProvider builds a Vibe provider for the given roots so package
|
|
// tests can exercise discovery through the Provider interface.
|
|
func newVibeTestProvider(t *testing.T, roots ...string) Provider {
|
|
t.Helper()
|
|
provider, ok := NewProvider(AgentVibe, ProviderConfig{
|
|
Roots: roots,
|
|
Machine: "local",
|
|
})
|
|
require.True(t, ok)
|
|
return provider
|
|
}
|
|
|
|
// parseVibeTestSession parses a Vibe messages.jsonl file at path into a
|
|
// ParseResult through the folded free function, replacing the removed
|
|
// package-level ParseVibeSession entrypoint.
|
|
func parseVibeTestSession(t *testing.T, path string, fileInfo FileInfo) (ParseResult, error) {
|
|
t.Helper()
|
|
return parseVibeResultFile(path, fileInfo)
|
|
}
|
|
|
|
// discoverVibeTestSessions discovers Vibe sessions under root through the
|
|
// provider, returning the legacy DiscoveredFile shape (path + project) the
|
|
// tests assert against.
|
|
func discoverVibeTestSessions(t *testing.T, root string) []DiscoveredFile {
|
|
t.Helper()
|
|
provider := newVibeTestProvider(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: AgentVibe,
|
|
})
|
|
}
|
|
return files
|
|
}
|
|
|
|
// findVibeTestSourceFile resolves a Vibe session ID to a messages.jsonl path,
|
|
// replacing the removed FindVibeSourceFile.
|
|
func findVibeTestSourceFile(t *testing.T, root, sessionID string) string {
|
|
t.Helper()
|
|
return findVibeSourceFile(root, sessionID)
|
|
}
|
|
|
|
func TestDiscoverVibeSessions(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create file system structure
|
|
files := map[string]string{
|
|
"session_20260613_123456_abc123def/messages.jsonl": "test",
|
|
}
|
|
setupFileSystem(t, tmpDir, files)
|
|
|
|
// Create invalid directory (no messages.jsonl)
|
|
invalidDir := filepath.Join(tmpDir, "session_invalid")
|
|
require.NoError(t, os.MkdirAll(invalidDir, 0755))
|
|
|
|
// Create directory without session prefix
|
|
otherDir := filepath.Join(tmpDir, "other_dir")
|
|
require.NoError(t, os.MkdirAll(otherDir, 0755))
|
|
|
|
// Run discovery
|
|
discovered := discoverVibeTestSessions(t, tmpDir)
|
|
|
|
// Verify results
|
|
require.Len(t, discovered, 1)
|
|
assert.Equal(t, AgentVibe, discovered[0].Agent)
|
|
assert.Equal(t, "session_20260613_123456_abc123def", discovered[0].Project)
|
|
}
|
|
|
|
func TestDiscoverVibeSessionsMultiple(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create file system structure with multiple sessions
|
|
files := map[string]string{
|
|
"session_20260613_100000_aaa/messages.jsonl": "test",
|
|
"session_20260613_110000_bbb/messages.jsonl": "test",
|
|
"session_20260613_120000_ccc/messages.jsonl": "test",
|
|
}
|
|
setupFileSystem(t, tmpDir, files)
|
|
|
|
// Create a directory without messages.jsonl
|
|
invalidDir := filepath.Join(tmpDir, "session_20260613_130000_ddd")
|
|
require.NoError(t, os.MkdirAll(invalidDir, 0755))
|
|
|
|
// Run discovery
|
|
discovered := discoverVibeTestSessions(t, tmpDir)
|
|
|
|
// Verify results - should find only 3 valid sessions
|
|
require.Len(t, discovered, 3)
|
|
for i, f := range discovered {
|
|
assert.Equal(t, AgentVibe, f.Agent)
|
|
assert.Contains(t, f.Path, "session_20260613_")
|
|
assert.Contains(t, f.Path, "messages.jsonl")
|
|
_ = i // avoid unused variable
|
|
}
|
|
}
|
|
|
|
func TestDiscoverVibeSessionsEmptyDir(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Run discovery on empty directory
|
|
files := discoverVibeTestSessions(t, tmpDir)
|
|
|
|
// Should return empty slice
|
|
assert.Len(t, files, 0)
|
|
}
|
|
|
|
func TestDiscoverVibeSessionsNonExistentDir(t *testing.T) {
|
|
// Run discovery on non-existent directory
|
|
files := discoverVibeTestSessions(t, "/nonexistent/path")
|
|
|
|
// Should return empty slice without error
|
|
assert.Len(t, files, 0)
|
|
}
|
|
|
|
func TestFindVibeSourceFile(t *testing.T) {
|
|
root := t.TempDir()
|
|
sessionID := "session_20260613_123456_abc123def"
|
|
setupFileSystem(t, root, map[string]string{
|
|
filepath.Join(sessionID, "messages.jsonl"): "test",
|
|
})
|
|
|
|
// When the ID matches the directory name (no meta.json), the file is
|
|
// resolved directly.
|
|
result := findVibeTestSourceFile(t, root, sessionID)
|
|
expected := filepath.Join(root, sessionID, "messages.jsonl")
|
|
assert.Equal(t, expected, result)
|
|
}
|
|
|
|
func TestFindVibeSourceFileWithSpecialChars(t *testing.T) {
|
|
root := t.TempDir()
|
|
sessionID := "session_20260613_123456_abc-123-def"
|
|
setupFileSystem(t, root, map[string]string{
|
|
filepath.Join(sessionID, "messages.jsonl"): "test",
|
|
})
|
|
|
|
result := findVibeTestSourceFile(t, root, sessionID)
|
|
expected := filepath.Join(root, sessionID, "messages.jsonl")
|
|
assert.Equal(t, expected, result)
|
|
}
|
|
|
|
func TestFindVibeSourceFileByMetaSessionID(t *testing.T) {
|
|
root := t.TempDir()
|
|
dirName := "session_20260613_123456_abc123def"
|
|
setupFileSystem(t, root, map[string]string{
|
|
filepath.Join(dirName, "messages.jsonl"): "test",
|
|
filepath.Join(dirName, "meta.json"): `{"session_id": "uuid-1234"}`,
|
|
})
|
|
|
|
// The canonical ID is the meta.json session_id, which differs from the
|
|
// directory name; the lookup must scan meta.json to resolve it.
|
|
result := findVibeTestSourceFile(t, root, "uuid-1234")
|
|
expected := filepath.Join(root, dirName, "messages.jsonl")
|
|
assert.Equal(t, expected, result)
|
|
|
|
// An unknown ID resolves to nothing.
|
|
assert.Empty(t, findVibeTestSourceFile(t, root, "does-not-exist"))
|
|
}
|
|
|
|
func TestParseVibeSession(t *testing.T) {
|
|
path := "testdata/vibe/session_basic/messages.jsonl"
|
|
fileInfo := FileInfo{
|
|
Path: path,
|
|
Mtime: time.Now().UnixNano(),
|
|
}
|
|
|
|
result, err := parseVibeTestSession(t, path, fileInfo)
|
|
require.NoError(t, err)
|
|
|
|
// Verify session metadata
|
|
assert.Equal(t, AgentVibe, result.Session.Agent)
|
|
assert.NotEmpty(t, result.Session.ID)
|
|
assert.True(t, len(result.Session.ID) > 0)
|
|
|
|
// Verify messages
|
|
require.NotEmpty(t, result.Messages)
|
|
assert.Equal(t, 5, len(result.Messages))
|
|
|
|
// Verify first message is user
|
|
assert.Equal(t, RoleUser, result.Messages[0].Role)
|
|
assert.Equal(t, "Create a Python function to sort a list", result.Messages[0].Content)
|
|
|
|
// Verify last message is assistant
|
|
assert.Equal(t, RoleAssistant, result.Messages[4].Role)
|
|
|
|
// Verify session metadata from meta.json
|
|
assert.Equal(t, "vibe:abc123def-0000-0000-0000-000000000000", result.Session.ID)
|
|
assert.Equal(t, "abc123def-0000-0000-0000-000000000000", result.Session.SourceSessionID)
|
|
assert.Equal(t, "Create a Python function to sort a list", result.Session.SessionName)
|
|
assert.Equal(t, "/home/user/projects/myapp", result.Session.Cwd)
|
|
// Project is derived from the working directory (basename here, since
|
|
// the path is not a real git repo), not the cryptic session directory.
|
|
assert.Equal(t, "myapp", result.Session.Project)
|
|
assert.Equal(t, "main", result.Session.GitBranch)
|
|
assert.Equal(t, "abc123def456", result.Session.SourceVersion)
|
|
assert.True(t, result.Session.HasTotalOutputTokens)
|
|
assert.Equal(t, 50, result.Session.TotalOutputTokens)
|
|
assert.True(t, result.Session.HasPeakContextTokens)
|
|
assert.Equal(t, 150, result.Session.PeakContextTokens)
|
|
|
|
// Verify usage events are created from session stats
|
|
require.Len(t, result.UsageEvents, 1)
|
|
usageEvent := result.UsageEvents[0]
|
|
assert.Equal(t, "vibe:abc123def-0000-0000-0000-000000000000", usageEvent.SessionID)
|
|
assert.Equal(t, "session", usageEvent.Source)
|
|
assert.Equal(t, "mistral-medium-3.5", usageEvent.Model)
|
|
assert.Equal(t, 100, usageEvent.InputTokens)
|
|
assert.Equal(t, 50, usageEvent.OutputTokens) // Uses session_completion_tokens
|
|
assert.Equal(t, 0, usageEvent.CacheCreationInputTokens)
|
|
assert.Equal(t, 0, usageEvent.CacheReadInputTokens)
|
|
assert.Equal(t, 0, usageEvent.ReasoningTokens)
|
|
assert.Nil(t, usageEvent.CostUSD)
|
|
assert.Equal(t, "", usageEvent.CostStatus)
|
|
assert.Equal(t, "", usageEvent.CostSource)
|
|
assert.Equal(t, "session:vibe:abc123def-0000-0000-0000-000000000000", usageEvent.DedupKey)
|
|
}
|
|
|
|
func TestParseVibeSessionWithTools(t *testing.T) {
|
|
path := "testdata/vibe/session_with_tools/messages.jsonl"
|
|
fileInfo := FileInfo{
|
|
Path: path,
|
|
Mtime: time.Now().UnixNano(),
|
|
}
|
|
|
|
result, err := parseVibeTestSession(t, path, fileInfo)
|
|
require.NoError(t, err)
|
|
|
|
// Verify messages
|
|
require.NotEmpty(t, result.Messages)
|
|
assert.Equal(t, 4, len(result.Messages))
|
|
|
|
// Verify tool calls were parsed
|
|
hasToolCalls := false
|
|
for _, msg := range result.Messages {
|
|
if len(msg.ToolCalls) > 0 {
|
|
hasToolCalls = true
|
|
assert.Equal(t, 1, len(msg.ToolCalls))
|
|
assert.Equal(t, "call_001", msg.ToolCalls[0].ToolUseID)
|
|
assert.Equal(t, "read_file", msg.ToolCalls[0].ToolName)
|
|
assert.Equal(t, "Read", msg.ToolCalls[0].Category)
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, hasToolCalls, "Expected tool calls to be parsed")
|
|
|
|
// Verify tool results are carried in a dedicated empty RoleUser
|
|
// message (matching the Hermes/QClaw/OpenClaw convention) rather
|
|
// than duplicated as a separate visible "tool" message.
|
|
hasToolResults := false
|
|
for _, msg := range result.Messages {
|
|
if len(msg.ToolResults) > 0 {
|
|
hasToolResults = true
|
|
assert.Equal(t, RoleUser, msg.Role)
|
|
assert.Equal(t, "", msg.Content)
|
|
assert.Equal(t, 1, len(msg.ToolResults))
|
|
assert.Equal(t, "call_001", msg.ToolResults[0].ToolUseID)
|
|
|
|
// ContentRaw must be valid JSON (a quoted string) so
|
|
// DecodeContent can surface the plain-text tool output.
|
|
var decoded string
|
|
require.NoError(
|
|
t, json.Unmarshal(
|
|
[]byte(msg.ToolResults[0].ContentRaw), &decoded,
|
|
),
|
|
)
|
|
assert.Contains(t, decoded, "# My Project")
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, hasToolResults, "Expected tool results to be linked")
|
|
|
|
for _, msg := range result.Messages {
|
|
assert.NotEqual(
|
|
t, RoleType("tool"), msg.Role,
|
|
"raw tool-result records must not appear as standalone messages",
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestParseVibeSessionEmpty(t *testing.T) {
|
|
path := "testdata/vibe/session_empty/messages.jsonl"
|
|
fileInfo := FileInfo{
|
|
Path: path,
|
|
Mtime: time.Now().UnixNano(),
|
|
}
|
|
|
|
result, err := parseVibeTestSession(t, path, fileInfo)
|
|
require.NoError(t, err)
|
|
|
|
// Empty file should have no messages
|
|
assert.Len(t, result.Messages, 0)
|
|
assert.Equal(t, 0, result.Session.MessageCount)
|
|
}
|
|
|
|
func TestParseVibeSessionMalformedLines(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a messages.jsonl with malformed JSON
|
|
content := `{"role": "user", "content": "valid", "message_id": "1"}
|
|
{this is not valid json}
|
|
{"role": "assistant", "content": "another valid", "message_id": "2"}
|
|
`
|
|
files := map[string]string{
|
|
"session_test/messages.jsonl": content,
|
|
}
|
|
setupFileSystem(t, tmpDir, files)
|
|
|
|
path := filepath.Join(tmpDir, "session_test", "messages.jsonl")
|
|
fileInfo := FileInfo{
|
|
Path: path,
|
|
Mtime: time.Now().UnixNano(),
|
|
}
|
|
|
|
result, err := parseVibeTestSession(t, path, fileInfo)
|
|
require.NoError(t, err)
|
|
|
|
// Should have parsed 2 valid messages and counted 1 malformed line
|
|
assert.Len(t, result.Messages, 2)
|
|
assert.Equal(t, 1, result.Session.MalformedLines)
|
|
}
|
|
|
|
func TestParseVibeSessionWithoutMeta(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a messages.jsonl without meta.json in a session subdirectory
|
|
content := `{"role": "user", "content": "test message", "message_id": "1"}
|
|
{"role": "assistant", "content": "test response", "message_id": "2"}
|
|
`
|
|
files := map[string]string{
|
|
"session_test/messages.jsonl": content,
|
|
}
|
|
setupFileSystem(t, tmpDir, files)
|
|
|
|
path := filepath.Join(tmpDir, "session_test", "messages.jsonl")
|
|
fileInfo := FileInfo{
|
|
Path: path,
|
|
Mtime: time.Now().UnixNano(),
|
|
}
|
|
|
|
result, err := parseVibeTestSession(t, path, fileInfo)
|
|
require.NoError(t, err)
|
|
|
|
// Should have parsed messages but no metadata from meta.json. The ID
|
|
// falls back to the directory name but still carries the "vibe:" prefix
|
|
// so prefix-based routing works.
|
|
assert.Len(t, result.Messages, 2)
|
|
assert.Equal(t, "vibe:session_test", result.Session.ID)
|
|
assert.Equal(t, "vibe", result.Session.Project) // Vibe sessions use "vibe" as project
|
|
|
|
// Should have no usage events since there's no meta.json with stats
|
|
assert.Empty(t, result.UsageEvents)
|
|
}
|
|
|
|
func TestParseVibeSessionEmptyStats(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a messages.jsonl with meta.json that has empty stats
|
|
content := `{"role": "user", "content": "test message", "message_id": "1"}
|
|
{"role": "assistant", "content": "test response", "message_id": "2"}
|
|
`
|
|
metaContent := `{
|
|
"session_id": "test-session-123",
|
|
"start_time": "2026-06-13T10:00:00Z",
|
|
"end_time": "2026-06-13T10:05:00Z",
|
|
"model": "mistral-medium-3.5",
|
|
"title": "Test session",
|
|
"stats": {
|
|
"steps": 0,
|
|
"session_prompt_tokens": 0,
|
|
"session_completion_tokens": 0,
|
|
"context_tokens": 0,
|
|
"last_turn_prompt_tokens": 0,
|
|
"last_turn_completion_tokens": 0,
|
|
"session_total_llm_tokens": 0,
|
|
"last_turn_total_tokens": 0
|
|
}
|
|
}
|
|
`
|
|
files := map[string]string{
|
|
"session_test/messages.jsonl": content,
|
|
"session_test/meta.json": metaContent,
|
|
}
|
|
setupFileSystem(t, tmpDir, files)
|
|
|
|
path := filepath.Join(tmpDir, "session_test", "messages.jsonl")
|
|
fileInfo := FileInfo{
|
|
Path: path,
|
|
Mtime: time.Now().UnixNano(),
|
|
}
|
|
|
|
result, err := parseVibeTestSession(t, path, fileInfo)
|
|
require.NoError(t, err)
|
|
|
|
// Should have parsed messages and metadata but no usage events due to empty stats
|
|
assert.Len(t, result.Messages, 2)
|
|
assert.Equal(t, "vibe:test-session-123", result.Session.ID)
|
|
assert.Equal(t, "Test session", result.Session.SessionName)
|
|
|
|
// Should have no usage events since all stats are zero
|
|
assert.Empty(t, result.UsageEvents)
|
|
}
|
|
|
|
func TestParseVibeSessionModelFromMessages(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a messages.jsonl with meta.json that has stats but no model
|
|
content := `{"role": "user", "content": "test message", "message_id": "1"}
|
|
{"role": "assistant", "content": "test response", "model": "mistral-medium", "message_id": "2"}
|
|
`
|
|
metaContent := `{
|
|
"session_id": "test-session-456",
|
|
"start_time": "2026-06-13T10:00:00Z",
|
|
"end_time": "2026-06-13T10:05:00Z",
|
|
"title": "Test session with model in messages",
|
|
"stats": {
|
|
"steps": 2,
|
|
"session_prompt_tokens": 50,
|
|
"session_completion_tokens": 25,
|
|
"context_tokens": 75,
|
|
"last_turn_prompt_tokens": 25,
|
|
"last_turn_completion_tokens": 12,
|
|
"session_total_llm_tokens": 75,
|
|
"last_turn_total_tokens": 37
|
|
}
|
|
}
|
|
`
|
|
files := map[string]string{
|
|
"session_test/messages.jsonl": content,
|
|
"session_test/meta.json": metaContent,
|
|
}
|
|
setupFileSystem(t, tmpDir, files)
|
|
|
|
path := filepath.Join(tmpDir, "session_test", "messages.jsonl")
|
|
fileInfo := FileInfo{
|
|
Path: path,
|
|
Mtime: time.Now().UnixNano(),
|
|
}
|
|
|
|
result, err := parseVibeTestSession(t, path, fileInfo)
|
|
require.NoError(t, err)
|
|
|
|
// Should have parsed messages and metadata
|
|
assert.Len(t, result.Messages, 2)
|
|
assert.Equal(t, "vibe:test-session-456", result.Session.ID)
|
|
assert.Equal(t, "Test session with model in messages", result.Session.SessionName)
|
|
|
|
// Should have usage events created with model extracted from assistant message
|
|
require.Len(t, result.UsageEvents, 1)
|
|
usageEvent := result.UsageEvents[0]
|
|
assert.Equal(t, "vibe:test-session-456", usageEvent.SessionID)
|
|
assert.Equal(t, "session", usageEvent.Source)
|
|
assert.Equal(t, "mistral-medium", usageEvent.Model)
|
|
assert.Equal(t, 50, usageEvent.InputTokens)
|
|
assert.Equal(t, 25, usageEvent.OutputTokens)
|
|
}
|
|
|
|
// TestParseVibeSessionModelFromConfig verifies that the model is read from
|
|
// config.active_model when there is no top-level "model" field. Real Vibe
|
|
// meta.json files record the model only under config.active_model, so without
|
|
// this fallback no usage event is emitted and the model never reaches the
|
|
// usage view.
|
|
func TestParseVibeSessionModelFromConfig(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
content := `{"role": "user", "content": "test message", "message_id": "1"}
|
|
{"role": "assistant", "content": "test response", "message_id": "2"}
|
|
`
|
|
// No top-level "model"; model lives under config.active_model, and no
|
|
// message carries a model either, matching real Vibe sessions.
|
|
metaContent := `{
|
|
"session_id": "test-session-789",
|
|
"start_time": "2026-06-13T10:00:00Z",
|
|
"end_time": "2026-06-13T10:05:00Z",
|
|
"title": "Test session with model in config",
|
|
"config": {"active_model": "mistral-medium-3.5"},
|
|
"stats": {
|
|
"session_prompt_tokens": 100,
|
|
"session_completion_tokens": 40,
|
|
"context_tokens": 140,
|
|
"session_total_llm_tokens": 140
|
|
}
|
|
}
|
|
`
|
|
files := map[string]string{
|
|
"session_test/messages.jsonl": content,
|
|
"session_test/meta.json": metaContent,
|
|
}
|
|
setupFileSystem(t, tmpDir, files)
|
|
|
|
path := filepath.Join(tmpDir, "session_test", "messages.jsonl")
|
|
fileInfo := FileInfo{Path: path, Mtime: time.Now().UnixNano()}
|
|
|
|
result, err := parseVibeTestSession(t, path, fileInfo)
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, result.UsageEvents, 1)
|
|
usageEvent := result.UsageEvents[0]
|
|
assert.Equal(t, "mistral-medium-3.5", usageEvent.Model)
|
|
assert.Equal(t, 100, usageEvent.InputTokens)
|
|
assert.Equal(t, 40, usageEvent.OutputTokens)
|
|
}
|
|
|
|
// TestParseVibeSessionInjectedUserExcluded verifies that an injected user
|
|
// record (system context) is marked system and excluded from both the first
|
|
// message and the user-message count, so it cannot masquerade as the user's
|
|
// opening prompt or inflate the count.
|
|
func TestParseVibeSessionInjectedUserExcluded(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
content := `{"role": "user", "content": "<system context>", "injected": true, "message_id": "0"}
|
|
{"role": "user", "content": "real prompt", "message_id": "1"}
|
|
{"role": "assistant", "content": "ok", "message_id": "2"}
|
|
`
|
|
setupFileSystem(t, tmpDir, map[string]string{
|
|
"session_test/messages.jsonl": content,
|
|
})
|
|
|
|
path := filepath.Join(tmpDir, "session_test", "messages.jsonl")
|
|
fileInfo := FileInfo{Path: path, Mtime: time.Now().UnixNano()}
|
|
|
|
result, err := parseVibeTestSession(t, path, fileInfo)
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, result.Messages, 3)
|
|
// The injected record is preserved but marked system so the UI hides it.
|
|
assert.True(t, result.Messages[0].IsSystem, "injected record must be system")
|
|
assert.False(t, result.Messages[1].IsSystem, "real prompt must not be system")
|
|
|
|
// The first message and user count skip the injected record.
|
|
assert.Equal(t, "real prompt", result.Session.FirstMessage)
|
|
assert.Equal(t, 1, result.Session.UserMessageCount)
|
|
}
|
|
|
|
// TestParseVibeSessionToolResultNotCountedAsUser verifies the empty RoleUser
|
|
// carrier emitted for a "tool" record is not counted as a user message.
|
|
func TestParseVibeSessionToolResultNotCountedAsUser(t *testing.T) {
|
|
path := "testdata/vibe/session_with_tools/messages.jsonl"
|
|
fileInfo := FileInfo{Path: path, Mtime: time.Now().UnixNano()}
|
|
|
|
result, err := parseVibeTestSession(t, path, fileInfo)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 1, result.Session.UserMessageCount)
|
|
assert.Equal(t, "Read the README file", result.Session.FirstMessage)
|
|
}
|
|
|
|
// TestParseVibeSessionMalformedMetaRecoversID verifies that when meta.json has
|
|
// a valid session_id but a malformed optional field (a bad timestamp here that
|
|
// fails the full parse), the canonical ID is still recovered rather than
|
|
// dropping to the directory-name fallback, which would abandon the canonical
|
|
// session row.
|
|
func TestParseVibeSessionMalformedMetaRecoversID(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
content := `{"role": "user", "content": "hello", "message_id": "1"}
|
|
`
|
|
metaContent := `{"session_id": "uuid-canonical-1", "start_time": "not-a-timestamp"}`
|
|
setupFileSystem(t, tmpDir, map[string]string{
|
|
"session_dir/messages.jsonl": content,
|
|
"session_dir/meta.json": metaContent,
|
|
})
|
|
|
|
path := filepath.Join(tmpDir, "session_dir", "messages.jsonl")
|
|
fileInfo := FileInfo{Path: path, Mtime: time.Now().UnixNano()}
|
|
|
|
result, err := parseVibeTestSession(t, path, fileInfo)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "vibe:uuid-canonical-1", result.Session.ID)
|
|
assert.Equal(t, "uuid-canonical-1", result.Session.SourceSessionID)
|
|
// The malformed optional fields are skipped, so no usage event is emitted.
|
|
assert.Empty(t, result.UsageEvents)
|
|
}
|
|
|
|
// TestParseVibeSessionCorruptMetaReturnsError verifies that a meta.json which
|
|
// is not even minimally valid JSON (a truncated/partial write) makes the parse
|
|
// fail, so the sync retries and leaves any existing canonical row untouched
|
|
// rather than silently re-creating it under the directory-name fallback.
|
|
func TestParseVibeSessionCorruptMetaReturnsError(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
content := `{"role": "user", "content": "hello", "message_id": "1"}
|
|
`
|
|
metaContent := `{"session_id": "uuid-canonical-2",`
|
|
setupFileSystem(t, tmpDir, map[string]string{
|
|
"session_dir/messages.jsonl": content,
|
|
"session_dir/meta.json": metaContent,
|
|
})
|
|
|
|
path := filepath.Join(tmpDir, "session_dir", "messages.jsonl")
|
|
fileInfo := FileInfo{Path: path, Mtime: time.Now().UnixNano()}
|
|
|
|
_, err := parseVibeTestSession(t, path, fileInfo)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "meta.json")
|
|
}
|
|
|
|
func TestVibeAgentByType(t *testing.T) {
|
|
def, ok := AgentByType(AgentVibe)
|
|
require.True(t, ok, "Expected AgentVibe to be registered")
|
|
|
|
assert.Equal(t, AgentVibe, def.Type)
|
|
assert.Equal(t, "Mistral Vibe", def.DisplayName)
|
|
assert.Equal(t, "VIBE_SESSIONS_DIR", def.EnvVar)
|
|
assert.Equal(t, "vibe_session_dirs", def.ConfigKey)
|
|
assert.Equal(t, "vibe:", def.IDPrefix)
|
|
assert.True(t, def.FileBased)
|
|
}
|
|
|
|
func TestVibeAgentByPrefix(t *testing.T) {
|
|
// Test with vibe: prefix
|
|
def, ok := AgentByPrefix("vibe:session_123")
|
|
require.True(t, ok, "Expected vibe: prefix to match")
|
|
assert.Equal(t, AgentVibe, def.Type)
|
|
|
|
// Test with just session_123 - this will match Claude (empty prefix) since it has no colon
|
|
// This is expected behavior per AgentByPrefix logic
|
|
def2, ok2 := AgentByPrefix("session_123")
|
|
assert.True(t, ok2, "session_123 matches Claude (empty prefix)")
|
|
assert.Equal(t, AgentClaude, def2.Type)
|
|
}
|
|
|
|
func TestConvertVibeMessageToolCalls(t *testing.T) {
|
|
// Tool categorization is delegated to the shared NormalizeToolCategory,
|
|
// and string-encoded arguments are unwrapped to the raw JSON object.
|
|
vibeMsg := VibeMessage{
|
|
Role: "assistant",
|
|
ToolCalls: []VibeToolCall{
|
|
{ID: "call_obj", Function: VibeToolCallFunction{
|
|
Name: "read_file", Arguments: json.RawMessage(`{"path":"a.txt"}`),
|
|
}},
|
|
{ID: "call_str", Function: VibeToolCallFunction{
|
|
Name: "run_shell_command", Arguments: json.RawMessage(`"{\"cmd\":\"ls\"}"`),
|
|
}},
|
|
},
|
|
}
|
|
|
|
msg, toolCalls := convertVibeMessage(vibeMsg, 0, "")
|
|
require.Len(t, toolCalls, 2)
|
|
assert.True(t, msg.HasToolUse)
|
|
|
|
assert.Equal(t, "Read", toolCalls[0].Category)
|
|
assert.JSONEq(t, `{"path":"a.txt"}`, toolCalls[0].InputJSON)
|
|
|
|
assert.Equal(t, "Bash", toolCalls[1].Category)
|
|
assert.JSONEq(t, `{"cmd":"ls"}`, toolCalls[1].InputJSON)
|
|
}
|
|
|
|
// TestParseRealVibeSession tests parsing with real Vibe session data only when
|
|
// explicitly requested. It is skipped by default so tests never read private
|
|
// local session data from a developer's home directory.
|
|
func TestParseRealVibeSession(t *testing.T) {
|
|
vibeSessionDir := os.Getenv("AGENTSVIEW_TEST_VIBE_SESSION_DIR")
|
|
if vibeSessionDir == "" {
|
|
t.Skip("AGENTSVIEW_TEST_VIBE_SESSION_DIR not set")
|
|
}
|
|
messagesPath := filepath.Join(vibeSessionDir, "messages.jsonl")
|
|
metaPath := filepath.Join(vibeSessionDir, "meta.json")
|
|
|
|
// Skip if test session doesn't exist
|
|
if _, err := os.Stat(messagesPath); os.IsNotExist(err) {
|
|
t.Skipf("Test Vibe session not found at %s", messagesPath)
|
|
}
|
|
if _, err := os.Stat(metaPath); os.IsNotExist(err) {
|
|
t.Skipf("Test Vibe meta.json not found at %s", metaPath)
|
|
}
|
|
|
|
fileInfo := FileInfo{
|
|
Path: messagesPath,
|
|
Mtime: time.Now().UnixNano(),
|
|
}
|
|
|
|
result, err := parseVibeTestSession(t, messagesPath, fileInfo)
|
|
require.NoError(t, err)
|
|
|
|
// Verify basic session metadata
|
|
assert.Equal(t, AgentVibe, result.Session.Agent)
|
|
assert.NotEmpty(t, result.Session.ID)
|
|
assert.True(t, len(result.Session.ID) > 0)
|
|
|
|
// Should have parsed messages
|
|
require.NotEmpty(t, result.Messages)
|
|
assert.Greater(t, len(result.Messages), 0)
|
|
|
|
// Verify timestamps from meta.json
|
|
assert.False(t, result.Session.StartedAt.IsZero())
|
|
assert.False(t, result.Session.EndedAt.IsZero())
|
|
|
|
// Verify token usage from meta.json
|
|
assert.True(t, result.Session.HasTotalOutputTokens || result.Session.TotalOutputTokens > 0)
|
|
|
|
t.Logf("Successfully parsed real Vibe session with %d messages", len(result.Messages))
|
|
}
|