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
169 lines
4.5 KiB
Go
169 lines
4.5 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func startupModelsTestSession(adapter *CodexAppServerAdapter) Session {
|
|
session := testAppServerSession()
|
|
adapter.mu.Lock()
|
|
adapter.sessions[session.AgentSessionID] = &codexAppServerSession{}
|
|
adapter.mu.Unlock()
|
|
return session
|
|
}
|
|
|
|
func startupModelsReadyFlag(adapter *CodexAppServerAdapter, agentSessionID string) bool {
|
|
adapter.mu.Lock()
|
|
defer adapter.mu.Unlock()
|
|
if appSession := adapter.sessions[agentSessionID]; appSession != nil {
|
|
return appSession.startupModelsReady
|
|
}
|
|
return false
|
|
}
|
|
|
|
// A single transient empty/slow model/list response must not pin the session at
|
|
// "loading" forever: the adapter keeps retrying in the background until codex
|
|
// returns a non-empty list, then resolves the startup state.
|
|
func TestRetryStartupModelsResolvesAfterTransientEmpty(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := NewCodexAppServerAdapter(newScriptedAppServerTransport())
|
|
adapter.startupModelRetryBackoffs = []time.Duration{0, 0, 0, 0}
|
|
session := startupModelsTestSession(adapter)
|
|
|
|
calls := 0
|
|
fetch := func(context.Context) []map[string]any {
|
|
calls++
|
|
if calls < 3 {
|
|
return nil
|
|
}
|
|
return []map[string]any{{"id": "gpt-5.3-codex", "displayName": "GPT-5.3 Codex"}}
|
|
}
|
|
resolved := adapter.retryStartupModels(
|
|
context.Background(),
|
|
session.AgentSessionID,
|
|
session,
|
|
nil,
|
|
fetch,
|
|
func(context.Context, time.Duration) bool { return true },
|
|
)
|
|
|
|
if !resolved {
|
|
t.Fatalf("retryStartupModels = false, want true once codex returns models")
|
|
}
|
|
if calls != 3 {
|
|
t.Fatalf("fetch calls = %d, want 3 (two empty, one populated)", calls)
|
|
}
|
|
if !startupModelsReadyFlag(adapter, session.AgentSessionID) {
|
|
t.Fatalf("startupModelsReady = false, want true after models resolved")
|
|
}
|
|
}
|
|
|
|
// When the model/list response stays empty, the retry must be bounded: it gives
|
|
// up after the configured budget instead of looping forever.
|
|
func TestRetryStartupModelsGivesUpAfterBudget(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := NewCodexAppServerAdapter(newScriptedAppServerTransport())
|
|
// len(backoffs) sleeps => len(backoffs)+1 attempts.
|
|
adapter.startupModelRetryBackoffs = []time.Duration{0, 0}
|
|
session := startupModelsTestSession(adapter)
|
|
|
|
calls := 0
|
|
fetch := func(context.Context) []map[string]any {
|
|
calls++
|
|
return nil
|
|
}
|
|
resolved := adapter.retryStartupModels(
|
|
context.Background(),
|
|
session.AgentSessionID,
|
|
session,
|
|
nil,
|
|
fetch,
|
|
func(context.Context, time.Duration) bool { return true },
|
|
)
|
|
|
|
if resolved {
|
|
t.Fatalf("retryStartupModels = true, want false when models never arrive")
|
|
}
|
|
if calls != 3 {
|
|
t.Fatalf("fetch calls = %d, want 3 (initial + 2 retries)", calls)
|
|
}
|
|
if startupModelsReadyFlag(adapter, session.AgentSessionID) {
|
|
t.Fatalf("startupModelsReady = true, want false after giving up")
|
|
}
|
|
}
|
|
|
|
// The retry loop must stop as soon as the session is torn down so the
|
|
// background goroutine cannot outlive its session.
|
|
func TestRetryStartupModelsStopsWhenSessionRemoved(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := NewCodexAppServerAdapter(newScriptedAppServerTransport())
|
|
adapter.startupModelRetryBackoffs = []time.Duration{0, 0, 0, 0, 0, 0}
|
|
session := startupModelsTestSession(adapter)
|
|
|
|
var once sync.Once
|
|
calls := 0
|
|
fetch := func(context.Context) []map[string]any {
|
|
calls++
|
|
return nil
|
|
}
|
|
sleep := func(context.Context, time.Duration) bool {
|
|
once.Do(func() {
|
|
adapter.mu.Lock()
|
|
delete(adapter.sessions, session.AgentSessionID)
|
|
adapter.mu.Unlock()
|
|
})
|
|
return true
|
|
}
|
|
resolved := adapter.retryStartupModels(
|
|
context.Background(),
|
|
session.AgentSessionID,
|
|
session,
|
|
nil,
|
|
fetch,
|
|
sleep,
|
|
)
|
|
|
|
if resolved {
|
|
t.Fatalf("retryStartupModels = true, want false after session removed")
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("fetch calls = %d, want 1 before the session was removed", calls)
|
|
}
|
|
}
|
|
|
|
// A canceled context aborts the retry loop instead of consuming the full budget.
|
|
func TestRetryStartupModelsStopsWhenContextCanceled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := NewCodexAppServerAdapter(newScriptedAppServerTransport())
|
|
adapter.startupModelRetryBackoffs = []time.Duration{0, 0, 0, 0}
|
|
session := startupModelsTestSession(adapter)
|
|
|
|
calls := 0
|
|
fetch := func(context.Context) []map[string]any {
|
|
calls++
|
|
return nil
|
|
}
|
|
resolved := adapter.retryStartupModels(
|
|
context.Background(),
|
|
session.AgentSessionID,
|
|
session,
|
|
nil,
|
|
fetch,
|
|
func(context.Context, time.Duration) bool { return false },
|
|
)
|
|
|
|
if resolved {
|
|
t.Fatalf("retryStartupModels = true, want false when the loop is aborted")
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("fetch calls = %d, want 1 before the abort", calls)
|
|
}
|
|
}
|