package service import ( "context" "testing" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" "github.com/Tencent/WeKnora/internal/sandbox" "github.com/Tencent/WeKnora/internal/types" ) type pinTestManager struct { typ sandbox.SandboxType } func (m *pinTestManager) Execute(context.Context, *sandbox.ExecuteConfig) (*sandbox.ExecuteResult, error) { return nil, nil } func (m *pinTestManager) Cleanup(context.Context) error { return nil } func (m *pinTestManager) GetSandbox() sandbox.Sandbox { return nil } func (m *pinTestManager) GetType() sandbox.SandboxType { return m.typ } func newPinTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.Migrator().DropTable(&types.Session{})) require.NoError(t, db.AutoMigrate(&types.Session{})) require.NoError(t, db.Model(&types.Session{}).Create(map[string]any{ "id": "s-1", "tenant_id": uint64(7), }).Error) return db } func TestPinWritesWhenUnset(t *testing.T) { pinner := NewSessionSandboxPinner(newPinTestDB(t)) ctx := context.Background() got, err := pinner.Pin(ctx, "s-1", "cfg-a") require.NoError(t, err) require.Equal(t, "cfg-a", got) read, err := pinner.Read(ctx, "s-1") require.NoError(t, err) require.Equal(t, "cfg-a", read) } // Two concurrent first-sandbox creations must converge on one config, or the // session would end up with two sandboxes on two backends. func TestPinIsIdempotentAndReturnsExistingWinner(t *testing.T) { pinner := NewSessionSandboxPinner(newPinTestDB(t)) ctx := context.Background() first, err := pinner.Pin(ctx, "s-1", "cfg-a") require.NoError(t, err) require.Equal(t, "cfg-a", first) second, err := pinner.Pin(ctx, "s-1", "cfg-b") require.NoError(t, err) require.Equal(t, "cfg-a", second, "the first writer wins; later callers adopt it") } func TestPinLeavesEmptyConfigUnpinned(t *testing.T) { pinner := NewSessionSandboxPinner(newPinTestDB(t)) ctx := context.Background() got, err := pinner.Pin(ctx, "s-1", "") require.NoError(t, err) require.Empty(t, got) } // A padded ID must land in the column exactly as Read will compare it, or the // conditional claim would never recognise its own write. func TestPinTrimsConfigIDBeforeStoring(t *testing.T) { pinner := NewSessionSandboxPinner(newPinTestDB(t)) ctx := context.Background() got, err := pinner.Pin(ctx, "s-1", " cfg-a ") require.NoError(t, err) require.Equal(t, "cfg-a", got) read, err := pinner.Read(ctx, "s-1") require.NoError(t, err) require.Equal(t, "cfg-a", read) } // Pin runs right after a sandbox was created, so a vanished session must be an // error: "" would read as "no live sandbox" and abandon a real one. func TestPinFailsWhenSessionIsGone(t *testing.T) { db := newPinTestDB(t) pinner := NewSessionSandboxPinner(db) ctx := context.Background() _, err := pinner.Pin(ctx, "missing", "cfg-a") require.ErrorIs(t, err, gorm.ErrRecordNotFound) require.NoError(t, db.Delete(&types.Session{}, "id = ?", "s-1").Error) _, err = pinner.Pin(ctx, "s-1", "cfg-a") require.ErrorIs(t, err, gorm.ErrRecordNotFound, "a soft-deleted session already had its sandbox destroyed") } // Read keeps the lenient contract: absent session and unpinned session are // both simply "no live sandbox". func TestReadReportsNoSandboxForMissingSession(t *testing.T) { pinner := NewSessionSandboxPinner(newPinTestDB(t)) read, err := pinner.Read(context.Background(), "missing") require.NoError(t, err) require.Empty(t, read) } // The pin dies with the sandbox: after teardown the session must be free to // follow its agent's CURRENT backend choice. func TestClearReleasesPin(t *testing.T) { pinner := NewSessionSandboxPinner(newPinTestDB(t)) ctx := context.Background() _, err := pinner.Pin(ctx, "s-1", "cfg-a") require.NoError(t, err) require.NoError(t, pinner.Clear(ctx, "s-1")) read, err := pinner.Read(ctx, "s-1") require.NoError(t, err) require.Empty(t, read) } // Session delete soft-deletes the row before any follow-up work. Destroy must // therefore read sandbox_config_id first; otherwise the pinner sees an absent // session and teardown resolves the wrong backend (T4 regression). func TestSoftDeleteHidesSandboxPin(t *testing.T) { db := newPinTestDB(t) pinner := NewSessionSandboxPinner(db) ctx := context.Background() _, err := pinner.Pin(ctx, "s-1", "cfg-a") require.NoError(t, err) require.NoError(t, db.Delete(&types.Session{}, "id = ?", "s-1").Error) read, err := pinner.Read(ctx, "s-1") require.NoError(t, err) require.Empty(t, read, "soft-deleted session must not expose its pin") } func TestResolveSandboxForExecutionDoesNotPinStatelessBackend(t *testing.T) { pinner := NewSessionSandboxPinner(newPinTestDB(t)) want := &pinTestManager{typ: sandbox.SandboxTypeDisabled} got, configID, err := resolveSandboxForExecution( context.Background(), stubSandboxResolver{mgr: want}, nil, pinner, 7, "s-1", "cfg-local", nil, ) require.NoError(t, err) require.Same(t, want, got) require.Equal(t, "cfg-local", configID) pinned, err := pinner.Read(context.Background(), "s-1") require.NoError(t, err) require.Empty(t, pinned, "disabled backends must not leave a session binding") } func TestResolveSandboxForExecutionPinsRemoteBackend(t *testing.T) { pinner := NewSessionSandboxPinner(newPinTestDB(t)) want := &pinTestManager{typ: sandbox.SandboxTypeCube} got, configID, err := resolveSandboxForExecution( context.Background(), stubSandboxResolver{mgr: want}, nil, pinner, 7, "s-1", "cfg-cube", nil, ) require.NoError(t, err) require.Same(t, want, got) require.Equal(t, "cfg-cube", configID) pinned, err := pinner.Read(context.Background(), "s-1") require.NoError(t, err) require.Equal(t, "cfg-cube", pinned) } // Docker is a session-persistent remote backend, same as Cube/E2B. Skipping // the pin used to make ArtifactCollector treat the turn as "no live sandbox" // and leave generated HTML/files showing as unavailable in chat. func TestResolveSandboxForExecutionPinsDockerBackend(t *testing.T) { pinner := NewSessionSandboxPinner(newPinTestDB(t)) want := &pinTestManager{typ: sandbox.SandboxTypeDocker} got, configID, err := resolveSandboxForExecution( context.Background(), stubSandboxResolver{mgr: want}, nil, pinner, 7, "s-1", "cfg-docker", nil, ) require.NoError(t, err) require.Same(t, want, got) require.Equal(t, "cfg-docker", configID) pinned, err := pinner.Read(context.Background(), "s-1") require.NoError(t, err) require.Equal(t, "cfg-docker", pinned) } func TestResolveSandboxForExecutionKeepsExistingRemotePin(t *testing.T) { pinner := NewSessionSandboxPinner(newPinTestDB(t)) _, err := pinner.Pin(context.Background(), "s-1", "cfg-existing") require.NoError(t, err) want := &pinTestManager{typ: sandbox.SandboxTypeE2B} got, configID, err := resolveSandboxForExecution( context.Background(), stubSandboxResolver{mgr: want}, nil, pinner, 7, "s-1", "cfg-new-agent-choice", nil, ) require.NoError(t, err) require.Same(t, want, got) require.Equal(t, "cfg-existing", configID, "re-pointing an agent must not move an existing remote session") }