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
5893 lines
184 KiB
Go
5893 lines
184 KiB
Go
// ABOUTME: Tests for sync engine helper functions.
|
|
// ABOUTME: Covers pairToolResults and related conversion logic.
|
|
package sync
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
gosync "sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.kenn.io/agentsview/internal/db"
|
|
"go.kenn.io/agentsview/internal/dbtest"
|
|
"go.kenn.io/agentsview/internal/export"
|
|
"go.kenn.io/agentsview/internal/parser"
|
|
"go.kenn.io/agentsview/internal/testjsonl"
|
|
)
|
|
|
|
func openTestDB(t *testing.T) *db.DB {
|
|
t.Helper()
|
|
return dbtest.OpenTestDB(t)
|
|
}
|
|
|
|
func TestStartupMaintenanceWaitsForForegroundSyncAndSerializesLaterSyncs(
|
|
t *testing.T,
|
|
) {
|
|
database := openTestDB(t)
|
|
engine := NewEngine(database, EngineConfig{
|
|
Machine: "local",
|
|
DeferStartupMaintenance: true,
|
|
})
|
|
t.Cleanup(engine.Close)
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
t.Cleanup(cancel)
|
|
closed := func(ch <-chan struct{}) bool {
|
|
select {
|
|
case <-ch:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
maintenanceStarted := make(chan struct{})
|
|
releaseMaintenance := make(chan struct{})
|
|
maintenanceDone := make(chan error, 1)
|
|
go func() {
|
|
maintenanceDone <- engine.RunStartupMaintenance(ctx, func() error {
|
|
close(maintenanceStarted)
|
|
<-releaseMaintenance
|
|
return nil
|
|
})
|
|
}()
|
|
assert.Never(t, func() bool {
|
|
return closed(maintenanceStarted)
|
|
}, 100*time.Millisecond, 10*time.Millisecond,
|
|
"deferred maintenance must wait for the foreground sync")
|
|
|
|
foregroundStarted := make(chan struct{})
|
|
releaseForeground := make(chan struct{})
|
|
foregroundDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := engine.SyncThenRun(ctx, false, nil, func(bool) error {
|
|
close(foregroundStarted)
|
|
<-releaseForeground
|
|
return nil
|
|
})
|
|
foregroundDone <- err
|
|
}()
|
|
require.Eventually(t, func() bool {
|
|
return closed(foregroundStarted)
|
|
}, time.Second, 10*time.Millisecond)
|
|
assert.Never(t, func() bool {
|
|
return closed(maintenanceStarted)
|
|
}, 100*time.Millisecond, 10*time.Millisecond,
|
|
"maintenance must remain deferred while the foreground sync runs")
|
|
|
|
close(releaseForeground)
|
|
require.NoError(t, <-foregroundDone)
|
|
require.Eventually(t, func() bool {
|
|
return closed(maintenanceStarted)
|
|
}, time.Second, 10*time.Millisecond,
|
|
"foreground completion must release startup maintenance")
|
|
|
|
laterSyncStarted := make(chan struct{})
|
|
releaseLaterSync := make(chan struct{})
|
|
laterSyncDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := engine.SyncThenRun(ctx, false, nil, func(bool) error {
|
|
close(laterSyncStarted)
|
|
<-releaseLaterSync
|
|
return nil
|
|
})
|
|
laterSyncDone <- err
|
|
}()
|
|
assert.Never(t, func() bool {
|
|
return closed(laterSyncStarted)
|
|
}, 100*time.Millisecond, 10*time.Millisecond,
|
|
"startup maintenance must hold the sync lock")
|
|
|
|
close(releaseMaintenance)
|
|
require.NoError(t, <-maintenanceDone)
|
|
require.Eventually(t, func() bool {
|
|
return closed(laterSyncStarted)
|
|
}, time.Second, 10*time.Millisecond)
|
|
close(releaseLaterSync)
|
|
require.NoError(t, <-laterSyncDone)
|
|
}
|
|
|
|
func TestStartupSyncFallbackRunsWhenForegroundSyncNeverArrives(t *testing.T) {
|
|
database := openTestDB(t)
|
|
engine := NewEngine(database, EngineConfig{
|
|
Machine: "local",
|
|
DeferStartupMaintenance: true,
|
|
})
|
|
t.Cleanup(engine.Close)
|
|
|
|
maintenanceStarted := make(chan struct{})
|
|
maintenanceDone := make(chan error, 1)
|
|
go func() {
|
|
maintenanceDone <- engine.RunStartupMaintenance(
|
|
t.Context(),
|
|
func() error {
|
|
close(maintenanceStarted)
|
|
return nil
|
|
},
|
|
)
|
|
}()
|
|
|
|
stats, ran, err := engine.RunStartupSyncFallback(t.Context(), nil)
|
|
require.NoError(t, err)
|
|
assert.True(t, ran)
|
|
assert.False(t, stats.Aborted)
|
|
assert.False(t, engine.LastSyncStartedAt().IsZero(),
|
|
"fallback must perform the skipped startup sync")
|
|
require.Eventually(t, func() bool {
|
|
select {
|
|
case <-maintenanceStarted:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}, time.Second, 10*time.Millisecond,
|
|
"fallback completion must release startup maintenance")
|
|
require.NoError(t, <-maintenanceDone)
|
|
}
|
|
|
|
func TestStartupSyncFallbackSkipsAfterForegroundSyncCompletes(t *testing.T) {
|
|
database := openTestDB(t)
|
|
engine := NewEngine(database, EngineConfig{
|
|
Machine: "local",
|
|
DeferStartupMaintenance: true,
|
|
})
|
|
t.Cleanup(engine.Close)
|
|
|
|
_, err := engine.SyncThenRun(
|
|
t.Context(), false, nil, func(bool) error { return nil },
|
|
)
|
|
require.NoError(t, err)
|
|
startedAt := engine.LastSyncStartedAt()
|
|
require.False(t, startedAt.IsZero())
|
|
|
|
_, ran, err := engine.RunStartupSyncFallback(t.Context(), nil)
|
|
require.NoError(t, err)
|
|
assert.False(t, ran,
|
|
"fallback must not duplicate a completed foreground sync")
|
|
assert.Equal(t, startedAt, engine.LastSyncStartedAt())
|
|
}
|
|
|
|
func TestStartupSyncFallbackRecoversCanceledForegroundSync(t *testing.T) {
|
|
database := openTestDB(t)
|
|
engine := NewEngine(database, EngineConfig{
|
|
Machine: "local",
|
|
DeferStartupMaintenance: true,
|
|
})
|
|
t.Cleanup(engine.Close)
|
|
|
|
requestCtx, cancelRequest := context.WithCancel(t.Context())
|
|
cancelRequest()
|
|
_, err := engine.SyncThenRun(
|
|
requestCtx, false, nil, func(bool) error { return nil },
|
|
)
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
|
|
_, ran, err := engine.RunStartupSyncFallback(t.Context(), nil)
|
|
require.NoError(t, err)
|
|
assert.True(t, ran,
|
|
"a failed foreground request must leave startup recovery eligible")
|
|
}
|
|
|
|
func TestStartupSyncFallbackReleasesMaintenanceAfterCanceledAttempt(
|
|
t *testing.T,
|
|
) {
|
|
database := openTestDB(t)
|
|
engine := NewEngine(database, EngineConfig{
|
|
Machine: "local",
|
|
DeferStartupMaintenance: true,
|
|
})
|
|
t.Cleanup(engine.Close)
|
|
|
|
maintenanceStarted := make(chan struct{})
|
|
maintenanceDone := make(chan error, 1)
|
|
go func() {
|
|
maintenanceDone <- engine.RunStartupMaintenance(
|
|
t.Context(),
|
|
func() error {
|
|
close(maintenanceStarted)
|
|
return nil
|
|
},
|
|
)
|
|
}()
|
|
|
|
fallbackCtx, cancelFallback := context.WithCancel(t.Context())
|
|
cancelFallback()
|
|
_, ran, err := engine.RunStartupSyncFallback(fallbackCtx, nil)
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
assert.True(t, ran)
|
|
|
|
require.Eventually(t, func() bool {
|
|
select {
|
|
case <-maintenanceStarted:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}, time.Second, 10*time.Millisecond,
|
|
"an attempted fallback must release startup maintenance")
|
|
require.NoError(t, <-maintenanceDone)
|
|
}
|
|
|
|
func TestStartupSyncFallbackRechecksAfterInFlightForegroundSync(t *testing.T) {
|
|
database := openTestDB(t)
|
|
engine := NewEngine(database, EngineConfig{
|
|
Machine: "local",
|
|
DeferStartupMaintenance: true,
|
|
})
|
|
t.Cleanup(engine.Close)
|
|
|
|
foregroundStarted := make(chan struct{})
|
|
releaseForeground := make(chan struct{})
|
|
foregroundDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := engine.SyncThenRun(
|
|
t.Context(), false, nil,
|
|
func(bool) error {
|
|
close(foregroundStarted)
|
|
<-releaseForeground
|
|
return nil
|
|
},
|
|
)
|
|
foregroundDone <- err
|
|
}()
|
|
select {
|
|
case <-foregroundStarted:
|
|
case <-time.After(time.Second):
|
|
require.FailNow(t, "foreground sync did not start")
|
|
}
|
|
|
|
type fallbackResult struct {
|
|
ran bool
|
|
err error
|
|
}
|
|
fallbackDone := make(chan fallbackResult, 1)
|
|
go func() {
|
|
_, ran, err := engine.RunStartupSyncFallback(t.Context(), nil)
|
|
fallbackDone <- fallbackResult{ran: ran, err: err}
|
|
}()
|
|
assert.Never(t, func() bool {
|
|
select {
|
|
case <-fallbackDone:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}, 100*time.Millisecond, 10*time.Millisecond,
|
|
"fallback must wait for the in-flight foreground sync")
|
|
|
|
close(releaseForeground)
|
|
require.NoError(t, <-foregroundDone)
|
|
select {
|
|
case result := <-fallbackDone:
|
|
require.NoError(t, result.err)
|
|
assert.False(t, result.ran,
|
|
"fallback must recheck completion after acquiring the sync lock")
|
|
case <-time.After(time.Second):
|
|
require.FailNow(t, "fallback did not finish")
|
|
}
|
|
}
|
|
|
|
// fakeFileInfo implements os.FileInfo for test use.
|
|
type fakeFileInfo struct {
|
|
size int64
|
|
mtime int64 // UnixNano
|
|
}
|
|
|
|
func (f fakeFileInfo) Name() string { return "test" }
|
|
func (f fakeFileInfo) Size() int64 { return f.size }
|
|
func (f fakeFileInfo) Mode() os.FileMode { return 0 }
|
|
func (f fakeFileInfo) ModTime() time.Time {
|
|
return time.Unix(0, f.mtime)
|
|
}
|
|
func (f fakeFileInfo) IsDir() bool { return false }
|
|
func (f fakeFileInfo) Sys() any { return nil }
|
|
|
|
func TestHasLegacyKiroCandidates(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
files []parser.DiscoveredFile
|
|
want bool
|
|
}{
|
|
{
|
|
name: "empty",
|
|
want: false,
|
|
},
|
|
{
|
|
name: "non-kiro files",
|
|
files: []parser.DiscoveredFile{
|
|
{Agent: parser.AgentClaude, Path: "/tmp/claude/session.jsonl"},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "kiro sqlite database source",
|
|
files: []parser.DiscoveredFile{
|
|
{Agent: parser.AgentKiro, Path: "/tmp/kiro/data.sqlite3"},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "legacy kiro jsonl",
|
|
files: []parser.DiscoveredFile{
|
|
{Agent: parser.AgentKiro, Path: "/tmp/kiro/session.jsonl"},
|
|
},
|
|
want: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, hasLegacyKiroCandidates(tt.files))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFilterEmptyMessages(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
msgs []db.Message
|
|
want []db.Message
|
|
}{
|
|
{
|
|
name: "removes empty-content user message after pairing",
|
|
msgs: []db.Message{
|
|
{
|
|
Role: "assistant",
|
|
Content: "Let me read the file.",
|
|
ToolCalls: []db.ToolCall{
|
|
{ToolUseID: "t1", ToolName: "Read"},
|
|
},
|
|
},
|
|
{
|
|
Role: "user",
|
|
Content: "",
|
|
ToolResults: []db.ToolResult{
|
|
{ToolUseID: "t1", ContentLength: 500},
|
|
},
|
|
},
|
|
},
|
|
want: []db.Message{
|
|
{
|
|
Role: "assistant",
|
|
Content: "Let me read the file.",
|
|
ToolCalls: []db.ToolCall{
|
|
{ToolUseID: "t1", ToolName: "Read", ResultContentLength: 500},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "keeps user message with real content",
|
|
msgs: []db.Message{
|
|
{
|
|
Role: "assistant",
|
|
Content: "Here is the result.",
|
|
ToolCalls: []db.ToolCall{
|
|
{ToolUseID: "t1", ToolName: "Bash"},
|
|
},
|
|
},
|
|
{
|
|
Role: "user",
|
|
Content: "",
|
|
ToolResults: []db.ToolResult{
|
|
{ToolUseID: "t1", ContentLength: 100},
|
|
},
|
|
},
|
|
{
|
|
Role: "user",
|
|
Content: "Thanks, now do something else.",
|
|
},
|
|
},
|
|
want: []db.Message{
|
|
{
|
|
Role: "assistant",
|
|
Content: "Here is the result.",
|
|
ToolCalls: []db.ToolCall{
|
|
{ToolUseID: "t1", ToolName: "Bash", ResultContentLength: 100},
|
|
},
|
|
},
|
|
{
|
|
Role: "user",
|
|
Content: "Thanks, now do something else.",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "whitespace-only content treated as empty",
|
|
msgs: []db.Message{
|
|
{
|
|
Role: "assistant",
|
|
Content: "Reading...",
|
|
ToolCalls: []db.ToolCall{
|
|
{ToolUseID: "t1", ToolName: "Read"},
|
|
},
|
|
},
|
|
{
|
|
Role: "user",
|
|
Content: " \n\t ",
|
|
ToolResults: []db.ToolResult{
|
|
{ToolUseID: "t1", ContentLength: 300},
|
|
},
|
|
},
|
|
},
|
|
want: []db.Message{
|
|
{
|
|
Role: "assistant",
|
|
Content: "Reading...",
|
|
ToolCalls: []db.ToolCall{
|
|
{ToolUseID: "t1", ToolName: "Read", ResultContentLength: 300},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "preserves empty assistant message",
|
|
msgs: []db.Message{
|
|
{
|
|
Role: "assistant",
|
|
Content: "",
|
|
},
|
|
},
|
|
want: []db.Message{
|
|
{
|
|
Role: "assistant",
|
|
Content: "",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "only removes user messages with tool results",
|
|
msgs: []db.Message{
|
|
{
|
|
Role: "assistant",
|
|
Content: "",
|
|
},
|
|
{
|
|
Role: "user",
|
|
Content: "",
|
|
},
|
|
},
|
|
want: []db.Message{
|
|
{
|
|
Role: "assistant",
|
|
Content: "",
|
|
},
|
|
{
|
|
Role: "user",
|
|
Content: "",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "no messages returns empty",
|
|
msgs: nil,
|
|
want: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := pairAndFilter(tt.msgs, nil)
|
|
diff := cmp.Diff(tt.want, got)
|
|
assert.Empty(t, diff, "pairAndFilter() mismatch (-want +got):\n%s", diff)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPostFilterCounts(t *testing.T) {
|
|
type counts struct {
|
|
Total int
|
|
User int
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
msgs []db.Message
|
|
want counts
|
|
}{
|
|
{
|
|
name: "mixed roles",
|
|
msgs: []db.Message{
|
|
{Role: "user", Content: "hello"},
|
|
{Role: "assistant", Content: "hi"},
|
|
{Role: "user", Content: "thanks"},
|
|
},
|
|
want: counts{Total: 3, User: 2},
|
|
},
|
|
{
|
|
name: "no user messages",
|
|
msgs: []db.Message{
|
|
{Role: "assistant", Content: "hi"},
|
|
},
|
|
want: counts{Total: 1, User: 0},
|
|
},
|
|
{
|
|
name: "empty slice",
|
|
msgs: nil,
|
|
want: counts{Total: 0, User: 0},
|
|
},
|
|
{
|
|
name: "all user messages",
|
|
msgs: []db.Message{
|
|
{Role: "user", Content: "a"},
|
|
{Role: "user", Content: "b"},
|
|
},
|
|
want: counts{Total: 2, User: 2},
|
|
},
|
|
{
|
|
name: "system messages excluded from user count",
|
|
msgs: []db.Message{
|
|
{Role: "user", Content: "hello", IsSystem: false},
|
|
{Role: "user", Content: "system notice", IsSystem: true},
|
|
{Role: "assistant", Content: "hi"},
|
|
{Role: "user", Content: "[Turn finished: endTurn]", IsSystem: true},
|
|
},
|
|
want: counts{Total: 4, User: 1},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
total, user := postFilterCounts(tt.msgs)
|
|
got := counts{Total: total, User: user}
|
|
diff := cmp.Diff(tt.want, got)
|
|
assert.Empty(t, diff, "postFilterCounts() mismatch (-want +got):\n%s", diff)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPairToolResults(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
msgs []db.Message
|
|
want []db.Message
|
|
}{
|
|
{
|
|
name: "basic pairing across messages",
|
|
msgs: []db.Message{
|
|
{ToolCalls: []db.ToolCall{
|
|
{ToolUseID: "t1", ToolName: "Read"},
|
|
{ToolUseID: "t2", ToolName: "Grep"},
|
|
}},
|
|
{ToolResults: []db.ToolResult{
|
|
{ToolUseID: "t1", ContentLength: 100},
|
|
{ToolUseID: "t2", ContentLength: 200},
|
|
}},
|
|
},
|
|
want: []db.Message{
|
|
{ToolCalls: []db.ToolCall{
|
|
{ToolUseID: "t1", ToolName: "Read", ResultContentLength: 100},
|
|
{ToolUseID: "t2", ToolName: "Grep", ResultContentLength: 200},
|
|
}},
|
|
{ToolResults: []db.ToolResult{
|
|
{ToolUseID: "t1", ContentLength: 100},
|
|
{ToolUseID: "t2", ContentLength: 200},
|
|
}},
|
|
},
|
|
},
|
|
{
|
|
name: "unmatched tool_result ignored",
|
|
msgs: []db.Message{
|
|
{ToolCalls: []db.ToolCall{
|
|
{ToolUseID: "t1", ToolName: "Read"},
|
|
}},
|
|
{ToolResults: []db.ToolResult{
|
|
{ToolUseID: "t1", ContentLength: 50},
|
|
{ToolUseID: "t_unknown", ContentLength: 999},
|
|
}},
|
|
},
|
|
want: []db.Message{
|
|
{ToolCalls: []db.ToolCall{
|
|
{ToolUseID: "t1", ToolName: "Read", ResultContentLength: 50},
|
|
}},
|
|
{ToolResults: []db.ToolResult{
|
|
{ToolUseID: "t1", ContentLength: 50},
|
|
{ToolUseID: "t_unknown", ContentLength: 999},
|
|
}},
|
|
},
|
|
},
|
|
{
|
|
name: "unmatched tool_call keeps zero",
|
|
msgs: []db.Message{
|
|
{ToolCalls: []db.ToolCall{
|
|
{ToolUseID: "t1", ToolName: "Read"},
|
|
{ToolUseID: "t2", ToolName: "Bash"},
|
|
}},
|
|
{ToolResults: []db.ToolResult{
|
|
{ToolUseID: "t1", ContentLength: 42},
|
|
}},
|
|
},
|
|
want: []db.Message{
|
|
{ToolCalls: []db.ToolCall{
|
|
{ToolUseID: "t1", ToolName: "Read", ResultContentLength: 42},
|
|
{ToolUseID: "t2", ToolName: "Bash", ResultContentLength: 0},
|
|
}},
|
|
{ToolResults: []db.ToolResult{
|
|
{ToolUseID: "t1", ContentLength: 42},
|
|
}},
|
|
},
|
|
},
|
|
{
|
|
name: "empty messages",
|
|
msgs: nil,
|
|
want: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pairToolResults(tt.msgs, nil)
|
|
diff := cmp.Diff(tt.want, tt.msgs)
|
|
assert.Empty(t, diff, "pairToolResults() mismatch (-want +got):\n%s", diff)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPairToolResultsContent(t *testing.T) {
|
|
ampToolResultText := "line 1\nline \"2\" output"
|
|
ampToolResultRaw := "\"line 1\\nline \\\"2\\\" output\""
|
|
|
|
tests := []struct {
|
|
name string
|
|
msgs []db.Message
|
|
blocked map[string]bool
|
|
want []db.Message
|
|
}{
|
|
{
|
|
name: "content stored for non-blocked category",
|
|
msgs: []db.Message{
|
|
{ToolCalls: []db.ToolCall{
|
|
{ToolUseID: "t1", ToolName: "Bash", Category: "Bash"},
|
|
}},
|
|
{ToolResults: []db.ToolResult{
|
|
{ToolUseID: "t1", ContentLength: 42, ContentRaw: `"output text"`},
|
|
}},
|
|
},
|
|
blocked: map[string]bool{"Read": true, "Glob": true},
|
|
want: []db.Message{
|
|
{ToolCalls: []db.ToolCall{
|
|
{ToolUseID: "t1", ToolName: "Bash", Category: "Bash",
|
|
ResultContentLength: 42, ResultContent: "output text"},
|
|
}},
|
|
{ToolResults: []db.ToolResult{
|
|
{ToolUseID: "t1", ContentLength: 42, ContentRaw: `"output text"`},
|
|
}},
|
|
},
|
|
},
|
|
{
|
|
name: "content blocked for Read category",
|
|
msgs: []db.Message{
|
|
{ToolCalls: []db.ToolCall{
|
|
{ToolUseID: "t1", ToolName: "Read", Category: "Read"},
|
|
}},
|
|
{ToolResults: []db.ToolResult{
|
|
{ToolUseID: "t1", ContentLength: 5000, ContentRaw: `"file data"`},
|
|
}},
|
|
},
|
|
blocked: map[string]bool{"Read": true, "Glob": true},
|
|
want: []db.Message{
|
|
{ToolCalls: []db.ToolCall{
|
|
{ToolUseID: "t1", ToolName: "Read", Category: "Read",
|
|
ResultContentLength: 5000, ResultContent: ""},
|
|
}},
|
|
{ToolResults: []db.ToolResult{
|
|
{ToolUseID: "t1", ContentLength: 5000, ContentRaw: `"file data"`},
|
|
}},
|
|
},
|
|
},
|
|
{
|
|
name: "nil blocked map stores all content",
|
|
msgs: []db.Message{
|
|
{ToolCalls: []db.ToolCall{
|
|
{ToolUseID: "t1", ToolName: "Read", Category: "Read"},
|
|
}},
|
|
{ToolResults: []db.ToolResult{
|
|
{ToolUseID: "t1", ContentLength: 100, ContentRaw: `"file content"`},
|
|
}},
|
|
},
|
|
blocked: nil,
|
|
want: []db.Message{
|
|
{ToolCalls: []db.ToolCall{
|
|
{ToolUseID: "t1", ToolName: "Read", Category: "Read",
|
|
ResultContentLength: 100, ResultContent: "file content"},
|
|
}},
|
|
{ToolResults: []db.ToolResult{
|
|
{ToolUseID: "t1", ContentLength: 100, ContentRaw: `"file content"`},
|
|
}},
|
|
},
|
|
},
|
|
{
|
|
// Mirrors ContentRaw produced by parser.extractAmpToolResults
|
|
// (JSON-marshaled plain-text output).
|
|
name: "amp: marshaled tool result text decodes into ResultContent",
|
|
msgs: []db.Message{
|
|
{ToolCalls: []db.ToolCall{
|
|
{ToolUseID: "t1", ToolName: "Bash", Category: "Bash"},
|
|
}},
|
|
{ToolResults: []db.ToolResult{
|
|
{
|
|
ToolUseID: "t1",
|
|
ContentLength: len(ampToolResultText),
|
|
ContentRaw: ampToolResultRaw,
|
|
},
|
|
}},
|
|
},
|
|
blocked: nil,
|
|
want: []db.Message{
|
|
{ToolCalls: []db.ToolCall{
|
|
{
|
|
ToolUseID: "t1", ToolName: "Bash", Category: "Bash",
|
|
ResultContentLength: len(ampToolResultText),
|
|
ResultContent: ampToolResultText,
|
|
},
|
|
}},
|
|
{ToolResults: []db.ToolResult{
|
|
{
|
|
ToolUseID: "t1",
|
|
ContentLength: len(ampToolResultText),
|
|
ContentRaw: ampToolResultRaw,
|
|
},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pairToolResults(tt.msgs, tt.blocked)
|
|
diff := cmp.Diff(tt.want, tt.msgs)
|
|
assert.Empty(t, diff, "pairToolResults() mismatch (-want +got):\n%s", diff)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPairToolResultEventSummaries(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
msgs []db.Message
|
|
blocked map[string]bool
|
|
want []db.Message
|
|
}{
|
|
{
|
|
name: "single event becomes summary",
|
|
msgs: []db.Message{{
|
|
ToolCalls: []db.ToolCall{{
|
|
ToolUseID: "call_wait",
|
|
ToolName: "wait",
|
|
Category: "Other",
|
|
ResultEvents: []db.ToolResultEvent{{
|
|
ToolUseID: "call_wait",
|
|
AgentID: "agent-1",
|
|
Source: "wait_output",
|
|
Status: "completed",
|
|
Content: "Finished successfully",
|
|
ContentLength: len("Finished successfully"),
|
|
}},
|
|
}},
|
|
}},
|
|
want: []db.Message{{
|
|
ToolCalls: []db.ToolCall{{
|
|
ToolUseID: "call_wait",
|
|
ToolName: "wait",
|
|
Category: "Other",
|
|
ResultContentLength: len("Finished successfully"),
|
|
ResultContent: "Finished successfully",
|
|
ResultEvents: []db.ToolResultEvent{{
|
|
ToolUseID: "call_wait",
|
|
AgentID: "agent-1",
|
|
Source: "wait_output",
|
|
Status: "completed",
|
|
Content: "Finished successfully",
|
|
ContentLength: len("Finished successfully"),
|
|
}},
|
|
}},
|
|
}},
|
|
},
|
|
{
|
|
name: "multi-agent latest summary keeps one line per agent",
|
|
msgs: []db.Message{{
|
|
ToolCalls: []db.ToolCall{{
|
|
ToolUseID: "call_wait",
|
|
ToolName: "wait",
|
|
Category: "Other",
|
|
ResultEvents: []db.ToolResultEvent{
|
|
{
|
|
ToolUseID: "call_wait",
|
|
AgentID: "agent-a",
|
|
Source: "wait_output",
|
|
Status: "completed",
|
|
Content: "First finished",
|
|
ContentLength: len("First finished"),
|
|
},
|
|
{
|
|
ToolUseID: "call_wait",
|
|
AgentID: "agent-b",
|
|
Source: "subagent_notification",
|
|
Status: "completed",
|
|
Content: "Second finished",
|
|
ContentLength: len("Second finished"),
|
|
},
|
|
},
|
|
}},
|
|
}},
|
|
want: []db.Message{{
|
|
ToolCalls: []db.ToolCall{{
|
|
ToolUseID: "call_wait",
|
|
ToolName: "wait",
|
|
Category: "Other",
|
|
ResultContentLength: len("agent-a:\nFirst finished\n\nagent-b:\nSecond finished"),
|
|
ResultContent: "agent-a:\nFirst finished\n\nagent-b:\nSecond finished",
|
|
ResultEvents: []db.ToolResultEvent{
|
|
{
|
|
ToolUseID: "call_wait",
|
|
AgentID: "agent-a",
|
|
Source: "wait_output",
|
|
Status: "completed",
|
|
Content: "First finished",
|
|
ContentLength: len("First finished"),
|
|
},
|
|
{
|
|
ToolUseID: "call_wait",
|
|
AgentID: "agent-b",
|
|
Source: "subagent_notification",
|
|
Status: "completed",
|
|
Content: "Second finished",
|
|
ContentLength: len("Second finished"),
|
|
},
|
|
},
|
|
}},
|
|
}},
|
|
},
|
|
{
|
|
name: "blocked category keeps length but drops summary content",
|
|
msgs: []db.Message{{
|
|
ToolCalls: []db.ToolCall{{
|
|
ToolUseID: "call_read",
|
|
ToolName: "Read",
|
|
Category: "Read",
|
|
ResultEvents: []db.ToolResultEvent{{
|
|
ToolUseID: "call_read",
|
|
Source: "wait_output",
|
|
Status: "completed",
|
|
Content: "secret file body",
|
|
ContentLength: len("secret file body"),
|
|
}},
|
|
}},
|
|
}},
|
|
blocked: map[string]bool{"Read": true},
|
|
want: []db.Message{{
|
|
ToolCalls: []db.ToolCall{{
|
|
ToolUseID: "call_read",
|
|
ToolName: "Read",
|
|
Category: "Read",
|
|
ResultContentLength: len("secret file body"),
|
|
ResultContent: "",
|
|
ResultEvents: nil,
|
|
}},
|
|
}},
|
|
},
|
|
{
|
|
name: "mixed anonymous and multi-agent content keeps both",
|
|
msgs: []db.Message{{
|
|
ToolCalls: []db.ToolCall{{
|
|
ToolUseID: "call_wait",
|
|
ToolName: "wait",
|
|
Category: "Other",
|
|
ResultEvents: []db.ToolResultEvent{
|
|
{
|
|
ToolUseID: "call_wait",
|
|
AgentID: "agent-a",
|
|
Source: "wait_output",
|
|
Status: "completed",
|
|
Content: "First finished",
|
|
ContentLength: len("First finished"),
|
|
},
|
|
{
|
|
ToolUseID: "call_wait",
|
|
AgentID: "agent-b",
|
|
Source: "wait_output",
|
|
Status: "completed",
|
|
Content: "Second finished",
|
|
ContentLength: len("Second finished"),
|
|
},
|
|
{
|
|
ToolUseID: "call_wait",
|
|
Source: "subagent_notification",
|
|
Status: "completed",
|
|
Content: "Detached note",
|
|
ContentLength: len("Detached note"),
|
|
},
|
|
},
|
|
}},
|
|
}},
|
|
want: []db.Message{{
|
|
ToolCalls: []db.ToolCall{{
|
|
ToolUseID: "call_wait",
|
|
ToolName: "wait",
|
|
Category: "Other",
|
|
ResultContentLength: len("agent-a:\nFirst finished\n\nagent-b:\nSecond finished\n\nDetached note"),
|
|
ResultContent: "agent-a:\nFirst finished\n\nagent-b:\nSecond finished\n\nDetached note",
|
|
ResultEvents: []db.ToolResultEvent{
|
|
{
|
|
ToolUseID: "call_wait",
|
|
AgentID: "agent-a",
|
|
Source: "wait_output",
|
|
Status: "completed",
|
|
Content: "First finished",
|
|
ContentLength: len("First finished"),
|
|
},
|
|
{
|
|
ToolUseID: "call_wait",
|
|
AgentID: "agent-b",
|
|
Source: "wait_output",
|
|
Status: "completed",
|
|
Content: "Second finished",
|
|
ContentLength: len("Second finished"),
|
|
},
|
|
{
|
|
ToolUseID: "call_wait",
|
|
Source: "subagent_notification",
|
|
Status: "completed",
|
|
Content: "Detached note",
|
|
ContentLength: len("Detached note"),
|
|
},
|
|
},
|
|
}},
|
|
}},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pairToolResultEventSummaries(tt.msgs, tt.blocked)
|
|
diff := cmp.Diff(tt.want, tt.msgs)
|
|
require.Empty(t, diff, "pairToolResultEventSummaries() mismatch (-want +got):\n%s", diff)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApplyRemoteRewrites(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
prefix string
|
|
rewriter func(string) string
|
|
sess db.Session
|
|
msgs []db.Message
|
|
wantSessID string
|
|
wantParent *string
|
|
wantFilePath *string
|
|
wantMsgSess string // expected SessionID on messages
|
|
wantSubs []string
|
|
wantEvSubs []string
|
|
}{
|
|
{
|
|
name: "no prefix is no-op",
|
|
prefix: "",
|
|
sess: db.Session{
|
|
ID: "abc",
|
|
},
|
|
msgs: []db.Message{
|
|
{SessionID: "abc"},
|
|
},
|
|
wantSessID: "abc",
|
|
wantMsgSess: "abc",
|
|
},
|
|
{
|
|
name: "all fields prefixed",
|
|
prefix: "host~",
|
|
sess: db.Session{
|
|
ID: "abc",
|
|
ParentSessionID: strPtr("parent-1"),
|
|
FilePath: strPtr("/tmp/file"),
|
|
},
|
|
msgs: []db.Message{
|
|
{
|
|
SessionID: "abc",
|
|
ToolCalls: []db.ToolCall{
|
|
{
|
|
SessionID: "abc",
|
|
SubagentSessionID: "sub-1",
|
|
ResultEvents: []db.ToolResultEvent{
|
|
{SubagentSessionID: "ev-1"},
|
|
{SubagentSessionID: ""},
|
|
},
|
|
},
|
|
{SessionID: "abc"},
|
|
},
|
|
},
|
|
},
|
|
wantSessID: "host~abc",
|
|
wantParent: strPtr("host~parent-1"),
|
|
wantFilePath: strPtr("/tmp/file"),
|
|
wantMsgSess: "host~abc",
|
|
wantSubs: []string{"host~sub-1", ""},
|
|
wantEvSubs: []string{"host~ev-1", ""},
|
|
},
|
|
{
|
|
name: "path rewriter applied",
|
|
prefix: "box~",
|
|
rewriter: func(p string) string {
|
|
return "box:" + p
|
|
},
|
|
sess: db.Session{
|
|
ID: "x",
|
|
FilePath: strPtr("/remote/path"),
|
|
},
|
|
msgs: nil,
|
|
wantSessID: "box~x",
|
|
wantFilePath: strPtr("box:/remote/path"),
|
|
},
|
|
{
|
|
name: "nil parent stays nil",
|
|
prefix: "h~",
|
|
sess: db.Session{
|
|
ID: "z",
|
|
},
|
|
wantSessID: "h~z",
|
|
wantParent: nil,
|
|
},
|
|
{
|
|
name: "empty parent stays empty",
|
|
prefix: "h~",
|
|
sess: db.Session{
|
|
ID: "z",
|
|
ParentSessionID: strPtr(""),
|
|
},
|
|
wantSessID: "h~z",
|
|
wantParent: strPtr(""),
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
e := &Engine{
|
|
idPrefix: tt.prefix,
|
|
pathRewriter: tt.rewriter,
|
|
}
|
|
e.applyRemoteRewrites(&tt.sess, tt.msgs)
|
|
|
|
assert.Equal(t, tt.wantSessID, tt.sess.ID)
|
|
diff := cmp.Diff(tt.wantParent, tt.sess.ParentSessionID)
|
|
assert.Empty(t, diff, "ParentSessionID %s", diff)
|
|
if tt.wantFilePath != nil {
|
|
diff := cmp.Diff(tt.wantFilePath, tt.sess.FilePath)
|
|
assert.Empty(t, diff, "FilePath %s", diff)
|
|
}
|
|
for _, m := range tt.msgs {
|
|
assert.Equal(t, tt.wantMsgSess, m.SessionID)
|
|
}
|
|
var gotSubs, gotEvSubs []string
|
|
for _, m := range tt.msgs {
|
|
for _, tc := range m.ToolCalls {
|
|
gotSubs = append(
|
|
gotSubs, tc.SubagentSessionID,
|
|
)
|
|
for _, ev := range tc.ResultEvents {
|
|
gotEvSubs = append(
|
|
gotEvSubs,
|
|
ev.SubagentSessionID,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
diff = cmp.Diff(tt.wantSubs, gotSubs)
|
|
assert.Empty(t, diff, "SubagentSessionIDs %s", diff)
|
|
diff = cmp.Diff(tt.wantEvSubs, gotEvSubs)
|
|
assert.Empty(t, diff, "ResultEvent SubagentSessionIDs %s", diff)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestToDBUsageEventsStampsFinalSessionID(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
sessionID string
|
|
events []parser.ParsedUsageEvent
|
|
wantIDs []string
|
|
}{
|
|
{
|
|
name: "empty event session id gets final id",
|
|
sessionID: "antigravity:abc",
|
|
events: []parser.ParsedUsageEvent{
|
|
{Source: "generation", Model: "gemini"},
|
|
},
|
|
wantIDs: []string{"antigravity:abc"},
|
|
},
|
|
{
|
|
name: "parser-stamped id matching final id is kept",
|
|
sessionID: "antigravity:abc",
|
|
events: []parser.ParsedUsageEvent{
|
|
{
|
|
SessionID: "antigravity:abc",
|
|
Source: "generation",
|
|
Model: "gemini",
|
|
},
|
|
},
|
|
wantIDs: []string{"antigravity:abc"},
|
|
},
|
|
{
|
|
name: "remote prefix overrides parser-stamped id",
|
|
sessionID: "host~antigravity:abc",
|
|
events: []parser.ParsedUsageEvent{
|
|
{
|
|
SessionID: "antigravity:abc",
|
|
Source: "generation",
|
|
Model: "gemini",
|
|
},
|
|
{
|
|
SessionID: "antigravity:abc",
|
|
Source: "generation",
|
|
Model: "claude",
|
|
},
|
|
},
|
|
wantIDs: []string{
|
|
"host~antigravity:abc",
|
|
"host~antigravity:abc",
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, _ := toDBUsageEvents(tt.sessionID, tt.events)
|
|
require.Len(t, got, len(tt.wantIDs))
|
|
for i, ev := range got {
|
|
assert.Equal(t, tt.wantIDs[i], ev.SessionID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestToDBUsageEventsPreservesSessionSummaryTokenUpperBounds(t *testing.T) {
|
|
rawInput := maxPlausibleTokens + 250_000
|
|
rawOutput := maxPlausibleTokens + 500_000
|
|
got, _ := toDBUsageEvents("hermes:summary", []parser.ParsedUsageEvent{
|
|
{
|
|
Source: "session",
|
|
Model: "gpt-5.4",
|
|
InputTokens: rawInput,
|
|
OutputTokens: rawOutput,
|
|
CacheCreationInputTokens: rawInput + 1,
|
|
CacheReadInputTokens: rawInput + 2,
|
|
ReasoningTokens: rawOutput + 3,
|
|
},
|
|
{
|
|
Source: "session",
|
|
Model: "gpt-5.4",
|
|
InputTokens: -1,
|
|
OutputTokens: -2,
|
|
CacheCreationInputTokens: -3,
|
|
CacheReadInputTokens: -4,
|
|
ReasoningTokens: -5,
|
|
},
|
|
})
|
|
|
|
require.Len(t, got, 2)
|
|
assert.Equal(t, rawInput, got[0].InputTokens)
|
|
assert.Equal(t, rawOutput, got[0].OutputTokens)
|
|
assert.Equal(t, rawInput+1, got[0].CacheCreationInputTokens)
|
|
assert.Equal(t, rawInput+2, got[0].CacheReadInputTokens)
|
|
assert.Equal(t, rawOutput+3, got[0].ReasoningTokens)
|
|
assert.Equal(t, 0, got[1].InputTokens)
|
|
assert.Equal(t, 0, got[1].OutputTokens)
|
|
assert.Equal(t, 0, got[1].CacheCreationInputTokens)
|
|
assert.Equal(t, 0, got[1].CacheReadInputTokens)
|
|
assert.Equal(t, 0, got[1].ReasoningTokens)
|
|
}
|
|
|
|
func TestWriteBatchRemoteIDPrefixUsageEvents(t *testing.T) {
|
|
database := openTestDB(t)
|
|
e := &Engine{db: database, idPrefix: "host~"}
|
|
|
|
ts := time.Unix(1700000000, 0).UTC()
|
|
pw := pendingWrite{
|
|
sess: parser.ParsedSession{
|
|
ID: "antigravity:abc",
|
|
Project: "proj",
|
|
Machine: "host",
|
|
Agent: parser.AgentAntigravity,
|
|
StartedAt: ts,
|
|
EndedAt: ts,
|
|
MessageCount: 1,
|
|
},
|
|
msgs: []parser.ParsedMessage{{
|
|
Role: parser.RoleUser,
|
|
Content: "hello",
|
|
Timestamp: ts,
|
|
}},
|
|
usageEvents: []parser.ParsedUsageEvent{{
|
|
// Parsers stamp the unprefixed session ID; the
|
|
// write path must replace it with the final
|
|
// remote-prefixed ID.
|
|
SessionID: "antigravity:abc",
|
|
Source: "generation",
|
|
Model: "gemini",
|
|
InputTokens: 100,
|
|
OutputTokens: 50,
|
|
OccurredAt: ts.Format(time.RFC3339Nano),
|
|
}},
|
|
}
|
|
|
|
written, _, failed, _ := e.writeBatch(
|
|
[]pendingWrite{pw}, syncWriteDefault, false,
|
|
)
|
|
require.Equal(t, 0, failed, "no session writes may fail")
|
|
require.Equal(t, 1, written)
|
|
|
|
events, err := database.GetUsageEvents(
|
|
context.Background(), "host~antigravity:abc",
|
|
)
|
|
require.NoError(t, err, "GetUsageEvents")
|
|
require.Len(t, events, 1)
|
|
assert.Equal(t, "host~antigravity:abc", events[0].SessionID)
|
|
assert.Equal(t, "gemini", events[0].Model)
|
|
assert.Equal(t, 100, events[0].InputTokens)
|
|
assert.Equal(t, 50, events[0].OutputTokens)
|
|
}
|
|
|
|
func TestProjectIdentityWriteBatchDiscoversLocalGitRemote(t *testing.T) {
|
|
database := openTestDB(t)
|
|
root := t.TempDir()
|
|
require.NoError(t, os.Mkdir(filepath.Join(root, ".git"), 0o755))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(root, ".git", "config"),
|
|
[]byte("[remote \"origin\"]\n\turl = git@github.com:Org/Repo.git\n"),
|
|
0o644,
|
|
))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(root, ".git", "HEAD"),
|
|
[]byte("ref: refs/heads/feature/export\n"), 0o644,
|
|
))
|
|
cwd := filepath.Join(root, "subdir")
|
|
require.NoError(t, os.Mkdir(cwd, 0o755))
|
|
|
|
e := NewEngine(database, EngineConfig{Machine: "laptop"})
|
|
written, _, failed, _ := e.writeBatch([]pendingWrite{{
|
|
sess: parser.ParsedSession{
|
|
ID: "identity-local",
|
|
Project: "repo",
|
|
Machine: "laptop",
|
|
Agent: parser.AgentCodex,
|
|
Cwd: cwd,
|
|
StartedAt: time.Now(),
|
|
},
|
|
}}, syncWriteDefault, true)
|
|
require.Equal(t, 1, written)
|
|
require.Equal(t, 0, failed)
|
|
|
|
observations, err := database.ListProjectIdentityObservations(
|
|
context.Background(), []string{"repo"},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, observations, 1)
|
|
expectedRoot, err := filepath.EvalSymlinks(root)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, expectedRoot, observations[0].RootPath)
|
|
assert.Equal(t, "origin", observations[0].GitRemoteName)
|
|
assert.Equal(t, "github.com:Org/Repo.git", observations[0].GitRemote)
|
|
assert.Equal(t, "github.com/Org/Repo", observations[0].NormalizedRemote)
|
|
assert.Equal(t, "git_remote", observations[0].KeySource)
|
|
assert.Equal(t, expectedRoot, observations[0].RepositoryPath)
|
|
assert.Equal(t, expectedRoot, observations[0].WorktreeRootPath)
|
|
assert.Equal(t, export.WorktreeMain, observations[0].WorktreeRelationship)
|
|
assert.Equal(t, export.CheckoutBranch, observations[0].CheckoutState)
|
|
assert.Equal(t, "feature/export", observations[0].GitBranch)
|
|
}
|
|
|
|
func TestProjectIdentityDiscoversLinkedWorktreeRepositoryContext(t *testing.T) {
|
|
database := openTestDB(t)
|
|
mainRoot := filepath.Join(t.TempDir(), "main")
|
|
linkedRoot := filepath.Join(t.TempDir(), "feature")
|
|
gitDir := filepath.Join(mainRoot, ".git")
|
|
linkedGitDir := filepath.Join(gitDir, "worktrees", "feature")
|
|
require.NoError(t, os.MkdirAll(linkedGitDir, 0o755))
|
|
require.NoError(t, os.MkdirAll(linkedRoot, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(gitDir, "config"), []byte(
|
|
"[remote \"origin\"]\n\turl = https://github.com/acme/app.git\n",
|
|
), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(linkedRoot, ".git"), []byte(
|
|
"gitdir: "+linkedGitDir+"\n",
|
|
), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(linkedGitDir, "commondir"),
|
|
[]byte("../..\n"), 0o644))
|
|
require.NoError(t, os.WriteFile(filepath.Join(linkedGitDir, "HEAD"),
|
|
[]byte("ref: refs/heads/feature/receipts\n"), 0o644))
|
|
|
|
e := NewEngine(database, EngineConfig{Machine: "laptop"})
|
|
written, _, failed, _ := e.writeBatch([]pendingWrite{{
|
|
sess: parser.ParsedSession{
|
|
ID: "linked-identity", Project: "app", Machine: "laptop",
|
|
Agent: parser.AgentCodex, Cwd: linkedRoot, StartedAt: time.Now(),
|
|
},
|
|
}}, syncWriteDefault, true)
|
|
require.Equal(t, 1, written)
|
|
require.Equal(t, 0, failed)
|
|
|
|
observations, err := database.ListProjectIdentityObservations(
|
|
context.Background(), []string{"app"},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, observations, 1)
|
|
expectedMainRoot, err := filepath.EvalSymlinks(mainRoot)
|
|
require.NoError(t, err)
|
|
expectedLinkedRoot, err := filepath.EvalSymlinks(linkedRoot)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, expectedMainRoot, observations[0].RepositoryPath)
|
|
assert.Equal(t, expectedLinkedRoot, observations[0].WorktreeRootPath)
|
|
assert.Equal(t, export.WorktreeLinked,
|
|
observations[0].WorktreeRelationship)
|
|
assert.Equal(t, export.CheckoutBranch, observations[0].CheckoutState)
|
|
assert.Equal(t, "feature/receipts", observations[0].GitBranch)
|
|
assert.Equal(t, "github.com/acme/app", observations[0].NormalizedRemote)
|
|
}
|
|
|
|
func TestProjectIdentityObservationWriteDeduplicatesSameEngine(t *testing.T) {
|
|
database := openTestDB(t)
|
|
root := t.TempDir()
|
|
e := NewEngine(database, EngineConfig{Machine: "laptop"})
|
|
ctx := context.Background()
|
|
session := db.Session{
|
|
ID: "identity-dedup",
|
|
Project: "dedup",
|
|
Machine: "laptop",
|
|
Agent: "codex",
|
|
Cwd: root,
|
|
StartedAt: strPtr(time.Now().UTC().Format(time.RFC3339Nano)),
|
|
}
|
|
|
|
require.NoError(t, e.writeProjectIdentityObservation(ctx, session))
|
|
observations, err := database.ListProjectIdentityObservations(ctx, []string{"dedup"})
|
|
require.NoError(t, err)
|
|
require.Len(t, observations, 1)
|
|
firstObservedAt := observations[0].ObservedAt
|
|
|
|
time.Sleep(time.Millisecond)
|
|
require.NoError(t, e.writeProjectIdentityObservation(ctx, session))
|
|
observations, err = database.ListProjectIdentityObservations(ctx, []string{"dedup"})
|
|
require.NoError(t, err)
|
|
require.Len(t, observations, 1)
|
|
assert.Equal(t, firstObservedAt, observations[0].ObservedAt)
|
|
}
|
|
|
|
func TestProjectIdentityObservationCachesLocalGitDiscovery(t *testing.T) {
|
|
database := openTestDB(t)
|
|
root := t.TempDir()
|
|
require.NoError(t, os.Mkdir(filepath.Join(root, ".git"), 0o755))
|
|
configPath := filepath.Join(root, ".git", "config")
|
|
require.NoError(t, os.WriteFile(
|
|
configPath,
|
|
[]byte("[remote \"origin\"]\n\turl = https://github.com/acme/cache.git\n"),
|
|
0o644,
|
|
))
|
|
cwd := filepath.Join(root, "subdir")
|
|
require.NoError(t, os.Mkdir(cwd, 0o755))
|
|
e := NewEngine(database, EngineConfig{Machine: "laptop"})
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, e.writeProjectIdentityObservation(ctx, db.Session{
|
|
ID: "identity-cache-a",
|
|
Project: "cache-a",
|
|
Machine: "laptop",
|
|
Agent: "codex",
|
|
Cwd: cwd,
|
|
StartedAt: strPtr(time.Now().UTC().Format(time.RFC3339Nano)),
|
|
}))
|
|
require.NoError(t, os.Remove(configPath))
|
|
require.NoError(t, e.writeProjectIdentityObservation(ctx, db.Session{
|
|
ID: "identity-cache-b",
|
|
Project: "cache-b",
|
|
Machine: "laptop",
|
|
Agent: "codex",
|
|
Cwd: cwd,
|
|
StartedAt: strPtr(time.Now().UTC().Format(time.RFC3339Nano)),
|
|
}))
|
|
|
|
observations, err := database.ListProjectIdentityObservations(ctx, []string{"cache-b"})
|
|
require.NoError(t, err)
|
|
require.Len(t, observations, 1)
|
|
assert.Equal(t, "https://github.com/acme/cache.git", observations[0].GitRemote)
|
|
assert.Equal(t, "github.com/acme/cache", observations[0].NormalizedRemote)
|
|
}
|
|
|
|
func TestProjectIdentitySnapshotsPreferParsedBranchesForSharedWorkingDirectory(
|
|
t *testing.T,
|
|
) {
|
|
database := openTestDB(t)
|
|
root := t.TempDir()
|
|
require.NoError(t, os.Mkdir(filepath.Join(root, ".git"), 0o755))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(root, ".git", "config"),
|
|
[]byte("[remote \"origin\"]\n\turl = https://github.com/acme/app.git\n"),
|
|
0o644,
|
|
))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(root, ".git", "HEAD"),
|
|
[]byte("ref: refs/heads/current-checkout\n"), 0o644,
|
|
))
|
|
e := NewEngine(database, EngineConfig{Machine: "laptop"})
|
|
now := time.Now()
|
|
|
|
written, _, failed, _ := e.writeBatch([]pendingWrite{
|
|
{sess: parser.ParsedSession{
|
|
ID: "branch-a", Project: "app", Machine: "laptop",
|
|
Agent: parser.AgentCodex, Cwd: root, GitBranch: "historical-a",
|
|
StartedAt: now,
|
|
}},
|
|
{sess: parser.ParsedSession{
|
|
ID: "branch-b", Project: "app", Machine: "laptop",
|
|
Agent: parser.AgentCodex, Cwd: root, GitBranch: "historical-b",
|
|
StartedAt: now.Add(time.Second),
|
|
}},
|
|
}, syncWriteDefault, true)
|
|
require.Equal(t, 2, written)
|
|
require.Equal(t, 0, failed)
|
|
|
|
snapshots, err := database.ListSessionProjectIdentitySnapshots(
|
|
context.Background(),
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, snapshots, 2)
|
|
assert.Equal(t, "branch-a", snapshots[0].SessionID)
|
|
assert.Equal(t, "historical-a", snapshots[0].GitBranch)
|
|
assert.Equal(t, export.CheckoutBranch, snapshots[0].CheckoutState)
|
|
assert.Equal(t, "branch-b", snapshots[1].SessionID)
|
|
assert.Equal(t, "historical-b", snapshots[1].GitBranch)
|
|
assert.Equal(t, export.CheckoutBranch, snapshots[1].CheckoutState)
|
|
}
|
|
|
|
func TestProjectIdentitySnapshotsDoNotCacheCheckoutHead(t *testing.T) {
|
|
database := openTestDB(t)
|
|
root := t.TempDir()
|
|
require.NoError(t, os.Mkdir(filepath.Join(root, ".git"), 0o755))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(root, ".git", "config"),
|
|
[]byte("[remote \"origin\"]\n\turl = https://github.com/acme/app.git\n"),
|
|
0o644,
|
|
))
|
|
headPath := filepath.Join(root, ".git", "HEAD")
|
|
require.NoError(t, os.WriteFile(
|
|
headPath, []byte("ref: refs/heads/branch-a\n"), 0o644,
|
|
))
|
|
e := NewEngine(database, EngineConfig{Machine: "laptop"})
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, e.writeProjectIdentityObservation(ctx, db.Session{
|
|
ID: "head-a", Project: "app-a", Machine: "laptop",
|
|
Agent: "codex", Cwd: root,
|
|
}))
|
|
require.NoError(t, os.WriteFile(
|
|
headPath, []byte("ref: refs/heads/branch-b\n"), 0o644,
|
|
))
|
|
require.NoError(t, e.writeProjectIdentityObservation(ctx, db.Session{
|
|
ID: "head-b", Project: "app-b", Machine: "laptop",
|
|
Agent: "codex", Cwd: root,
|
|
}))
|
|
|
|
observations, err := database.ListProjectIdentityObservations(
|
|
ctx, []string{"app-a", "app-b"},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, observations, 2)
|
|
branches := map[string]string{}
|
|
for _, observation := range observations {
|
|
branches[observation.Project] = observation.GitBranch
|
|
}
|
|
assert.Equal(t, "branch-a", branches["app-a"])
|
|
assert.Equal(t, "branch-b", branches["app-b"])
|
|
}
|
|
|
|
func TestProjectIdentityBackfillPreservesEvidenceAcrossSchemaUpgrade(
|
|
t *testing.T,
|
|
) {
|
|
dir := t.TempDir()
|
|
dbPath := filepath.Join(dir, "sessions.db")
|
|
repo := filepath.Join(dir, "repo")
|
|
require.NoError(t, os.MkdirAll(filepath.Join(repo, ".git"), 0o755))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(repo, ".git", "config"),
|
|
[]byte("[remote \"origin\"]\n\turl = https://github.com/kenn-io/agentsview.git\n"),
|
|
0o644,
|
|
))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(repo, ".git", "HEAD"),
|
|
[]byte("ref: refs/heads/current-checkout\n"), 0o644,
|
|
))
|
|
|
|
database, err := db.Open(dbPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, database.UpsertSession(db.Session{
|
|
ID: "legacy-evidence", Project: "agentsview", Machine: "laptop",
|
|
Agent: "codex", Cwd: repo, GitBranch: "historical-branch",
|
|
MessageCount: 2,
|
|
}))
|
|
require.NoError(t, database.Close())
|
|
|
|
raw, err := sql.Open("sqlite3", dbPath)
|
|
require.NoError(t, err)
|
|
_, err = raw.Exec(`
|
|
DROP TABLE session_project_identity_snapshots;
|
|
DROP TABLE background_migrations;
|
|
`)
|
|
require.NoError(t, err)
|
|
require.NoError(t, raw.Close())
|
|
require.NoError(t, db.UpgradeExportSchemaInPlace(dbPath,
|
|
&db.SchemaUpgradeRequiredError{
|
|
Table: "session_project_identity_snapshots", Column: "session_id",
|
|
}))
|
|
|
|
database, err = db.Open(dbPath)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, database.Close()) })
|
|
engine := NewEngine(database, EngineConfig{Machine: "laptop"})
|
|
require.NoError(t, engine.BackfillProjectIdentitySnapshots(
|
|
context.Background()))
|
|
|
|
snapshots, err := database.ListSessionProjectIdentitySnapshots(
|
|
context.Background())
|
|
require.NoError(t, err)
|
|
require.Len(t, snapshots, 1)
|
|
canonicalRepo, err := filepath.EvalSymlinks(repo)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "legacy-evidence", snapshots[0].SessionID)
|
|
assert.Equal(t, "github.com/kenn-io/agentsview",
|
|
snapshots[0].NormalizedRemote)
|
|
assert.Equal(t, canonicalRepo, snapshots[0].WorktreeRootPath)
|
|
assert.Equal(t, "historical-branch", snapshots[0].GitBranch)
|
|
assert.Equal(t, export.CheckoutBranch, snapshots[0].CheckoutState)
|
|
|
|
status, err := database.ProjectIdentityBackfillStatus(context.Background())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "completed", status.State)
|
|
assert.Equal(t, 1, status.TotalItems)
|
|
assert.Equal(t, 1, status.CompletedItems)
|
|
}
|
|
|
|
func TestCountNormalizedRemoteCandidatesDeduplicatesAliases(t *testing.T) {
|
|
assert.Equal(t, 2, countNormalizedRemoteCandidates(map[string]string{
|
|
"origin": "git@example.com:acme/app.git",
|
|
"mirror": "https://example.com/acme/app.git",
|
|
"upstream": "https://example.com/acme/upstream.git",
|
|
"local": "/tmp/app.git",
|
|
}))
|
|
}
|
|
|
|
// TestProjectIdentityObservationSkipsDiscoveryForRemoteMachine pins that
|
|
// local git discovery never probes the filesystem for a session recorded on
|
|
// another machine: the cwd names a path on that machine, so resolving it
|
|
// locally is wrong (and on macOS, stat'ing a remote /home/... cwd wakes the
|
|
// automounter — see cachedProjectIdentity). The cwd here is a real local git
|
|
// repo, so if discovery ran anyway the observation would carry its remote.
|
|
func TestProjectIdentityObservationSkipsDiscoveryForRemoteMachine(t *testing.T) {
|
|
database := openTestDB(t)
|
|
root := t.TempDir()
|
|
require.NoError(t, os.Mkdir(filepath.Join(root, ".git"), 0o755))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(root, ".git", "config"),
|
|
[]byte("[remote \"origin\"]\n\turl = https://github.com/acme/remote.git\n"),
|
|
0o644,
|
|
))
|
|
cwd := filepath.Join(root, "subdir")
|
|
require.NoError(t, os.Mkdir(cwd, 0o755))
|
|
e := NewEngine(database, EngineConfig{Machine: "laptop"})
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, e.writeProjectIdentityObservation(ctx, db.Session{
|
|
ID: "identity-remote-machine",
|
|
Project: "remote-proj",
|
|
Machine: "remote-linux",
|
|
Agent: "codex",
|
|
Cwd: cwd,
|
|
StartedAt: strPtr(time.Now().UTC().Format(time.RFC3339Nano)),
|
|
}))
|
|
|
|
observations, err := database.ListProjectIdentityObservations(
|
|
ctx, []string{"remote-proj"},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, observations, 1)
|
|
assert.Empty(t, observations[0].GitRemote,
|
|
"foreign-machine cwd must not be probed for a local git identity")
|
|
assert.Equal(t, cwd, observations[0].RootPath,
|
|
"root path must stay the raw cwd, not a locally resolved git root")
|
|
}
|
|
|
|
func TestProjectIdentitySafeLocalAbsolutePathHandlesWindowsDriveRootsByOS(t *testing.T) {
|
|
wantWindowsDriveLocal := runtime.GOOS == "windows"
|
|
assert.Equal(t, wantWindowsDriveLocal, safeLocalAbsolutePath(`C:\repo`))
|
|
assert.Equal(t, wantWindowsDriveLocal, safeLocalAbsolutePath("C:/repo"))
|
|
assert.False(t, safeLocalAbsolutePath("host:/srv/repo"))
|
|
}
|
|
|
|
func TestProjectIdentityWriteBatchRejectsNonNativeWindowsDriveGitRemote(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("non-Windows guard does not apply on Windows")
|
|
}
|
|
database := openTestDB(t)
|
|
base := t.TempDir()
|
|
t.Chdir(base)
|
|
root := "C:/repo"
|
|
require.NoError(t, os.MkdirAll(filepath.Join(root, ".git"), 0o755))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(root, ".git", "config"),
|
|
[]byte("[remote \"origin\"]\n\turl = https://github.com/acme/windows.git\n"),
|
|
0o644,
|
|
))
|
|
cwd := root + "/subdir"
|
|
require.NoError(t, os.MkdirAll(cwd, 0o755))
|
|
|
|
e := NewEngine(database, EngineConfig{Machine: "windows-host"})
|
|
written, _, failed, _ := e.writeBatch([]pendingWrite{{
|
|
sess: parser.ParsedSession{
|
|
ID: "identity-windows",
|
|
Project: "windows",
|
|
Machine: "windows-host",
|
|
Agent: parser.AgentCodex,
|
|
Cwd: cwd,
|
|
StartedAt: time.Now(),
|
|
},
|
|
}}, syncWriteDefault, true)
|
|
require.Equal(t, 1, written)
|
|
require.Equal(t, 0, failed)
|
|
|
|
observations, err := database.ListProjectIdentityObservations(
|
|
context.Background(), []string{"windows"},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, observations, 1)
|
|
assert.Equal(t, "C:/repo/subdir", observations[0].RootPath)
|
|
assert.Empty(t, observations[0].GitRemoteName)
|
|
assert.Empty(t, observations[0].GitRemote)
|
|
assert.Empty(t, observations[0].NormalizedRemote)
|
|
assert.Equal(t, "root_path", observations[0].KeySource)
|
|
assert.NotEmpty(t, observations[0].Key)
|
|
}
|
|
|
|
func TestProjectIdentityRemoteWriteSkipsLiveDiscovery(t *testing.T) {
|
|
database := openTestDB(t)
|
|
e := NewEngine(database, EngineConfig{
|
|
Machine: "remote-host",
|
|
IDPrefix: "remote-host~",
|
|
PathRewriter: func(path string) string {
|
|
return "remote-host:" + path
|
|
},
|
|
})
|
|
written, _, failed, _ := e.writeBatch([]pendingWrite{{
|
|
sess: parser.ParsedSession{
|
|
ID: "identity-remote",
|
|
Project: "remote-project",
|
|
Machine: "remote-host",
|
|
Agent: parser.AgentCodex,
|
|
Cwd: "remote-host:/srv/app",
|
|
StartedAt: time.Now(),
|
|
},
|
|
}}, syncWriteDefault, true)
|
|
require.Equal(t, 1, written)
|
|
require.Equal(t, 0, failed)
|
|
|
|
observations, err := database.ListProjectIdentityObservations(
|
|
context.Background(), []string{"remote-project"},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, observations, 1)
|
|
assert.Equal(t, "remote-host:/srv/app", observations[0].RootPath)
|
|
assert.Empty(t, observations[0].GitRemote)
|
|
assert.Empty(t, observations[0].NormalizedRemote)
|
|
assert.Empty(t, observations[0].Key)
|
|
}
|
|
|
|
func TestProjectIdentityIncrementalAppendPersistsObservation(t *testing.T) {
|
|
database := openTestDB(t)
|
|
root := t.TempDir()
|
|
require.NoError(t, os.Mkdir(filepath.Join(root, ".git"), 0o755))
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(root, ".git", "config"),
|
|
[]byte("[remote \"origin\"]\n\turl = https://github.com/acme/inc.git\n"),
|
|
0o644,
|
|
))
|
|
|
|
e := NewEngine(database, EngineConfig{Machine: "laptop"})
|
|
start := time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)
|
|
require.NoError(t, database.UpsertSession(db.Session{
|
|
ID: "inc-identity",
|
|
Project: "inc",
|
|
Machine: "laptop",
|
|
Agent: "codex",
|
|
Cwd: root,
|
|
StartedAt: strPtr(start.Format(time.RFC3339Nano)),
|
|
MessageCount: 1,
|
|
}))
|
|
|
|
err := e.writeIncremental(&incrementalUpdate{
|
|
sessionID: "inc-identity",
|
|
project: "inc",
|
|
machine: "laptop",
|
|
cwd: root,
|
|
msgs: []parser.ParsedMessage{{
|
|
Role: parser.RoleAssistant,
|
|
Content: "delta",
|
|
Timestamp: start.Add(time.Minute),
|
|
Ordinal: 1,
|
|
}},
|
|
msgCount: 2,
|
|
userMsgCount: 1,
|
|
fileSize: 100,
|
|
fileMtime: start.Add(time.Minute).UnixNano(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
observations, err := database.ListProjectIdentityObservations(
|
|
context.Background(), []string{"inc"},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, observations, 1)
|
|
assert.Equal(t, "https://github.com/acme/inc.git", observations[0].GitRemote)
|
|
assert.Equal(t, "github.com/acme/inc", observations[0].NormalizedRemote)
|
|
}
|
|
|
|
func TestProjectIdentityIncrementalRemoteAppendSkipsLiveDiscovery(t *testing.T) {
|
|
database := openTestDB(t)
|
|
e := NewEngine(database, EngineConfig{
|
|
Machine: "remote-host",
|
|
IDPrefix: "remote-host~",
|
|
PathRewriter: func(path string) string {
|
|
return "remote-host:" + path
|
|
},
|
|
})
|
|
require.NoError(t, database.UpsertSession(db.Session{
|
|
ID: "remote-host~inc-remote-identity",
|
|
Project: "remote-inc",
|
|
Machine: "remote-host",
|
|
Agent: "codex",
|
|
Cwd: "remote-host:/srv/app",
|
|
MessageCount: 1,
|
|
}))
|
|
|
|
err := e.writeIncremental(&incrementalUpdate{
|
|
sessionID: "remote-host~inc-remote-identity",
|
|
project: "remote-inc",
|
|
machine: "remote-host",
|
|
cwd: "remote-host:/srv/app",
|
|
msgs: []parser.ParsedMessage{{
|
|
Role: parser.RoleAssistant,
|
|
Content: "delta",
|
|
Ordinal: 1,
|
|
}},
|
|
msgCount: 2,
|
|
userMsgCount: 1,
|
|
fileSize: 100,
|
|
fileMtime: time.Now().UnixNano(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
observations, err := database.ListProjectIdentityObservations(
|
|
context.Background(), []string{"remote-inc"},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, observations, 1)
|
|
assert.Equal(t, "remote-host:/srv/app", observations[0].RootPath)
|
|
assert.Empty(t, observations[0].GitRemote)
|
|
assert.Empty(t, observations[0].Key)
|
|
}
|
|
|
|
// TestWriteBatchAntigravityReplacesMessages covers a live Antigravity
|
|
// IDE session synced before its gen_metadata rows exist: the next sync
|
|
// re-parses the same ordinals with model/token metadata attached, and
|
|
// that enrichment must reach the stored message rows rather than being
|
|
// dropped by the append-only write path.
|
|
func TestWriteBatchAntigravityReplacesMessages(t *testing.T) {
|
|
database := openTestDB(t)
|
|
e := &Engine{db: database}
|
|
|
|
ts := time.Unix(1700000000, 0).UTC()
|
|
mkWrite := func(withMeta bool) pendingWrite {
|
|
msg := parser.ParsedMessage{
|
|
Role: parser.RoleAssistant,
|
|
Content: "assistant reply",
|
|
Timestamp: ts,
|
|
}
|
|
if withMeta {
|
|
msg.Model = "Test Gemini 3.5"
|
|
msg.ContextTokens = 2400
|
|
msg.OutputTokens = 210
|
|
msg.HasContextTokens = true
|
|
msg.HasOutputTokens = true
|
|
}
|
|
return pendingWrite{
|
|
sess: parser.ParsedSession{
|
|
ID: "antigravity:meta",
|
|
Project: "proj",
|
|
Machine: "m",
|
|
Agent: parser.AgentAntigravity,
|
|
StartedAt: ts,
|
|
EndedAt: ts,
|
|
MessageCount: 1,
|
|
},
|
|
msgs: []parser.ParsedMessage{msg},
|
|
}
|
|
}
|
|
|
|
written, _, failed, _ := e.writeBatch(
|
|
[]pendingWrite{mkWrite(false)}, syncWriteDefault, false,
|
|
)
|
|
require.Equal(t, 0, failed)
|
|
require.Equal(t, 1, written)
|
|
|
|
written, _, failed, _ = e.writeBatch(
|
|
[]pendingWrite{mkWrite(true)}, syncWriteDefault, false,
|
|
)
|
|
require.Equal(t, 0, failed)
|
|
require.Equal(t, 1, written)
|
|
|
|
msgs, err := database.GetMessages(
|
|
context.Background(), "antigravity:meta", 0, 10, true,
|
|
)
|
|
require.NoError(t, err, "GetMessages")
|
|
require.Len(t, msgs, 1)
|
|
assert.Equal(t, "Test Gemini 3.5", msgs[0].Model,
|
|
"re-parsed model metadata must reach existing message rows")
|
|
}
|
|
|
|
// TestWriteBatchQwenPawReplacesMessages covers a QwenPaw session file
|
|
// being rewritten wholesale on every save. QwenPaw's
|
|
// _atomic_write_json rewrites the entire sessions/<name>.json on each
|
|
// save, and the parser assigns Ordinal by position in
|
|
// agent.memory.content. If that array is ever compacted, summarized,
|
|
// or reordered — common in agent-memory frameworks — ordinals shift,
|
|
// and the append-only writeMessages path would silently keep stale
|
|
// rows. The session must go through the replace path so a rewrite is
|
|
// applied as a delete+insert, not an ordinal-greater-than append.
|
|
func TestWriteBatchQwenPawReplacesMessages(t *testing.T) {
|
|
database := openTestDB(t)
|
|
e := &Engine{db: database}
|
|
|
|
ts := time.Unix(1700000000, 0).UTC()
|
|
mkWrite := func(content string) pendingWrite {
|
|
msg := parser.ParsedMessage{
|
|
Ordinal: 0,
|
|
Role: parser.RoleAssistant,
|
|
Content: content,
|
|
Timestamp: ts,
|
|
}
|
|
return pendingWrite{
|
|
sess: parser.ParsedSession{
|
|
ID: "qwenpaw:default:rewrite",
|
|
Project: "default",
|
|
Machine: "m",
|
|
Agent: parser.AgentQwenPaw,
|
|
StartedAt: ts,
|
|
EndedAt: ts,
|
|
MessageCount: 1,
|
|
},
|
|
msgs: []parser.ParsedMessage{msg},
|
|
}
|
|
}
|
|
|
|
written, _, failed, _ := e.writeBatch(
|
|
[]pendingWrite{mkWrite("old content")}, syncWriteDefault, false,
|
|
)
|
|
require.Equal(t, 0, failed)
|
|
require.Equal(t, 1, written)
|
|
|
|
written, _, failed, _ = e.writeBatch(
|
|
[]pendingWrite{mkWrite("new content")}, syncWriteDefault, false,
|
|
)
|
|
require.Equal(t, 0, failed)
|
|
require.Equal(t, 1, written)
|
|
|
|
msgs, err := database.GetMessages(
|
|
context.Background(), "qwenpaw:default:rewrite", 0, 10, true,
|
|
)
|
|
require.NoError(t, err, "GetMessages")
|
|
require.Len(t, msgs, 1, "rewrite must replace, not append")
|
|
assert.Equal(t, "new content", msgs[0].Content,
|
|
"rewritten content must reach existing message rows")
|
|
}
|
|
|
|
// TestSyncSingleSession_QwenPawPreservesWorkspaceFromDB covers the
|
|
// case where a QwenPaw session's stored DB file_path points outside
|
|
// any currently configured QWENPAW_DIR (e.g. the root was removed or
|
|
// the session was synced from a custom path). FindSourceFile still
|
|
// returns the stored path, and the provider must resolve the workspace
|
|
// from that path's implicit <root>/<workspace>/sessions/ layout rather
|
|
// than emitting a brand-new qwenpaw::<stem> session that orphans the
|
|
// requested qwenpaw:<workspace>:<stem> row.
|
|
func TestSyncSingleSession_QwenPawPreservesWorkspaceFromDB(t *testing.T) {
|
|
database := openTestDB(t)
|
|
|
|
// File at an arbitrary path NOT under any configured QWENPAW_DIR.
|
|
root := t.TempDir()
|
|
sessDir := filepath.Join(root, "my_ws", "sessions")
|
|
require.NoError(t, os.MkdirAll(sessDir, 0o755))
|
|
path := filepath.Join(sessDir, "default_1.json")
|
|
require.NoError(t, os.WriteFile(path, []byte(
|
|
`{"agent":{"memory":{"content":[[`+
|
|
`{"id":"u1","name":"user","role":"user","content":[{"type":"text","text":"hi"}],"metadata":{},"timestamp":"2026-04-19 22:37:34.000"},[]`+
|
|
`]]}}}`), 0o644))
|
|
|
|
// Engine configured with QWENPAW_DIR pointing somewhere else
|
|
// entirely, so the configured-root loop cannot match.
|
|
otherDir := t.TempDir()
|
|
e := NewEngine(database, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentQwenPaw: {otherDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
// Seed the DB with the canonical session row. file_path is the
|
|
// stored source of truth that FindSourceFile prefers.
|
|
const sessionID = "qwenpaw:my_ws:default_1"
|
|
fp := path
|
|
require.NoError(t, database.UpsertSession(db.Session{
|
|
ID: sessionID,
|
|
Project: "my_ws",
|
|
Machine: "local",
|
|
Agent: "qwenpaw",
|
|
FilePath: &fp,
|
|
}))
|
|
|
|
require.NoError(t, e.SyncSingleSession(sessionID))
|
|
|
|
got, err := database.GetSession(context.Background(), sessionID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got, "original session must still exist")
|
|
assert.Equal(t, "my_ws", got.Project,
|
|
"workspace must be preserved when the file is outside configured roots")
|
|
|
|
// No empty-workspace orphan should have been written.
|
|
orphan, err := database.GetSession(
|
|
context.Background(), "qwenpaw::default_1",
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, orphan,
|
|
"no empty-workspace orphan session should be created")
|
|
}
|
|
|
|
// TestProcessAntigravityWALOnlyUpdateNotSkipped covers a live IDE
|
|
// session whose gen_metadata commits land in the SQLite WAL: the main
|
|
// .db file's size/mtime are unchanged, so the skip check must consult
|
|
// the sidecar set or the session never reparses.
|
|
func TestProcessAntigravityWALOnlyUpdateNotSkipped(t *testing.T) {
|
|
database := openTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
root := t.TempDir()
|
|
e := NewEngine(database, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentAntigravity: {root},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
convDir := filepath.Join(root, "conversations")
|
|
require.NoError(t, os.MkdirAll(convDir, 0o755))
|
|
dbPath := filepath.Join(
|
|
convDir, "abcdabcd-1111-2222-3333-444455556666.db",
|
|
)
|
|
sqlDB, err := sql.Open("sqlite3", dbPath)
|
|
require.NoError(t, err)
|
|
_, err = sqlDB.Exec(
|
|
`CREATE TABLE steps (idx integer, step_type integer, ` +
|
|
`step_payload blob, PRIMARY KEY (idx))`,
|
|
)
|
|
require.NoError(t, err)
|
|
require.NoError(t, sqlDB.Close())
|
|
|
|
// The Antigravity provider is provider-authoritative, so processFile
|
|
// routes through processProviderFile. The provider resolves the source,
|
|
// fingerprints (folding the WAL/SHM and sidecar set into the freshness
|
|
// identity), and parses.
|
|
file := parser.DiscoveredFile{
|
|
Agent: parser.AgentAntigravity,
|
|
Path: dbPath,
|
|
}
|
|
|
|
res := e.processFile(ctx, file)
|
|
require.NoError(t, res.err)
|
|
require.False(t, res.skip)
|
|
require.Len(t, res.results, 1)
|
|
|
|
pw := pendingWrite{
|
|
sess: res.results[0].Session,
|
|
msgs: res.results[0].Messages,
|
|
usageEvents: res.results[0].UsageEvents,
|
|
forceReplace: res.forceReplace,
|
|
}
|
|
written, _, failed, _ := e.writeBatch(
|
|
[]pendingWrite{pw}, syncWriteDefault, false,
|
|
)
|
|
require.Equal(t, 0, failed)
|
|
require.Equal(t, 1, written)
|
|
// Record the skip-cache entry the collectAndBatch flow would write so the
|
|
// next unchanged processFile sees a cached, current fingerprint.
|
|
if res.cacheSkip && res.mtime != 0 && !res.noCacheSkip {
|
|
e.cacheSkip(res.skipCacheKey(file.Path), res.mtime)
|
|
}
|
|
|
|
res = e.processFile(ctx, file)
|
|
require.True(t, res.skip, "unchanged session should skip")
|
|
|
|
// WAL-only update: the main .db is untouched.
|
|
walPath := dbPath + "-wal"
|
|
require.NoError(t, os.WriteFile(walPath, []byte("wal bytes"), 0o644))
|
|
info, err := os.Stat(dbPath)
|
|
require.NoError(t, err)
|
|
walTime := info.ModTime().Add(5 * time.Second)
|
|
require.NoError(t, os.Chtimes(walPath, walTime, walTime))
|
|
|
|
res = e.processFile(ctx, file)
|
|
assert.False(t, res.skip, "WAL-only update must trigger a reparse")
|
|
}
|
|
|
|
func TestProcessVibeMetaOnlyUpdateNotSkipped(t *testing.T) {
|
|
database := openTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
root := t.TempDir()
|
|
e := NewEngine(database, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentVibe: {root},
|
|
},
|
|
})
|
|
|
|
sessionDir := filepath.Join(root, "session_20260616_083518_0107f266")
|
|
require.NoError(t, os.MkdirAll(sessionDir, 0o755))
|
|
|
|
msgPath := filepath.Join(sessionDir, "messages.jsonl")
|
|
require.NoError(t, os.WriteFile(
|
|
msgPath,
|
|
[]byte(`{"role":"user","content":"hi"}`+"\n"),
|
|
0o644,
|
|
))
|
|
|
|
metaPath := filepath.Join(sessionDir, "meta.json")
|
|
require.NoError(t, os.WriteFile(
|
|
metaPath,
|
|
[]byte(`{"session_id":"abc","title":"Original title"}`+"\n"),
|
|
0o644,
|
|
))
|
|
|
|
canonicalID := "vibe:abc"
|
|
|
|
e.SyncPaths([]string{msgPath})
|
|
sess, err := database.GetSession(ctx, canonicalID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
require.NotNil(t, sess.DisplayName)
|
|
assert.Equal(t, "Original title", *sess.DisplayName)
|
|
|
|
// meta.json-only update: messages.jsonl is untouched, but the title
|
|
// (sourced from meta.json) changes. The Vibe provider's composite
|
|
// fingerprint folds the sibling meta.json mtime in, so the change busts
|
|
// the skip cache and triggers a reparse rather than a skip.
|
|
info, err := os.Stat(msgPath)
|
|
require.NoError(t, err)
|
|
metaTime := info.ModTime().Add(5 * time.Second)
|
|
require.NoError(t, os.WriteFile(
|
|
metaPath,
|
|
[]byte(`{"session_id":"abc","title":"Renamed title"}`+"\n"),
|
|
0o644,
|
|
))
|
|
require.NoError(t, os.Chtimes(metaPath, metaTime, metaTime))
|
|
|
|
e.SyncPaths([]string{msgPath})
|
|
sess, err = database.GetSession(ctx, canonicalID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
require.NotNil(t, sess.DisplayName)
|
|
assert.Equal(t, "Renamed title", *sess.DisplayName)
|
|
}
|
|
|
|
func TestProcessAntigravityBrainOnlyUpdateNotSkipped(t *testing.T) {
|
|
database := openTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
root := t.TempDir()
|
|
e := NewEngine(database, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentAntigravity: {root},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
convDir := filepath.Join(root, "conversations")
|
|
require.NoError(t, os.MkdirAll(convDir, 0o755))
|
|
id := "abcdabcd-1111-2222-3333-444455557777"
|
|
dbPath := filepath.Join(convDir, id+".db")
|
|
sqlDB, err := sql.Open("sqlite3", dbPath)
|
|
require.NoError(t, err)
|
|
_, err = sqlDB.Exec(
|
|
`CREATE TABLE steps (idx integer, step_type integer, ` +
|
|
`step_payload blob, PRIMARY KEY (idx))`,
|
|
)
|
|
require.NoError(t, err)
|
|
require.NoError(t, sqlDB.Close())
|
|
|
|
// Provider-authoritative: the provider Fingerprint folds the brain
|
|
// artifacts into the freshness identity, so a brain-only change busts the
|
|
// skip cache and triggers a reparse.
|
|
file := parser.DiscoveredFile{
|
|
Agent: parser.AgentAntigravity,
|
|
Path: dbPath,
|
|
}
|
|
|
|
res := e.processFile(ctx, file)
|
|
require.NoError(t, res.err)
|
|
require.False(t, res.skip)
|
|
require.Len(t, res.results, 1)
|
|
|
|
pw := pendingWrite{
|
|
sess: res.results[0].Session,
|
|
msgs: res.results[0].Messages,
|
|
usageEvents: res.results[0].UsageEvents,
|
|
forceReplace: res.forceReplace,
|
|
}
|
|
written, _, failed, _ := e.writeBatch(
|
|
[]pendingWrite{pw}, syncWriteDefault, false,
|
|
)
|
|
require.Equal(t, 0, failed)
|
|
require.Equal(t, 1, written)
|
|
if res.cacheSkip && res.mtime != 0 && !res.noCacheSkip {
|
|
e.cacheSkip(res.skipCacheKey(file.Path), res.mtime)
|
|
}
|
|
|
|
res = e.processFile(ctx, file)
|
|
require.True(t, res.skip, "unchanged session should skip")
|
|
|
|
// Brain-only update: the conversation DB files are untouched.
|
|
brainDir := filepath.Join(root, "brain", id)
|
|
require.NoError(t, os.MkdirAll(brainDir, 0o755))
|
|
brainPath := filepath.Join(brainDir, "task.md")
|
|
require.NoError(t, os.WriteFile(
|
|
brainPath, []byte("brain artifact body"), 0o644,
|
|
))
|
|
info, err := os.Stat(dbPath)
|
|
require.NoError(t, err)
|
|
brainTime := info.ModTime().Add(5 * time.Second)
|
|
require.NoError(t, os.Chtimes(brainPath, brainTime, brainTime))
|
|
|
|
res = e.processFile(ctx, file)
|
|
require.False(t, res.skip,
|
|
"brain-only update must trigger a reparse")
|
|
require.Len(t, res.results, 1)
|
|
var found bool
|
|
for _, m := range res.results[0].Messages {
|
|
if strings.Contains(m.Content, "brain artifact body") {
|
|
found = true
|
|
}
|
|
}
|
|
assert.True(t, found,
|
|
"reparse must pick up the brain artifact message")
|
|
}
|
|
|
|
func TestShouldSkipFileWithIDPrefix(t *testing.T) {
|
|
database := openTestDB(t)
|
|
|
|
// Store a session with prefixed ID and file metadata.
|
|
sess := db.Session{
|
|
ID: "host~abc-123",
|
|
Project: "test",
|
|
Machine: "host",
|
|
Agent: "claude",
|
|
FilePath: strPtr("host:/remote/session.jsonl"),
|
|
FileSize: int64Ptr(1024),
|
|
FileMtime: int64Ptr(
|
|
int64(1700000000000000000),
|
|
),
|
|
}
|
|
require.NoError(t, database.UpsertSession(sess))
|
|
// data_version is no longer persisted by UpsertSession;
|
|
// stamp it explicitly so the skip check sees a current
|
|
// row.
|
|
require.NoError(t, database.SetSessionDataVersion(
|
|
sess.ID, db.CurrentDataVersion(),
|
|
))
|
|
|
|
// Engine with IDPrefix should find the session.
|
|
e := &Engine{
|
|
db: database,
|
|
idPrefix: "host~",
|
|
}
|
|
got := e.shouldSkipFile(
|
|
"abc-123",
|
|
fakeFileInfo{size: 1024, mtime: 1700000000000000000},
|
|
)
|
|
assert.True(t, got, "shouldSkipFile should return true")
|
|
|
|
// Engine WITHOUT IDPrefix should NOT find it.
|
|
e2 := &Engine{db: database}
|
|
got2 := e2.shouldSkipFile(
|
|
"abc-123",
|
|
fakeFileInfo{size: 1024, mtime: 1700000000000000000},
|
|
)
|
|
assert.False(t, got2, "shouldSkipFile without prefix should return false")
|
|
}
|
|
|
|
func TestShouldSkipCodexReparsesStaleProject(t *testing.T) {
|
|
database := openTestDB(t)
|
|
path := filepath.Join(t.TempDir(), "rollout-2026-06-21T18-59-38-abc.jsonl")
|
|
require.NoError(t, os.WriteFile(path, []byte("{}\n"), 0o600))
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err, "stat codex fixture")
|
|
|
|
sess := db.Session{
|
|
ID: "host~codex:abc",
|
|
Project: "roborev_ci_28293_3831737461",
|
|
Machine: "host",
|
|
Agent: "codex",
|
|
FilePath: strPtr("host:" + path),
|
|
FileSize: int64Ptr(info.Size()),
|
|
FileMtime: int64Ptr(info.ModTime().UnixNano()),
|
|
}
|
|
require.NoError(t, database.UpsertSession(sess))
|
|
require.NoError(t, database.SetSessionDataVersion(
|
|
sess.ID, db.CurrentDataVersion(),
|
|
))
|
|
|
|
e := &Engine{
|
|
db: database,
|
|
idPrefix: "host~",
|
|
pathRewriter: func(path string) string {
|
|
return "host:" + path
|
|
},
|
|
}
|
|
|
|
assert.False(t, e.shouldSkipCodexFingerprint(path, parser.SourceFingerprint{
|
|
Size: info.Size(),
|
|
MTimeNS: info.ModTime().UnixNano(),
|
|
}), "stale generated roborev CI projects must be reparsed")
|
|
}
|
|
|
|
func TestProcessFileSkipCacheReparsesStaleCodexProject(t *testing.T) {
|
|
database := openTestDB(t)
|
|
root := t.TempDir()
|
|
path := filepath.Join(root, "rollout-2026-06-21T18-59-38-abc.jsonl")
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
"abc",
|
|
"/home/roborev/.roborev/ci-worktrees/agentsview/roborev-ci-28293-3831737461",
|
|
"user",
|
|
"2024-01-01T10:00:00Z",
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "review this", "2024-01-01T10:00:01Z"),
|
|
)
|
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err, "stat codex fixture")
|
|
|
|
sess := db.Session{
|
|
ID: "host~codex:abc",
|
|
Project: "roborev_ci_28293_3831737461",
|
|
Machine: "host",
|
|
Agent: "codex",
|
|
FilePath: strPtr("host:" + path),
|
|
FileSize: int64Ptr(info.Size()),
|
|
FileMtime: int64Ptr(info.ModTime().UnixNano()),
|
|
}
|
|
require.NoError(t, database.UpsertSession(sess))
|
|
require.NoError(t, database.SetSessionDataVersion(
|
|
sess.ID, db.CurrentDataVersion(),
|
|
))
|
|
|
|
e := &Engine{
|
|
db: database,
|
|
idPrefix: "host~",
|
|
skipCache: map[string]int64{path: info.ModTime().UnixNano()},
|
|
agentDirs: map[parser.AgentType][]string{
|
|
parser.AgentCodex: {root},
|
|
},
|
|
providerFactories: providerFactoryMap(parser.ProviderFactories()),
|
|
providerMigrationModes: map[parser.AgentType]parser.ProviderMigrationMode{
|
|
parser.AgentCodex: parser.ProviderMigrationProviderAuthoritative,
|
|
},
|
|
pathRewriter: func(path string) string {
|
|
return "host:" + path
|
|
},
|
|
}
|
|
|
|
res := e.processFile(context.Background(), parser.DiscoveredFile{
|
|
Agent: parser.AgentCodex,
|
|
Path: path,
|
|
})
|
|
require.NoError(t, res.err)
|
|
require.False(t, res.skip,
|
|
"remote skip cache must not hide stale generated roborev CI projects")
|
|
require.Len(t, res.results, 1)
|
|
assert.Equal(t, "agentsview", res.results[0].Session.Project)
|
|
}
|
|
|
|
func TestProcessFileSkipCacheReparsesStaleCodexDataVersion(t *testing.T) {
|
|
database := openTestDB(t)
|
|
root := t.TempDir()
|
|
path := filepath.Join(root, "rollout-2026-06-21T18-59-38-abc.jsonl")
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
"abc",
|
|
"/home/user/code/agentsview",
|
|
"user",
|
|
"2024-01-01T10:00:00Z",
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "review this", "2024-01-01T10:00:01Z"),
|
|
)
|
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err, "stat codex fixture")
|
|
|
|
sess := db.Session{
|
|
ID: "host~codex:abc",
|
|
Project: "agentsview",
|
|
Machine: "host",
|
|
Agent: "codex",
|
|
FilePath: strPtr("host:" + path),
|
|
FileSize: int64Ptr(info.Size()),
|
|
FileMtime: int64Ptr(info.ModTime().UnixNano()),
|
|
}
|
|
require.NoError(t, database.UpsertSession(sess))
|
|
require.NoError(t, database.SetSessionDataVersion(
|
|
sess.ID, db.CurrentDataVersion()-1,
|
|
))
|
|
|
|
e := &Engine{
|
|
db: database,
|
|
idPrefix: "host~",
|
|
skipCache: map[string]int64{path: info.ModTime().UnixNano()},
|
|
agentDirs: map[parser.AgentType][]string{
|
|
parser.AgentCodex: {root},
|
|
},
|
|
providerFactories: providerFactoryMap(parser.ProviderFactories()),
|
|
providerMigrationModes: map[parser.AgentType]parser.ProviderMigrationMode{
|
|
parser.AgentCodex: parser.ProviderMigrationProviderAuthoritative,
|
|
},
|
|
pathRewriter: func(path string) string {
|
|
return "host:" + path
|
|
},
|
|
}
|
|
|
|
res := e.processFile(context.Background(), parser.DiscoveredFile{
|
|
Agent: parser.AgentCodex,
|
|
Path: path,
|
|
})
|
|
require.NoError(t, res.err)
|
|
require.False(t, res.skip,
|
|
"skip cache must not hide stale parser data versions")
|
|
require.Len(t, res.results, 1)
|
|
}
|
|
|
|
func TestSyncPathsCodexCachedFingerprintStillRefreshesChangedTitle(t *testing.T) {
|
|
database := openTestDB(t)
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
dayDir := filepath.Join(codexDir, "2024", "01", "01")
|
|
require.NoError(t, os.MkdirAll(dayDir, 0o755))
|
|
|
|
const uuid = "019eb791-cf7d-75c1-8439-9ed74c1229ed"
|
|
path := filepath.Join(
|
|
dayDir, "rollout-2024-01-01T10-00-00-"+uuid+".jsonl",
|
|
)
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/home/user/code/agentsview", "codex_cli_rs",
|
|
"2024-01-01T10:00:00Z",
|
|
),
|
|
testjsonl.CodexMsgJSON(
|
|
"user", "preserve this message", "2024-01-01T10:00:01Z",
|
|
),
|
|
)
|
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
|
indexPath := filepath.Join(root, parser.CodexSessionIndexFilename)
|
|
require.NoError(t, os.WriteFile(indexPath, []byte(
|
|
`{"id":"`+uuid+`","thread_name":"Original title"}`+"\n",
|
|
), 0o600))
|
|
transcriptTime := time.Now().Add(-2 * time.Hour).Truncate(time.Second)
|
|
indexTime := time.Now().Add(-time.Hour).Truncate(time.Second)
|
|
require.NoError(t, os.Chtimes(path, transcriptTime, transcriptTime))
|
|
require.NoError(t, os.Chtimes(indexPath, indexTime, indexTime))
|
|
|
|
engine := NewEngine(database, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentCodex: {codexDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
engine.SyncAll(context.Background(), nil)
|
|
|
|
before, err := database.GetSessionFull(
|
|
context.Background(), "codex:"+uuid,
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, before)
|
|
require.NotNil(t, before.SessionName)
|
|
require.NotNil(t, before.FileMtime)
|
|
assert.Equal(t, "Original title", *before.SessionName)
|
|
engine.cacheSkip(path, *before.FileMtime)
|
|
require.Equal(t, *before.FileMtime, engine.SnapshotSkipCache()[path],
|
|
"pre-existing skip-cache entry precondition")
|
|
|
|
require.NoError(t, os.WriteFile(indexPath, []byte(
|
|
`{"id":"`+uuid+`","thread_name":"Renamed title"}`+"\n",
|
|
), 0o600))
|
|
storedMtime := time.Unix(0, *before.FileMtime)
|
|
require.NoError(t, os.Chtimes(indexPath, storedMtime, storedMtime))
|
|
indexInfo, err := os.Stat(indexPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, *before.FileMtime, indexInfo.ModTime().UnixNano())
|
|
|
|
engine.SyncPaths([]string{path})
|
|
|
|
after, err := database.GetSessionFull(
|
|
context.Background(), "codex:"+uuid,
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, after)
|
|
require.NotNil(t, after.SessionName)
|
|
assert.Equal(t, "Renamed title", *after.SessionName)
|
|
assert.False(t, after.LastWriteIncremental)
|
|
msgs, err := database.GetAllMessages(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err)
|
|
require.Len(t, msgs, 1)
|
|
assert.Equal(t, "preserve this message", msgs[0].Content)
|
|
}
|
|
|
|
func cacheCodexProviderFingerprint(
|
|
t *testing.T, engine *Engine, path string,
|
|
) (string, int64) {
|
|
t.Helper()
|
|
factory, ok := engine.providerFactories[parser.AgentCodex]
|
|
require.True(t, ok)
|
|
provider := factory.NewProvider(parser.ProviderConfig{
|
|
Roots: engine.agentDirs[parser.AgentCodex],
|
|
Machine: engine.machine,
|
|
PathRewriter: engine.pathRewriter,
|
|
})
|
|
file := parser.DiscoveredFile{Agent: parser.AgentCodex, Path: path}
|
|
source, found, err := engine.providerSourceForDiscoveredFile(
|
|
context.Background(), provider, file,
|
|
)
|
|
require.NoError(t, err)
|
|
require.True(t, found)
|
|
fingerprint, err := provider.Fingerprint(context.Background(), source)
|
|
require.NoError(t, err)
|
|
key := providerProcessCacheKey(file, source, fingerprint)
|
|
require.NotEmpty(t, key)
|
|
return key, fingerprint.MTimeNS
|
|
}
|
|
|
|
func TestSyncPathsCodexCachedFailureWithoutStoredSessionStaysSkipped(t *testing.T) {
|
|
database := openTestDB(t)
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
dayDir := filepath.Join(codexDir, "2024", "01", "01")
|
|
require.NoError(t, os.MkdirAll(dayDir, 0o755))
|
|
|
|
const uuid = "019eb791-cf7d-75c1-8439-9ed74c1229ee"
|
|
path := filepath.Join(
|
|
dayDir, "rollout-2024-01-01T10-00-00-"+uuid+".jsonl",
|
|
)
|
|
require.NoError(t, os.WriteFile(path, []byte(testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/home/user/code/project-a", "codex_cli_rs",
|
|
"2024-01-01T10:00:00Z",
|
|
),
|
|
testjsonl.CodexMsgJSON(
|
|
"user", "this source must stay suppressed", "2024-01-01T10:00:01Z",
|
|
),
|
|
)), 0o600))
|
|
engine := NewEngine(database, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentCodex: {codexDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
cacheKey, mtime := cacheCodexProviderFingerprint(t, engine, path)
|
|
engine.cacheSkip(cacheKey, mtime)
|
|
|
|
engine.SyncPaths([]string{path})
|
|
|
|
sess, err := database.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, sess,
|
|
"a cached failure without stored state must not be reparsed")
|
|
assert.Equal(t, mtime, engine.SnapshotSkipCache()[cacheKey],
|
|
"cached failure remains applicable after the skipped pass")
|
|
}
|
|
|
|
func TestSyncPathsCodexCachedTitleRefreshUsesRewrittenDBPath(t *testing.T) {
|
|
database := openTestDB(t)
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
dayDir := filepath.Join(codexDir, "2024", "01", "01")
|
|
require.NoError(t, os.MkdirAll(dayDir, 0o755))
|
|
|
|
const uuid = "019eb791-cf7d-75c1-8439-9ed74c1229ef"
|
|
filename := "rollout-2024-01-01T10-00-00-" + uuid + ".jsonl"
|
|
path := filepath.Join(dayDir, filename)
|
|
logicalPath := "remote:/sessions/" + filename
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/home/user/code/project-a", "codex_cli_rs",
|
|
"2024-01-01T10:00:00Z",
|
|
),
|
|
testjsonl.CodexMsgJSON(
|
|
"user", "preserve rewritten session", "2024-01-01T10:00:01Z",
|
|
),
|
|
)
|
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
|
indexPath := filepath.Join(root, parser.CodexSessionIndexFilename)
|
|
require.NoError(t, os.WriteFile(indexPath, []byte(
|
|
`{"id":"`+uuid+`","thread_name":"Original title"}`+"\n",
|
|
), 0o600))
|
|
transcriptTime := time.Now().Add(-2 * time.Hour).Truncate(time.Second)
|
|
indexTime := time.Now().Add(-time.Hour).Truncate(time.Second)
|
|
require.NoError(t, os.Chtimes(path, transcriptTime, transcriptTime))
|
|
require.NoError(t, os.Chtimes(indexPath, indexTime, indexTime))
|
|
|
|
engine := NewEngine(database, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentCodex: {codexDir},
|
|
},
|
|
Machine: "remote",
|
|
IDPrefix: "remote~",
|
|
PathRewriter: func(candidate string) string {
|
|
if filepath.Clean(candidate) == filepath.Clean(path) {
|
|
return logicalPath
|
|
}
|
|
return "remote:" + candidate
|
|
},
|
|
})
|
|
engine.SyncAll(context.Background(), nil)
|
|
|
|
const sessionID = "remote~codex:" + uuid
|
|
before, err := database.GetSessionFull(context.Background(), sessionID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, before)
|
|
require.NotNil(t, before.FilePath)
|
|
require.NotNil(t, before.FileMtime)
|
|
require.NotNil(t, before.SessionName)
|
|
assert.Equal(t, logicalPath, *before.FilePath)
|
|
assert.Equal(t, "Original title", *before.SessionName)
|
|
cacheKey, mtime := cacheCodexProviderFingerprint(t, engine, path)
|
|
engine.cacheSkip(cacheKey, mtime)
|
|
require.Equal(t, mtime, engine.SnapshotSkipCache()[cacheKey])
|
|
|
|
require.NoError(t, os.WriteFile(indexPath, []byte(
|
|
`{"id":"`+uuid+`","thread_name":"Renamed title"}`+"\n",
|
|
), 0o600))
|
|
storedMtime := time.Unix(0, *before.FileMtime)
|
|
require.NoError(t, os.Chtimes(indexPath, storedMtime, storedMtime))
|
|
indexInfo, err := os.Stat(indexPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, *before.FileMtime, indexInfo.ModTime().UnixNano())
|
|
|
|
engine.SyncPaths([]string{path})
|
|
|
|
after, err := database.GetSessionFull(context.Background(), sessionID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, after)
|
|
require.NotNil(t, after.SessionName)
|
|
require.NotNil(t, after.FilePath)
|
|
assert.Equal(t, "Renamed title", *after.SessionName)
|
|
assert.Equal(t, logicalPath, *after.FilePath)
|
|
assert.False(t, after.LastWriteIncremental)
|
|
msgs, err := database.GetAllMessages(context.Background(), sessionID)
|
|
require.NoError(t, err)
|
|
require.Len(t, msgs, 1)
|
|
assert.Equal(t, "preserve rewritten session", msgs[0].Content)
|
|
}
|
|
|
|
func TestProcessFileCodexDBFreshSkipIsNotCached(t *testing.T) {
|
|
database := openTestDB(t)
|
|
root := t.TempDir()
|
|
path := filepath.Join(root, "rollout-2026-06-21T18-59-38-abc.jsonl")
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
"abc",
|
|
"/home/user/code/agentsview",
|
|
"user",
|
|
"2024-01-01T10:00:00Z",
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "review this", "2024-01-01T10:00:01Z"),
|
|
)
|
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err, "stat codex fixture")
|
|
fileHash, err := ComputeFileHash(path)
|
|
require.NoError(t, err, "hash codex fixture")
|
|
|
|
sess := db.Session{
|
|
ID: "host~codex:abc",
|
|
Project: "agentsview",
|
|
Machine: "host",
|
|
Agent: "codex",
|
|
FilePath: strPtr("host:" + path),
|
|
FileSize: int64Ptr(info.Size()),
|
|
FileMtime: int64Ptr(info.ModTime().UnixNano()),
|
|
FileHash: &fileHash,
|
|
}
|
|
require.NoError(t, database.UpsertSession(sess))
|
|
require.NoError(t, database.SetSessionDataVersion(
|
|
sess.ID, db.CurrentDataVersion(),
|
|
))
|
|
|
|
e := &Engine{
|
|
db: database,
|
|
idPrefix: "host~",
|
|
skipCache: map[string]int64{},
|
|
agentDirs: map[parser.AgentType][]string{
|
|
parser.AgentCodex: {root},
|
|
},
|
|
providerFactories: providerFactoryMap(parser.ProviderFactories()),
|
|
providerMigrationModes: map[parser.AgentType]parser.ProviderMigrationMode{
|
|
parser.AgentCodex: parser.ProviderMigrationProviderAuthoritative,
|
|
},
|
|
pathRewriter: func(path string) string {
|
|
return "host:" + path
|
|
},
|
|
}
|
|
|
|
res := e.processFile(context.Background(), parser.DiscoveredFile{
|
|
Agent: parser.AgentCodex,
|
|
Path: path,
|
|
})
|
|
require.NoError(t, res.err)
|
|
require.True(t, res.skip)
|
|
assert.True(t, res.noCacheSkip)
|
|
assert.Empty(t, e.SnapshotSkipCache())
|
|
}
|
|
|
|
func TestClassifyCodexIndexPathSkipsMissingTranscript(t *testing.T) {
|
|
database := openTestDB(t)
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
require.NoError(t, os.MkdirAll(codexDir, 0o755))
|
|
indexPath := filepath.Join(root, parser.CodexSessionIndexFilename)
|
|
uuid := "019eb791-cf7d-75c1-8439-9ed74c1229e7"
|
|
missingPath := filepath.Join(
|
|
codexDir,
|
|
"2026", "06", "11",
|
|
"rollout-2026-06-11T12-44-06-"+uuid+".jsonl",
|
|
)
|
|
require.NoError(t, database.UpsertSession(db.Session{
|
|
ID: "codex:" + uuid,
|
|
Project: "agentsview",
|
|
Machine: "local",
|
|
Agent: string(parser.AgentCodex),
|
|
SessionName: strPtr("Old title"),
|
|
FilePath: &missingPath,
|
|
}))
|
|
require.NoError(t, os.WriteFile(indexPath, []byte(
|
|
`{"id":"`+uuid+`","thread_name":"New title",`+
|
|
`"updated_at":"2026-06-11T17:34:20Z"}`+"\n",
|
|
), 0o644))
|
|
engine := NewEngine(database, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentCodex: {codexDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
files := engine.classifyCodexIndexPath(indexPath)
|
|
|
|
assert.Empty(t, files)
|
|
}
|
|
|
|
func TestProcessCodexAppendedStaleProjectDoesFullReparse(t *testing.T) {
|
|
database := openTestDB(t)
|
|
root := t.TempDir()
|
|
path := filepath.Join(root, "rollout-2026-06-21T18-59-38-abc.jsonl")
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
"abc",
|
|
"/home/roborev/.roborev/ci-worktrees/agentsview/roborev-ci-28293-3831737461",
|
|
"user",
|
|
"2024-01-01T10:00:00Z",
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "review this", "2024-01-01T10:00:01Z"),
|
|
)
|
|
require.NoError(t, os.WriteFile(path, []byte(initial), 0o600))
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err, "stat initial codex fixture")
|
|
|
|
sess := db.Session{
|
|
ID: "host~codex:abc",
|
|
Project: "roborev_ci_28293_3831737461",
|
|
Machine: "host",
|
|
Agent: "codex",
|
|
FirstMessage: strPtr("review this"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
FilePath: strPtr("host:" + path),
|
|
FileSize: int64Ptr(info.Size()),
|
|
FileMtime: int64Ptr(info.ModTime().UnixNano()),
|
|
NextOrdinal: 1,
|
|
}
|
|
require.NoError(t, database.UpsertSession(sess))
|
|
require.NoError(t, database.SetSessionDataVersion(
|
|
sess.ID, db.CurrentDataVersion(),
|
|
))
|
|
require.NoError(t, database.InsertMessages([]db.Message{
|
|
{
|
|
SessionID: "host~codex:abc",
|
|
Ordinal: 0,
|
|
Role: "user",
|
|
Content: "review this",
|
|
},
|
|
}))
|
|
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
|
|
require.NoError(t, err, "open codex fixture for append")
|
|
_, err = f.WriteString(testjsonl.CodexMsgJSON(
|
|
"assistant", "done", "2024-01-01T10:00:02Z",
|
|
) + "\n")
|
|
require.NoError(t, err, "append codex fixture")
|
|
require.NoError(t, f.Close(), "close codex fixture")
|
|
|
|
e := &Engine{
|
|
db: database,
|
|
idPrefix: "host~",
|
|
agentDirs: map[parser.AgentType][]string{
|
|
parser.AgentCodex: {root},
|
|
},
|
|
providerFactories: providerFactoryMap(parser.ProviderFactories()),
|
|
providerMigrationModes: map[parser.AgentType]parser.ProviderMigrationMode{
|
|
parser.AgentCodex: parser.ProviderMigrationProviderAuthoritative,
|
|
},
|
|
pathRewriter: func(path string) string {
|
|
return "host:" + path
|
|
},
|
|
}
|
|
|
|
res := e.processFile(context.Background(), parser.DiscoveredFile{
|
|
Agent: parser.AgentCodex,
|
|
Path: path,
|
|
})
|
|
require.NoError(t, res.err)
|
|
require.Nil(t, res.incremental,
|
|
"stale project metadata must force full parse even when file appended")
|
|
require.Len(t, res.results, 1)
|
|
assert.Equal(t, "agentsview", res.results[0].Session.Project)
|
|
}
|
|
|
|
func TestProcessCodexAppendedStaleProjectCarriesForceReplace(t *testing.T) {
|
|
database := openTestDB(t)
|
|
root := t.TempDir()
|
|
path := filepath.Join(root, "rollout-2026-06-21T18-59-38-abc.jsonl")
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
"abc",
|
|
"/home/roborev/.roborev/ci-worktrees/agentsview/roborev-ci-28293-3831737461",
|
|
"user",
|
|
"2024-01-01T10:00:00Z",
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "run command", "2024-01-01T10:00:01Z"),
|
|
testjsonl.CodexFunctionCallWithCallIDJSON(
|
|
"exec_command",
|
|
"call_cmd",
|
|
map[string]any{"cmd": "go test"},
|
|
"2024-01-01T10:00:02Z",
|
|
),
|
|
)
|
|
require.NoError(t, os.WriteFile(path, []byte(initial), 0o600))
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err, "stat initial codex fixture")
|
|
|
|
sess := db.Session{
|
|
ID: "host~codex:abc",
|
|
Project: "roborev_ci_28293_3831737461",
|
|
Machine: "host",
|
|
Agent: "codex",
|
|
FirstMessage: strPtr("run command"),
|
|
MessageCount: 2,
|
|
UserMessageCount: 1,
|
|
FilePath: strPtr("host:" + path),
|
|
FileSize: int64Ptr(info.Size()),
|
|
FileMtime: int64Ptr(info.ModTime().UnixNano()),
|
|
NextOrdinal: 2,
|
|
}
|
|
require.NoError(t, database.UpsertSession(sess))
|
|
require.NoError(t, database.SetSessionDataVersion(
|
|
sess.ID, db.CurrentDataVersion(),
|
|
))
|
|
require.NoError(t, database.InsertMessages([]db.Message{
|
|
{
|
|
SessionID: "host~codex:abc",
|
|
Ordinal: 0,
|
|
Role: "user",
|
|
Content: "run command",
|
|
},
|
|
{
|
|
SessionID: "host~codex:abc",
|
|
Ordinal: 1,
|
|
Role: "assistant",
|
|
ToolCalls: []db.ToolCall{
|
|
{
|
|
ToolUseID: "call_cmd",
|
|
ToolName: "exec_command",
|
|
InputJSON: `{"cmd":"go test"}`,
|
|
},
|
|
},
|
|
},
|
|
}))
|
|
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
|
|
require.NoError(t, err, "open codex fixture for append")
|
|
_, err = f.WriteString(testjsonl.CodexFunctionCallOutputJSON(
|
|
"call_cmd", `{"status":"ok"}`, "2024-01-01T10:00:03Z",
|
|
) + "\n")
|
|
require.NoError(t, err, "append codex fixture")
|
|
require.NoError(t, f.Close(), "close codex fixture")
|
|
|
|
e := &Engine{
|
|
db: database,
|
|
idPrefix: "host~",
|
|
agentDirs: map[parser.AgentType][]string{
|
|
parser.AgentCodex: {root},
|
|
},
|
|
providerFactories: providerFactoryMap(parser.ProviderFactories()),
|
|
providerMigrationModes: map[parser.AgentType]parser.ProviderMigrationMode{
|
|
parser.AgentCodex: parser.ProviderMigrationProviderAuthoritative,
|
|
},
|
|
pathRewriter: func(path string) string {
|
|
return "host:" + path
|
|
},
|
|
}
|
|
|
|
res := e.processFile(context.Background(), parser.DiscoveredFile{
|
|
Agent: parser.AgentCodex,
|
|
Path: path,
|
|
})
|
|
require.NoError(t, res.err)
|
|
require.Nil(t, res.incremental,
|
|
"stale project metadata must force full parse even when file appended")
|
|
require.Len(t, res.results, 1)
|
|
assert.Equal(t, "agentsview", res.results[0].Session.Project)
|
|
assert.True(t, res.forceReplace,
|
|
"fallback-triggering appended data must replace existing messages")
|
|
}
|
|
|
|
type incrementalRequestRecorder struct {
|
|
parser.ProviderBase
|
|
request parser.IncrementalRequest
|
|
}
|
|
|
|
func TestStampProviderFileIdentityPreservesProviderSnapshotIdentity(t *testing.T) {
|
|
root := t.TempDir()
|
|
path := filepath.Join(root, "session.jsonl")
|
|
require.NoError(t, os.WriteFile(path, []byte("old snapshot\n"), 0o600))
|
|
oldInfo, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
oldInode, oldDevice := getFileIdentity(oldInfo)
|
|
|
|
replacementPath := path + ".replacement"
|
|
require.NoError(t, os.WriteFile(replacementPath, []byte("new pathname\n"), 0o600))
|
|
if runtime.GOOS == "windows" {
|
|
require.NoError(t, os.Remove(path))
|
|
}
|
|
require.NoError(t, os.Rename(replacementPath, path))
|
|
replacementInfo, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
replacementInode, replacementDevice := getFileIdentity(replacementInfo)
|
|
|
|
// Platforms without file identity report 0/0. Use a provider-owned token
|
|
// there so this still proves that an authoritative nonzero result is not
|
|
// erased merely because a later path stat cannot supply an identity.
|
|
authoritativeInode, authoritativeDevice := oldInode, oldDevice
|
|
if authoritativeInode == 0 && authoritativeDevice == 0 {
|
|
authoritativeInode, authoritativeDevice = 101, 202
|
|
}
|
|
|
|
provider := &incrementalRequestRecorder{ProviderBase: parser.ProviderBase{
|
|
Def: parser.AgentDef{Type: parser.AgentCodex},
|
|
Caps: parser.Capabilities{Source: parser.SourceCapabilities{
|
|
IncrementalAppend: parser.CapabilitySupported,
|
|
}},
|
|
}}
|
|
results := []parser.ParseResult{
|
|
{Session: parser.ParsedSession{File: parser.FileInfo{
|
|
Path: path, Inode: authoritativeInode, Device: authoritativeDevice,
|
|
}}},
|
|
{Session: parser.ParsedSession{File: parser.FileInfo{Path: path}}},
|
|
}
|
|
|
|
(&Engine{}).stampProviderFileIdentity(
|
|
provider,
|
|
parser.SourceRef{Provider: parser.AgentCodex, DisplayPath: path},
|
|
results,
|
|
)
|
|
|
|
assert.Equal(t, authoritativeInode, results[0].Session.File.Inode)
|
|
assert.Equal(t, authoritativeDevice, results[0].Session.File.Device)
|
|
assert.Equal(t, replacementInode, results[1].Session.File.Inode)
|
|
assert.Equal(t, replacementDevice, results[1].Session.File.Device)
|
|
}
|
|
|
|
func TestProviderProcessCacheKeyCodexIncludesContentHash(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "session.jsonl")
|
|
file := parser.DiscoveredFile{Path: path, Agent: parser.AgentCodex}
|
|
source := parser.SourceRef{
|
|
Provider: parser.AgentCodex,
|
|
FingerprintKey: path,
|
|
}
|
|
|
|
first := providerProcessCacheKey(file, source, parser.SourceFingerprint{
|
|
Key: path, Hash: "first-content-hash",
|
|
})
|
|
second := providerProcessCacheKey(file, source, parser.SourceFingerprint{
|
|
Key: path, Hash: "second-content-hash",
|
|
})
|
|
|
|
assert.Equal(t, path+"?source_hash=first-content-hash", first)
|
|
assert.Equal(t, path+"?source_hash=second-content-hash", second)
|
|
assert.NotEqual(t, first, second,
|
|
"same-stat content rewrites must not reuse a rowless skip entry")
|
|
}
|
|
|
|
func (p *incrementalRequestRecorder) Parse(
|
|
context.Context,
|
|
parser.ParseRequest,
|
|
) (parser.ParseOutcome, error) {
|
|
return parser.ParseOutcome{}, errors.New("unexpected full parse")
|
|
}
|
|
|
|
func (p *incrementalRequestRecorder) ParseIncremental(
|
|
_ context.Context,
|
|
req parser.IncrementalRequest,
|
|
) (parser.IncrementalOutcome, parser.IncrementalStatus, error) {
|
|
p.request = req
|
|
return parser.IncrementalOutcome{
|
|
SessionID: req.SessionID,
|
|
ConsumedBytes: 3,
|
|
}, parser.IncrementalApplied, nil
|
|
}
|
|
|
|
func TestTryProviderIncrementalAppendPassesPersistedSessionID(t *testing.T) {
|
|
database := openTestDB(t)
|
|
root := t.TempDir()
|
|
path := filepath.Join(
|
|
root, "rollout-2024-01-01T00-00-00-abc.jsonl",
|
|
)
|
|
require.NoError(t, os.WriteFile(path, []byte("{}\n{}\n"), 0o600))
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
|
|
const persistedID = "remote~codex:abc"
|
|
require.NoError(t, database.UpsertSession(db.Session{
|
|
ID: persistedID,
|
|
Project: "project",
|
|
Machine: "remote",
|
|
Agent: string(parser.AgentCodex),
|
|
FirstMessage: strPtr("hello"),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
FilePath: strPtr(path),
|
|
FileSize: int64Ptr(3),
|
|
FileMtime: int64Ptr(info.ModTime().UnixNano()),
|
|
NextOrdinal: 1,
|
|
}))
|
|
require.NoError(t, database.SetSessionDataVersion(
|
|
persistedID, db.CurrentDataVersion(),
|
|
))
|
|
require.NoError(t, database.InsertMessages([]db.Message{{
|
|
SessionID: persistedID,
|
|
Ordinal: 0,
|
|
Role: "user",
|
|
Content: "hello",
|
|
}}))
|
|
|
|
provider := &incrementalRequestRecorder{ProviderBase: parser.ProviderBase{
|
|
Def: parser.AgentDef{
|
|
Type: parser.AgentCodex,
|
|
IDPrefix: "codex:",
|
|
},
|
|
Caps: parser.Capabilities{Source: parser.SourceCapabilities{
|
|
IncrementalAppend: parser.CapabilitySupported,
|
|
}},
|
|
}}
|
|
e := &Engine{
|
|
db: database,
|
|
idPrefix: "remote~",
|
|
}
|
|
source := parser.SourceRef{
|
|
Provider: parser.AgentCodex,
|
|
DisplayPath: path,
|
|
FingerprintKey: path,
|
|
}
|
|
result, applied := e.tryProviderIncrementalAppend(
|
|
context.Background(),
|
|
provider,
|
|
source,
|
|
parser.DiscoveredFile{Agent: parser.AgentCodex, Path: path},
|
|
parser.SourceFingerprint{
|
|
Key: path,
|
|
Size: info.Size(),
|
|
MTimeNS: info.ModTime().UnixNano(),
|
|
},
|
|
)
|
|
|
|
require.True(t, applied)
|
|
require.NotNil(t, result.incremental)
|
|
assert.Equal(t, persistedID, provider.request.SessionID,
|
|
"provider continuation identity must come from the persisted row")
|
|
assert.Equal(t, persistedID, result.incremental.sessionID)
|
|
}
|
|
|
|
func TestCollectAndBatchPrefixesParserExcludedIDs(t *testing.T) {
|
|
database := openTestDB(t)
|
|
ctx := context.Background()
|
|
|
|
raw := db.Session{
|
|
ID: "probe",
|
|
Project: "local",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
}
|
|
prefixed := db.Session{
|
|
ID: "host~probe",
|
|
Project: "remote",
|
|
Machine: "host",
|
|
Agent: "claude",
|
|
}
|
|
require.NoError(t, database.UpsertSession(raw))
|
|
require.NoError(t, database.UpsertSession(prefixed))
|
|
|
|
results := make(chan syncJob, 1)
|
|
results <- syncJob{
|
|
processResult: processResult{
|
|
excludedSessionIDs: []string{"probe"},
|
|
},
|
|
path: "/remote/probe.jsonl",
|
|
}
|
|
close(results)
|
|
|
|
e := &Engine{db: database, idPrefix: "host~"}
|
|
stats := e.collectAndBatch(
|
|
ctx, results, 1, 1, nil, syncWriteDefault,
|
|
)
|
|
|
|
assert.Equal(t, []string{"host~probe"}, stats.parserExcludedIDs)
|
|
gotRaw, err := database.GetSession(ctx, "probe")
|
|
require.NoError(t, err, "raw local session lookup")
|
|
assert.NotNil(t, gotRaw, "raw local session must not be deleted")
|
|
gotPrefixed, err := database.GetSession(ctx, "host~probe")
|
|
require.NoError(t, err, "prefixed remote session lookup")
|
|
assert.Nil(t, gotPrefixed, "prefixed remote session should be deleted")
|
|
}
|
|
|
|
func TestShouldSkipByPathWithRewriter(t *testing.T) {
|
|
database := openTestDB(t)
|
|
|
|
// Store a session with rewritten file path.
|
|
sess := db.Session{
|
|
ID: "host~codex:abc",
|
|
Project: "test",
|
|
Machine: "host",
|
|
Agent: "codex",
|
|
FilePath: strPtr("host:/remote/codex/abc.jsonl"),
|
|
FileSize: int64Ptr(2048),
|
|
FileMtime: int64Ptr(
|
|
int64(1700000000000000000),
|
|
),
|
|
}
|
|
require.NoError(t, database.UpsertSession(sess))
|
|
require.NoError(t, database.SetSessionDataVersion(
|
|
sess.ID, db.CurrentDataVersion(),
|
|
))
|
|
|
|
rewriter := func(p string) string {
|
|
return "host:" + p
|
|
}
|
|
|
|
// Engine with PathRewriter should find the session.
|
|
e := &Engine{
|
|
db: database,
|
|
pathRewriter: rewriter,
|
|
}
|
|
got := e.shouldSkipByPath(
|
|
"/remote/codex/abc.jsonl",
|
|
fakeFileInfo{size: 2048, mtime: 1700000000000000000},
|
|
)
|
|
assert.True(t, got, "shouldSkipByPath should return true")
|
|
|
|
// Without rewriter, lookup misses.
|
|
e2 := &Engine{db: database}
|
|
got2 := e2.shouldSkipByPath(
|
|
"/remote/codex/abc.jsonl",
|
|
fakeFileInfo{size: 2048, mtime: 1700000000000000000},
|
|
)
|
|
assert.False(t, got2, "shouldSkipByPath without rewriter should return false")
|
|
}
|
|
|
|
// writeAiderHistory writes a two-content-run plus one header-only-run
|
|
// history file under a fresh repo dir and returns its path. The header-only
|
|
// trailing run produces no session, so a fan-out parse yields two sessions.
|
|
func writeAiderHistory(t *testing.T) string {
|
|
t.Helper()
|
|
repo := filepath.Join(t.TempDir(), "myrepo")
|
|
require.NoError(t, os.MkdirAll(repo, 0o755))
|
|
path := filepath.Join(repo, parser.AiderHistoryFileName())
|
|
content := "# aider chat started at 2026-06-09 14:01:00\n" +
|
|
"#### first prompt\nanswer one\n" +
|
|
"# aider chat started at 2026-06-09 15:30:00\n" +
|
|
"#### second prompt\nanswer two\n" +
|
|
"# aider chat started at 2026-06-09 16:45:00\n"
|
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
|
return path
|
|
}
|
|
|
|
func newAiderProviderTestEngine(
|
|
database *db.DB,
|
|
path string,
|
|
forceParse bool,
|
|
) *Engine {
|
|
root := filepath.Dir(filepath.Dir(path))
|
|
return &Engine{
|
|
db: database,
|
|
machine: "local",
|
|
forceParse: forceParse,
|
|
skipCache: make(map[string]int64),
|
|
agentDirs: map[parser.AgentType][]string{
|
|
parser.AgentAider: {root},
|
|
},
|
|
providerFactories: providerFactoryMap(parser.ProviderFactories()),
|
|
providerMigrationModes: map[parser.AgentType]parser.ProviderMigrationMode{
|
|
parser.AgentAider: parser.ProviderMigrationProviderAuthoritative,
|
|
},
|
|
}
|
|
}
|
|
|
|
func persistAiderProviderResults(
|
|
t *testing.T,
|
|
database *db.DB,
|
|
results []parser.ParseResult,
|
|
mutate func(index int, session *db.Session, dataVersion *int),
|
|
) {
|
|
t.Helper()
|
|
for i, r := range results {
|
|
dataVersion := db.CurrentDataVersion()
|
|
row := db.Session{
|
|
ID: r.Session.ID,
|
|
Project: r.Session.Project,
|
|
Machine: "local",
|
|
Agent: string(parser.AgentAider),
|
|
FilePath: strPtr(r.Session.File.Path),
|
|
FileSize: int64Ptr(r.Session.File.Size),
|
|
FileMtime: int64Ptr(r.Session.File.Mtime),
|
|
FileHash: strPtr(r.Session.File.Hash),
|
|
}
|
|
if mutate != nil {
|
|
mutate(i, &row, &dataVersion)
|
|
}
|
|
require.NoError(t, database.UpsertSession(row))
|
|
require.NoError(t, database.SetSessionDataVersion(row.ID, dataVersion))
|
|
}
|
|
}
|
|
|
|
// TestProcessFileAiderProviderFanOut verifies the migrated Aider provider, run
|
|
// through processFile, fans one history file out into one session per
|
|
// content-bearing run under stable "<history>#<idx>" virtual paths and
|
|
// force-replaces on parse. An unchanged re-sync drops every already-current run
|
|
// (the per-run skip that the legacy aiderFileUnchanged provided, now handled by
|
|
// dropUnchangedSharedSQLiteResults), while a forced parse re-emits them all.
|
|
func TestProcessFileAiderProviderFanOut(t *testing.T) {
|
|
database := openTestDB(t)
|
|
path := writeAiderHistory(t)
|
|
file := parser.DiscoveredFile{Agent: parser.AgentAider, Path: path}
|
|
|
|
res := newAiderProviderTestEngine(database, path, false).
|
|
processFile(context.Background(), file)
|
|
require.NoError(t, res.err)
|
|
require.True(t, res.forceReplace,
|
|
"aider fan-out must force-replace stored runs")
|
|
require.Len(t, res.results, 2,
|
|
"two content-bearing runs must each produce a session")
|
|
for _, r := range res.results {
|
|
historyPath, _, ok := parser.ParseAiderVirtualPath(r.Session.File.Path)
|
|
require.True(t, ok, "each run is stored under a virtual run path")
|
|
assert.Equal(t, path, historyPath)
|
|
assert.Equal(t, parser.AgentAider, r.Session.Agent)
|
|
}
|
|
|
|
// Persist the parsed runs as current so the unchanged re-sync can drop them.
|
|
persistAiderProviderResults(t, database, res.results, nil)
|
|
|
|
again := newAiderProviderTestEngine(database, path, false).
|
|
processFile(context.Background(), file)
|
|
require.NoError(t, again.err)
|
|
assert.Empty(t, again.results,
|
|
"an unchanged aider history must drop every already-current run")
|
|
|
|
forced := newAiderProviderTestEngine(database, path, true).
|
|
processFile(context.Background(), file)
|
|
require.NoError(t, forced.err)
|
|
assert.Len(t, forced.results, 2,
|
|
"a forced parse must re-emit every content-bearing run")
|
|
}
|
|
|
|
func TestProcessFileAiderProviderSameMtimeContentChangeIgnoresSkipCache(t *testing.T) {
|
|
database := openTestDB(t)
|
|
path := writeAiderHistory(t)
|
|
file := parser.DiscoveredFile{Agent: parser.AgentAider, Path: path}
|
|
|
|
initial := newAiderProviderTestEngine(database, path, false).
|
|
processFile(context.Background(), file)
|
|
require.NoError(t, initial.err)
|
|
require.Len(t, initial.results, 2)
|
|
persistAiderProviderResults(t, database, initial.results, nil)
|
|
|
|
mtime := time.Unix(0, initial.mtime)
|
|
updated := "# aider chat started at 2026-06-09 14:01:00\n" +
|
|
"#### first prompt\nanswer one changed\n" +
|
|
"# aider chat started at 2026-06-09 15:30:00\n" +
|
|
"#### second prompt\nanswer two changed\n"
|
|
require.NoError(t, os.WriteFile(path, []byte(updated), 0o644))
|
|
require.NoError(t, os.Chtimes(path, mtime, mtime))
|
|
|
|
engine := newAiderProviderTestEngine(database, path, false)
|
|
engine.cacheSkip(path, initial.mtime)
|
|
after := engine.processFile(context.Background(), file)
|
|
require.NoError(t, after.err)
|
|
assert.False(t, after.skip,
|
|
"a stale mtime-only skip cache entry must not bypass Aider hashing")
|
|
assert.Len(t, after.results, 2,
|
|
"same-mtime content changes must re-emit every Aider run")
|
|
}
|
|
|
|
func TestProcessFileAiderProviderSkipCacheDoesNotHidePartialOrStaleRows(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(index int, session *db.Session, dataVersion *int)
|
|
storeRows func([]parser.ParseResult) []parser.ParseResult
|
|
}{
|
|
{
|
|
name: "missing run row",
|
|
storeRows: func(results []parser.ParseResult) []parser.ParseResult {
|
|
require.Len(t, results, 2)
|
|
return results[:1]
|
|
},
|
|
},
|
|
{
|
|
name: "stale data version",
|
|
mutate: func(_ int, _ *db.Session, dataVersion *int) {
|
|
*dataVersion = db.CurrentDataVersion() - 1
|
|
},
|
|
},
|
|
{
|
|
name: "stale hash",
|
|
mutate: func(_ int, session *db.Session, _ *int) {
|
|
session.FileHash = strPtr("stale-hash")
|
|
},
|
|
},
|
|
{
|
|
name: "stale mtime",
|
|
mutate: func(_ int, session *db.Session, _ *int) {
|
|
session.FileMtime = int64Ptr(1)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
database := openTestDB(t)
|
|
path := writeAiderHistory(t)
|
|
file := parser.DiscoveredFile{Agent: parser.AgentAider, Path: path}
|
|
initial := newAiderProviderTestEngine(database, path, false).
|
|
processFile(context.Background(), file)
|
|
require.NoError(t, initial.err)
|
|
require.Len(t, initial.results, 2)
|
|
|
|
rows := initial.results
|
|
if tt.storeRows != nil {
|
|
rows = tt.storeRows(rows)
|
|
}
|
|
persistAiderProviderResults(t, database, rows, tt.mutate)
|
|
|
|
engine := newAiderProviderTestEngine(database, path, false)
|
|
engine.cacheSkip(path, initial.mtime)
|
|
after := engine.processFile(context.Background(), file)
|
|
require.NoError(t, after.err)
|
|
assert.False(t, after.skip,
|
|
"a generic skip cache entry must not hide %s", tt.name)
|
|
assert.NotEmpty(t, after.results)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFindSourceFileProviderAuthoritativePrefersProviderOverStoredPath(t *testing.T) {
|
|
database := openTestDB(t)
|
|
root := t.TempDir()
|
|
stalePath := filepath.Join(root, "stale.jsonl")
|
|
currentPath := filepath.Join(root, "current.jsonl")
|
|
require.NoError(t, os.WriteFile(stalePath, []byte("{}\n"), 0o644))
|
|
require.NoError(t, os.WriteFile(currentPath, []byte("{}\n"), 0o644))
|
|
require.NoError(t, database.UpsertSession(db.Session{
|
|
ID: "cowork:lookup",
|
|
Project: "project",
|
|
Machine: "local",
|
|
Agent: string(parser.AgentCowork),
|
|
FilePath: strPtr(stalePath),
|
|
}))
|
|
|
|
provider := &lookupSourceProvider{
|
|
ProviderBase: parser.ProviderBase{
|
|
Def: parser.AgentDef{
|
|
Type: parser.AgentCowork,
|
|
DisplayName: "Cowork",
|
|
IDPrefix: "cowork:",
|
|
FileBased: true,
|
|
},
|
|
Caps: parser.Capabilities{
|
|
Source: parser.SourceCapabilities{
|
|
FindSource: parser.CapabilitySupported,
|
|
},
|
|
},
|
|
},
|
|
source: parser.SourceRef{
|
|
Provider: parser.AgentCowork,
|
|
Key: currentPath,
|
|
DisplayPath: currentPath,
|
|
FingerprintKey: currentPath,
|
|
},
|
|
}
|
|
engine := &Engine{
|
|
db: database,
|
|
agentDirs: map[parser.AgentType][]string{
|
|
parser.AgentCowork: {root},
|
|
},
|
|
providerFactories: providerFactoryMap([]parser.ProviderFactory{
|
|
lookupSourceFactory{provider: provider},
|
|
}),
|
|
providerMigrationModes: map[parser.AgentType]parser.ProviderMigrationMode{
|
|
parser.AgentCowork: parser.ProviderMigrationProviderAuthoritative,
|
|
},
|
|
}
|
|
|
|
got := engine.FindSourceFile("cowork:lookup")
|
|
|
|
assert.Equal(t, currentPath, got)
|
|
require.Len(t, provider.findRequests, 1)
|
|
assert.Equal(t, "lookup", provider.findRequests[0].RawSessionID)
|
|
assert.Equal(t, "cowork:lookup", provider.findRequests[0].FullSessionID)
|
|
assert.Equal(t, stalePath, provider.findRequests[0].StoredFilePath)
|
|
assert.Equal(t, stalePath, provider.findRequests[0].FingerprintKey)
|
|
assert.True(t, provider.findRequests[0].RequireFreshSource)
|
|
}
|
|
|
|
// TestStripVirtualSourceSuffixAider verifies that an aider
|
|
// <history>#<runIdx> virtual path strips back to its physical history file,
|
|
// so parse-diff missing-run and parse-error reporting keys on the on-disk
|
|
// file rather than the run-scoped virtual path.
|
|
func TestStripVirtualSourceSuffixAider(t *testing.T) {
|
|
historyPath := "/home/user/myrepo/" + parser.AiderHistoryFileName()
|
|
virtual := parser.AiderVirtualPath(historyPath, 3)
|
|
assert.Equal(t, historyPath, stripVirtualSourceSuffix(virtual),
|
|
"the run-index suffix must strip to the physical history path")
|
|
}
|
|
|
|
type lookupSourceFactory struct {
|
|
provider *lookupSourceProvider
|
|
}
|
|
|
|
func (f lookupSourceFactory) Definition() parser.AgentDef {
|
|
return f.provider.Definition()
|
|
}
|
|
|
|
func (f lookupSourceFactory) Capabilities() parser.Capabilities {
|
|
return f.provider.Capabilities()
|
|
}
|
|
|
|
func (f lookupSourceFactory) NewProvider(parser.ProviderConfig) parser.Provider {
|
|
return f.provider
|
|
}
|
|
|
|
type lookupSourceProvider struct {
|
|
parser.ProviderBase
|
|
source parser.SourceRef
|
|
findRequests []parser.FindSourceRequest
|
|
}
|
|
|
|
func (p *lookupSourceProvider) FindSource(
|
|
_ context.Context,
|
|
req parser.FindSourceRequest,
|
|
) (parser.SourceRef, bool, error) {
|
|
p.findRequests = append(p.findRequests, req)
|
|
return p.source, true, nil
|
|
}
|
|
|
|
func (p *lookupSourceProvider) Parse(
|
|
context.Context,
|
|
parser.ParseRequest,
|
|
) (parser.ParseOutcome, error) {
|
|
return parser.ParseOutcome{}, nil
|
|
}
|
|
|
|
func TestToDBSessionStoresSessionName(t *testing.T) {
|
|
pw := pendingWrite{sess: parser.ParsedSession{
|
|
ID: "commandcode:test",
|
|
Project: "sample_project",
|
|
Machine: "local",
|
|
Agent: parser.AgentCommandCode,
|
|
SessionName: "Startup investigation",
|
|
FirstMessage: "Inspect server logs",
|
|
}}
|
|
|
|
got := toDBSession(pw)
|
|
require.NotNil(t, got.SessionName)
|
|
assert.Equal(t, "Startup investigation", *got.SessionName)
|
|
require.NotNil(t, got.FirstMessage)
|
|
assert.Equal(t, "Inspect server logs", *got.FirstMessage)
|
|
}
|
|
|
|
func TestBlockedCategorySet(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input []string
|
|
check string
|
|
want bool
|
|
}{
|
|
{"exact match", []string{"Read"}, "Read", true},
|
|
{"lowercase normalized", []string{"read"}, "Read", true},
|
|
{"uppercase normalized", []string{"GLOB"}, "Glob", true},
|
|
{"trimmed", []string{" Read "}, "Read", true},
|
|
{"empty entry skipped", []string{""}, "Read", false},
|
|
{"nil input", nil, "Read", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
m := blockedCategorySet(tt.input)
|
|
got := m[tt.check]
|
|
assert.Equal(t, tt.want, got,
|
|
"blockedCategorySet(%v)[%q]", tt.input, tt.check)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenCodeLegacyArchiveLooksIncomplete(t *testing.T) {
|
|
stored := []db.Message{
|
|
{
|
|
Ordinal: 1,
|
|
Role: "assistant",
|
|
ContentLength: 100,
|
|
HasOutputTokens: true,
|
|
OutputTokens: 200,
|
|
HasContextTokens: true,
|
|
ContextTokens: 400,
|
|
ToolCalls: []db.ToolCall{{ToolName: "Read"}},
|
|
HasThinking: true,
|
|
},
|
|
}
|
|
|
|
t.Run("extra parsed messages still preserve incomplete prefix", func(t *testing.T) {
|
|
parsed := []db.Message{
|
|
{
|
|
Ordinal: 1,
|
|
Role: "assistant",
|
|
ContentLength: 50,
|
|
HasOutputTokens: false,
|
|
HasContextTokens: false,
|
|
ToolCalls: nil,
|
|
HasThinking: false,
|
|
},
|
|
{
|
|
Ordinal: 2,
|
|
Role: "assistant",
|
|
ContentLength: 25,
|
|
},
|
|
}
|
|
|
|
require.True(t, openCodeLegacyArchiveLooksIncomplete(parsed, stored),
|
|
"want incomplete archive detection")
|
|
})
|
|
|
|
t.Run("extra parsed messages with complete prefix do not preserve", func(t *testing.T) {
|
|
parsed := []db.Message{
|
|
{
|
|
Ordinal: 1,
|
|
Role: "assistant",
|
|
ContentLength: 100,
|
|
HasOutputTokens: true,
|
|
OutputTokens: 200,
|
|
HasContextTokens: true,
|
|
ContextTokens: 400,
|
|
ToolCalls: []db.ToolCall{{ToolName: "Read"}},
|
|
HasThinking: true,
|
|
},
|
|
{
|
|
Ordinal: 2,
|
|
Role: "assistant",
|
|
ContentLength: 25,
|
|
},
|
|
}
|
|
|
|
require.False(t, openCodeLegacyArchiveLooksIncomplete(parsed, stored),
|
|
"got incomplete archive detection, want false")
|
|
})
|
|
|
|
t.Run("stripped control bytes cannot pad parsed content", func(t *testing.T) {
|
|
stored := []db.Message{
|
|
{
|
|
Ordinal: 1,
|
|
Role: "assistant",
|
|
Content: "complete archived content",
|
|
ContentLength: len("complete archived content"),
|
|
},
|
|
}
|
|
parsed := []db.Message{
|
|
{
|
|
Ordinal: 1,
|
|
Role: "assistant",
|
|
Content: "short" + strings.Repeat("\x00", 20),
|
|
ContentLength: len("complete archived content"),
|
|
},
|
|
}
|
|
|
|
require.True(t, openCodeLegacyArchiveLooksIncomplete(parsed, stored),
|
|
"want sanitized parsed content to preserve complete archive")
|
|
})
|
|
}
|
|
|
|
func TestVisualStudioCopilotArchiveDecisionMergesNewRowsWithArchiveOnlyRows(t *testing.T) {
|
|
stored := []db.Message{
|
|
{
|
|
Ordinal: 0,
|
|
Role: "assistant",
|
|
Content: "Run command: dotnet build",
|
|
ContentLength: len("Run command: dotnet build"),
|
|
Timestamp: "2026-06-12T19:46:40Z",
|
|
},
|
|
{
|
|
Ordinal: 1,
|
|
Role: "user",
|
|
Content: "Archived prompt.",
|
|
ContentLength: len("Archived prompt."),
|
|
Timestamp: "2026-06-12T19:47:00Z",
|
|
},
|
|
}
|
|
parsed := []db.Message{
|
|
{
|
|
Ordinal: 0,
|
|
Role: "assistant",
|
|
Content: "Run command: dotnet build",
|
|
ContentLength: len("Run command: dotnet build"),
|
|
Timestamp: "2026-06-12T19:46:40Z",
|
|
},
|
|
{
|
|
Ordinal: 1,
|
|
Role: "user",
|
|
Content: "New follow-up.",
|
|
ContentLength: len("New follow-up."),
|
|
Timestamp: "2026-06-12T19:47:20Z",
|
|
},
|
|
}
|
|
|
|
decision := visualStudioCopilotArchiveDecision(parsed, stored)
|
|
|
|
require.False(t, decision.preserve)
|
|
require.Len(t, decision.merged, 3)
|
|
assert.Equal(t, "Run command: dotnet build", decision.merged[0].Content)
|
|
assert.Equal(t, "Archived prompt.", decision.merged[1].Content)
|
|
assert.Equal(t, "New follow-up.", decision.merged[2].Content)
|
|
for i, msg := range decision.merged {
|
|
assert.Equal(t, i, msg.Ordinal)
|
|
}
|
|
}
|
|
|
|
// TestPrepareSessionWriteReclampsMessageDerivedTokenTotals proves the full
|
|
// write path does not strand a corrupt per-message token value in the session
|
|
// aggregates. A message with an implausible OutputTokens/ContextTokens is
|
|
// clamped to maxPlausibleTokens in its row, so the message-derived session
|
|
// totals must be re-derived from the clamped rows -- while a legitimately
|
|
// large sum over many messages (above the per-message bound) is preserved.
|
|
func TestPrepareSessionWriteReclampsMessageDerivedTokenTotals(t *testing.T) {
|
|
d := openTestDB(t)
|
|
e := NewEngine(d, EngineConfig{Machine: "test-machine"})
|
|
ts := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
|
|
|
msgs := []parser.ParsedMessage{
|
|
{
|
|
Ordinal: 0, Role: parser.RoleAssistant, Content: "a",
|
|
ContentLength: 1, Timestamp: ts,
|
|
OutputTokens: 1_000_000, HasOutputTokens: true,
|
|
ContextTokens: 1_500_000, HasContextTokens: true,
|
|
},
|
|
{
|
|
Ordinal: 1, Role: parser.RoleAssistant, Content: "b",
|
|
ContentLength: 1, Timestamp: ts.Add(time.Second),
|
|
OutputTokens: 1_500_000, HasOutputTokens: true,
|
|
ContextTokens: 1_000_000, HasContextTokens: true,
|
|
},
|
|
{
|
|
Ordinal: 2, Role: parser.RoleAssistant, Content: "c",
|
|
ContentLength: 1, Timestamp: ts.Add(2 * time.Second),
|
|
// Corrupt: both counts are far above maxPlausibleTokens and
|
|
// will be clamped to it in the stored row.
|
|
OutputTokens: 999_999_999, HasOutputTokens: true,
|
|
ContextTokens: 999_999_999, HasContextTokens: true,
|
|
},
|
|
}
|
|
|
|
newSess := func() parser.ParsedSession {
|
|
return parser.ParsedSession{
|
|
ID: "tok-session", Project: "proj", Machine: "test-machine",
|
|
Agent: parser.AgentClaude, StartedAt: ts,
|
|
EndedAt: ts.Add(time.Minute), MessageCount: len(msgs),
|
|
File: parser.FileInfo{
|
|
Path: "/tmp/tok.jsonl", Size: 10, Mtime: ts.UnixNano(),
|
|
},
|
|
}
|
|
}
|
|
|
|
// Message-derived totals (the parser accumulated them via
|
|
// accumulateMessageTokenUsage): sum of output and peak context, raw.
|
|
sess := newSess()
|
|
sess.TotalOutputTokens = 1_000_000 + 1_500_000 + 999_999_999
|
|
sess.HasTotalOutputTokens = true
|
|
sess.PeakContextTokens = 999_999_999
|
|
sess.HasPeakContextTokens = true
|
|
|
|
prepared, dbMsgs, verdict := e.prepareSessionWrite(
|
|
pendingWrite{sess: sess, msgs: msgs}, nil,
|
|
)
|
|
require.Equal(t, sessionWriteOK, verdict)
|
|
require.Len(t, dbMsgs, 3)
|
|
|
|
// The corrupt message row is clamped to the per-message bound.
|
|
assert.Equal(t, maxPlausibleTokens, dbMsgs[2].OutputTokens,
|
|
"corrupt message OutputTokens clamped")
|
|
assert.Equal(t, maxPlausibleTokens, dbMsgs[2].ContextTokens,
|
|
"corrupt message ContextTokens clamped")
|
|
// The session total is re-derived from the clamped rows: a legitimately
|
|
// large sum (above maxPlausibleTokens) survives, the corrupt value does
|
|
// not pollute it.
|
|
assert.Equal(t, 1_000_000+1_500_000+maxPlausibleTokens,
|
|
prepared.TotalOutputTokens,
|
|
"message-derived total re-derived from clamped rows")
|
|
assert.Equal(t, maxPlausibleTokens, prepared.PeakContextTokens,
|
|
"message-derived peak re-derived from clamped rows")
|
|
|
|
// Summary-derived totals (agents like Warp/Vibe set the session totals
|
|
// directly, not from per-message rows) must survive the per-message
|
|
// clamp untouched: they do not match the message-derived values.
|
|
const summaryTotal = 4_242_424
|
|
const summaryPeak = 3_333_333
|
|
summarySess := newSess()
|
|
summarySess.TotalOutputTokens = summaryTotal
|
|
summarySess.HasTotalOutputTokens = true
|
|
summarySess.PeakContextTokens = summaryPeak
|
|
summarySess.HasPeakContextTokens = true
|
|
|
|
preparedSummary, _, verdict := e.prepareSessionWrite(
|
|
pendingWrite{sess: summarySess, msgs: msgs}, nil,
|
|
)
|
|
require.Equal(t, sessionWriteOK, verdict)
|
|
assert.Equal(t, summaryTotal, preparedSummary.TotalOutputTokens,
|
|
"summary-derived total left untouched by per-message clamp")
|
|
assert.Equal(t, summaryPeak, preparedSummary.PeakContextTokens,
|
|
"summary-derived peak left untouched by per-message clamp")
|
|
}
|
|
|
|
// TestPrepareSessionWriteReclampsEventDerivedTokenTotals covers the
|
|
// usage-event-derived case (VS Code Copilot accumulates session totals from
|
|
// per-turn usage events, not per-message rows). A corrupt usage event is
|
|
// clamped in its usage_events row, so the event-derived session aggregates
|
|
// must be re-derived from the clamped events rather than left at the raw
|
|
// inflated value.
|
|
func TestPrepareSessionWriteReclampsEventDerivedTokenTotals(t *testing.T) {
|
|
d := openTestDB(t)
|
|
e := NewEngine(d, EngineConfig{Machine: "test-machine"})
|
|
ts := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
|
|
|
// Per-message rows carry no tokens; the tokens live in usage events.
|
|
msgs := []parser.ParsedMessage{
|
|
{
|
|
Ordinal: 0, Role: parser.RoleUser, Content: "q",
|
|
ContentLength: 1, Timestamp: ts,
|
|
},
|
|
{
|
|
Ordinal: 1, Role: parser.RoleAssistant, Content: "a",
|
|
ContentLength: 1, Timestamp: ts.Add(time.Second),
|
|
},
|
|
}
|
|
events := []parser.ParsedUsageEvent{
|
|
{OutputTokens: 1_000_000, InputTokens: 1_500_000},
|
|
{OutputTokens: 1_500_000, InputTokens: 1_000_000},
|
|
// Corrupt event: both counts are clamped in the usage_events row.
|
|
{OutputTokens: 999_999_999, InputTokens: 999_999_999},
|
|
}
|
|
sess := parser.ParsedSession{
|
|
ID: "evt-session", Project: "proj", Machine: "test-machine",
|
|
Agent: parser.AgentVSCodeCopilot, StartedAt: ts,
|
|
EndedAt: ts.Add(time.Minute), MessageCount: len(msgs),
|
|
File: parser.FileInfo{
|
|
Path: "/tmp/evt.json", Size: 10, Mtime: ts.UnixNano(),
|
|
},
|
|
// Event-derived aggregates, raw (as the parser accumulates them):
|
|
// sum of event output tokens, peak of event input tokens.
|
|
TotalOutputTokens: 1_000_000 + 1_500_000 + 999_999_999,
|
|
HasTotalOutputTokens: true,
|
|
PeakContextTokens: 999_999_999,
|
|
HasPeakContextTokens: true,
|
|
}
|
|
|
|
prepared, _, verdict := e.prepareSessionWrite(
|
|
pendingWrite{sess: sess, msgs: msgs, usageEvents: events}, nil,
|
|
)
|
|
require.Equal(t, sessionWriteOK, verdict)
|
|
assert.Equal(t, 1_000_000+1_500_000+maxPlausibleTokens,
|
|
prepared.TotalOutputTokens,
|
|
"event-derived total re-derived from clamped usage events")
|
|
assert.Equal(t, maxPlausibleTokens, prepared.PeakContextTokens,
|
|
"event-derived peak re-derived from clamped usage events")
|
|
}
|
|
|
|
func TestPrepareSessionWritePreservesSummaryUsageEventTokenTotals(t *testing.T) {
|
|
d := openTestDB(t)
|
|
e := NewEngine(d, EngineConfig{Machine: "test-machine"})
|
|
ts := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
|
|
|
rawTotal := maxPlausibleTokens + 500_000
|
|
rawPeak := maxPlausibleTokens + 250_000
|
|
msgs := []parser.ParsedMessage{
|
|
{
|
|
Ordinal: 0, Role: parser.RoleUser, Content: "q",
|
|
ContentLength: 1, Timestamp: ts,
|
|
},
|
|
{
|
|
Ordinal: 1, Role: parser.RoleAssistant, Content: "a",
|
|
ContentLength: 1, Timestamp: ts.Add(time.Second),
|
|
},
|
|
}
|
|
events := []parser.ParsedUsageEvent{{
|
|
Source: "session",
|
|
Model: "claude-sonnet-4",
|
|
InputTokens: rawPeak,
|
|
OutputTokens: rawTotal,
|
|
}}
|
|
sess := parser.ParsedSession{
|
|
ID: "summary-event-session", Project: "proj", Machine: "test-machine",
|
|
Agent: parser.AgentHermes, StartedAt: ts,
|
|
EndedAt: ts.Add(time.Minute), MessageCount: len(msgs),
|
|
File: parser.FileInfo{
|
|
Path: "/tmp/summary.json", Size: 10, Mtime: ts.UnixNano(),
|
|
},
|
|
TotalOutputTokens: rawTotal,
|
|
HasTotalOutputTokens: true,
|
|
PeakContextTokens: rawPeak,
|
|
HasPeakContextTokens: true,
|
|
}
|
|
|
|
prepared, _, verdict := e.prepareSessionWrite(
|
|
pendingWrite{sess: sess, msgs: msgs, usageEvents: events}, nil,
|
|
)
|
|
require.Equal(t, sessionWriteOK, verdict)
|
|
assert.Equal(t, rawTotal, prepared.TotalOutputTokens,
|
|
"session-summary usage event must not make the session aggregate event-derived")
|
|
assert.Equal(t, rawPeak, prepared.PeakContextTokens,
|
|
"summary-derived peak context must survive the per-row event clamp")
|
|
}
|
|
|
|
// TestPrepareSessionWriteReclampsEventDerivedCacheContext covers an
|
|
// event-derived peak context that, like the parser-side rollup, sums input and
|
|
// cache tokens. A corrupt cache value is clamped per-component in its
|
|
// usage_events row, so the event-derived peak must be re-derived from the
|
|
// clamped components rather than left at the raw inflated value.
|
|
func TestPrepareSessionWriteReclampsEventDerivedCacheContext(t *testing.T) {
|
|
d := openTestDB(t)
|
|
e := NewEngine(d, EngineConfig{Machine: "test-machine"})
|
|
ts := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
|
|
|
msgs := []parser.ParsedMessage{
|
|
{
|
|
Ordinal: 0, Role: parser.RoleUser, Content: "q",
|
|
ContentLength: 1, Timestamp: ts,
|
|
},
|
|
{
|
|
Ordinal: 1, Role: parser.RoleAssistant, Content: "a",
|
|
ContentLength: 1, Timestamp: ts.Add(time.Second),
|
|
},
|
|
}
|
|
// Per-event context = input + cache-creation + cache-read.
|
|
events := []parser.ParsedUsageEvent{
|
|
{OutputTokens: 100_000, InputTokens: 1_000_000, CacheReadInputTokens: 500_000},
|
|
{OutputTokens: 100_000, InputTokens: 800_000, CacheCreationInputTokens: 200_000},
|
|
// Corrupt event: each component is clamped to maxPlausibleTokens.
|
|
{
|
|
OutputTokens: 999_999_999, InputTokens: 999_999_999,
|
|
CacheReadInputTokens: 999_999_999,
|
|
},
|
|
}
|
|
rawTotal := 100_000 + 100_000 + 999_999_999
|
|
rawPeak := 999_999_999 + 999_999_999 // the corrupt event's input+cache
|
|
sess := parser.ParsedSession{
|
|
ID: "evt-cache-session", Project: "proj", Machine: "test-machine",
|
|
Agent: parser.AgentVSCodeCopilot, StartedAt: ts,
|
|
EndedAt: ts.Add(time.Minute), MessageCount: len(msgs),
|
|
File: parser.FileInfo{
|
|
Path: "/tmp/evtcache.json", Size: 10, Mtime: ts.UnixNano(),
|
|
},
|
|
TotalOutputTokens: rawTotal,
|
|
HasTotalOutputTokens: true,
|
|
PeakContextTokens: rawPeak,
|
|
HasPeakContextTokens: true,
|
|
}
|
|
|
|
prepared, _, verdict := e.prepareSessionWrite(
|
|
pendingWrite{sess: sess, msgs: msgs, usageEvents: events}, nil,
|
|
)
|
|
require.Equal(t, sessionWriteOK, verdict)
|
|
assert.Equal(t, 100_000+100_000+maxPlausibleTokens,
|
|
prepared.TotalOutputTokens,
|
|
"event-derived total re-derived from clamped output tokens")
|
|
// Peak = the corrupt event's input + cache-read, each clamped to the
|
|
// per-row bound: 2M + 2M. The sum is not clamped to the per-row bound.
|
|
assert.Equal(t, maxPlausibleTokens+maxPlausibleTokens,
|
|
prepared.PeakContextTokens,
|
|
"event-derived peak re-derived from clamped input+cache components")
|
|
}
|
|
|
|
// TestPrepareSessionWriteReclampsEventDerivedMixedSignTokens covers the
|
|
// parser rollup semantics shared with parser.UsageEventTokenAggregate: only
|
|
// positive output is summed and only positive context contributes to the peak.
|
|
// A mix of a negative (corrupt) event and an over-bound event must still be
|
|
// recognized as event-derived and re-derived from the clamped rows.
|
|
func TestPrepareSessionWriteReclampsEventDerivedMixedSignTokens(t *testing.T) {
|
|
d := openTestDB(t)
|
|
e := NewEngine(d, EngineConfig{Machine: "test-machine"})
|
|
ts := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
|
|
|
msgs := []parser.ParsedMessage{
|
|
{
|
|
Ordinal: 0, Role: parser.RoleUser, Content: "q",
|
|
ContentLength: 1, Timestamp: ts,
|
|
},
|
|
{
|
|
Ordinal: 1, Role: parser.RoleAssistant, Content: "a",
|
|
ContentLength: 1, Timestamp: ts.Add(time.Second),
|
|
},
|
|
}
|
|
events := []parser.ParsedUsageEvent{
|
|
{OutputTokens: 1_000_000, InputTokens: 1_500_000},
|
|
// Corrupt negative event: excluded from the positive-only rollup.
|
|
{OutputTokens: -5, InputTokens: -10},
|
|
// Over-bound event: clamped to the per-row cap.
|
|
{OutputTokens: 999_999_999, InputTokens: 999_999_999},
|
|
}
|
|
// Raw rollup (positive-only): output 1M + 999,999,999; peak context
|
|
// max(1.5M, 999,999,999).
|
|
rawTotal := 1_000_000 + 999_999_999
|
|
rawPeak := 999_999_999
|
|
sess := parser.ParsedSession{
|
|
ID: "evt-mixed-session", Project: "proj", Machine: "test-machine",
|
|
Agent: parser.AgentVSCodeCopilot, StartedAt: ts,
|
|
EndedAt: ts.Add(time.Minute), MessageCount: len(msgs),
|
|
File: parser.FileInfo{
|
|
Path: "/tmp/evtmixed.json", Size: 10, Mtime: ts.UnixNano(),
|
|
},
|
|
TotalOutputTokens: rawTotal,
|
|
HasTotalOutputTokens: true,
|
|
PeakContextTokens: rawPeak,
|
|
HasPeakContextTokens: true,
|
|
}
|
|
|
|
prepared, _, verdict := e.prepareSessionWrite(
|
|
pendingWrite{sess: sess, msgs: msgs, usageEvents: events}, nil,
|
|
)
|
|
require.Equal(t, sessionWriteOK, verdict)
|
|
// Negative output floors to 0 (dropped); over-bound output clamps to 2M.
|
|
assert.Equal(t, 1_000_000+maxPlausibleTokens, prepared.TotalOutputTokens,
|
|
"negative event excluded, over-bound event clamped in event total")
|
|
assert.Equal(t, maxPlausibleTokens, prepared.PeakContextTokens,
|
|
"event peak re-derived from clamped positive context")
|
|
}
|
|
|
|
// TestVisualStudioCopilotArchiveCompareUsesSanitizedParsed guards against a
|
|
// truncated reparse padded with control bytes bypassing archive preservation.
|
|
// Stored rows are sanitized and length-adjusted on write, so the reconcile must
|
|
// measure the parsed side the same way rather than against its raw length.
|
|
func TestVisualStudioCopilotArchiveCompareUsesSanitizedParsed(t *testing.T) {
|
|
stored := db.Message{
|
|
Role: "assistant",
|
|
Content: "complete answer",
|
|
ContentLength: len("complete answer"),
|
|
}
|
|
// Genuinely truncated content ("trunc"), padded with BEL control bytes so
|
|
// the RAW length (25) exceeds the stored length (15); sanitized it is 5.
|
|
raw := "trunc" + strings.Repeat("\x07", 20)
|
|
require.Greater(t, len(raw), stored.ContentLength,
|
|
"raw length must look long enough to expose the bug")
|
|
truncated := db.Message{Role: "assistant", Content: raw, ContentLength: len(raw)}
|
|
|
|
assert.True(t,
|
|
visualStudioCopilotMessageLooksIncomplete(truncated, stored),
|
|
"truncated reparse padded with control bytes must be incomplete")
|
|
|
|
// A reparse that differs only by stripped control bytes is neither
|
|
// incomplete nor an archive update: "complete\x07 answer" sanitizes to the
|
|
// stored "complete answer".
|
|
withControl := db.Message{
|
|
Role: "assistant",
|
|
Content: "complete\x07 answer",
|
|
ContentLength: len("complete\x07 answer"),
|
|
}
|
|
assert.False(t,
|
|
visualStudioCopilotMessageLooksIncomplete(withControl, stored),
|
|
"stripped-control reparse of equal text is not incomplete")
|
|
assert.False(t,
|
|
visualStudioCopilotMessageHasArchiveUpdate(withControl, stored),
|
|
"stripped-control reparse of equal text is not an archive update")
|
|
}
|
|
|
|
func TestVisualStudioCopilotArchiveDecisionMatchesTimestampShiftedToolCall(t *testing.T) {
|
|
stored := []db.Message{
|
|
{
|
|
Ordinal: 0,
|
|
Role: "assistant",
|
|
Content: "Run command: dotnet build",
|
|
ContentLength: len("Run command: dotnet build"),
|
|
Timestamp: "2026-06-12T19:46:40Z",
|
|
ToolCalls: []db.ToolCall{{
|
|
ToolName: "run_command_in_terminal",
|
|
ToolUseID: "call_build",
|
|
}},
|
|
},
|
|
{
|
|
Ordinal: 1,
|
|
Role: "user",
|
|
Content: "Archived prompt.",
|
|
ContentLength: len("Archived prompt."),
|
|
Timestamp: "2026-06-12T19:47:00Z",
|
|
},
|
|
}
|
|
parsed := []db.Message{{
|
|
Ordinal: 0,
|
|
Role: "assistant",
|
|
Content: "Run command: dotnet build",
|
|
ContentLength: len("Run command: dotnet build"),
|
|
Timestamp: "2026-06-12T19:47:40Z",
|
|
ToolCalls: []db.ToolCall{{
|
|
ToolName: "run_command_in_terminal",
|
|
ToolUseID: "call_build",
|
|
ResultEvents: []db.ToolResultEvent{{
|
|
ToolUseID: "call_build",
|
|
Source: "visualstudio-copilot",
|
|
Status: "completed",
|
|
Content: "Build succeeded.",
|
|
ContentLength: len("Build succeeded."),
|
|
}},
|
|
}},
|
|
}}
|
|
|
|
decision := visualStudioCopilotArchiveDecision(parsed, stored)
|
|
|
|
require.False(t, decision.preserve)
|
|
require.Len(t, decision.merged, 2)
|
|
assert.Equal(t, "Run command: dotnet build", decision.merged[0].Content)
|
|
assert.Equal(t, "2026-06-12T19:46:40Z", decision.merged[0].Timestamp,
|
|
"fallback merge should preserve the archived transcript anchor")
|
|
require.Len(t, decision.merged[0].ToolCalls, 1)
|
|
require.Len(t, decision.merged[0].ToolCalls[0].ResultEvents, 1)
|
|
assert.Equal(t, "Build succeeded.",
|
|
decision.merged[0].ToolCalls[0].ResultEvents[0].Content)
|
|
assert.Equal(t, "Archived prompt.", decision.merged[1].Content)
|
|
}
|
|
|
|
func TestVisualStudioCopilotArchiveDecisionMergesOnlyTimestampShiftedToolCall(t *testing.T) {
|
|
stored := []db.Message{{
|
|
Ordinal: 0,
|
|
Role: "assistant",
|
|
Content: "Run command: dotnet build",
|
|
ContentLength: len("Run command: dotnet build"),
|
|
Timestamp: "2026-06-12T19:46:40Z",
|
|
ToolCalls: []db.ToolCall{{
|
|
ToolName: "run_command_in_terminal",
|
|
ToolUseID: "call_build",
|
|
}},
|
|
}}
|
|
parsed := []db.Message{{
|
|
Ordinal: 0,
|
|
Role: "assistant",
|
|
Content: "Run command: dotnet build",
|
|
ContentLength: len("Run command: dotnet build"),
|
|
Timestamp: "2026-06-12T19:47:40Z",
|
|
ToolCalls: []db.ToolCall{{
|
|
ToolName: "run_command_in_terminal",
|
|
ToolUseID: "call_build",
|
|
}},
|
|
}}
|
|
|
|
decision := visualStudioCopilotArchiveDecision(parsed, stored)
|
|
|
|
require.False(t, decision.preserve)
|
|
require.Len(t, decision.merged, 1)
|
|
assert.Equal(t, "2026-06-12T19:46:40Z", decision.merged[0].Timestamp)
|
|
}
|
|
|
|
func TestVisualStudioCopilotArchiveDecisionMatchesTimestampShiftedUserPrompt(t *testing.T) {
|
|
stored := []db.Message{
|
|
{
|
|
Ordinal: 0,
|
|
Role: "user",
|
|
Content: "Archived prompt.",
|
|
ContentLength: len("Archived prompt."),
|
|
Timestamp: "2026-06-12T19:46:40Z",
|
|
},
|
|
{
|
|
Ordinal: 1,
|
|
Role: "assistant",
|
|
Content: "Archived answer.",
|
|
ContentLength: len("Archived answer."),
|
|
Timestamp: "2026-06-12T19:47:00Z",
|
|
},
|
|
}
|
|
parsed := []db.Message{{
|
|
Ordinal: 0,
|
|
Role: "user",
|
|
Content: "Archived prompt.",
|
|
ContentLength: len("Archived prompt."),
|
|
Timestamp: "2026-06-12T19:47:40Z",
|
|
}}
|
|
|
|
decision := visualStudioCopilotArchiveDecision(parsed, stored)
|
|
|
|
require.False(t, decision.preserve)
|
|
require.Len(t, decision.merged, 2)
|
|
assert.Equal(t, "Archived prompt.", decision.merged[0].Content)
|
|
assert.Equal(t, "2026-06-12T19:46:40Z", decision.merged[0].Timestamp)
|
|
assert.Equal(t, "Archived answer.", decision.merged[1].Content)
|
|
}
|
|
|
|
// fakeEmitter records scopes passed to Emit. Thread-safe so it
|
|
// can be called from engine goroutines under test.
|
|
type fakeEmitter struct {
|
|
mu gosync.Mutex
|
|
scopes []string
|
|
}
|
|
|
|
func (f *fakeEmitter) Emit(scope string) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.scopes = append(f.scopes, scope)
|
|
}
|
|
|
|
func (f *fakeEmitter) got() []string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := make([]string, len(f.scopes))
|
|
copy(out, f.scopes)
|
|
return out
|
|
}
|
|
|
|
// engineFixture bundles a *db.DB, a Claude directory, and an
|
|
// *Engine for emitter tests. The engine is rebuilt by
|
|
// engineWithEmitter so tests can swap emitters in.
|
|
type engineFixture struct {
|
|
db *db.DB
|
|
claudeDir string
|
|
engine *Engine
|
|
}
|
|
|
|
func newEngineFixture(t *testing.T) *engineFixture {
|
|
t.Helper()
|
|
fx := &engineFixture{
|
|
db: openTestDB(t),
|
|
claudeDir: t.TempDir(),
|
|
}
|
|
fx.engineWithEmitter(nil)
|
|
return fx
|
|
}
|
|
|
|
// engineWithEmitter builds a new *Engine wired to the fixture's
|
|
// db and claude dir, using em as the Emitter (nil for no
|
|
// emitter).
|
|
func (fx *engineFixture) engineWithEmitter(em Emitter) {
|
|
fx.engine = NewEngine(fx.db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentClaude: {fx.claudeDir},
|
|
},
|
|
Machine: "local",
|
|
Emitter: em,
|
|
})
|
|
}
|
|
|
|
// writeClaudeSession writes a minimal single-user-message
|
|
// Claude JSONL file under <claudeDir>/<proj>/<filename> and
|
|
// returns the full path. The session ID derived by the parser
|
|
// is the filename with .jsonl stripped.
|
|
func (fx *engineFixture) writeClaudeSession(
|
|
t *testing.T, proj, filename, firstMessage string,
|
|
) string {
|
|
t.Helper()
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser("2024-01-01T00:00:00Z", firstMessage).
|
|
String()
|
|
path := filepath.Join(fx.claudeDir, proj, filename)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
|
return path
|
|
}
|
|
|
|
// appendClaudeMessage appends a single user message to the
|
|
// existing JSONL file so that SyncSingleSession has new data
|
|
// to ingest.
|
|
func (fx *engineFixture) appendClaudeMessage(
|
|
t *testing.T, path, message string,
|
|
) {
|
|
t.Helper()
|
|
line := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser("2024-01-01T00:00:05Z", message).
|
|
String()
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "OpenFile")
|
|
defer f.Close()
|
|
_, err = f.WriteString(line)
|
|
require.NoError(t, err, "WriteString")
|
|
}
|
|
|
|
// sessionIDFor returns the session ID the engine uses for the
|
|
// given Claude JSONL file. For Claude sessions the ID is the
|
|
// filename stem (no .jsonl suffix).
|
|
func (fx *engineFixture) sessionIDFor(
|
|
t *testing.T, path string,
|
|
) string {
|
|
t.Helper()
|
|
return filepath.Base(path[:len(path)-len(".jsonl")])
|
|
}
|
|
|
|
func TestEngine_SyncAllEmitsWhenSessionsChange(t *testing.T) {
|
|
fx := newEngineFixture(t)
|
|
em := &fakeEmitter{}
|
|
fx.engineWithEmitter(em)
|
|
|
|
fx.writeClaudeSession(t, "proj", "s1.jsonl", "hello")
|
|
stats := fx.engine.SyncAll(context.Background(), nil)
|
|
require.NotZero(t, stats.Synced, "expected Synced > 0")
|
|
got := em.got()
|
|
require.Len(t, got, 1, "expected 1 emission, got %v", got)
|
|
assert.Equal(t, "sessions", got[0], "SyncAll scope")
|
|
}
|
|
|
|
func TestEngine_SyncAllDoesNotEmitOnEmptyRun(t *testing.T) {
|
|
fx := newEngineFixture(t)
|
|
em := &fakeEmitter{}
|
|
fx.engineWithEmitter(em)
|
|
|
|
// No session files — sync finds nothing.
|
|
stats := fx.engine.SyncAll(context.Background(), nil)
|
|
require.Zero(t, stats.Synced)
|
|
assert.Empty(t, em.got(), "expected no emissions")
|
|
}
|
|
|
|
func TestEngine_SyncPathsEmitsWhenSessionsChange(t *testing.T) {
|
|
fx := newEngineFixture(t)
|
|
em := &fakeEmitter{}
|
|
fx.engineWithEmitter(em)
|
|
|
|
path := fx.writeClaudeSession(t, "proj", "s1.jsonl", "hello")
|
|
fx.engine.SyncPaths([]string{path})
|
|
|
|
got := em.got()
|
|
require.Len(t, got, 1, "expected 1 emission, got %v", got)
|
|
assert.Equal(t, "sessions", got[0], "SyncPaths scope")
|
|
}
|
|
|
|
// emitterFunc adapts a plain function to the Emitter interface so
|
|
// tests can inline probing behavior without declaring a new type.
|
|
type emitterFunc func(scope string)
|
|
|
|
func (f emitterFunc) Emit(scope string) { f(scope) }
|
|
|
|
// TestEngine_SyncPathsEmitsAfterSyncMuReleased asserts that SyncPaths
|
|
// releases syncMu BEFORE invoking Emitter.Emit. The probe uses
|
|
// sync.Mutex.TryLock() synchronously: if the emit caller still holds
|
|
// the lock, TryLock returns false immediately; if the lock is already
|
|
// released, TryLock returns true. No goroutines, no wall-clock
|
|
// timeouts — deterministic under load.
|
|
func TestEngine_SyncPathsEmitsAfterSyncMuReleased(t *testing.T) {
|
|
fx := newEngineFixture(t)
|
|
|
|
var acquired atomic.Bool
|
|
em := emitterFunc(func(scope string) {
|
|
if fx.engine.syncMu.TryLock() {
|
|
fx.engine.syncMu.Unlock()
|
|
acquired.Store(true)
|
|
}
|
|
})
|
|
fx.engineWithEmitter(em)
|
|
|
|
path := fx.writeClaudeSession(t, "proj", "s1.jsonl", "hello")
|
|
fx.engine.SyncPaths([]string{path})
|
|
|
|
assert.True(t, acquired.Load(),
|
|
"syncMu was still held when SyncPaths emitted — defer-order regression")
|
|
}
|
|
|
|
func TestEngine_SyncPathsDoesNotEmitOnNoMatches(t *testing.T) {
|
|
fx := newEngineFixture(t)
|
|
em := &fakeEmitter{}
|
|
fx.engineWithEmitter(em)
|
|
|
|
// Path doesn't match any known session pattern — classifyPaths
|
|
// returns zero files and SyncPaths returns early.
|
|
fx.engine.SyncPaths([]string{"/nonexistent/bogus.txt"})
|
|
|
|
assert.Empty(t, em.got(), "expected no emissions")
|
|
}
|
|
|
|
func TestEngine_ClassifyOnePathClaudeStatPermissionErrorStillClassifies(
|
|
t *testing.T,
|
|
) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("permission semantics differ on Windows")
|
|
}
|
|
|
|
db := openTestDB(t)
|
|
claudeDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentClaude: {claudeDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
projectDir := filepath.Join(claudeDir, "proj")
|
|
path := filepath.Join(projectDir, "session.jsonl")
|
|
require.NoError(t, os.MkdirAll(projectDir, 0o755), "MkdirAll(%q)", projectDir)
|
|
require.NoError(t, os.WriteFile(path, []byte("[]"), 0o644), "WriteFile(%q)", path)
|
|
require.NoError(t, os.Chmod(projectDir, 0o000), "Chmod(%q)", projectDir)
|
|
defer func() {
|
|
_ = os.Chmod(projectDir, 0o755)
|
|
}()
|
|
|
|
// Claude is provider-authoritative, so classification flows through
|
|
// the provider's changed-path handling rather than the legacy
|
|
// classifyOnePath Claude block. A transient stat-permission error
|
|
// must still classify the path by shape so the change is not dropped.
|
|
files := engine.classifyPaths([]string{path})
|
|
require.Len(t, files, 1, "expected path to classify despite stat permission error")
|
|
assert.Equal(t, path, files[0].Path)
|
|
assert.Equal(t, parser.AgentClaude, files[0].Agent)
|
|
}
|
|
|
|
func TestEngine_ClassifyPathsDedupesOpenCodeChildPaths(t *testing.T) {
|
|
db := openTestDB(t)
|
|
opencodeDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentOpenCode: {opencodeDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionPath := filepath.Join(
|
|
opencodeDir, "storage", "session", "global",
|
|
"ses_123.json",
|
|
)
|
|
messagePath := filepath.Join(
|
|
opencodeDir, "storage", "message", "ses_123",
|
|
"msg_1.json",
|
|
)
|
|
partPath := filepath.Join(
|
|
opencodeDir, "storage", "part", "msg_1",
|
|
"part_1.json",
|
|
)
|
|
for path, content := range map[string]string{
|
|
sessionPath: `{"id":"ses_123","directory":"/tmp/proj","time":{"created":1,"updated":2}}`,
|
|
messagePath: `{"id":"msg_1","sessionID":"ses_123","role":"user","time":{"created":1}}`,
|
|
partPath: `{"id":"part_1","sessionID":"ses_123","messageID":"msg_1","type":"text","text":"hi","time":{"created":1}}`,
|
|
} {
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755), "MkdirAll(%q)", path)
|
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o644), "WriteFile(%q)", path)
|
|
}
|
|
|
|
files := engine.classifyPaths([]string{
|
|
messagePath,
|
|
partPath,
|
|
})
|
|
require.Len(t, files, 1)
|
|
assert.Equal(t, sessionPath, files[0].Path)
|
|
}
|
|
|
|
func TestEngine_ClassifyPathsOpenCodeRemovedMessageDir(
|
|
t *testing.T,
|
|
) {
|
|
db := openTestDB(t)
|
|
opencodeDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentOpenCode: {opencodeDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionPath := filepath.Join(
|
|
opencodeDir, "storage", "session", "global",
|
|
"ses_123.json",
|
|
)
|
|
messagePath := filepath.Join(
|
|
opencodeDir, "storage", "message", "ses_123",
|
|
"msg_1.json",
|
|
)
|
|
for path, content := range map[string]string{
|
|
sessionPath: `{"id":"ses_123","directory":"/tmp/proj","time":{"created":1,"updated":2}}`,
|
|
messagePath: `{"id":"msg_1","sessionID":"ses_123","role":"user","time":{"created":1}}`,
|
|
} {
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755), "MkdirAll(%q)", path)
|
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o644), "WriteFile(%q)", path)
|
|
}
|
|
|
|
messageDir := filepath.Dir(messagePath)
|
|
require.NoError(t, os.RemoveAll(messageDir), "RemoveAll(%q)", messageDir)
|
|
|
|
files := engine.classifyPaths([]string{messageDir})
|
|
require.Len(t, files, 1)
|
|
assert.Equal(t, sessionPath, files[0].Path)
|
|
}
|
|
|
|
// TestEngine_ClassifyPathsOpenCodeSQLiteWALFile covers a WAL-file change on
|
|
// a pure-SQLite OpenCode root. OpenCode is provider-authoritative, so the
|
|
// provider facade classifies the change into the per-session SQLite virtual
|
|
// paths it would re-parse rather than the raw opencode.db path.
|
|
func TestEngine_ClassifyPathsOpenCodeSQLiteWALFile(
|
|
t *testing.T,
|
|
) {
|
|
db := openTestDB(t)
|
|
opencodeDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentOpenCode: {opencodeDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
dbPath := filepath.Join(opencodeDir, "opencode.db")
|
|
seedOpenCodeSQLiteWALSession(t, dbPath, "ses_wal")
|
|
walPath := filepath.Join(opencodeDir, "opencode.db-wal")
|
|
walInfo, err := os.Stat(walPath)
|
|
require.NoError(t, err, "Stat(%q)", walPath)
|
|
require.Greater(t, walInfo.Size(), int64(32), "WAL must contain transaction frames")
|
|
|
|
files := engine.classifyPaths([]string{walPath})
|
|
require.Len(t, files, 1)
|
|
assert.Equal(t,
|
|
parser.OpenCodeSQLiteVirtualPath(dbPath, "ses_wal"),
|
|
files[0].Path,
|
|
)
|
|
assert.Equal(t, parser.AgentOpenCode, files[0].Agent)
|
|
}
|
|
|
|
// seedOpenCodeSQLiteWALSession creates a minimal OpenCode-shaped SQLite
|
|
// database and keeps its writer open with the session commit held in the WAL.
|
|
// This exercises the same uncheckpointed state produced by a live OpenCode
|
|
// process rather than using a synthetic sidecar that SQLite cannot read.
|
|
func seedOpenCodeSQLiteWALSession(t *testing.T, dbPath, sessionID string) {
|
|
t.Helper()
|
|
d, err := sql.Open("sqlite3", dbPath)
|
|
require.NoError(t, err, "open opencode db")
|
|
t.Cleanup(func() { d.Close() })
|
|
_, err = d.Exec(`
|
|
CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT NOT NULL);
|
|
CREATE TABLE session (
|
|
id TEXT PRIMARY KEY,
|
|
project_id TEXT NOT NULL,
|
|
parent_id TEXT,
|
|
title TEXT,
|
|
time_created INTEGER NOT NULL,
|
|
time_updated INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE message (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
data TEXT NOT NULL,
|
|
time_created INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE part (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
message_id TEXT NOT NULL,
|
|
data TEXT NOT NULL,
|
|
time_created INTEGER NOT NULL
|
|
);
|
|
`)
|
|
require.NoError(t, err, "create opencode schema")
|
|
var journalMode string
|
|
require.NoError(t, d.QueryRow("PRAGMA journal_mode=WAL").Scan(&journalMode))
|
|
require.Equal(t, "wal", journalMode)
|
|
_, err = d.Exec("PRAGMA wal_autocheckpoint=0")
|
|
require.NoError(t, err, "disable WAL autocheckpoint")
|
|
_, err = d.Exec(
|
|
"INSERT INTO project (id, worktree) VALUES ('prj_1', '/home/user/code/app')",
|
|
)
|
|
require.NoError(t, err, "insert project")
|
|
_, err = d.Exec(
|
|
`INSERT INTO session (id, project_id, time_created, time_updated)
|
|
VALUES (?, 'prj_1', 1, 2)`,
|
|
sessionID,
|
|
)
|
|
require.NoError(t, err, "insert session")
|
|
}
|
|
|
|
func TestEngine_ClassifyPathsOpenCodeRemovedMessageFile(
|
|
t *testing.T,
|
|
) {
|
|
db := openTestDB(t)
|
|
opencodeDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentOpenCode: {opencodeDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionPath := filepath.Join(
|
|
opencodeDir, "storage", "session", "global",
|
|
"ses_123.json",
|
|
)
|
|
messagePath := filepath.Join(
|
|
opencodeDir, "storage", "message", "ses_123",
|
|
"msg_1.json",
|
|
)
|
|
for path, content := range map[string]string{
|
|
sessionPath: `{"id":"ses_123","directory":"/tmp/proj","time":{"created":1,"updated":2}}`,
|
|
messagePath: `{"id":"msg_1","sessionID":"ses_123","role":"user","time":{"created":1}}`,
|
|
} {
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755), "MkdirAll(%q)", path)
|
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o644), "WriteFile(%q)", path)
|
|
}
|
|
|
|
require.NoError(t, os.Remove(messagePath), "Remove(%q)", messagePath)
|
|
|
|
files := engine.classifyPaths([]string{messagePath})
|
|
require.Len(t, files, 1)
|
|
assert.Equal(t, sessionPath, files[0].Path)
|
|
}
|
|
|
|
// TestEngine_ClassifyPathsOpenCodeFamilyRemovedSessionFile covers a removed
|
|
// storage session file for the provider-authoritative OpenCode-format agents.
|
|
// A delete event yields no reparse classification: there is no source to
|
|
// re-read, and the deletion is reconciled by the presence sweep rather than
|
|
// changed-path classification.
|
|
func TestEngine_ClassifyPathsOpenCodeFamilyRemovedSessionFile(
|
|
t *testing.T,
|
|
) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
agent parser.AgentType
|
|
sessionSubdir string
|
|
}{
|
|
{name: "opencode", agent: parser.AgentOpenCode, sessionSubdir: "session"},
|
|
{name: "kilo", agent: parser.AgentKilo, sessionSubdir: "session"},
|
|
{name: "mimocode", agent: parser.AgentMiMoCode, sessionSubdir: "session_diff"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
db := openTestDB(t)
|
|
root := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
tc.agent: {root},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionPath := filepath.Join(
|
|
root, "storage", tc.sessionSubdir, "global",
|
|
"ses_removed.json",
|
|
)
|
|
require.NoError(
|
|
t, os.MkdirAll(filepath.Dir(sessionPath), 0o755),
|
|
"MkdirAll(%q)", sessionPath,
|
|
)
|
|
require.NoError(
|
|
t,
|
|
os.WriteFile(
|
|
sessionPath,
|
|
[]byte(`{"id":"ses_removed","directory":"/tmp/proj","time":{"created":1,"updated":2}}`),
|
|
0o644,
|
|
),
|
|
"WriteFile(%q)", sessionPath,
|
|
)
|
|
require.NoError(t, os.Remove(sessionPath), "Remove(%q)", sessionPath)
|
|
|
|
files := engine.classifyPaths([]string{sessionPath})
|
|
assert.Empty(t, files)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEngine_ClassifyPathsProviderRemoveKeepsDeletedSQLiteSources(
|
|
t *testing.T,
|
|
) {
|
|
tests := []struct {
|
|
name string
|
|
agent parser.AgentType
|
|
path func(string) string
|
|
}{
|
|
{
|
|
name: "zed",
|
|
agent: parser.AgentZed,
|
|
path: func(root string) string {
|
|
return filepath.Join(root, "threads", "threads.db")
|
|
},
|
|
},
|
|
{
|
|
name: "shelley",
|
|
agent: parser.AgentShelley,
|
|
path: func(root string) string {
|
|
return filepath.Join(root, shelleyDBFile)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
db := openTestDB(t)
|
|
root := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
tt.agent: {root},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
dbPath := tt.path(root)
|
|
require.NoFileExists(t, dbPath)
|
|
|
|
files := engine.classifyPaths([]string{dbPath})
|
|
require.Len(t, files, 1)
|
|
assert.Equal(t, dbPath, files[0].Path)
|
|
assert.Equal(t, tt.agent, files[0].Agent)
|
|
assert.True(t, files[0].ForceParse)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEngine_ProcessFileProviderDeletedSQLiteSourcesDoNotFail(
|
|
t *testing.T,
|
|
) {
|
|
tests := []struct {
|
|
name string
|
|
agent parser.AgentType
|
|
path func(string) string
|
|
}{
|
|
{
|
|
name: "zed",
|
|
agent: parser.AgentZed,
|
|
path: func(root string) string {
|
|
return filepath.Join(root, "threads", "threads.db")
|
|
},
|
|
},
|
|
{
|
|
name: "shelley",
|
|
agent: parser.AgentShelley,
|
|
path: func(root string) string {
|
|
return filepath.Join(root, shelleyDBFile)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
db := openTestDB(t)
|
|
root := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
tt.agent: {root},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
dbPath := tt.path(root)
|
|
require.NoFileExists(t, dbPath)
|
|
|
|
res := engine.processFile(context.Background(), parser.DiscoveredFile{
|
|
Path: dbPath,
|
|
Agent: tt.agent,
|
|
ForceParse: true,
|
|
})
|
|
require.NoError(t, res.err)
|
|
assert.Empty(t, res.results)
|
|
assert.True(t, res.forceReplace)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEngine_ClassifyPathsOpenCodeRemovedPartDir(
|
|
t *testing.T,
|
|
) {
|
|
db := openTestDB(t)
|
|
opencodeDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentOpenCode: {opencodeDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionPath := filepath.Join(
|
|
opencodeDir, "storage", "session", "global",
|
|
"ses_123.json",
|
|
)
|
|
messagePath := filepath.Join(
|
|
opencodeDir, "storage", "message", "ses_123",
|
|
"msg_1.json",
|
|
)
|
|
partPath := filepath.Join(
|
|
opencodeDir, "storage", "part", "msg_1",
|
|
"part_1.json",
|
|
)
|
|
for path, content := range map[string]string{
|
|
sessionPath: `{"id":"ses_123","directory":"/tmp/proj","time":{"created":1,"updated":2}}`,
|
|
messagePath: `{"id":"msg_1","sessionID":"ses_123","role":"user","time":{"created":1}}`,
|
|
partPath: `{"id":"part_1","messageID":"msg_1","type":"text","text":"hi","time":{"created":1}}`,
|
|
} {
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755), "MkdirAll(%q)", path)
|
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o644), "WriteFile(%q)", path)
|
|
}
|
|
|
|
partDir := filepath.Dir(partPath)
|
|
require.NoError(t, os.RemoveAll(partDir), "RemoveAll(%q)", partDir)
|
|
|
|
files := engine.classifyPaths([]string{partDir})
|
|
require.Len(t, files, 1)
|
|
assert.Equal(t, sessionPath, files[0].Path)
|
|
}
|
|
|
|
func TestEngine_ClassifyPathsOpenCodeRemovedPartFile(
|
|
t *testing.T,
|
|
) {
|
|
db := openTestDB(t)
|
|
opencodeDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentOpenCode: {opencodeDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionPath := filepath.Join(
|
|
opencodeDir, "storage", "session", "global",
|
|
"ses_123.json",
|
|
)
|
|
messagePath := filepath.Join(
|
|
opencodeDir, "storage", "message", "ses_123",
|
|
"msg_1.json",
|
|
)
|
|
partPath := filepath.Join(
|
|
opencodeDir, "storage", "part", "msg_1",
|
|
"part_1.json",
|
|
)
|
|
for path, content := range map[string]string{
|
|
sessionPath: `{"id":"ses_123","directory":"/tmp/proj","time":{"created":1,"updated":2}}`,
|
|
messagePath: `{"id":"msg_1","sessionID":"ses_123","role":"user","time":{"created":1}}`,
|
|
partPath: `{"id":"part_1","messageID":"msg_1","type":"text","text":"hi","time":{"created":1}}`,
|
|
} {
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755), "MkdirAll(%q)", path)
|
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o644), "WriteFile(%q)", path)
|
|
}
|
|
|
|
require.NoError(t, os.Remove(partPath), "Remove(%q)", partPath)
|
|
|
|
files := engine.classifyPaths([]string{partPath})
|
|
require.Len(t, files, 1)
|
|
assert.Equal(t, sessionPath, files[0].Path)
|
|
}
|
|
|
|
// TestEngine_ClassifyPathsQwenSession verifies fsnotify events for
|
|
// Qwen session files (which live two levels deep under the projects
|
|
// root, at <projectsDir>/<encoded-project>/chats/<session>.jsonl) are
|
|
// classified as AgentQwen — the original WatchSubdirs="chats" wiring
|
|
// pointed the watcher at the wrong path, leaving live sync broken
|
|
// even after the classifier branch is reachable.
|
|
func TestEngine_ClassifyPathsQwenPawRejectsColon(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
// ":" is invalid in Windows filenames, so colon-bearing
|
|
// workspace/subdir/stem paths cannot be created there.
|
|
t.Skip("':' is invalid in Windows filenames")
|
|
}
|
|
db := openTestDB(t)
|
|
qwenpawDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentQwenPaw: {qwenpawDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
write := func(parts ...string) string {
|
|
p := filepath.Join(append([]string{qwenpawDir}, parts...)...)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755))
|
|
require.NoError(t, os.WriteFile(p, []byte("{}"), 0o644))
|
|
return p
|
|
}
|
|
|
|
rootPath := write("default", "sessions", "ok.json")
|
|
subPath := write("default", "sessions", "console", "ok.json")
|
|
// ":" in the workspace, subdir, or stem makes the joined ID
|
|
// ambiguous, so these must not classify.
|
|
colonWorkspace := write("ws:bad", "sessions", "ok.json")
|
|
colonSubdir := write("default", "sessions", "sub:bad", "ok.json")
|
|
colonStem := write("default", "sessions", "foo:bar.json")
|
|
|
|
files := engine.classifyPaths([]string{rootPath, subPath})
|
|
require.Len(t, files, 2)
|
|
for _, f := range files {
|
|
assert.Equal(t, parser.AgentQwenPaw, f.Agent)
|
|
assert.Equal(t, "default", f.Project)
|
|
}
|
|
|
|
got := engine.classifyPaths([]string{
|
|
colonWorkspace, colonSubdir, colonStem,
|
|
})
|
|
assert.Empty(t, got,
|
|
"colon-containing ID parts must not classify: %v", got)
|
|
}
|
|
|
|
func TestEngine_ClassifyPathsQwenSession(t *testing.T) {
|
|
db := openTestDB(t)
|
|
qwenDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentQwen: {qwenDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionID := "adc026b4-c620-43e4-8cc4-295593889d18"
|
|
encodedProject := "-Users-alice-code-sample-project"
|
|
chatsDir := filepath.Join(qwenDir, encodedProject, "chats")
|
|
require.NoError(t, os.MkdirAll(chatsDir, 0o755), "MkdirAll(%q)", chatsDir)
|
|
sessionPath := filepath.Join(chatsDir, sessionID+".jsonl")
|
|
require.NoError(t, os.WriteFile(sessionPath, []byte("{}\n"), 0o644), "WriteFile(%q)", sessionPath)
|
|
|
|
files := engine.classifyPaths([]string{sessionPath})
|
|
require.Len(t, files, 1, "len(files) = %d, want 1 (%v)", len(files), files)
|
|
assert.Equal(t, sessionPath, files[0].Path)
|
|
assert.Equal(t, parser.AgentQwen, files[0].Agent)
|
|
assert.Equal(t, "sample_project", files[0].Project)
|
|
|
|
// Non-Qwen siblings (a stray file directly under projectsDir, a
|
|
// file under <project>/<not-chats>/, a non-jsonl in chats/, and a
|
|
// path outside the canonical <encoded-project>/chats/ shape) must
|
|
// not classify as Qwen.
|
|
bogus := []string{
|
|
filepath.Join(qwenDir, "stray.jsonl"),
|
|
filepath.Join(qwenDir, "proj", "notes", "a.jsonl"),
|
|
filepath.Join(chatsDir, "notes.txt"),
|
|
filepath.Join(qwenDir, "chats", sessionID+".jsonl"),
|
|
}
|
|
for _, p := range bogus {
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755), "MkdirAll(%q)", p)
|
|
require.NoError(t, os.WriteFile(p, []byte("{}"), 0o644), "WriteFile(%q)", p)
|
|
}
|
|
got := engine.classifyPaths(bogus)
|
|
assert.Empty(t, got, "expected no Qwen classifications for %v, got %v", bogus, got)
|
|
}
|
|
|
|
func TestEngine_ClassifyPathsDeepSeekTUISession(t *testing.T) {
|
|
db := openTestDB(t)
|
|
deepSeekDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentDeepSeekTUI: {deepSeekDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionID := "adc026b4-c620-43e4-8cc4-295593889d18"
|
|
sessionPath := filepath.Join(deepSeekDir, sessionID+".json")
|
|
dbtest.WriteTestFile(t, sessionPath, []byte("{}"))
|
|
|
|
files := engine.classifyPaths([]string{sessionPath})
|
|
require.Len(t, files, 1, "len(files) = %d, want 1 (%v)", len(files), files)
|
|
assert.Equal(t, sessionPath, files[0].Path)
|
|
assert.Equal(t, parser.AgentDeepSeekTUI, files[0].Agent)
|
|
assert.True(t, files[0].ProviderProcess)
|
|
require.NotNil(t, files[0].ProviderSource)
|
|
assert.Equal(t, sessionPath, files[0].ProviderSource.DisplayPath)
|
|
|
|
bogus := []string{
|
|
filepath.Join(deepSeekDir, "stray.jsonl"),
|
|
filepath.Join(deepSeekDir, "latest.json"),
|
|
filepath.Join(deepSeekDir, "offline_queue.json"),
|
|
filepath.Join(deepSeekDir, "nested", sessionID+".json"),
|
|
filepath.Join(deepSeekDir, "..bad.json"),
|
|
}
|
|
for _, p := range bogus {
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755), "MkdirAll(%q)", p)
|
|
dbtest.WriteTestFile(t, p, []byte("{}"))
|
|
}
|
|
got := engine.classifyPaths(bogus)
|
|
assert.Empty(t, got, "expected no DeepSeek TUI classifications for %v, got %v", bogus, got)
|
|
}
|
|
|
|
func TestEngine_ClassifyPathsCommandCodeSession(t *testing.T) {
|
|
db := openTestDB(t)
|
|
commandCodeDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentCommandCode: {commandCodeDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionID := "adc026b4-c620-43e4-8cc4-295593889d18"
|
|
projectDir := filepath.Join(commandCodeDir, "users-alice-code-sample-project")
|
|
require.NoError(t, os.MkdirAll(projectDir, 0o755), "MkdirAll(%q)", projectDir)
|
|
sessionPath := filepath.Join(projectDir, sessionID+".jsonl")
|
|
dbtest.WriteTestFile(t, sessionPath, []byte("{}\n"))
|
|
|
|
files := engine.classifyPaths([]string{sessionPath})
|
|
require.Len(t, files, 1, "len(files) = %d, want 1 (%v)", len(files), files)
|
|
assert.Equal(t, sessionPath, files[0].Path)
|
|
assert.Equal(t, parser.AgentCommandCode, files[0].Agent)
|
|
// Command Code is provider-authoritative: classification attaches a
|
|
// provider source and recomputes the project during parse, so the
|
|
// classification carries no informational project hint.
|
|
assert.Empty(t, files[0].Project)
|
|
require.NotNil(t, files[0].ProviderSource)
|
|
|
|
bogus := []string{
|
|
filepath.Join(commandCodeDir, "stray.jsonl"),
|
|
filepath.Join(projectDir, "notes.txt"),
|
|
filepath.Join(projectDir, sessionID+".checkpoints.jsonl"),
|
|
filepath.Join(projectDir, sessionID+".prompts.jsonl"),
|
|
filepath.Join(projectDir, "nested", sessionID+".jsonl"),
|
|
}
|
|
for _, p := range bogus {
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755), "MkdirAll(%q)", p)
|
|
dbtest.WriteTestFile(t, p, []byte("{}"))
|
|
}
|
|
got := engine.classifyPaths(bogus)
|
|
assert.Empty(t, got, "expected no Command Code classifications for %v, got %v", bogus, got)
|
|
|
|
metaPath := filepath.Join(projectDir, sessionID+".meta.json")
|
|
dbtest.WriteTestFile(t, metaPath, []byte("{}"))
|
|
files = engine.classifyPaths([]string{metaPath})
|
|
require.Len(t, files, 1, "len(files) = %d, want 1 (%v)", len(files), files)
|
|
assert.Equal(t, sessionPath, files[0].Path)
|
|
assert.Equal(t, parser.AgentCommandCode, files[0].Agent)
|
|
}
|
|
|
|
func TestEngine_ClassifyPathsQClawSession(t *testing.T) {
|
|
db := openTestDB(t)
|
|
qclawDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentQClaw: {qclawDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
agentID := "main"
|
|
sessionID := "adc026b4-c620-43e4-8cc4-295593889d18"
|
|
sessionsDir := filepath.Join(qclawDir, agentID, "sessions")
|
|
sessionPath := filepath.Join(sessionsDir, sessionID+".jsonl")
|
|
dbtest.WriteTestFile(t, sessionPath, []byte("{}\n"))
|
|
|
|
files := engine.classifyPaths([]string{sessionPath})
|
|
require.Len(t, files, 1, "len(files) = %d, want 1 (%v)", len(files), files)
|
|
assert.Equal(t, sessionPath, files[0].Path)
|
|
assert.Equal(t, parser.AgentQClaw, files[0].Agent)
|
|
|
|
bogus := []string{
|
|
filepath.Join(qclawDir, "stray.jsonl"),
|
|
filepath.Join(qclawDir, agentID, "notes", sessionID+".jsonl"),
|
|
filepath.Join(sessionsDir, "notes.txt"),
|
|
filepath.Join(qclawDir, "not a session id", "sessions", sessionID+".jsonl"),
|
|
}
|
|
for _, p := range bogus {
|
|
dbtest.WriteTestFile(t, p, []byte("{}"))
|
|
}
|
|
got := engine.classifyPaths(bogus)
|
|
assert.Empty(t, got, "expected no QClaw classifications for %v, got %v", bogus, got)
|
|
}
|
|
|
|
func TestEngine_ClassifyPathsQClawArchivedSession(t *testing.T) {
|
|
db := openTestDB(t)
|
|
qclawDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentQClaw: {qclawDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
agentID := "main"
|
|
sessionID := "adc026b4-c620-43e4-8cc4-295593889d18"
|
|
sessionsDir := filepath.Join(qclawDir, agentID, "sessions")
|
|
|
|
active := filepath.Join(sessionsDir, sessionID+".jsonl")
|
|
archived := filepath.Join(
|
|
sessionsDir,
|
|
sessionID+".jsonl.deleted.2026-02-19T08-59-24.951Z",
|
|
)
|
|
dbtest.WriteTestFile(t, active, []byte("{}\n"))
|
|
dbtest.WriteTestFile(t, archived, []byte("{}\n"))
|
|
|
|
got := engine.classifyPaths([]string{archived})
|
|
require.Empty(t, got, "expected archived file shadowed by active to be ignored, got %v", got)
|
|
|
|
require.NoError(t, os.Remove(active), "Remove(%q)", active)
|
|
files := engine.classifyPaths([]string{archived})
|
|
require.Len(t, files, 1, "len(files) = %d, want 1 (%v)", len(files), files)
|
|
assert.Equal(t, archived, files[0].Path)
|
|
assert.Equal(t, parser.AgentQClaw, files[0].Agent)
|
|
}
|
|
|
|
func TestEngine_ClassifyOnePathReasonixProjectBareMeta(t *testing.T) {
|
|
db := openTestDB(t)
|
|
reasonixDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentReasonix: {reasonixDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionPath := filepath.Join(
|
|
reasonixDir, "projects", "proj", "sessions", "session-123.jsonl",
|
|
)
|
|
metaPath := sessionPath + ".meta"
|
|
dbtest.WriteTestFile(t, sessionPath, []byte(`{"role":"user","content":"hi"}`))
|
|
dbtest.WriteTestFile(t, metaPath, []byte(`{"model":"claude"}`))
|
|
|
|
files := engine.classifyPaths([]string{metaPath})
|
|
require.Len(t, files, 1, "expected Reasonix sidecar to classify")
|
|
assert.Equal(t, sessionPath, files[0].Path)
|
|
assert.Equal(t, "proj", files[0].Project)
|
|
assert.Equal(t, parser.AgentReasonix, files[0].Agent)
|
|
}
|
|
|
|
func TestEngine_ClassifyOnePathReasonixDeletedMeta(t *testing.T) {
|
|
db := openTestDB(t)
|
|
reasonixDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentReasonix: {reasonixDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionPath := filepath.Join(
|
|
reasonixDir, "projects", "proj", "sessions", "session-123.jsonl",
|
|
)
|
|
metaPath := sessionPath + ".meta"
|
|
dbtest.WriteTestFile(t, sessionPath, []byte(`{"role":"user","content":"hi"}`))
|
|
|
|
files := engine.classifyPaths([]string{metaPath})
|
|
require.Len(t, files, 1, "expected deleted Reasonix sidecar to classify")
|
|
assert.Equal(t, sessionPath, files[0].Path)
|
|
assert.Equal(t, "proj", files[0].Project)
|
|
assert.Equal(t, parser.AgentReasonix, files[0].Agent)
|
|
}
|
|
|
|
func TestEngine_ClassifyOnePathReasonixDeletedTranscriptIgnored(t *testing.T) {
|
|
db := openTestDB(t)
|
|
reasonixDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentReasonix: {reasonixDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionPath := filepath.Join(
|
|
reasonixDir, "projects", "proj", "sessions", "session-123.jsonl",
|
|
)
|
|
|
|
files := engine.classifyPaths([]string{sessionPath})
|
|
assert.Empty(t, files, "expected deleted Reasonix transcript to be ignored")
|
|
}
|
|
|
|
func TestEngine_SyncPathsReasonixMetadataOnlySessionFieldUpdate(t *testing.T) {
|
|
db := openTestDB(t)
|
|
reasonixDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentReasonix: {reasonixDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionPath := filepath.Join(reasonixDir, "sessions", "session-123.jsonl")
|
|
metaPath := sessionPath + ".meta"
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(sessionPath), 0o755))
|
|
require.NoError(t, os.WriteFile(sessionPath, []byte(
|
|
"{\"role\":\"user\",\"content\":\"hi\"}\n{\"role\":\"assistant\",\"content\":\"hello\"}\n",
|
|
), 0o644))
|
|
|
|
initialRoot := filepath.Join("workspace", "my-app")
|
|
initialMeta, err := json.Marshal(map[string]string{
|
|
"created_at": "2026-06-12T10:42:35.2672024Z",
|
|
"updated_at": "2026-06-12T10:58:03.6456434Z",
|
|
"topic_title": "Initial title",
|
|
"workspace_root": initialRoot,
|
|
"model": "claude-opus-4",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(metaPath, initialMeta, 0o644))
|
|
|
|
engine.SyncPaths([]string{sessionPath})
|
|
|
|
got, err := db.GetSessionFull(context.Background(), "reasonix:session-123")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got)
|
|
require.NotNil(t, got.DisplayName)
|
|
require.NotNil(t, got.SessionName)
|
|
assert.Equal(t, "Initial title", *got.DisplayName)
|
|
assert.Equal(t, "Initial title", *got.SessionName)
|
|
assert.Equal(t, initialRoot, got.Cwd)
|
|
assert.Equal(t, "my_app", got.Project)
|
|
|
|
updatedRoot := filepath.Join("workspace", "renamed-app")
|
|
updatedMeta, err := json.Marshal(map[string]string{
|
|
"created_at": "2026-06-12T10:42:35.2672024Z",
|
|
"updated_at": "2026-06-12T10:58:03.6456434Z",
|
|
"topic_title": "Updated title",
|
|
"workspace_root": updatedRoot,
|
|
"model": "claude-opus-4",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(metaPath, updatedMeta, 0o644))
|
|
future := time.Date(2026, time.June, 19, 2, 55, 0, 0, time.UTC)
|
|
require.NoError(t, os.Chtimes(metaPath, future, future))
|
|
|
|
engine.SyncPaths([]string{metaPath})
|
|
|
|
got, err = db.GetSessionFull(context.Background(), "reasonix:session-123")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got)
|
|
require.NotNil(t, got.DisplayName)
|
|
require.NotNil(t, got.SessionName)
|
|
assert.Equal(t, "Updated title", *got.DisplayName)
|
|
assert.Equal(t, "Updated title", *got.SessionName)
|
|
assert.Equal(t, updatedRoot, got.Cwd)
|
|
assert.Equal(t, "renamed_app", got.Project)
|
|
}
|
|
|
|
func TestEngine_SyncPathsReasonixDeletedMetadataClearsSessionFields(t *testing.T) {
|
|
db := openTestDB(t)
|
|
reasonixDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentReasonix: {reasonixDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionPath := filepath.Join(reasonixDir, "sessions", "session-123.jsonl")
|
|
metaPath := sessionPath + ".meta"
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(sessionPath), 0o755))
|
|
require.NoError(t, os.WriteFile(sessionPath, []byte(
|
|
"{\"role\":\"user\",\"content\":\"hi\"}\n"+
|
|
"{\"role\":\"assistant\",\"content\":\"hello\"}\n",
|
|
), 0o644))
|
|
|
|
initialRoot := filepath.Join("workspace", "my-app")
|
|
initialMeta, err := json.Marshal(map[string]string{
|
|
"created_at": "2026-06-12T10:42:35.2672024Z",
|
|
"updated_at": "2026-06-12T10:58:03.6456434Z",
|
|
"topic_title": "Initial title",
|
|
"workspace_root": initialRoot,
|
|
"model": "claude-opus-4",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(metaPath, initialMeta, 0o644))
|
|
|
|
engine.SyncPaths([]string{sessionPath})
|
|
|
|
require.NoError(t, os.Remove(metaPath))
|
|
engine.SyncPaths([]string{metaPath})
|
|
|
|
got, err := db.GetSessionFull(context.Background(), "reasonix:session-123")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got)
|
|
assert.Nil(t, got.DisplayName)
|
|
assert.Nil(t, got.SessionName)
|
|
assert.Equal(t, "", got.Cwd)
|
|
assert.Equal(t, "", got.Project)
|
|
}
|
|
|
|
func TestEngine_SyncSingleSessionReasonixDeletedMetadataClearsProject(t *testing.T) {
|
|
db := openTestDB(t)
|
|
reasonixDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentReasonix: {reasonixDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionPath := filepath.Join(reasonixDir, "sessions", "session-123.jsonl")
|
|
metaPath := sessionPath + ".meta"
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(sessionPath), 0o755))
|
|
require.NoError(t, os.WriteFile(sessionPath, []byte(
|
|
"{\"role\":\"user\",\"content\":\"hi\"}\n"+
|
|
"{\"role\":\"assistant\",\"content\":\"hello\"}\n",
|
|
), 0o644))
|
|
|
|
initialRoot := filepath.Join("workspace", "my-app")
|
|
initialMeta, err := json.Marshal(map[string]string{
|
|
"created_at": "2026-06-12T10:42:35.2672024Z",
|
|
"updated_at": "2026-06-12T10:58:03.6456434Z",
|
|
"topic_title": "Initial title",
|
|
"workspace_root": initialRoot,
|
|
"model": "claude-opus-4",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(metaPath, initialMeta, 0o644))
|
|
|
|
engine.SyncPaths([]string{sessionPath})
|
|
|
|
got, err := db.GetSessionFull(context.Background(), "reasonix:session-123")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "my_app", got.Project)
|
|
|
|
require.NoError(t, os.Remove(metaPath))
|
|
require.NoError(t, db.Update(func(tx *sql.Tx) error {
|
|
_, err := tx.Exec(
|
|
"UPDATE sessions SET file_mtime = NULL WHERE id = ?",
|
|
"reasonix:session-123",
|
|
)
|
|
return err
|
|
}))
|
|
|
|
require.NoError(t, engine.SyncSingleSession("reasonix:session-123"))
|
|
|
|
got, err = db.GetSessionFull(context.Background(), "reasonix:session-123")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "", got.Project)
|
|
}
|
|
|
|
func TestEngine_SyncPathsReasonixMalformedMetadataPreservesSessionFields(t *testing.T) {
|
|
db := openTestDB(t)
|
|
reasonixDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentReasonix: {reasonixDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionPath := filepath.Join(reasonixDir, "sessions", "session-123.jsonl")
|
|
metaPath := sessionPath + ".meta"
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(sessionPath), 0o755))
|
|
require.NoError(t, os.WriteFile(sessionPath, []byte(
|
|
"{\"role\":\"user\",\"content\":\"hi\"}\n"+
|
|
"{\"role\":\"assistant\",\"content\":\"hello\"}\n",
|
|
), 0o644))
|
|
|
|
initialRoot := filepath.Join("workspace", "my-app")
|
|
initialMeta, err := json.Marshal(map[string]string{
|
|
"created_at": "2026-06-12T10:42:35.2672024Z",
|
|
"updated_at": "2026-06-12T10:58:03.6456434Z",
|
|
"topic_title": "Initial title",
|
|
"workspace_root": initialRoot,
|
|
"model": "claude-opus-4",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(metaPath, initialMeta, 0o644))
|
|
|
|
engine.SyncPaths([]string{sessionPath})
|
|
|
|
require.NoError(t, os.WriteFile(metaPath, []byte(`{"topic_title":`), 0o644))
|
|
future := time.Date(2026, time.June, 19, 4, 15, 0, 0, time.UTC)
|
|
require.NoError(t, os.Chtimes(metaPath, future, future))
|
|
|
|
engine.SyncPaths([]string{metaPath})
|
|
|
|
got, err := db.GetSessionFull(context.Background(), "reasonix:session-123")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got)
|
|
require.NotNil(t, got.DisplayName)
|
|
require.NotNil(t, got.SessionName)
|
|
assert.Equal(t, "Initial title", *got.DisplayName)
|
|
assert.Equal(t, "Initial title", *got.SessionName)
|
|
assert.Equal(t, initialRoot, got.Cwd)
|
|
assert.Equal(t, "my_app", got.Project)
|
|
}
|
|
|
|
func TestEngine_SyncPathsReasonixMalformedMetadataRecoveryUpdatesSession(t *testing.T) {
|
|
db := openTestDB(t)
|
|
reasonixDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentReasonix: {reasonixDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionPath := filepath.Join(reasonixDir, "sessions", "session-123.jsonl")
|
|
metaPath := sessionPath + ".meta"
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(sessionPath), 0o755))
|
|
require.NoError(t, os.WriteFile(sessionPath, []byte(
|
|
"{\"role\":\"user\",\"content\":\"hi\"}\n"+
|
|
"{\"role\":\"assistant\",\"content\":\"hello\"}\n",
|
|
), 0o644))
|
|
|
|
initialRoot := filepath.Join("workspace", "my-app")
|
|
initialMeta, err := json.Marshal(map[string]string{
|
|
"created_at": "2026-06-12T10:42:35.2672024Z",
|
|
"updated_at": "2026-06-12T10:58:03.6456434Z",
|
|
"topic_title": "Initial title",
|
|
"workspace_root": initialRoot,
|
|
"model": "claude-opus-4",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(metaPath, initialMeta, 0o644))
|
|
|
|
engine.SyncPaths([]string{sessionPath})
|
|
|
|
transcriptInfo, err := os.Stat(sessionPath)
|
|
require.NoError(t, err)
|
|
badMtime := transcriptInfo.ModTime().Add(time.Minute)
|
|
require.NoError(t, os.WriteFile(metaPath, []byte(`{"topic_title":`), 0o644))
|
|
require.NoError(t, os.Chtimes(metaPath, badMtime, badMtime))
|
|
engine.SyncPaths([]string{metaPath})
|
|
|
|
updatedRoot := filepath.Join("workspace", "renamed-app")
|
|
updatedMeta, err := json.Marshal(map[string]string{
|
|
"created_at": "2026-06-12T10:42:35.2672024Z",
|
|
"updated_at": "2026-06-12T10:58:03.6456434Z",
|
|
"topic_title": "Recovered title",
|
|
"workspace_root": updatedRoot,
|
|
"model": "claude-opus-4",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(metaPath, updatedMeta, 0o644))
|
|
recoveredMtime := badMtime.Add(time.Minute)
|
|
require.NoError(t, os.Chtimes(metaPath, recoveredMtime, recoveredMtime))
|
|
|
|
engine.SyncPaths([]string{metaPath})
|
|
|
|
got, err := db.GetSessionFull(context.Background(), "reasonix:session-123")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got)
|
|
require.NotNil(t, got.DisplayName)
|
|
require.NotNil(t, got.SessionName)
|
|
assert.Equal(t, "Recovered title", *got.DisplayName)
|
|
assert.Equal(t, "Recovered title", *got.SessionName)
|
|
assert.Equal(t, updatedRoot, got.Cwd)
|
|
assert.Equal(t, "renamed_app", got.Project)
|
|
}
|
|
|
|
func TestEngine_SyncPathsReasonixProjectLayoutMetadataProjectUpdate(t *testing.T) {
|
|
db := openTestDB(t)
|
|
reasonixDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentReasonix: {reasonixDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionPath := filepath.Join(
|
|
reasonixDir, "projects", "layout-name", "sessions", "session-123", "session-123.jsonl",
|
|
)
|
|
metaPath := sessionPath + ".meta"
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(sessionPath), 0o755))
|
|
require.NoError(t, os.WriteFile(sessionPath, []byte(
|
|
"{\"role\":\"user\",\"content\":\"hi\"}\n{\"role\":\"assistant\",\"content\":\"hello\"}\n",
|
|
), 0o644))
|
|
|
|
initialMeta, err := json.Marshal(map[string]string{
|
|
"created_at": "2026-06-12T10:42:35.2672024Z",
|
|
"updated_at": "2026-06-12T10:58:03.6456434Z",
|
|
"topic_title": "Initial title",
|
|
"workspace_root": filepath.Join("workspace", "my-app"),
|
|
"model": "claude-opus-4",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(metaPath, initialMeta, 0o644))
|
|
|
|
engine.SyncPaths([]string{sessionPath})
|
|
|
|
got, err := db.GetSessionFull(context.Background(), "reasonix:session-123")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "my_app", got.Project)
|
|
|
|
updatedMeta, err := json.Marshal(map[string]string{
|
|
"created_at": "2026-06-12T10:42:35.2672024Z",
|
|
"updated_at": "2026-06-12T10:58:03.6456434Z",
|
|
"topic_title": "Updated title",
|
|
"workspace_root": filepath.Join("workspace", "renamed-app"),
|
|
"model": "claude-opus-4",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(metaPath, updatedMeta, 0o644))
|
|
future := time.Date(2026, time.June, 19, 3, 30, 0, 0, time.UTC)
|
|
require.NoError(t, os.Chtimes(metaPath, future, future))
|
|
|
|
engine.SyncPaths([]string{metaPath})
|
|
|
|
got, err = db.GetSessionFull(context.Background(), "reasonix:session-123")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "renamed_app", got.Project)
|
|
}
|
|
|
|
func TestEngine_SyncSingleSessionReasonixProjectLayoutPreservesProject(t *testing.T) {
|
|
db := openTestDB(t)
|
|
reasonixDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentReasonix: {reasonixDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionPath := filepath.Join(
|
|
reasonixDir, "projects", "layout-name", "sessions",
|
|
"session-123", "session-123.jsonl",
|
|
)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(sessionPath), 0o755))
|
|
require.NoError(t, os.WriteFile(sessionPath, []byte(
|
|
"{\"role\":\"user\",\"content\":\"hi\"}\n"+
|
|
"{\"role\":\"assistant\",\"content\":\"hello\"}\n",
|
|
), 0o644))
|
|
|
|
engine.SyncPaths([]string{sessionPath})
|
|
|
|
got, err := db.GetSessionFull(context.Background(), "reasonix:session-123")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "layout-name", got.Project)
|
|
|
|
require.NoError(t, db.Update(func(tx *sql.Tx) error {
|
|
_, err := tx.Exec(
|
|
"UPDATE sessions SET file_mtime = NULL WHERE id = ?",
|
|
"reasonix:session-123",
|
|
)
|
|
return err
|
|
}))
|
|
|
|
require.NoError(t, engine.SyncSingleSession("reasonix:session-123"))
|
|
|
|
got, err = db.GetSessionFull(context.Background(), "reasonix:session-123")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, got)
|
|
assert.Equal(t, "layout-name", got.Project)
|
|
}
|
|
|
|
func TestEngine_SyncPathsReasonixPersistsToolResultContent(t *testing.T) {
|
|
database := openTestDB(t)
|
|
reasonixDir := t.TempDir()
|
|
engine := NewEngine(database, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentReasonix: {reasonixDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionPath := filepath.Join(reasonixDir, "sessions", "tool-result.jsonl")
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(sessionPath), 0o755))
|
|
require.NoError(t, os.WriteFile(sessionPath, []byte(
|
|
"{\"role\":\"user\",\"content\":\"Read the file\"}\n"+
|
|
"{\"role\":\"assistant\",\"content\":\"I'll read it\","+
|
|
"\"tool_calls\":[{\"id\":\"call_1\",\"name\":\"read_file\","+
|
|
"\"arguments\":\"{\\\"path\\\":\\\"config.json\\\"}\"}]}\n"+
|
|
"{\"role\":\"tool\",\"content\":\"file contents here\","+
|
|
"\"tool_call_id\":\"call_1\"}\n",
|
|
), 0o644))
|
|
|
|
engine.SyncPaths([]string{sessionPath})
|
|
|
|
msgs, err := database.GetAllMessages(context.Background(), "reasonix:tool-result")
|
|
require.NoError(t, err)
|
|
require.Len(t, msgs, 2)
|
|
require.Len(t, msgs[1].ToolCalls, 1)
|
|
assert.Equal(t, "file contents here", msgs[1].ToolCalls[0].ResultContent)
|
|
assert.Equal(t, len("file contents here"), msgs[1].ToolCalls[0].ResultContentLength)
|
|
}
|
|
|
|
func TestEngine_SyncSingleSessionEmitsOnSuccess(t *testing.T) {
|
|
fx := newEngineFixture(t)
|
|
em := &fakeEmitter{}
|
|
fx.engineWithEmitter(em)
|
|
|
|
path := fx.writeClaudeSession(t, "proj", "s1.jsonl", "hello")
|
|
// Seed DB first so SyncSingleSession has something to find.
|
|
fx.engine.SyncPaths([]string{path})
|
|
|
|
// Clear emissions from the seed, then append + SyncSingleSession.
|
|
em.mu.Lock()
|
|
em.scopes = em.scopes[:0]
|
|
em.mu.Unlock()
|
|
|
|
fx.appendClaudeMessage(t, path, "world")
|
|
sessionID := fx.sessionIDFor(t, path)
|
|
require.NoError(t, fx.engine.SyncSingleSession(sessionID), "SyncSingleSession")
|
|
got := em.got()
|
|
require.Len(t, got, 1, "expected 1 emission, got %v", got)
|
|
assert.Equal(t, "messages", got[0], "SyncSingleSession scope")
|
|
}
|
|
|
|
func TestToDBSessionTerminationStatus(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
in parser.TerminationStatus
|
|
want *string
|
|
}{
|
|
{name: "empty maps to nil", in: "", want: nil},
|
|
{name: "clean maps to pointer", in: parser.TerminationClean, want: new("clean")},
|
|
{name: "tool_call_pending maps to pointer", in: parser.TerminationToolCallPending, want: new("tool_call_pending")},
|
|
{name: "truncated maps to pointer", in: parser.TerminationTruncated, want: new("truncated")},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
pw := pendingWrite{
|
|
sess: parser.ParsedSession{
|
|
ID: "s1",
|
|
Project: "p",
|
|
Machine: "m",
|
|
Agent: parser.AgentClaude,
|
|
StartedAt: time.Now(),
|
|
EndedAt: time.Now(),
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
TerminationStatus: tc.in,
|
|
},
|
|
}
|
|
got := toDBSession(pw)
|
|
|
|
if tc.want == nil {
|
|
assert.Nil(t, got.TerminationStatus)
|
|
} else {
|
|
require.NotNil(t, got.TerminationStatus)
|
|
assert.Equal(t, *tc.want, *got.TerminationStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestToDBSessionCarriesSessionName(t *testing.T) {
|
|
pw := pendingWrite{sess: parser.ParsedSession{
|
|
ID: "s1",
|
|
Project: "p",
|
|
Agent: parser.AgentClaude,
|
|
SessionName: "agent-name",
|
|
}}
|
|
s := toDBSession(pw)
|
|
require.NotNil(t, s.SessionName)
|
|
assert.Equal(t, "agent-name", *s.SessionName)
|
|
// converter must not touch display_name — only RenameSession may write it.
|
|
assert.Nil(t, s.DisplayName)
|
|
|
|
s2 := toDBSession(pendingWrite{sess: parser.ParsedSession{
|
|
ID: "s2",
|
|
Project: "p",
|
|
Agent: parser.AgentClaude,
|
|
}})
|
|
assert.Nil(t, s2.SessionName)
|
|
assert.Nil(t, s2.DisplayName)
|
|
}
|
|
|
|
// TestDiscoveredFileMtimeVisualStudioCopilotResolvesVirtualPath verifies that
|
|
// the mtime helper resolves a <traceFile>#<conversationID> virtual path to its
|
|
// physical trace before stat. Without resolution os.Stat fails on the virtual
|
|
// path, so SyncAllSince's mtime filter cannot drop unchanged Visual Studio
|
|
// conversations and re-syncs every one of them on each poll.
|
|
func TestDiscoveredFileMtimeVisualStudioCopilotResolvesVirtualPath(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tracePath := filepath.Join(
|
|
dir, "20260612T194439_257709a3_VSGitHubCopilot_traces.jsonl",
|
|
)
|
|
require.NoError(t, os.WriteFile(tracePath, []byte("{}\n"), 0o644))
|
|
info, err := os.Stat(tracePath)
|
|
require.NoError(t, err)
|
|
|
|
virtual := parser.VisualStudioCopilotVirtualPath(
|
|
tracePath, "4a8f63f6-7626-4416-a874-fc7bd2c3f005",
|
|
)
|
|
mtime, err := discoveredFileMtime(parser.DiscoveredFile{
|
|
Path: virtual,
|
|
Agent: parser.AgentVSCopilot,
|
|
})
|
|
require.NoError(t, err,
|
|
"virtual path must resolve to the physical trace for stat")
|
|
assert.Equal(t, info.ModTime().UnixNano(), mtime)
|
|
}
|
|
|
|
// TestWriteIncrementalBlanksImplausibleEndedAt verifies that the
|
|
// incremental sync path runs the appended ended_at through the same
|
|
// timestamp plausibility check the full path applies in sanitizeSession.
|
|
// An out-of-window ended_at must not persist via incremental sync while a
|
|
// full sync of the same file would blank it (an incremental-vs-full
|
|
// parity divergence).
|
|
func TestWriteIncrementalBlanksImplausibleEndedAt(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
endedAt time.Time
|
|
}{
|
|
{name: "far past", endedAt: time.Date(1850, 1, 1, 0, 0, 0, 0, time.UTC)},
|
|
{name: "far future", endedAt: time.Date(2999, 1, 1, 0, 0, 0, 0, time.UTC)},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
database := openTestDB(t)
|
|
// writeIncremental needs the signal scheduler, so build
|
|
// via NewEngine rather than a bare struct literal.
|
|
e := NewEngine(database, EngineConfig{})
|
|
|
|
plausibleEnd := time.Date(2026, 6, 20, 10, 0, 0, 0, time.UTC)
|
|
start := plausibleEnd.Add(-time.Hour)
|
|
pw := pendingWrite{
|
|
sess: parser.ParsedSession{
|
|
ID: "inc-ts",
|
|
Project: "proj",
|
|
Machine: "host",
|
|
Agent: parser.AgentClaude,
|
|
StartedAt: start,
|
|
EndedAt: plausibleEnd,
|
|
MessageCount: 1,
|
|
},
|
|
msgs: []parser.ParsedMessage{{
|
|
Role: parser.RoleUser,
|
|
Content: "hello",
|
|
Timestamp: start,
|
|
}},
|
|
}
|
|
_, _, failed, _ := e.writeBatch(
|
|
[]pendingWrite{pw}, syncWriteDefault, false,
|
|
)
|
|
require.Equal(t, 0, failed, "initial session write must not fail")
|
|
|
|
before, err := database.GetSessionFull(context.Background(), "inc-ts")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, before)
|
|
require.NotNil(t, before.EndedAt, "baseline ended_at must be set")
|
|
wantEnd := *before.EndedAt
|
|
|
|
err = e.writeIncremental(&incrementalUpdate{
|
|
sessionID: "inc-ts",
|
|
msgs: []parser.ParsedMessage{{
|
|
Role: parser.RoleAssistant,
|
|
Content: "world",
|
|
Timestamp: plausibleEnd,
|
|
Ordinal: 1,
|
|
}},
|
|
endedAt: tc.endedAt,
|
|
msgCount: 2,
|
|
userMsgCount: 1,
|
|
fileSize: 100,
|
|
fileMtime: plausibleEnd.UnixNano(),
|
|
})
|
|
require.NoError(t, err, "writeIncremental")
|
|
|
|
after, err := database.GetSessionFull(context.Background(), "inc-ts")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, after)
|
|
require.NotNil(t, after.EndedAt,
|
|
"implausible ended_at must be blanked, leaving the prior value via COALESCE")
|
|
// The implausible appended timestamp must not have been
|
|
// stored. Because it is blanked to nil, COALESCE keeps the
|
|
// prior plausible value.
|
|
assert.Equal(t, wantEnd, *after.EndedAt,
|
|
"implausible ended_at must not overwrite the plausible value")
|
|
assert.NotContains(t, *after.EndedAt, "1850",
|
|
"far-past ended_at must not persist")
|
|
assert.NotContains(t, *after.EndedAt, "2999",
|
|
"far-future ended_at must not persist")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWriteIncrementalKeepsPlausibleEndedAt is the positive control for
|
|
// TestWriteIncrementalBlanksImplausibleEndedAt: a plausible appended
|
|
// ended_at must still update the column.
|
|
func TestWriteIncrementalKeepsPlausibleEndedAt(t *testing.T) {
|
|
database := openTestDB(t)
|
|
// writeIncremental needs the signal scheduler, so build via
|
|
// NewEngine rather than a bare struct literal.
|
|
e := NewEngine(database, EngineConfig{})
|
|
|
|
start := time.Date(2026, 6, 20, 10, 0, 0, 0, time.UTC)
|
|
firstEnd := start.Add(time.Hour)
|
|
pw := pendingWrite{
|
|
sess: parser.ParsedSession{
|
|
ID: "inc-ts-ok",
|
|
Project: "proj",
|
|
Machine: "host",
|
|
Agent: parser.AgentClaude,
|
|
StartedAt: start,
|
|
EndedAt: firstEnd,
|
|
MessageCount: 1,
|
|
},
|
|
msgs: []parser.ParsedMessage{{
|
|
Role: parser.RoleUser,
|
|
Content: "hello",
|
|
Timestamp: start,
|
|
}},
|
|
}
|
|
_, _, failed, _ := e.writeBatch(
|
|
[]pendingWrite{pw}, syncWriteDefault, false,
|
|
)
|
|
require.Equal(t, 0, failed, "initial session write must not fail")
|
|
|
|
newEnd := start.Add(2 * time.Hour)
|
|
err := e.writeIncremental(&incrementalUpdate{
|
|
sessionID: "inc-ts-ok",
|
|
msgs: []parser.ParsedMessage{{
|
|
Role: parser.RoleAssistant,
|
|
Content: "world",
|
|
Timestamp: newEnd,
|
|
Ordinal: 1,
|
|
}},
|
|
endedAt: newEnd,
|
|
msgCount: 2,
|
|
userMsgCount: 1,
|
|
fileSize: 100,
|
|
fileMtime: newEnd.UnixNano(),
|
|
})
|
|
require.NoError(t, err, "writeIncremental")
|
|
|
|
after, err := database.GetSessionFull(context.Background(), "inc-ts-ok")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, after)
|
|
require.NotNil(t, after.EndedAt)
|
|
gotEnd, ok := parseStoredTimestamp(*after.EndedAt)
|
|
require.True(t, ok, "stored ended_at must parse")
|
|
assert.True(t, gotEnd.Equal(newEnd),
|
|
"plausible appended ended_at must update the column: got %q want %s",
|
|
*after.EndedAt, newEnd.Format(time.RFC3339Nano))
|
|
}
|
|
|
|
func TestConvertToolCallsFilePathAndCallIndex(t *testing.T) {
|
|
parsed := []parser.ParsedToolCall{
|
|
{ToolName: "Edit", Category: "Edit", ToolUseID: "a",
|
|
InputJSON: `{"file_path":"/x.go"}`}, // resolved from JSON
|
|
{ToolName: "Write", Category: "Write", ToolUseID: "b",
|
|
InputJSON: "raw diff not json", FilePath: "/native.go"}, // native wins
|
|
{ToolName: "Bash", Category: "Bash", ToolUseID: "c",
|
|
InputJSON: `{"command":"ls"}`}, // no path
|
|
}
|
|
got := convertToolCalls("sess-1", parsed)
|
|
require.Len(t, got, 3)
|
|
assert.Equal(t, "/x.go", got[0].FilePath)
|
|
assert.Equal(t, 0, got[0].CallIndex)
|
|
assert.Equal(t, "/native.go", got[1].FilePath)
|
|
assert.Equal(t, 1, got[1].CallIndex)
|
|
assert.Equal(t, "", got[2].FilePath)
|
|
assert.Equal(t, 2, got[2].CallIndex)
|
|
}
|
|
|
|
// codexRenameFixture is a seeded Codex session whose stored file_mtime is the
|
|
// folded index-mtime watermark, used to exercise title-rename detection in
|
|
// codexIndexSessionNameChanged.
|
|
type codexRenameFixture struct {
|
|
e *Engine
|
|
path string
|
|
info os.FileInfo
|
|
effectiveMtime int64
|
|
root string
|
|
uuid string
|
|
}
|
|
|
|
// writeCodexIndexForTest writes the session_index.jsonl mapping uuid -> title
|
|
// at indexMtime, the file codexIndexSessionNameChanged's title check reads.
|
|
func writeCodexIndexForTest(
|
|
t *testing.T, root, uuid, title string, indexMtime time.Time,
|
|
) string {
|
|
t.Helper()
|
|
idxPath := filepath.Join(root, parser.CodexSessionIndexFilename)
|
|
line := `{"id":"` + uuid + `","thread_name":"` + title + `"}` + "\n"
|
|
require.NoError(t, os.WriteFile(idxPath, []byte(line), 0o600))
|
|
require.NoError(t, os.Chtimes(idxPath, indexMtime, indexMtime))
|
|
return idxPath
|
|
}
|
|
|
|
// seedCodexRenameCase stores a Codex session whose file_mtime watermark is the
|
|
// folded index mtime (the index is newer than the transcript). That is the
|
|
// exact shape where a later title-only rename whose index mtime lands at or
|
|
// below the watermark is invisible to an mtime comparison.
|
|
func seedCodexRenameCase(t *testing.T, database *db.DB) codexRenameFixture {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
const uuid = "11111111-2222-3333-4444-555555555555"
|
|
sessDir := filepath.Join(root, "sessions", "2026", "06", "21")
|
|
require.NoError(t, os.MkdirAll(sessDir, 0o755))
|
|
path := filepath.Join(sessDir, "rollout-2026-06-21T18-59-38-"+uuid+".jsonl")
|
|
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/home/user/code/api", "user", "2026-06-21T18:59:38Z",
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "review this", "2026-06-21T18:59:39Z"),
|
|
)
|
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
|
|
|
transcriptMtime := time.Unix(1_700_000_000, 0)
|
|
require.NoError(t, os.Chtimes(path, transcriptMtime, transcriptMtime))
|
|
origIndexMtime := transcriptMtime.Add(time.Hour)
|
|
writeCodexIndexForTest(t, root, uuid, "Original Title", origIndexMtime)
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err, "stat codex fixture")
|
|
effectiveMtime := parser.CodexEffectiveMtime(path, info.ModTime().UnixNano())
|
|
require.Equal(t, origIndexMtime.UnixNano(), effectiveMtime,
|
|
"folded watermark should be the index mtime")
|
|
|
|
sess := db.Session{
|
|
ID: "host~codex:" + uuid,
|
|
Project: "api",
|
|
Machine: "host",
|
|
Agent: "codex",
|
|
SessionName: strPtr("Original Title"),
|
|
FilePath: strPtr("host:" + path),
|
|
FileSize: int64Ptr(info.Size()),
|
|
FileMtime: int64Ptr(effectiveMtime),
|
|
}
|
|
require.NoError(t, database.UpsertSession(sess))
|
|
require.NoError(t, database.SetSessionDataVersion(
|
|
sess.ID, db.CurrentDataVersion(),
|
|
))
|
|
|
|
e := &Engine{
|
|
db: database,
|
|
idPrefix: "host~",
|
|
pathRewriter: func(p string) string {
|
|
return "host:" + p
|
|
},
|
|
}
|
|
return codexRenameFixture{
|
|
e: e, path: path, info: info,
|
|
effectiveMtime: effectiveMtime, root: root, uuid: uuid,
|
|
}
|
|
}
|
|
|
|
// TestCodexIndexSessionNameChangedDetectsTitleRenameBelowStoredMtime pins the
|
|
// masking fix: a title-only rename whose folded index mtime is at or below the
|
|
// stored watermark is invisible to an mtime gate, so the skip decision instead
|
|
// consults codexIndexSessionNameChanged, which compares the live index title to
|
|
// the stored session_name directly. It must report a change for a renamed title
|
|
// while staying quiet for an unchanged one.
|
|
func TestCodexIndexSessionNameChangedDetectsTitleRenameBelowStoredMtime(t *testing.T) {
|
|
database := openTestDB(t)
|
|
f := seedCodexRenameCase(t, database)
|
|
|
|
// Control: nothing changed -> no rename reported, so the caller may skip.
|
|
assert.False(t, f.e.codexIndexSessionNameChanged(f.path),
|
|
"unchanged title must not report a change")
|
|
|
|
// Title-only rename whose index mtime lands at or below the stored
|
|
// watermark. The transcript bytes are untouched, so the mtime gate would
|
|
// skip; the mtime-independent title check must catch the rename.
|
|
writeCodexIndexForTest(t, f.root, f.uuid, "Renamed Title",
|
|
time.Unix(0, f.effectiveMtime))
|
|
renamedEff := parser.CodexEffectiveMtime(f.path, f.info.ModTime().UnixNano())
|
|
require.LessOrEqual(t, renamedEff, f.effectiveMtime,
|
|
"renamed index mtime must be at or below the stored watermark")
|
|
require.Equal(t, "Renamed Title",
|
|
parser.LookupCodexThreadName(f.path, f.uuid),
|
|
"live index must report the renamed title")
|
|
|
|
assert.True(t, f.e.codexIndexSessionNameChanged(f.path),
|
|
"title-only rename at or below stored watermark must report a change")
|
|
}
|
|
|
|
func TestCodexStoredNameDiffersPreservesMissingSemantics(t *testing.T) {
|
|
database := openTestDB(t)
|
|
require.NoError(t, database.UpsertSession(db.Session{
|
|
ID: "codex:null-name", Project: "p", Machine: "host", Agent: "codex",
|
|
}))
|
|
require.NoError(t, database.UpsertSession(db.Session{
|
|
ID: "codex:stored-name", Project: "p", Machine: "host", Agent: "codex",
|
|
SessionName: strPtr("Stored Title"),
|
|
}))
|
|
e := &Engine{db: database}
|
|
|
|
tests := []struct {
|
|
name string
|
|
sessionID string
|
|
indexTitle string
|
|
missingDiffers bool
|
|
want bool
|
|
}{
|
|
{
|
|
name: "direct refresh treats missing as changed", sessionID: "missing",
|
|
missingDiffers: true, want: true,
|
|
},
|
|
{
|
|
name: "index-only lookup ignores missing", sessionID: "missing",
|
|
indexTitle: "New Title", want: false,
|
|
},
|
|
{
|
|
name: "null name equals blank index title", sessionID: "codex:null-name",
|
|
indexTitle: " ", want: false,
|
|
},
|
|
{
|
|
name: "stored name trims whitespace", sessionID: "codex:stored-name",
|
|
indexTitle: " Stored Title ", want: false,
|
|
},
|
|
{
|
|
name: "stored rename differs", sessionID: "codex:stored-name",
|
|
indexTitle: "Renamed Title", want: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, e.codexStoredNameDiffersBySessionID(
|
|
tt.sessionID, tt.indexTitle, tt.missingDiffers,
|
|
))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEngine_ClassifyPathsProviderRemoveSkipsMissingGeminiSource(
|
|
t *testing.T,
|
|
) {
|
|
db := openTestDB(t)
|
|
geminiDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentGemini: {geminiDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
sessionPath := filepath.Join(
|
|
geminiDir, "tmp", "alias", "chats", "session-001.json",
|
|
)
|
|
dbtest.WriteTestFile(t, sessionPath, []byte("{}"))
|
|
require.NoError(t, os.Remove(sessionPath), "Remove(%q)", sessionPath)
|
|
|
|
files := engine.classifyPaths([]string{sessionPath})
|
|
assert.Empty(t, files)
|
|
}
|
|
|
|
func TestEngine_ClassifyPathsProviderSidecarKeepsExistingGeminiSources(
|
|
t *testing.T,
|
|
) {
|
|
db := openTestDB(t)
|
|
geminiDir := t.TempDir()
|
|
engine := NewEngine(db, EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentGemini: {geminiDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
projectsPath := filepath.Join(geminiDir, "projects.json")
|
|
dbtest.WriteTestFile(
|
|
t,
|
|
projectsPath,
|
|
[]byte(`{"projects":{"/Users/alice/code/sample":"alias"}}`),
|
|
)
|
|
sessionPath := filepath.Join(
|
|
geminiDir, "tmp", "alias", "chats", "session-001.json",
|
|
)
|
|
dbtest.WriteTestFile(t, sessionPath, []byte("{}"))
|
|
|
|
files := engine.classifyPaths([]string{projectsPath})
|
|
require.Len(t, files, 1)
|
|
assert.Equal(t, sessionPath, files[0].Path)
|
|
assert.Equal(t, parser.AgentGemini, files[0].Agent)
|
|
assert.True(t, files[0].ForceParse)
|
|
}
|