Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

452 lines
15 KiB
Go

package sync_test
import (
"database/sql"
"encoding/json"
"path/filepath"
"strings"
"testing"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/agentsview/internal/sync"
)
type forgeTestDB struct {
path string
db *sql.DB
}
func createForgeDB(t *testing.T, dir string) *forgeTestDB {
t.Helper()
path := filepath.Join(dir, ".forge.db")
d, err := sql.Open("sqlite3", path)
require.NoError(t, err, "opening forge test db")
t.Cleanup(func() { d.Close() })
schema := `
CREATE TABLE conversations (
conversation_id TEXT PRIMARY KEY NOT NULL,
title TEXT,
workspace_id BIGINT NOT NULL,
context TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
metrics TEXT
);
`
_, err = d.Exec(schema)
require.NoError(t, err, "creating forge schema")
return &forgeTestDB{path: path, db: d}
}
func (f *forgeTestDB) mustExec(t *testing.T, msg, query string, args ...any) {
t.Helper()
_, err := f.db.Exec(query, args...)
require.NoError(t, err, msg)
}
func (f *forgeTestDB) addConversation(
t *testing.T,
conversationID, title, context, createdAt, updatedAt, metrics string,
) {
t.Helper()
f.mustExec(t, "insert conversation",
`INSERT INTO conversations
(conversation_id, title, workspace_id, context, created_at, updated_at, metrics)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
conversationID, title, int64(1), context, createdAt, updatedAt, metrics,
)
}
func forgeTestContext(userPrompt, finalAnswer string) string {
messages := []map[string]any{
{
"message": map[string]any{
"text": map[string]any{
"role": "System",
"content": "<system_information>\n<current_working_directory>/home/mj/dev/projects/agentsview</current_working_directory>\n</system_information>",
"model": "gpt-5.4",
"timestamp": "2026-05-02T09:58:15.741021507Z",
},
},
"usage": map[string]any{
"prompt_tokens": map[string]any{"actual": 0},
"completion_tokens": map[string]any{"actual": 0},
"cached_tokens": map[string]any{"actual": 0},
},
},
{
"message": map[string]any{
"text": map[string]any{
"role": "User",
"content": userPrompt,
"raw_content": map[string]any{"Text": userPrompt},
"model": "gpt-5.4",
"timestamp": "2026-05-02T09:58:16.000000000Z",
},
},
"usage": map[string]any{
"prompt_tokens": map[string]any{"actual": 100},
"completion_tokens": map[string]any{"actual": 5},
"cached_tokens": map[string]any{"actual": 20},
},
},
{
"message": map[string]any{
"text": map[string]any{
"role": "Assistant",
"content": "",
"tool_calls": []map[string]any{{
"name": "read",
"call_id": "call_read_1",
"arguments": map[string]any{"file_path": "/tmp/example.go", "show_line_numbers": true},
}},
"model": "gpt-5.4",
"reasoning_details": []map[string]any{{
"text": "Inspecting the code first.",
}},
"timestamp": "2026-05-02T09:58:17.000000000Z",
},
},
"usage": map[string]any{
"prompt_tokens": map[string]any{"actual": 120},
"completion_tokens": map[string]any{"actual": 10},
"cached_tokens": map[string]any{"actual": 30},
},
},
{
"message": map[string]any{
"tool": map[string]any{
"name": "read",
"call_id": "call_read_1",
"output": map[string]any{
"is_error": false,
"values": []map[string]any{{
"text": "<file path=\"/tmp/example.go\">package main</file>",
}},
},
},
},
},
{
"message": map[string]any{
"text": map[string]any{
"role": "Assistant",
"content": finalAnswer,
"raw_content": map[string]any{"Text": finalAnswer},
"model": "gpt-5.4",
"timestamp": "2026-05-02T09:58:18.000000000Z",
},
},
"usage": map[string]any{
"prompt_tokens": map[string]any{"actual": 140},
"completion_tokens": map[string]any{"actual": 40},
"cached_tokens": map[string]any{"actual": 35},
},
},
}
root := map[string]any{
"conversation_id": "forge-sync-1",
"messages": messages,
}
raw, _ := json.Marshal(root)
return string(raw)
}
func TestSyncEngineForgeBulkSync(t *testing.T) {
env := setupTestEnv(t)
forge := createForgeDB(t, env.forgeDir)
forge.addConversation(
t,
"forge-sync-1",
"Forge Bulk Sync",
forgeTestContext("Please add Forge support.", "Added Forge support."),
"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, Skipped: 0})
assertSessionProject(t, env.db, "forge:forge-sync-1", "agentsview")
assertSessionMessageCount(t, env.db, "forge:forge-sync-1", 3)
assertMessageRoles(t, env.db, "forge:forge-sync-1", "user", "assistant", "assistant")
assertToolCallCount(t, env.db, "forge:forge-sync-1", 1)
assertMessageContent(
t, env.db, "forge:forge-sync-1",
"Please add Forge support.",
"[Thinking]\nInspecting the code first.\n[/Thinking]",
"Added Forge support.",
)
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 0, Synced: 0, Skipped: 0})
}
func TestSyncSingleSessionForge(t *testing.T) {
env := setupTestEnv(t)
forge := createForgeDB(t, env.forgeDir)
forge.addConversation(
t,
"forge-sync-single",
"Forge Single Sync",
forgeTestContext("Please add single-sync support.", "Single sync complete."),
"2026-05-02 09:58:15.741021507",
"2026-05-02 10:00:16.848497543",
`{"input_tokens":360,"output_tokens":55,"cached_input_tokens":85}`,
)
require.NoError(t, env.engine.SyncSingleSession("forge:forge-sync-single"), "SyncSingleSession")
assertSessionProject(t, env.db, "forge:forge-sync-single", "agentsview")
assertSessionMessageCount(t, env.db, "forge:forge-sync-single", 3)
src := env.engine.FindSourceFile("forge:forge-sync-single")
// Forge is a provider-authoritative DB-backed provider: FindSourceFile
// resolves the per-session virtual <db>#<conversationID> path the
// provider parses, matching the stored session file_path.
wantSrc := filepath.Join(env.forgeDir, ".forge.db") + "#forge-sync-single"
assert.Equal(t, wantSrc, src)
mtime := env.engine.SourceMtime("forge:forge-sync-single")
require.NotZero(t, mtime, "SourceMtime returned zero")
_, storedMtime, ok := env.db.GetSessionFileInfo("forge:forge-sync-single")
require.True(t, ok, "session file info not found")
assert.Equal(t, mtime, storedMtime)
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 0, Synced: 0, Skipped: 0})
}
// ---------------------------------------------------------------------------
// Priority 1 — Multi-conversation incremental sync
// ---------------------------------------------------------------------------
func TestSyncForgeMultiConversationIncremental(t *testing.T) {
env := setupTestEnv(t)
forge := createForgeDB(t, env.forgeDir)
// Seed two conversations.
forge.addConversation(
t,
"multi-conv-A", "Conversation A",
forgeTestContext("First 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,
"multi-conv-B", "Conversation B",
forgeTestContext("First prompt B.", "Answer B."),
"2026-05-02 09:00:00", "2026-05-02 09:02:00",
`{"input_tokens":120,"output_tokens":25}`,
)
// Full sync: both must be written.
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 2, Synced: 2, Skipped: 0})
assertSessionProject(t, env.db, "forge:multi-conv-A", "agentsview")
assertSessionProject(t, env.db, "forge:multi-conv-B", "agentsview")
// Capture the stored mtime for A (should remain unchanged after the partial sync).
_, storedMtimeA, okA := env.db.GetSessionFileInfo("forge:multi-conv-A")
require.True(t, okA, "session A file info not found after initial sync")
// Update only B's updated_at (and its context) to simulate a newer version.
updatedContextB := forgeTestContext("First prompt B updated.", "Answer B updated.")
forge.mustExec(t, "update B updated_at",
`UPDATE conversations SET updated_at = '2026-05-02 09:03:00', context = ? WHERE conversation_id = 'multi-conv-B'`,
updatedContextB,
)
// Partial sync: only B changed.
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 1, Synced: 1, Skipped: 0})
// A's stored mtime must be unchanged.
_, storedMtimeA2, okA2 := env.db.GetSessionFileInfo("forge:multi-conv-A")
require.True(t, okA2, "session A file info not found after partial sync")
assert.Equal(t, storedMtimeA, storedMtimeA2, "A's stored mtime changed")
// B's stored mtime must have advanced.
_, storedMtimeB2, okB2 := env.db.GetSessionFileInfo("forge:multi-conv-B")
require.True(t, okB2, "session B file info not found after partial sync")
assert.Greater(t, storedMtimeB2, storedMtimeA, "B's stored mtime did not advance")
}
// ---------------------------------------------------------------------------
// Priority 3 — FindSourceFile / SourceMtime when conversation disappears
// ---------------------------------------------------------------------------
func TestSyncForgeMissingConversation(t *testing.T) {
env := setupTestEnv(t)
forge := createForgeDB(t, env.forgeDir)
forge.addConversation(
t,
"disappearing-conv", "Disappearing",
forgeTestContext("I will vanish.", "Gone soon."),
"2026-05-02 09:00:00", "2026-05-02 09:01:00",
`{"input_tokens":50,"output_tokens":10}`,
)
require.NoError(t, env.engine.SyncSingleSession("forge:disappearing-conv"), "SyncSingleSession")
// Now delete the conversation from .forge.db.
forge.mustExec(t, "delete conversation",
`DELETE FROM conversations WHERE conversation_id = 'disappearing-conv'`,
)
// FindSourceFile still returns the db path (it's a directory-level lookup).
// SourceMtime returns 0 because the conversation row is gone.
mtime := env.engine.SourceMtime("forge:disappearing-conv")
assert.Zero(t, mtime, "SourceMtime after delete")
src := env.engine.FindSourceFile("forge:disappearing-conv")
assert.Empty(t, src, "FindSourceFile after delete")
// SyncSingleSession must return an error indicating the conversation
// could not be found (either "not found" or a db no-rows error).
err := env.engine.SyncSingleSession("forge:disappearing-conv")
require.Error(t, err, "expected error from SyncSingleSession for deleted conversation")
msg := strings.ToLower(err.Error())
assert.True(t, strings.Contains(msg, "not found") || strings.Contains(msg, "no rows"),
"expected 'not found' or 'no rows' error, got: %v", err)
}
func TestSyncForgeSyncSingleNonExistent(t *testing.T) {
env := setupTestEnv(t)
// Create an empty .forge.db so the provider discovers the DB.
createForgeDB(t, env.forgeDir)
err := env.engine.SyncSingleSession("forge:does-not-exist")
require.Error(t, err, "expected error for non-existent session")
msg := strings.ToLower(err.Error())
assert.True(t, strings.Contains(msg, "not found") || strings.Contains(msg, "no rows"),
"expected 'not found' or 'no rows' error, got: %v", err)
}
// ---------------------------------------------------------------------------
// Priority 4 — Subagent linking end-to-end
// ---------------------------------------------------------------------------
func forgeParentContext(childConvID string) string {
messages := []map[string]any{
{
"message": map[string]any{
"text": map[string]any{
"role": "User",
"content": "Run a sub-task.",
"timestamp": "2026-05-02T10:00:00Z",
},
},
},
{
"message": map[string]any{
"text": map[string]any{
"role": "Assistant",
"tool_calls": []map[string]any{{
"name": "task",
"call_id": "call_task_parent",
"arguments": map[string]any{
"session_id": childConvID,
"prompt": "do the child work",
},
}},
"timestamp": "2026-05-02T10:00:01Z",
},
},
},
}
raw, _ := json.Marshal(map[string]any{"messages": messages})
return string(raw)
}
func TestSyncForgeSubagentLinking(t *testing.T) {
env := setupTestEnv(t)
forge := createForgeDB(t, env.forgeDir)
childID := "child-conv"
parentID := "parent-conv"
forge.addConversation(
t,
parentID, "Parent",
forgeParentContext(childID),
"2026-05-02 09:00:00", "2026-05-02 09:01:00",
"",
)
forge.addConversation(
t,
childID, "Child",
forgeTestContext("Child work.", "Child done."),
"2026-05-02 09:00:30", "2026-05-02 09:01:30",
"",
)
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 2, Synced: 2, Skipped: 0})
// After SyncAll the tool_call row must already carry subagent_session_id.
// This is set by the parser before LinkSubagentSessions runs.
var subagentSessID sql.NullString
err := env.db.Reader().QueryRow(
`SELECT subagent_session_id FROM tool_calls WHERE session_id = ? AND tool_name = 'task'`,
"forge:"+parentID,
).Scan(&subagentSessID)
require.NoError(t, err, "query tool_calls for parent")
require.True(t, subagentSessID.Valid, "tool_calls.subagent_session_id not valid")
assert.Equal(t, "forge:"+childID, subagentSessID.String)
// SyncAll must now call LinkSubagentSessions after the Forge write,
// so parent_session_id and relationship_type on the child must already
// be set without any SyncSingleSession workaround.
var parentSessID sql.NullString
var relType sql.NullString
err = env.db.Reader().QueryRow(
`SELECT parent_session_id, relationship_type FROM sessions WHERE id = ?`,
"forge:"+childID,
).Scan(&parentSessID, &relType)
require.NoError(t, err, "query child session")
require.True(t, parentSessID.Valid, "child parent_session_id not valid")
assert.Equal(t, "forge:"+parentID, parentSessID.String)
require.True(t, relType.Valid, "child relationship_type not valid")
assert.Equal(t, "subagent", relType.String)
}
// ---------------------------------------------------------------------------
// Priority 6 — Failure isolation: malformed JSON in one conversation
// ---------------------------------------------------------------------------
func TestSyncForgeFailureIsolation(t *testing.T) {
env := setupTestEnv(t)
forge := createForgeDB(t, env.forgeDir)
// Valid conversation.
forge.addConversation(
t,
"valid-conv", "Valid",
forgeTestContext("Valid prompt.", "Valid answer."),
"2026-05-02 09:00:00", "2026-05-02 09:01:00",
`{"input_tokens":50,"output_tokens":10}`,
)
// Malformed JSON context — buildForgeSession will return nil, nil, nil
// (gjson.Parse(...).Get("messages").IsArray() == false).
forge.addConversation(
t,
"broken-conv", "Broken",
"{not valid json",
"2026-05-02 09:00:00", "2026-05-02 09:02:00",
"",
)
// Should not panic; valid session must be written.
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 1, Synced: 1, Skipped: 0})
assertSessionProject(t, env.db, "forge:valid-conv", "agentsview")
// Broken session must not be in the DB.
var count int
err := env.db.Reader().QueryRow(
"SELECT COUNT(*) FROM sessions WHERE id = 'forge:broken-conv'",
).Scan(&count)
require.NoError(t, err, "query broken session")
assert.Zero(t, count, "broken session present in DB")
}