Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

362 lines
16 KiB
Go

package storesqlite
import (
"context"
"reflect"
"testing"
)
// snapshotSessionMessages captures the full rendered message sequence of a
// session, the acceptance signal for the turns backfill: migration reruns
// must leave it byte-for-byte identical.
func snapshotSessionMessages(t *testing.T, store *Store, workspaceID string, agentSessionID string) []Message {
t.Helper()
page, ok, err := store.ListSessionMessages(context.Background(), ListSessionMessagesInput{
WorkspaceID: workspaceID,
AgentSessionID: agentSessionID,
Limit: 100,
})
if err != nil || !ok {
t.Fatalf("ListSessionMessages(%s) ok=%v error=%v", agentSessionID, ok, err)
}
return page.Messages
}
func reportTestMessage(t *testing.T, store *Store, agentSessionID string, messageID string, turnID string, occurred int64) {
t.Helper()
if turnID != "" {
if _, ok, err := store.GetTurn(context.Background(), "ws-1", agentSessionID, turnID); err != nil {
t.Fatalf("GetTurn(%s/%s) error = %v", agentSessionID, turnID, err)
} else if !ok {
if _, accepted, err := store.RecordTurnTransition(context.Background(), TurnTransition{
WorkspaceID: "ws-1", AgentSessionID: agentSessionID, TurnID: turnID,
Phase: TurnPhaseSettled, Outcome: TurnOutcomeCompleted, OccurredAtUnixMS: occurred - 1,
}); err != nil || !accepted {
t.Fatalf("RecordTurnTransition(%s/%s) accepted=%v error=%v", agentSessionID, turnID, accepted, err)
}
}
}
kind := "text"
if turnID == "" {
kind = "session_audit"
}
_, err := store.ReportSessionMessages(context.Background(), SessionMessageReport{
WorkspaceID: "ws-1",
AgentSessionID: agentSessionID,
Origin: "runtime",
Messages: []MessageUpdate{{
MessageID: messageID,
TurnID: turnID,
Role: "assistant",
Kind: kind,
Status: "completed",
Payload: map[string]any{"text": messageID},
OccurredAtUnixMS: occurred,
}},
})
if err != nil {
t.Fatalf("ReportSessionMessages(%s/%s) error = %v", agentSessionID, messageID, err)
}
}
func TestWorkspaceAgentTurnsBackfillPreservesMessagesAndIsRerunSafe(t *testing.T) {
t.Parallel()
store := openTestStore(t, testOptions(&staticProjectPaths{}))
ctx := context.Background()
for _, seed := range []struct {
sessionID string
status string
}{
{sessionID: "session-done", status: "completed"},
{sessionID: "session-failed", status: "failed"},
} {
if _, err := store.ReportSessionState(ctx, SessionStateReport{
WorkspaceID: "ws-1",
AgentSessionID: seed.sessionID,
Origin: "runtime",
Provider: "codex",
Status: seed.status,
OccurredAtUnixMS: 100,
}); err != nil {
t.Fatalf("ReportSessionState(%s) error = %v", seed.sessionID, err)
}
}
reportTestMessage(t, store, "session-done", "msg-1", "turn-1", 110)
reportTestMessage(t, store, "session-done", "msg-1b", "turn-1", 115)
reportTestMessage(t, store, "session-done", "msg-2", "turn-2", 120)
// Turnless message: stays session-level (turn_id NULL) and must never
// gain a fabricated turn from the backfill.
reportTestMessage(t, store, "session-done", "msg-3", "", 130)
reportTestMessage(t, store, "session-failed", "msg-1", "turn-b1", 110)
reportTestMessage(t, store, "session-failed", "msg-2", "turn-b2", 120)
if _, err := store.ReportSessionState(ctx, SessionStateReport{
WorkspaceID: "ws-1",
AgentSessionID: "session-deleted",
Origin: "runtime",
Provider: "codex",
Status: "completed",
OccurredAtUnixMS: 100,
}); err != nil {
t.Fatalf("ReportSessionState(session-deleted) error = %v", err)
}
reportTestMessage(t, store, "session-deleted", "msg-deleted", "turn-deleted", 125)
// Preserve the historical turn id while marking both legacy rows deleted.
// This is the pre-turn-schema shape that the migration must ignore.
if _, err := store.db.ExecContext(ctx, `
UPDATE workspace_agent_sessions SET deleted_at_unix_ms = 130 WHERE workspace_id = 'ws-1' AND agent_session_id = 'session-deleted';
UPDATE workspace_agent_messages SET deleted_at_unix_ms = 130 WHERE workspace_id = 'ws-1' AND agent_session_id = 'session-deleted';
`); err != nil {
t.Fatalf("mark legacy session deleted: %v", err)
}
messagesDoneBefore := snapshotSessionMessages(t, store, "ws-1", "session-done")
messagesFailedBefore := snapshotSessionMessages(t, store, "ws-1", "session-failed")
// Simulate a legacy database that has messages but predates the turns
// migration, then re-run Migrate so the backfill executes against them.
if _, err := store.db.ExecContext(ctx, `DELETE FROM `+schemaMigrationsTable+` WHERE id IN (?, ?, ?)`, schemaMigrationWorkspaceAgentActivityTurnsV1, schemaMigrationWorkspaceAgentActivityTurnIntegrityV1, schemaMigrationWorkspaceAgentTurnCapabilityRefsV1); err != nil {
t.Fatalf("reset turns migration ledger: %v", err)
}
if _, err := store.db.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
t.Fatalf("disable foreign keys for legacy fixture: %v", err)
}
for _, drop := range []string{
`DROP TABLE workspace_agent_interactions`,
`DROP TABLE workspace_agent_turns`,
} {
if _, err := store.db.ExecContext(ctx, drop); err != nil {
t.Fatalf("%s: %v", drop, err)
}
}
if _, err := store.db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil {
t.Fatalf("enable foreign keys after legacy fixture: %v", err)
}
if err := store.Migrate(ctx); err != nil {
t.Fatalf("Migrate(backfill) error = %v", err)
}
if deleted, err := store.SessionDeleted(ctx, "ws-1", "session-deleted"); err != nil || !deleted {
t.Fatalf("SessionDeleted(session-deleted) deleted=%v error=%v", deleted, err)
}
doneTurns, err := store.ListSessionTurns(ctx, "ws-1", "session-done")
if err != nil {
t.Fatalf("ListSessionTurns(session-done) error = %v", err)
}
if len(doneTurns) != 2 {
t.Fatalf("session-done turns = %d, want 2 (turnless message must not create a turn)", len(doneTurns))
}
for _, turn := range doneTurns {
if turn.Phase != TurnPhaseSettled || turn.Outcome != TurnOutcomeCompleted || !turn.Backfilled {
t.Fatalf("backfilled turn = %#v, want settled/completed/backfilled", turn)
}
}
turnOne, ok, err := store.GetTurn(ctx, "ws-1", "session-done", "turn-1")
if err != nil || !ok {
t.Fatalf("GetTurn(session-done/turn-1) ok=%v error=%v", ok, err)
}
if turnOne.StartedAtUnixMS != 110 || turnOne.SettledAtUnixMS != 115 ||
turnOne.CreatedAtUnixMS != 110 || turnOne.UpdatedAtUnixMS != 115 {
t.Fatalf("backfilled turn timestamps = %#v, want message-derived 110..115", turnOne)
}
deletedTurns, err := store.ListSessionTurns(ctx, "ws-1", "session-deleted")
if err != nil {
t.Fatalf("ListSessionTurns(session-deleted) error = %v", err)
}
if len(deletedTurns) != 0 {
t.Fatalf("deleted session turns = %#v, want none", deletedTurns)
}
var deletedMessageTurnID *string
if err := store.db.QueryRowContext(ctx, `
SELECT turn_id
FROM workspace_agent_messages
WHERE workspace_id = 'ws-1' AND agent_session_id = 'session-deleted' AND message_id = 'msg-deleted'
`).Scan(&deletedMessageTurnID); err != nil {
t.Fatalf("read deleted message turn id: %v", err)
}
if deletedMessageTurnID != nil {
t.Fatalf("deleted message turn id = %q, want NULL", *deletedMessageTurnID)
}
var foreignKeyViolationCount int
if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM pragma_foreign_key_check`).Scan(&foreignKeyViolationCount); err != nil {
t.Fatalf("check foreign keys: %v", err)
}
if foreignKeyViolationCount != 0 {
t.Fatalf("foreign key violations = %d, want 0", foreignKeyViolationCount)
}
var latestTurnIndexCount int
if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_workspace_agent_turns_session_latest'`).Scan(&latestTurnIndexCount); err != nil || latestTurnIndexCount != 1 {
t.Fatalf("latest-turn index count = %d, error = %v", latestTurnIndexCount, err)
}
failedTurns, err := store.ListSessionTurns(ctx, "ws-1", "session-failed")
if err != nil {
t.Fatalf("ListSessionTurns(session-failed) error = %v", err)
}
outcomes := map[string]string{}
for _, turn := range failedTurns {
outcomes[turn.TurnID] = turn.Outcome
}
// Once the legacy session status column has been removed, a deliberately
// destroyed-and-rebuilt turn table can only recover message-backed turns
// as completed. Real upgrades run the turn migration before dropping that
// legacy column and are covered by the legacy upgrade fixture.
if outcomes["turn-b2"] != TurnOutcomeCompleted {
t.Fatalf("newest rebuilt turn outcome = %q, want completed (all = %#v)", outcomes["turn-b2"], outcomes)
}
if outcomes["turn-b1"] != TurnOutcomeCompleted {
t.Fatalf("older turn of failed session outcome = %q, want completed", outcomes["turn-b1"])
}
messagesDoneAfter := snapshotSessionMessages(t, store, "ws-1", "session-done")
messagesFailedAfter := snapshotSessionMessages(t, store, "ws-1", "session-failed")
if !reflect.DeepEqual(messagesDoneBefore, messagesDoneAfter) {
t.Fatalf("session-done messages changed after backfill:\nbefore = %#v\nafter = %#v", messagesDoneBefore, messagesDoneAfter)
}
if !reflect.DeepEqual(messagesFailedBefore, messagesFailedAfter) {
t.Fatalf("session-failed messages changed after backfill:\nbefore = %#v\nafter = %#v", messagesFailedBefore, messagesFailedAfter)
}
// Live (non-backfilled) turn written after the migration must survive a
// backfill rerun untouched.
liveTurn, accepted, err := store.RecordTurnTransition(ctx, TurnTransition{
WorkspaceID: "ws-1",
AgentSessionID: "session-done",
TurnID: "turn-live",
Phase: TurnPhaseRunning,
OccurredAtUnixMS: 200,
})
if err != nil || !accepted {
t.Fatalf("RecordTurnTransition(live) accepted=%v error=%v", accepted, err)
}
if _, err := store.db.ExecContext(ctx, `DELETE FROM `+schemaMigrationsTable+` WHERE id = ?`, schemaMigrationWorkspaceAgentActivityTurnsV1); err != nil {
t.Fatalf("reset turns migration ledger for rerun: %v", err)
}
if err := store.Migrate(ctx); err != nil {
t.Fatalf("Migrate(rerun) error = %v", err)
}
rerunTurns, err := store.ListSessionTurns(ctx, "ws-1", "session-done")
if err != nil {
t.Fatalf("ListSessionTurns(rerun) error = %v", err)
}
if len(rerunTurns) != 3 {
t.Fatalf("session-done turns after rerun = %d, want 3 (no duplicates)", len(rerunTurns))
}
liveAfter, ok, err := store.GetTurn(ctx, "ws-1", "session-done", "turn-live")
if err != nil || !ok {
t.Fatalf("GetTurn(turn-live) ok=%v error=%v", ok, err)
}
if !reflect.DeepEqual(liveTurn, liveAfter) {
t.Fatalf("live turn mutated by backfill rerun:\nbefore = %#v\nafter = %#v", liveTurn, liveAfter)
}
messagesDoneRerun := snapshotSessionMessages(t, store, "ws-1", "session-done")
if !reflect.DeepEqual(messagesDoneBefore, messagesDoneRerun) {
t.Fatalf("session-done messages changed after rerun:\nbefore = %#v\nafter = %#v", messagesDoneBefore, messagesDoneRerun)
}
}
func TestWorkspaceAgentTurnsMigrationRollsBackSchemaBackfillAndLedger(t *testing.T) {
store := openTestStore(t, testOptions(&staticProjectPaths{}))
ctx := context.Background()
if _, err := store.ReportSessionState(ctx, SessionStateReport{WorkspaceID: "ws-1", AgentSessionID: "session-rollback", Provider: "codex", OccurredAtUnixMS: 90}); err != nil {
t.Fatalf("seed session: %v", err)
}
reportTestMessage(t, store, "session-rollback", "message-1", "turn-1", 100)
for _, statement := range []string{
`DELETE FROM ` + schemaMigrationsTable + ` WHERE id = '` + schemaMigrationWorkspaceAgentActivityTurnsV1 + `'`,
`DELETE FROM ` + schemaMigrationsTable + ` WHERE id = '` + schemaMigrationWorkspaceAgentTurnCapabilityRefsV1 + `'`,
`PRAGMA foreign_keys = OFF`,
`DROP TABLE workspace_agent_interactions`,
`DROP TABLE workspace_agent_turns`,
`PRAGMA foreign_keys = ON`,
`CREATE TRIGGER fail_turns_migration_ledger BEFORE INSERT ON ` + schemaMigrationsTable + `
WHEN NEW.id = '` + schemaMigrationWorkspaceAgentActivityTurnsV1 + `'
BEGIN SELECT RAISE(ABORT, 'forced turns migration ledger failure'); END`,
} {
if _, err := store.db.ExecContext(ctx, statement); err != nil {
t.Fatalf("prepare rollback fixture %q: %v", statement, err)
}
}
if err := store.applyWorkspaceAgentActivityTurnsV1(ctx); err == nil {
t.Fatal("turns migration error = nil, want forced ledger failure")
}
if exists, err := store.hasTable(ctx, "workspace_agent_turns"); err != nil || exists {
t.Fatalf("turns table exists=%v error=%v, want rolled back", exists, err)
}
if exists, err := store.hasTable(ctx, "workspace_agent_interactions"); err != nil || exists {
t.Fatalf("interactions table exists=%v error=%v, want rolled back", exists, err)
}
if applied, err := store.hasMigration(ctx, schemaMigrationWorkspaceAgentActivityTurnsV1); err != nil || applied {
t.Fatalf("migration applied=%v error=%v, want false", applied, err)
}
if _, err := store.db.ExecContext(ctx, `DROP TRIGGER fail_turns_migration_ledger`); err != nil {
t.Fatalf("drop failure trigger: %v", err)
}
if err := store.applyWorkspaceAgentActivityTurnsV1(ctx); err != nil {
t.Fatalf("retry turns migration: %v", err)
}
if err := store.applyWorkspaceAgentTurnCapabilityRefsV1(ctx); err != nil {
t.Fatalf("retry turn capability refs migration: %v", err)
}
if turn, ok, err := store.GetTurn(ctx, "ws-1", "session-rollback", "turn-1"); err != nil || !ok || !turn.Backfilled {
t.Fatalf("retried backfill turn=%#v ok=%v error=%v", turn, ok, err)
}
}
func TestWorkspaceAgentMessagesV2MigrationRollsBackRebuildAndLedger(t *testing.T) {
store := openTestStore(t, testOptions(&staticProjectPaths{}))
ctx := context.Background()
if _, err := store.ReportSessionState(ctx, SessionStateReport{WorkspaceID: "ws-1", AgentSessionID: "session-rollback", Provider: "codex", OccurredAtUnixMS: 90}); err != nil {
t.Fatalf("seed session: %v", err)
}
if _, accepted, err := store.RecordTurnTransition(ctx, TurnTransition{WorkspaceID: "ws-1", AgentSessionID: "session-rollback", TurnID: "turn-1", Phase: TurnPhaseSettled, Outcome: TurnOutcomeCompleted, OccurredAtUnixMS: 95}); err != nil || !accepted {
t.Fatalf("seed turn accepted=%v error=%v", accepted, err)
}
reportTestMessage(t, store, "session-rollback", "message-1", "turn-1", 100)
if messages := snapshotSessionMessages(t, store, "ws-1", "session-rollback"); len(messages) != 1 {
t.Fatalf("seed messages = %#v, want one row", messages)
}
for _, statement := range []string{
`DELETE FROM ` + schemaMigrationsTable + ` WHERE id = '` + schemaMigrationWorkspaceAgentActivityMessagesV2 + `'`,
`CREATE TRIGGER fail_messages_v2_migration_ledger BEFORE INSERT ON ` + schemaMigrationsTable + `
WHEN NEW.id = '` + schemaMigrationWorkspaceAgentActivityMessagesV2 + `'
BEGIN SELECT RAISE(ABORT, 'forced messages v2 migration ledger failure'); END`,
} {
if _, err := store.db.ExecContext(ctx, statement); err != nil {
t.Fatalf("prepare messages rollback fixture %q: %v", statement, err)
}
}
if err := store.applyWorkspaceAgentActivityMessagesV2(ctx); err == nil {
t.Fatal("messages v2 migration error = nil, want forced ledger failure")
}
if messages := snapshotSessionMessages(t, store, "ws-1", "session-rollback"); len(messages) != 1 || messages[0].MessageID != "message-1" {
t.Fatalf("messages after rollback = %#v, want original row", messages)
}
if applied, err := store.hasMigration(ctx, schemaMigrationWorkspaceAgentActivityMessagesV2); err != nil || applied {
t.Fatalf("migration applied=%v error=%v, want false", applied, err)
}
if exists, err := store.hasTable(ctx, "workspace_agent_messages_v2"); err != nil || exists {
t.Fatalf("temporary table exists=%v error=%v, want rolled back", exists, err)
}
if _, err := store.db.ExecContext(ctx, `DROP TRIGGER fail_messages_v2_migration_ledger`); err != nil {
t.Fatalf("drop failure trigger: %v", err)
}
if err := store.applyWorkspaceAgentActivityMessagesV2(ctx); err != nil {
t.Fatalf("retry messages v2 migration: %v", err)
}
if messages := snapshotSessionMessages(t, store, "ws-1", "session-rollback"); len(messages) != 1 || messages[0].MessageID != "message-1" {
t.Fatalf("messages after retry = %#v, want original row", messages)
}
}