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
376 lines
14 KiB
Go
376 lines
14 KiB
Go
package sync_test
|
|
|
|
// Integration coverage for parse-diff over the DB-backed
|
|
// provider-authoritative agents (Forge, Piebald, Warp). Each provider
|
|
// discovers a shared SQLite store as one virtual source per session and
|
|
// re-parses it through the provider facade, so these tests prove the
|
|
// report-only run actually re-parses and compares those sessions rather than
|
|
// bucketing them as skipped/"database-backed agent". Assertions go through the
|
|
// exported DiffClass/ParseDiffTotals contract, never rendered strings.
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"go.kenn.io/agentsview/internal/parser"
|
|
"go.kenn.io/agentsview/internal/sync"
|
|
)
|
|
|
|
// warpTestDB is a minimal Warp SQLite store for the sync test package,
|
|
// mirroring the schema the parser reads (agent_conversations + ai_queries).
|
|
type warpTestDB struct {
|
|
path string
|
|
db *sql.DB
|
|
}
|
|
|
|
func createWarpDB(t *testing.T, dir string) *warpTestDB {
|
|
t.Helper()
|
|
path := filepath.Join(dir, "warp.sqlite")
|
|
d, err := sql.Open("sqlite3", path)
|
|
require.NoError(t, err, "opening warp test db")
|
|
t.Cleanup(func() { _ = d.Close() })
|
|
_, err = d.Exec(`
|
|
CREATE TABLE agent_conversations (
|
|
id INTEGER PRIMARY KEY NOT NULL,
|
|
conversation_id TEXT NOT NULL,
|
|
conversation_data TEXT NOT NULL,
|
|
last_modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE UNIQUE INDEX ux_agent_conversations_conversation_id
|
|
ON agent_conversations (conversation_id);
|
|
CREATE TABLE ai_queries (
|
|
id INTEGER PRIMARY KEY NOT NULL,
|
|
exchange_id TEXT NOT NULL,
|
|
conversation_id TEXT NOT NULL,
|
|
start_ts DATETIME NOT NULL,
|
|
input TEXT NOT NULL,
|
|
working_directory TEXT,
|
|
output_status TEXT NOT NULL,
|
|
model_id TEXT NOT NULL DEFAULT ''
|
|
);
|
|
CREATE UNIQUE INDEX ux_ai_queries_exchange_id
|
|
ON ai_queries(exchange_id);
|
|
`)
|
|
require.NoError(t, err, "creating warp schema")
|
|
return &warpTestDB{path: path, db: d}
|
|
}
|
|
|
|
// addConversation seeds one Warp conversation with one user-query exchange per
|
|
// prompt. conversation_data stays "{}" so no aggregate tool messages are
|
|
// synthesized and the message count equals the number of prompts.
|
|
func (w *warpTestDB) addConversation(
|
|
t *testing.T, convID, lastModified string, prompts ...string,
|
|
) {
|
|
t.Helper()
|
|
_, err := w.db.Exec(
|
|
`INSERT INTO agent_conversations
|
|
(conversation_id, conversation_data, last_modified_at)
|
|
VALUES (?, '{}', ?)`,
|
|
convID, lastModified,
|
|
)
|
|
require.NoError(t, err, "insert warp conversation")
|
|
for i, p := range prompts {
|
|
input := fmt.Sprintf(`[{"Query":{"text":%q,"context":[]}}]`, p)
|
|
_, err := w.db.Exec(
|
|
`INSERT INTO ai_queries
|
|
(exchange_id, conversation_id, start_ts, input,
|
|
working_directory, output_status, model_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
fmt.Sprintf("%s-ex-%d", convID, i), convID,
|
|
fmt.Sprintf("2026-04-07 09:5%d:00.000000", i), input,
|
|
"/Users/alice/code/myproject", `"Completed"`, "auto-genius",
|
|
)
|
|
require.NoError(t, err, "insert warp exchange")
|
|
}
|
|
}
|
|
|
|
func createWindsurfWorkspaceDB(t *testing.T, root, payload string) string {
|
|
t.Helper()
|
|
workspaceDir := filepath.Join(root, "workspaceStorage", "workspace-hash")
|
|
require.NoError(t, os.MkdirAll(workspaceDir, 0o755))
|
|
require.NoError(t,
|
|
os.WriteFile(
|
|
filepath.Join(workspaceDir, "workspace.json"),
|
|
[]byte(`{"folder":"file:///work/demo"}`),
|
|
0o644,
|
|
),
|
|
)
|
|
dbPath := filepath.Join(workspaceDir, "state.vscdb")
|
|
conn, err := sql.Open("sqlite3", dbPath)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
_, err = conn.Exec(`CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)`)
|
|
require.NoError(t, err)
|
|
_, err = conn.Exec(
|
|
`INSERT INTO ItemTable (key, value) VALUES (?, ?)`,
|
|
"workbench.panel.aichat.view.aichat.chatdata",
|
|
payload,
|
|
)
|
|
require.NoError(t, err)
|
|
return dbPath
|
|
}
|
|
|
|
// TestParseDiffCoversForge proves Forge's shared .forge.db, discovered as one
|
|
// virtual source per conversation, is re-parsed and compared by parse-diff.
|
|
// Examined:1/Identical:1 means the stored conversation was matched and vetted,
|
|
// not bucketed as skipped/"database-backed agent".
|
|
func TestParseDiffCoversForge(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentForge)
|
|
forge := createForgeDB(t, env.forgeDir)
|
|
forge.addConversation(
|
|
t, "forge-clean-1", "Forge Clean",
|
|
forgeTestContext("Add Forge coverage.", "Added Forge coverage."),
|
|
"2026-05-02 09:58:15.741021507",
|
|
"2026-05-02 10:00:16.848497543",
|
|
`{"input_tokens":360,"output_tokens":55,"cached_input_tokens":85}`,
|
|
)
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 1, Synced: 1})
|
|
|
|
report := runParseDiff(t, env, sync.ParseDiffOptions{
|
|
Agents: []parser.AgentType{parser.AgentForge},
|
|
})
|
|
assert.Equal(t, sync.ParseDiffTotals{Examined: 1, Identical: 1},
|
|
report.Totals, "forge conversation must be examined, not skipped")
|
|
assert.Equal(t, 1, report.FilesExamined, ".forge.db examined")
|
|
assert.False(t, report.HasFailures(), "clean forge run")
|
|
}
|
|
|
|
// TestParseDiffCoversPiebald proves Piebald's shared app.db is re-parsed and
|
|
// compared per chat.
|
|
func TestParseDiffCoversPiebald(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentPiebald)
|
|
piebald := createPiebaldDB(t, env.piebaldDir)
|
|
piebald.addChat(t, 7, "Piebald Clean",
|
|
"Add Piebald coverage.", "Added Piebald coverage.",
|
|
"2026-05-01T10:05:00Z")
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 1, Synced: 1})
|
|
|
|
report := runParseDiff(t, env, sync.ParseDiffOptions{
|
|
Agents: []parser.AgentType{parser.AgentPiebald},
|
|
})
|
|
assert.Equal(t, sync.ParseDiffTotals{Examined: 1, Identical: 1},
|
|
report.Totals, "piebald chat must be examined, not skipped")
|
|
assert.Equal(t, 1, report.FilesExamined, "app.db examined")
|
|
assert.False(t, report.HasFailures(), "clean piebald run")
|
|
}
|
|
|
|
// TestParseDiffCoversWarp proves Warp's shared warp.sqlite is re-parsed and
|
|
// compared per conversation.
|
|
func TestParseDiffCoversWarp(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentWarp)
|
|
warp := createWarpDB(t, env.warpDir)
|
|
warp.addConversation(t, "warp-clean-1", "2026-04-07 10:00:00",
|
|
"Fix the JSON parsing bug.")
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 1, Synced: 1})
|
|
|
|
report := runParseDiff(t, env, sync.ParseDiffOptions{
|
|
Agents: []parser.AgentType{parser.AgentWarp},
|
|
})
|
|
assert.Equal(t, sync.ParseDiffTotals{Examined: 1, Identical: 1},
|
|
report.Totals, "warp conversation must be examined, not skipped")
|
|
assert.Equal(t, 1, report.FilesExamined, "warp.sqlite examined")
|
|
assert.False(t, report.HasFailures(), "clean warp run")
|
|
}
|
|
|
|
// TestParseDiffForgeDetectsStoredDrift mutates a stored Forge row after a sync
|
|
// and verifies the re-parse from the unchanged .forge.db surfaces the drift as
|
|
// DiffChanged. This proves the DB-backed re-parse+compare path is real, not a
|
|
// no-op that always reports identical, and that the raced guard does not mask
|
|
// the change (the virtual DB source is held out of the guard, so it keeps its
|
|
// real changed verdict).
|
|
func TestParseDiffForgeDetectsStoredDrift(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentForge)
|
|
forge := createForgeDB(t, env.forgeDir)
|
|
forge.addConversation(
|
|
t, "forge-drift-1", "Forge Drift",
|
|
forgeTestContext("Original prompt.", "Original answer."),
|
|
"2026-05-02 09:58:15.741021507",
|
|
"2026-05-02 10:00:16.848497543",
|
|
`{"input_tokens":360,"output_tokens":55,"cached_input_tokens":85}`,
|
|
)
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 1, Synced: 1})
|
|
|
|
// Stored-row drift against the unchanged source.
|
|
mutateDB(t, env,
|
|
"UPDATE sessions SET first_message = ? WHERE id = ?",
|
|
"drifted first message", "forge:forge-drift-1")
|
|
|
|
report := runParseDiff(t, env, sync.ParseDiffOptions{
|
|
Agents: []parser.AgentType{parser.AgentForge},
|
|
})
|
|
assert.Equal(t, sync.ParseDiffTotals{Examined: 1, Changed: 1},
|
|
report.Totals, "forge drift must be detected as changed")
|
|
|
|
sd := findSessionDiff(report, "forge:forge-drift-1")
|
|
require.NotNil(t, sd, "drifted forge session must be listed")
|
|
assert.Equal(t, sync.DiffChanged, sd.Class, "class")
|
|
assert.ElementsMatch(t, []string{sync.FieldFirstMessage},
|
|
sessionDiffFieldNames(sd, false), "non-informational fields")
|
|
assert.Equal(t, 1, report.FieldCounts[sync.FieldFirstMessage],
|
|
"FieldCounts[first_message]")
|
|
assert.True(t, report.HasFailures(), "drift must trip --fail-on-change")
|
|
}
|
|
|
|
// TestParseDiffDBBackedLimitScopesPerSession is the per-session-keying acid
|
|
// test. A shared .forge.db holds two conversations; --limit 1 samples one and
|
|
// cuts the other. Because these providers discover one virtual source per
|
|
// session, the cut conversation must be reported as an ordinary
|
|
// not-sampled skip -- NOT fanned into a presence "changed" false positive
|
|
// (which a shared-DB base key would produce) and NOT bucketed as
|
|
// "database-backed agent" (which the pre-relaxation sweep would produce for a
|
|
// FileBased=false agent). Totals.Changed must stay 0.
|
|
func TestParseDiffDBBackedLimitScopesPerSession(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentForge)
|
|
forge := createForgeDB(t, env.forgeDir)
|
|
forge.addConversation(
|
|
t, "forge-a", "Conversation A",
|
|
forgeTestContext("Prompt A.", "Answer A."),
|
|
"2026-05-02 09:00:00", "2026-05-02 09:01:00",
|
|
`{"input_tokens":100,"output_tokens":20}`,
|
|
)
|
|
forge.addConversation(
|
|
t, "forge-b", "Conversation B",
|
|
forgeTestContext("Prompt B.", "Answer B."),
|
|
"2026-05-02 09:00:00", "2026-05-02 09:02:00",
|
|
`{"input_tokens":120,"output_tokens":25}`,
|
|
)
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 2, Synced: 2})
|
|
|
|
report := runParseDiff(t, env, sync.ParseDiffOptions{
|
|
Agents: []parser.AgentType{parser.AgentForge},
|
|
Limit: 1,
|
|
})
|
|
|
|
assert.True(t, report.FilesLimited, "files limited")
|
|
assert.Equal(t, sync.ParseDiffTotals{
|
|
Examined: 1, Identical: 1, Skipped: 1,
|
|
}, report.Totals, "one conversation sampled, one cut")
|
|
// The cut conversation must never be counted as parser drift.
|
|
assert.Zero(t, report.Totals.Changed,
|
|
"cut sibling conversation must not become a presence change")
|
|
assert.Empty(t, report.FieldCounts,
|
|
"no field drift from an unsampled sibling")
|
|
|
|
// Exactly one session is listed: the cut one, as a not-sampled skip.
|
|
var skipped []sync.SessionDiff
|
|
for _, s := range report.Sessions {
|
|
if s.Class == sync.DiffSkipped {
|
|
skipped = append(skipped, s)
|
|
}
|
|
}
|
|
require.Len(t, skipped, 1, "exactly one skipped session listed")
|
|
assert.Contains(t, skipped[0].Reason, "limit",
|
|
"cut DB-backed session must read as not-sampled, "+
|
|
"not 'database-backed agent'")
|
|
}
|
|
|
|
// TestParseDiffDBBackedLimitOrdersByPerSessionMtime is the ordering acid test.
|
|
// A shared .forge.db holds two conversations whose virtual-path order (by
|
|
// conversation ID) is the opposite of their updated_at order: forge-a sorts
|
|
// lexicographically first but is older, forge-b sorts later but is newer. Under
|
|
// --limit 1 the newest session must be the one sampled, so forge-a is cut.
|
|
// Before the ordering fix both virtual sources stat to mtime 0, --limit
|
|
// tie-breaks on the lexicographically-earlier path, forge-a is sampled instead,
|
|
// and the newer forge-b is dropped -- so this asserted skip would be
|
|
// "forge:forge-b" and the test would fail.
|
|
func TestParseDiffDBBackedLimitOrdersByPerSessionMtime(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentForge)
|
|
forge := createForgeDB(t, env.forgeDir)
|
|
// forge-a: sorts first by path, older updated_at.
|
|
forge.addConversation(
|
|
t, "forge-a", "Older A",
|
|
forgeTestContext("Prompt A.", "Answer A."),
|
|
"2026-05-01 09:00:00", "2026-05-01 09:00:00",
|
|
`{"input_tokens":100,"output_tokens":20}`,
|
|
)
|
|
// forge-b: sorts later by path, newer updated_at.
|
|
forge.addConversation(
|
|
t, "forge-b", "Newer B",
|
|
forgeTestContext("Prompt B.", "Answer B."),
|
|
"2026-06-01 09:00:00", "2026-06-01 09:00:00",
|
|
`{"input_tokens":120,"output_tokens":25}`,
|
|
)
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 2, Synced: 2})
|
|
|
|
report := runParseDiff(t, env, sync.ParseDiffOptions{
|
|
Agents: []parser.AgentType{parser.AgentForge},
|
|
Limit: 1,
|
|
})
|
|
|
|
assert.True(t, report.FilesLimited, "files limited")
|
|
assert.Equal(t, sync.ParseDiffTotals{
|
|
Examined: 1, Identical: 1, Skipped: 1,
|
|
}, report.Totals, "the newer conversation is sampled, the older cut")
|
|
|
|
// A clean identical sample is not listed, so the sole listed session is the
|
|
// cut (older) one -- and it must be forge-a, proving the newer forge-b won
|
|
// the --limit slot by mtime rather than by lexicographic path.
|
|
var skipped []sync.SessionDiff
|
|
for _, s := range report.Sessions {
|
|
if s.Class == sync.DiffSkipped {
|
|
skipped = append(skipped, s)
|
|
}
|
|
}
|
|
require.Len(t, skipped, 1, "exactly one skipped session listed")
|
|
assert.Equal(t, "forge:forge-a", skipped[0].SessionID,
|
|
"the older conversation must be the one cut by --limit")
|
|
assert.Contains(t, skipped[0].Reason, "limit",
|
|
"cut session reads as not-sampled")
|
|
}
|
|
|
|
func TestParseDiffWindsurfLimitScopesPerSession(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentWindsurf)
|
|
createWindsurfWorkspaceDB(t, env.windsurfDir, `{
|
|
"tabs": [
|
|
{
|
|
"tabId": "windsurf-a",
|
|
"chatTitle": "Conversation A",
|
|
"bubbles": [
|
|
{"type": "user", "text": "Prompt A."},
|
|
{"type": "assistant", "text": "Answer A."}
|
|
]
|
|
},
|
|
{
|
|
"tabId": "windsurf-b",
|
|
"chatTitle": "Conversation B",
|
|
"bubbles": [
|
|
{"type": "user", "text": "Prompt B."},
|
|
{"type": "assistant", "text": "Answer B."}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 2, Synced: 2})
|
|
|
|
report := runParseDiff(t, env, sync.ParseDiffOptions{
|
|
Agents: []parser.AgentType{parser.AgentWindsurf},
|
|
Limit: 1,
|
|
})
|
|
|
|
assert.True(t, report.FilesLimited, "files limited")
|
|
assert.Equal(t, sync.ParseDiffTotals{
|
|
Examined: 1, Identical: 1, Skipped: 1,
|
|
}, report.Totals, "one Windsurf tab sampled, one cut")
|
|
assert.Zero(t, report.Totals.Changed,
|
|
"cut Windsurf sibling must not become a presence change")
|
|
assert.Empty(t, report.FieldCounts,
|
|
"no field drift from an unsampled Windsurf sibling")
|
|
|
|
var skipped []sync.SessionDiff
|
|
for _, s := range report.Sessions {
|
|
if s.Class == sync.DiffSkipped {
|
|
skipped = append(skipped, s)
|
|
}
|
|
}
|
|
require.Len(t, skipped, 1, "exactly one skipped Windsurf session listed")
|
|
assert.Contains(t, skipped[0].Reason, "limit",
|
|
"cut Windsurf session reads as not-sampled")
|
|
}
|