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
307 lines
8.9 KiB
Go
307 lines
8.9 KiB
Go
package workspace
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"testing"
|
|
|
|
agentsessionstore "github.com/tutti-os/tutti/packages/agent/daemon/activity"
|
|
agenthost "github.com/tutti-os/tutti/packages/agent/host"
|
|
agentactivitybiz "github.com/tutti-os/tutti/packages/agent/store-sqlite"
|
|
"github.com/tutti-os/tutti/packages/agent/store-sqlite/canonical"
|
|
workspacebiz "github.com/tutti-os/tutti/services/tuttid/biz/workspace"
|
|
)
|
|
|
|
func TestCaptureReplayStateExcludesUnrelatedSentinelSession(t *testing.T) {
|
|
store := openTestSQLiteStore(t)
|
|
ctx := context.Background()
|
|
const workspaceID = "workspace-fixture"
|
|
if err := store.Create(ctx, workspacebiz.Summary{ID: workspaceID, Name: "Fixture"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, report := range []agentactivitybiz.SessionStateReport{
|
|
{
|
|
WorkspaceID: workspaceID,
|
|
AgentSessionID: "root-session",
|
|
Kind: "root",
|
|
Origin: agentsessionstore.WorkspaceAgentSessionOriginRuntime,
|
|
AgentTargetID: "local:codex",
|
|
Provider: "codex",
|
|
ProviderSessionID: "provider-root",
|
|
Status: "ready",
|
|
Title: workspaceID,
|
|
RuntimeContext: map[string]any{
|
|
canonical.ProviderResumeCheckpointRuntimeContextKey: map[string]any{
|
|
"defaultModel": "gpt-5.5",
|
|
"defaultModeMask": map[string]any{
|
|
"mode": "default",
|
|
},
|
|
},
|
|
},
|
|
OccurredAtUnixMS: 100,
|
|
},
|
|
{
|
|
WorkspaceID: workspaceID,
|
|
AgentSessionID: "child-session",
|
|
Kind: "child",
|
|
RootAgentSessionID: "root-session",
|
|
RootTurnID: "root-turn",
|
|
ParentAgentSessionID: "root-session",
|
|
ParentTurnID: "root-turn",
|
|
ParentToolCallID: "tool-call",
|
|
Origin: agentsessionstore.WorkspaceAgentSessionOriginRuntime,
|
|
AgentTargetID: "local:codex",
|
|
Provider: "codex",
|
|
ProviderSessionID: "provider-child",
|
|
Status: "ready",
|
|
OccurredAtUnixMS: 110,
|
|
},
|
|
{
|
|
WorkspaceID: workspaceID,
|
|
AgentSessionID: "unrelated-sentinel-session",
|
|
Kind: "root",
|
|
Origin: agentsessionstore.WorkspaceAgentSessionOriginRuntime,
|
|
AgentTargetID: "local:codex",
|
|
Provider: "codex",
|
|
ProviderSessionID: "provider-unrelated",
|
|
Status: "ready",
|
|
Title: "UNRELATED_SENTINEL_MUST_NOT_EXPORT",
|
|
OccurredAtUnixMS: 120,
|
|
},
|
|
} {
|
|
if report.AgentSessionID == "child-session" {
|
|
seedTestAgentTurn(
|
|
t,
|
|
store,
|
|
ctx,
|
|
workspaceID,
|
|
"root-session",
|
|
"root-turn",
|
|
"codex",
|
|
105,
|
|
)
|
|
}
|
|
if _, err := store.ReportSessionState(ctx, report); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, accepted, err := store.agentStore().RecordTurnTransition(
|
|
ctx,
|
|
agentactivitybiz.TurnTransition{
|
|
WorkspaceID: workspaceID, AgentSessionID: "root-session",
|
|
TurnID: "root-turn", Phase: agentactivitybiz.TurnPhaseSettled,
|
|
Outcome: agentactivitybiz.TurnOutcomeCompleted,
|
|
Origin: agentactivitybiz.TurnOriginLegacyUnknown,
|
|
OccurredAtUnixMS: 130,
|
|
},
|
|
); err != nil || !accepted {
|
|
t.Fatalf("settle root Turn accepted=%v error=%v", accepted, err)
|
|
}
|
|
|
|
rootID, err := store.ResolveRootAgentSession(ctx, workspaceID, "child-session")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rootID != "root-session" {
|
|
t.Fatalf("root id = %q", rootID)
|
|
}
|
|
raw, err := store.CaptureReplayState(ctx, workspaceID, rootID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var state TuttiReplayState
|
|
if err := json.Unmarshal(raw, &state); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if state.SchemaVersion != tuttiReplayStateSchemaVersion {
|
|
t.Fatalf("semantic state schema version = %d", state.SchemaVersion)
|
|
}
|
|
var sessionIDs []string
|
|
foundSourceIDInUserData := false
|
|
for _, session := range state.Agent.Sessions {
|
|
sessionIDs = append(sessionIDs, session.ID)
|
|
if session.Title == workspaceID {
|
|
foundSourceIDInUserData = true
|
|
}
|
|
}
|
|
if len(sessionIDs) != 2 || sessionIDs[0] != "root-session" || sessionIDs[1] != "child-session" {
|
|
t.Fatalf("exported sessions = %#v", sessionIDs)
|
|
}
|
|
if !foundSourceIDInUserData {
|
|
t.Fatal("fixture export rewrote user data equal to the source Workspace id")
|
|
}
|
|
if checkpoint := state.Agent.Sessions[0].ProviderResumeCheckpoint; checkpoint == nil ||
|
|
checkpoint["defaultModel"] != "gpt-5.5" {
|
|
t.Fatalf("provider resume checkpoint = %#v", checkpoint)
|
|
}
|
|
if string(raw) == "" ||
|
|
jsonContainsKey(t, raw, "workspaceId") ||
|
|
jsonContainsKey(t, raw, "table") ||
|
|
jsonContainsKey(t, raw, "values") {
|
|
t.Fatalf("semantic state leaks persistence vocabulary: %s", raw)
|
|
}
|
|
|
|
const replayWorkspaceID = "workspace-fixture-replay"
|
|
if err := store.Create(ctx, workspacebiz.Summary{
|
|
ID: replayWorkspaceID, Name: "Replay",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.AgentCanonicalStore().RestoreHistoricalSessionGraph(
|
|
ctx,
|
|
agenthost.HistoricalSessionGraphRestoreInput{
|
|
WorkspaceID: replayWorkspaceID,
|
|
UserID: "user-replay",
|
|
Graph: state.Agent,
|
|
},
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restored, err := store.AgentCanonicalStore().CaptureHistoricalSessionGraph(
|
|
ctx,
|
|
replayWorkspaceID,
|
|
state.Agent.RootSessionID,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
restoredRaw, err := json.Marshal(restored)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expectedRaw, err := json.Marshal(state.Agent)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(restoredRaw, expectedRaw) {
|
|
t.Fatalf(
|
|
"restored graph differs:\nrestored=%s\nexpected=%s",
|
|
restoredRaw,
|
|
expectedRaw,
|
|
)
|
|
}
|
|
restoredSession, ok, err := store.AgentCanonicalStore().GetSession(
|
|
ctx,
|
|
replayWorkspaceID,
|
|
"root-session",
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("restored root Session is missing")
|
|
}
|
|
if checkpoint := restoredSession.InternalRuntimeContext[canonical.ProviderResumeCheckpointRuntimeContextKey]; !bytes.Equal(mustJSON(t, checkpoint), mustJSON(
|
|
t,
|
|
state.Agent.Sessions[0].ProviderResumeCheckpoint,
|
|
)) {
|
|
t.Fatalf("restored provider resume checkpoint = %#v", checkpoint)
|
|
}
|
|
if err := store.AgentCanonicalStore().RestoreHistoricalSessionGraph(
|
|
ctx,
|
|
agenthost.HistoricalSessionGraphRestoreInput{
|
|
WorkspaceID: replayWorkspaceID,
|
|
UserID: "user-replay",
|
|
Graph: state.Agent,
|
|
},
|
|
); err != nil {
|
|
t.Fatalf("idempotent restore error = %v", err)
|
|
}
|
|
conflicting := state.Agent
|
|
conflicting.Sessions = append([]agenthost.HistoricalSession(nil), state.Agent.Sessions...)
|
|
conflicting.Sessions[0].Title = "conflicting title"
|
|
if err := store.AgentCanonicalStore().RestoreHistoricalSessionGraph(
|
|
ctx,
|
|
agenthost.HistoricalSessionGraphRestoreInput{
|
|
WorkspaceID: replayWorkspaceID,
|
|
UserID: "user-replay",
|
|
Graph: conflicting,
|
|
},
|
|
); !errors.Is(err, agenthost.ErrHistoricalStateConflict) {
|
|
t.Fatalf("conflicting restore error = %v", err)
|
|
}
|
|
}
|
|
|
|
func mustJSON(t *testing.T, value any) []byte {
|
|
t.Helper()
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func TestCaptureReplayStateIgnoresUnrelatedSQLiteColumns(t *testing.T) {
|
|
store := openTestSQLiteStore(t)
|
|
ctx := context.Background()
|
|
const workspaceID = "workspace-portability"
|
|
if err := store.Create(ctx, workspacebiz.Summary{ID: workspaceID, Name: "Portable"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.ReportSessionState(ctx, agentactivitybiz.SessionStateReport{
|
|
WorkspaceID: workspaceID, AgentSessionID: "session-1", Kind: "root",
|
|
Origin: agentsessionstore.WorkspaceAgentSessionOriginRuntime,
|
|
AgentTargetID: "local:codex", Provider: "codex",
|
|
ProviderSessionID: "provider-session-1",
|
|
Status: "ready", OccurredAtUnixMS: 100,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.writeDB.ExecContext(ctx, `
|
|
UPDATE workspace_agent_session_goals
|
|
SET desired_json = NULL, observed_json = NULL, last_evidence_json = NULL
|
|
WHERE workspace_id = ? AND agent_session_id = ?
|
|
`, workspaceID, "session-1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
beforeRaw, err := store.CaptureReplayState(ctx, workspaceID, "session-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.writeDB.ExecContext(ctx, `
|
|
ALTER TABLE tutti_mode_turn_snapshots
|
|
ADD COLUMN unrelated_future_column TEXT NOT NULL DEFAULT 'speed-mismatch-regression'
|
|
`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
afterRaw, err := store.CaptureReplayState(ctx, workspaceID, "session-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(beforeRaw) != string(afterRaw) {
|
|
t.Fatalf("unrelated SQLite column changed semantic output\nbefore=%s\nafter=%s", beforeRaw, afterRaw)
|
|
}
|
|
}
|
|
|
|
func jsonContainsKey(t *testing.T, raw []byte, key string) bool {
|
|
t.Helper()
|
|
var visit func(any) bool
|
|
visit = func(value any) bool {
|
|
switch value := value.(type) {
|
|
case map[string]any:
|
|
if _, ok := value[key]; ok {
|
|
return true
|
|
}
|
|
for _, child := range value {
|
|
if visit(child) {
|
|
return true
|
|
}
|
|
}
|
|
case []any:
|
|
for _, child := range value {
|
|
if visit(child) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
var value any
|
|
if err := json.Unmarshal(raw, &value); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return visit(value)
|
|
}
|