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
863 lines
32 KiB
Go
863 lines
32 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestCodexAppServerStartupTimeoutsStayScoped(t *testing.T) {
|
|
if got, want := codexAppServerThreadStartTimeout, 90*time.Second; got != want {
|
|
t.Fatalf("thread/start timeout = %s, want %s", got, want)
|
|
}
|
|
if got, want := acpStartCallTimeout, 30*time.Second; got != want {
|
|
t.Fatalf("generic ACP timeout = %s, want %s", got, want)
|
|
}
|
|
if got, want := defaultCodexAppServerTurnStartAckTimeout, 30*time.Second; got != want {
|
|
t.Fatalf("turn/start ACK timeout = %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterStartCreatesThread(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter, transport, _ := startedAppServerAdapter(t)
|
|
if len(transport.specs) != 1 {
|
|
t.Fatalf("process starts = %d, want 1", len(transport.specs))
|
|
}
|
|
spec := transport.specs[0]
|
|
descriptor := codexProviderDescriptorForTest(t)
|
|
wantCommand := descriptor.Runtime.Command
|
|
if len(spec.Command) != 2 || spec.Command[0] != wantCommand[0] || spec.Command[1] != wantCommand[1] {
|
|
t.Fatalf("command = %#v, want %#v", spec.Command, wantCommand)
|
|
}
|
|
if spec.CWD != "/workspace" {
|
|
t.Fatalf("cwd = %q, want /workspace (workspace-mapped)", spec.CWD)
|
|
}
|
|
if !containsString(spec.Env, codexAgentRoutingEnv) {
|
|
t.Fatalf("env = %#v, want agent routing env", spec.Env)
|
|
}
|
|
if !containsString(spec.Env, codexAppServerLogFormatEnv) {
|
|
t.Fatalf("env = %#v, want structured Codex logs", spec.Env)
|
|
}
|
|
if !containsString(spec.Env, codexAppServerRustLogEnv) {
|
|
t.Fatalf("env = %#v, want Codex startup span log level", spec.Env)
|
|
}
|
|
|
|
initialize := appServerRequestParams(t, transport.conn, appServerMethodInitialize)
|
|
clientInfo, _ := initialize["clientInfo"].(map[string]any)
|
|
if asString(clientInfo["name"]) != descriptor.Runtime.ClientInfoName || asString(clientInfo["version"]) == "" {
|
|
t.Fatalf("initialize clientInfo = %#v, want name=%q + non-empty version",
|
|
initialize["clientInfo"], descriptor.Runtime.ClientInfoName)
|
|
}
|
|
capabilities, _ := initialize["capabilities"].(map[string]any)
|
|
if experimental, _ := capabilities["experimentalApi"].(bool); !experimental {
|
|
t.Fatalf("initialize capabilities = %#v, want experimentalApi=true", initialize["capabilities"])
|
|
}
|
|
if params := appServerRequestParamsList(t, transport.conn, appServerMethodInitialized); len(params) != 1 {
|
|
t.Fatalf("initialized notifications = %d, want 1", len(params))
|
|
}
|
|
threadStart := appServerRequestParams(t, transport.conn, appServerMethodThreadStart)
|
|
if asString(threadStart["cwd"]) != "/workspace" {
|
|
t.Fatalf("thread/start cwd = %q, want /workspace", threadStart["cwd"])
|
|
}
|
|
state := adapter.SessionState(testAppServerSession())
|
|
if state.AuthState != "authenticated" {
|
|
t.Fatalf("auth state = %q, want authenticated", state.AuthState)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterStartClampsProviderDefaultReasoning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newScriptedAppServerTransport()
|
|
adapter := NewCodexAppServerAdapter(transport)
|
|
session := testAppServerSession()
|
|
session.Settings = &SessionSettings{ReasoningEffort: "ultra"}
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
threadStart := appServerRequestParams(t, transport.conn, appServerMethodThreadStart)
|
|
if got := asString(threadStart["model"]); got != "gpt-5.1-codex" {
|
|
t.Fatalf("thread/start model = %q, want gpt-5.1-codex", got)
|
|
}
|
|
config, _ := threadStart["config"].(map[string]any)
|
|
if got := asString(config["model_reasoning_effort"]); got != "medium" {
|
|
t.Fatalf("thread/start reasoning effort = %q, want medium", got)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterStartClampsPersistedReasoningForExplicitModel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newScriptedAppServerTransport()
|
|
adapter := NewCodexAppServerAdapter(transport)
|
|
session := testAppServerSession()
|
|
session.Settings = &SessionSettings{
|
|
Model: "gpt-5.1-codex-mini",
|
|
ReasoningEffort: "high",
|
|
}
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
if requests := appServerRequestParamsList(t, transport.conn, appServerMethodModelList); len(requests) != 1 {
|
|
t.Fatalf("model/list requests = %d, want 1 to validate persisted reasoning", len(requests))
|
|
}
|
|
threadStart := appServerRequestParams(t, transport.conn, appServerMethodThreadStart)
|
|
if got := asString(threadStart["model"]); got != "gpt-5.1-codex-mini" {
|
|
t.Fatalf("thread/start model = %q, want gpt-5.1-codex-mini", got)
|
|
}
|
|
config, _ := threadStart["config"].(map[string]any)
|
|
if got := asString(config["model_reasoning_effort"]); got != "medium" {
|
|
t.Fatalf("thread/start reasoning effort = %q, want catalog default medium", got)
|
|
}
|
|
state := adapter.SessionState(session)
|
|
options, _ := state.RuntimeContext["configOptions"].([]map[string]any)
|
|
reasoning := configOptionByID(options, "reasoning_effort")
|
|
if got := configOptionValues(reasoning); !slices.Equal(got, []string{"low", "medium"}) {
|
|
t.Fatalf("hidden current model reasoning options = %#v, want catalog profile", got)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterResumeClampsPersistedReasoningBeforeRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newScriptedAppServerTransport()
|
|
adapter := NewCodexAppServerAdapter(transport)
|
|
session := testAppServerSession()
|
|
session.ProviderSessionID = "codex-thread-1"
|
|
session.Settings = &SessionSettings{
|
|
Model: "gpt-5.1-codex-mini",
|
|
ReasoningEffort: "high",
|
|
}
|
|
if err := adapter.Resume(context.Background(), session); err != nil {
|
|
t.Fatalf("Resume: %v", err)
|
|
}
|
|
threadResume := appServerRequestParams(t, transport.conn, appServerMethodThreadResume)
|
|
config, _ := threadResume["config"].(map[string]any)
|
|
if got := asString(config["model_reasoning_effort"]); got != "medium" {
|
|
t.Fatalf("thread/resume reasoning effort = %q, want catalog default medium", got)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterResumeDoesNotInferModelOverrideForPersistedReasoning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newScriptedAppServerTransport()
|
|
transport.server.modelList = []any{map[string]any{
|
|
"id": "gpt-current-default",
|
|
"model": "gpt-current-default",
|
|
"isDefault": true,
|
|
"defaultReasoningEffort": "medium",
|
|
"supportedReasoningEfforts": []any{"low", "medium"},
|
|
}}
|
|
adapter := NewCodexAppServerAdapter(transport)
|
|
session := testAppServerSession()
|
|
session.ProviderSessionID = "codex-thread-1"
|
|
session.Settings = &SessionSettings{ReasoningEffort: "ultra"}
|
|
|
|
if err := adapter.Resume(context.Background(), session); err != nil {
|
|
t.Fatalf("Resume: %v", err)
|
|
}
|
|
threadResume := appServerRequestParams(t, transport.conn, appServerMethodThreadResume)
|
|
if got := asString(threadResume["model"]); got != "" {
|
|
t.Fatalf("thread/resume model = %q, want existing thread model preserved", got)
|
|
}
|
|
config, _ := threadResume["config"].(map[string]any)
|
|
if got := asString(config["model_reasoning_effort"]); got != "medium" {
|
|
t.Fatalf("thread/resume reasoning effort = %q, want catalog default medium", got)
|
|
}
|
|
state := adapter.SessionState(session)
|
|
if state.Settings == nil || state.Settings.Model != "gpt-5.1-codex" {
|
|
t.Fatalf("resumed settings = %#v, want model reported by existing thread", state.Settings)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterStartUsesModelFieldForCatalogDefault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newScriptedAppServerTransport()
|
|
transport.server.modelList = []any{map[string]any{
|
|
"id": "catalog-record-id",
|
|
"model": "gpt-default-api-name",
|
|
"displayName": "Default API Model",
|
|
"isDefault": true,
|
|
"defaultReasoningEffort": "high",
|
|
"supportedReasoningEfforts": []any{"low", "high"},
|
|
}}
|
|
adapter := NewCodexAppServerAdapter(transport)
|
|
session := testAppServerSession()
|
|
session.Settings = &SessionSettings{ReasoningEffort: "ultra"}
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
|
|
threadStart := appServerRequestParams(t, transport.conn, appServerMethodThreadStart)
|
|
if got := asString(threadStart["model"]); got != "gpt-default-api-name" {
|
|
t.Fatalf("thread/start model = %q, want catalog model field", got)
|
|
}
|
|
config, _ := threadStart["config"].(map[string]any)
|
|
if got := asString(config["model_reasoning_effort"]); got != "high" {
|
|
t.Fatalf("thread/start reasoning effort = %q, want model-specific default high", got)
|
|
}
|
|
state := adapter.SessionState(session)
|
|
options, _ := state.RuntimeContext["configOptions"].([]map[string]any)
|
|
modelOption := configOptionByID(options, "model")
|
|
if got := asString(modelOption["currentValue"]); got != "gpt-default-api-name" {
|
|
t.Fatalf("model current value = %q, want catalog model field", got)
|
|
}
|
|
reasoningOption := configOptionByID(options, "reasoning_effort")
|
|
if got := configOptionValues(reasoningOption); !slices.Equal(got, []string{"low", "high"}) {
|
|
t.Fatalf("reasoning options = %#v, want model-specific catalog values", got)
|
|
}
|
|
}
|
|
|
|
func TestCodexClientInfoParamsPresentsOfficialOriginator(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A resolved codex version is used verbatim and overrides the host name,
|
|
// so the outbound originator/User-Agent match the genuine codex_cli_rs.
|
|
host := HostMetadata{ClientInfo: ClientInfo{Name: "tutti-desktop", Title: "Tutti", Version: "0.1.0"}}
|
|
descriptor := codexProviderDescriptorForTest(t)
|
|
got := clientInfoParamsForVersion(host, descriptor.Runtime.ClientInfoName, "1.2.3")
|
|
|
|
if got["name"] != descriptor.Runtime.ClientInfoName {
|
|
t.Fatalf("name = %v, want %q (official originator, not the host name)", got["name"], descriptor.Runtime.ClientInfoName)
|
|
}
|
|
if got["version"] != "1.2.3" {
|
|
t.Fatalf("version = %v, want the resolved codex version 1.2.3 (not host %q)", got["version"], host.ClientInfo.Version)
|
|
}
|
|
if got["title"] != "Tutti" {
|
|
t.Fatalf("title = %v, want Tutti (passed through from host)", got["title"])
|
|
}
|
|
}
|
|
|
|
func TestCodexClientInfoParamsFallsBackToHostVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// When the codex version cannot be resolved, fall back to the host version
|
|
// rather than emitting a blank version segment.
|
|
host := HostMetadata{ClientInfo: ClientInfo{Name: "tutti-desktop", Title: "Tutti", Version: "9.9.9"}}
|
|
descriptor := codexProviderDescriptorForTest(t)
|
|
got := clientInfoParamsForVersion(host, descriptor.Runtime.ClientInfoName, "")
|
|
|
|
if got["name"] != descriptor.Runtime.ClientInfoName {
|
|
t.Fatalf("name = %v, want %q", got["name"], descriptor.Runtime.ClientInfoName)
|
|
}
|
|
if got["version"] != "9.9.9" {
|
|
t.Fatalf("version = %v, want host fallback 9.9.9", got["version"])
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterWireFormatOmitsJSONRPCVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, transport, _ := startedAppServerAdapter(t)
|
|
transport.conn.mu.Lock()
|
|
defer transport.conn.mu.Unlock()
|
|
for _, data := range transport.conn.sent {
|
|
for _, line := range acpScanLines(data) {
|
|
var message map[string]any
|
|
if err := json.Unmarshal([]byte(line), &message); err != nil {
|
|
t.Fatalf("unmarshal sent line: %v", err)
|
|
}
|
|
if _, found := message["jsonrpc"]; found {
|
|
t.Fatalf("sent message includes jsonrpc version header: %s", line)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterStartAppliesSettingsAndPermissionMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newScriptedAppServerTransport()
|
|
adapter := NewCodexAppServerAdapter(transport)
|
|
session := testAppServerSession()
|
|
session.PermissionModeID = "read-only"
|
|
session.Settings = &SessionSettings{
|
|
Model: "gpt-5.3-codex-spark",
|
|
ReasoningEffort: "xhigh",
|
|
PermissionModeID: "read-only",
|
|
}
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
threadStart := appServerRequestParams(t, transport.conn, appServerMethodThreadStart)
|
|
if asString(threadStart["model"]) != "gpt-5.3-codex-spark" {
|
|
t.Fatalf("thread/start model = %q", threadStart["model"])
|
|
}
|
|
if asString(threadStart["approvalPolicy"]) != "on-request" {
|
|
t.Fatalf("thread/start approvalPolicy = %q, want on-request", threadStart["approvalPolicy"])
|
|
}
|
|
if asString(threadStart["sandbox"]) != "read-only" {
|
|
t.Fatalf("thread/start sandbox = %q, want read-only", threadStart["sandbox"])
|
|
}
|
|
if asString(threadStart["approvalsReviewer"]) != "user" {
|
|
t.Fatalf("thread/start approvalsReviewer = %q, want user", threadStart["approvalsReviewer"])
|
|
}
|
|
config, _ := threadStart["config"].(map[string]any)
|
|
if asString(config["model_reasoning_effort"]) != "xhigh" {
|
|
t.Fatalf("thread/start config = %#v, want model_reasoning_effort=xhigh", config)
|
|
}
|
|
if asString(config["model_reasoning_summary"]) != "auto" {
|
|
t.Fatalf("thread/start config = %#v, want reasoning summaries enabled for inline review on spark", config)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterCommandNetworkAccessPreservesPermissionModes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testAppServerAdapterCommandNetworkAccessPreservesPermissionModes(
|
|
t,
|
|
func(transport ProcessTransport) *CodexAppServerAdapter {
|
|
return NewCodexAppServerAdapterWithHostMetadataAndOptions(
|
|
transport,
|
|
LegacyHostMetadata(),
|
|
CodexAppServerAdapterOptions{CommandNetworkAccess: true},
|
|
)
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestTuttiAgentAppServerAdapterCommandNetworkAccessPreservesPermissionModes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
testAppServerAdapterCommandNetworkAccessPreservesPermissionModes(
|
|
t,
|
|
func(transport ProcessTransport) *CodexAppServerAdapter {
|
|
return NewTuttiAgentAppServerAdapterWithHostMetadataAndOptions(
|
|
transport,
|
|
LegacyHostMetadata(),
|
|
CodexAppServerAdapterOptions{CommandNetworkAccess: true},
|
|
)
|
|
},
|
|
)
|
|
}
|
|
|
|
func testAppServerAdapterCommandNetworkAccessPreservesPermissionModes(
|
|
t *testing.T,
|
|
newAdapter func(ProcessTransport) *CodexAppServerAdapter,
|
|
) {
|
|
t.Helper()
|
|
tests := []struct {
|
|
mode string
|
|
threadSandbox string
|
|
turnSandbox string
|
|
approvalPolicy string
|
|
approvalsReviewer string
|
|
}{
|
|
{
|
|
mode: "read-only",
|
|
threadSandbox: "read-only",
|
|
turnSandbox: "readOnly",
|
|
approvalPolicy: "on-request",
|
|
approvalsReviewer: "user",
|
|
},
|
|
{
|
|
mode: "auto",
|
|
threadSandbox: "workspace-write",
|
|
turnSandbox: "workspaceWrite",
|
|
approvalPolicy: "on-request",
|
|
approvalsReviewer: "auto_review",
|
|
},
|
|
{
|
|
mode: "full-access",
|
|
threadSandbox: "danger-full-access",
|
|
turnSandbox: "dangerFullAccess",
|
|
approvalPolicy: "never",
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.mode, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newScriptedAppServerTransport()
|
|
adapter := newAdapter(transport)
|
|
session := testAppServerSession()
|
|
session.PermissionModeID = test.mode
|
|
session.Settings = &SessionSettings{PermissionModeID: test.mode}
|
|
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
threadStart := appServerRequestParams(t, transport.conn, appServerMethodThreadStart)
|
|
if got := asString(threadStart["sandbox"]); got != test.threadSandbox {
|
|
t.Fatalf("thread/start sandbox = %q, want %q", got, test.threadSandbox)
|
|
}
|
|
if got := asString(threadStart["approvalPolicy"]); got != test.approvalPolicy {
|
|
t.Fatalf("thread/start approvalPolicy = %q, want %q", got, test.approvalPolicy)
|
|
}
|
|
if got := asString(threadStart["approvalsReviewer"]); got != test.approvalsReviewer {
|
|
t.Fatalf("thread/start approvalsReviewer = %q, want %q", got, test.approvalsReviewer)
|
|
}
|
|
|
|
if _, err := adapter.Exec(context.Background(), session, textPrompt("go"), "", "turn-local-1", nil, nil); err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
turnStart := appServerRequestParams(t, transport.conn, appServerMethodTurnStart)
|
|
policy, _ := turnStart["sandboxPolicy"].(map[string]any)
|
|
if got := asString(policy["type"]); got != test.turnSandbox {
|
|
t.Fatalf("turn/start sandboxPolicy = %#v, want type %q", policy, test.turnSandbox)
|
|
}
|
|
if test.mode == "full-access" {
|
|
if _, ok := policy["networkAccess"]; ok {
|
|
t.Fatalf("turn/start sandboxPolicy = %#v, want implicit full-access networking", policy)
|
|
}
|
|
} else if enabled, _ := policy["networkAccess"].(bool); !enabled {
|
|
t.Fatalf("turn/start sandboxPolicy = %#v, want networkAccess=true", policy)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterDefaultCommandNetworkAccessRemainsDisabled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newScriptedAppServerTransport()
|
|
adapter := NewCodexAppServerAdapter(transport)
|
|
session := testAppServerSession()
|
|
session.PermissionModeID = "read-only"
|
|
session.Settings = &SessionSettings{PermissionModeID: "read-only"}
|
|
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
if _, err := adapter.Exec(context.Background(), session, textPrompt("go"), "", "turn-local-1", nil, nil); err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
turnStart := appServerRequestParams(t, transport.conn, appServerMethodTurnStart)
|
|
policy, _ := turnStart["sandboxPolicy"].(map[string]any)
|
|
if _, ok := policy["networkAccess"]; ok {
|
|
t.Fatalf("turn/start sandboxPolicy = %#v, want legacy network default", policy)
|
|
}
|
|
}
|
|
|
|
func TestTuttiAgentAppServerAdapterDefaultCommandNetworkAccessRemainsDisabled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newScriptedAppServerTransport()
|
|
adapter := NewTuttiAgentAppServerAdapterWithHostMetadata(transport, LegacyHostMetadata())
|
|
session := testAppServerSession()
|
|
session.PermissionModeID = "read-only"
|
|
session.Settings = &SessionSettings{PermissionModeID: "read-only"}
|
|
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
if _, err := adapter.Exec(context.Background(), session, textPrompt("go"), "", "turn-local-1", nil, nil); err != nil {
|
|
t.Fatalf("Exec: %v", err)
|
|
}
|
|
turnStart := appServerRequestParams(t, transport.conn, appServerMethodTurnStart)
|
|
policy, _ := turnStart["sandboxPolicy"].(map[string]any)
|
|
if _, ok := policy["networkAccess"]; ok {
|
|
t.Fatalf("turn/start sandboxPolicy = %#v, want legacy network default", policy)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerReasoningEffortValuePreservesCatalogValues(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if got := codexACPReasoningEffortValue("max"); got != "xhigh" {
|
|
t.Fatalf("legacy codexACPReasoningEffortValue(max) = %q, want xhigh", got)
|
|
}
|
|
for _, value := range []string{"max", "ultra", "deep", "none"} {
|
|
if got := codexAppServerReasoningEffortValue(value); got != value {
|
|
t.Fatalf("codexAppServerReasoningEffortValue(%q) = %q, want %q", value, got, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAppServerThreadStartParamsPreservesCatalogReasoningEffort(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, effort := range []string{"max", "deep"} {
|
|
session := testAppServerSession()
|
|
session.Settings = &SessionSettings{Model: "gpt-5.6-sol", ReasoningEffort: effort}
|
|
params := appServerThreadStartParams(session, "/workspace")
|
|
config, _ := params["config"].(map[string]any)
|
|
if got := asString(config["model_reasoning_effort"]); got != effort {
|
|
t.Fatalf("thread/start reasoning effort = %q, want %q", got, effort)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAppServerReasoningEffortForModelPreservesAdvertisedValue(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
models := []map[string]any{{
|
|
"id": "gpt-future",
|
|
"defaultReasoningEffort": "medium",
|
|
"supportedReasoningEfforts": []any{"low", "medium", "deep"},
|
|
}}
|
|
if got := appServerReasoningEffortForModel(models, "gpt-future", "deep"); got != "deep" {
|
|
t.Fatalf("appServerReasoningEffortForModel(deep) = %q, want deep", got)
|
|
}
|
|
}
|
|
|
|
func TestAppServerReasoningEffortForModelHonorsAdvertisedEmptyList(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
models := []map[string]any{{
|
|
"model": "gpt-no-reasoning",
|
|
"supportedReasoningEfforts": []any{},
|
|
}}
|
|
if got := appServerReasoningEffortForModel(models, "gpt-no-reasoning", "high"); got != "" {
|
|
t.Fatalf("appServerReasoningEffortForModel(high) = %q, want empty", got)
|
|
}
|
|
descriptors := codexAppServerConfigOptionDescriptors(models, Session{
|
|
Settings: &SessionSettings{Model: "gpt-no-reasoning", ReasoningEffort: "high"},
|
|
}, nil)
|
|
option := configOptionByID(descriptors, "reasoning_effort")
|
|
if option == nil {
|
|
t.Fatalf("reasoning descriptor missing, want explicit empty live profile")
|
|
}
|
|
if value, ok := option["currentValue"]; !ok || value != nil {
|
|
t.Fatalf("reasoning currentValue = %#v, want nil tombstone", value)
|
|
}
|
|
if values := configOptionValues(option); len(values) != 0 {
|
|
t.Fatalf("reasoning options = %#v, want advertised empty list", values)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterStartAutoPermissionUsesAutoReviewer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newScriptedAppServerTransport()
|
|
adapter := NewCodexAppServerAdapter(transport)
|
|
session := testAppServerSession()
|
|
session.PermissionModeID = "auto"
|
|
session.Settings = &SessionSettings{
|
|
PermissionModeID: "auto",
|
|
}
|
|
if _, err := adapter.Start(context.Background(), session); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
threadStart := appServerRequestParams(t, transport.conn, appServerMethodThreadStart)
|
|
if asString(threadStart["approvalPolicy"]) != "on-request" {
|
|
t.Fatalf("thread/start approvalPolicy = %q, want on-request", threadStart["approvalPolicy"])
|
|
}
|
|
if asString(threadStart["sandbox"]) != "workspace-write" {
|
|
t.Fatalf("thread/start sandbox = %q, want workspace-write", threadStart["sandbox"])
|
|
}
|
|
if asString(threadStart["approvalsReviewer"]) != "auto_review" {
|
|
t.Fatalf("thread/start approvalsReviewer = %q, want auto_review", threadStart["approvalsReviewer"])
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterStartAuthRequired(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newScriptedAppServerTransport()
|
|
transport.server.requiresAuth = true
|
|
adapter := NewCodexAppServerAdapter(transport)
|
|
session := testAppServerSession()
|
|
|
|
events, err := adapter.Start(context.Background(), session)
|
|
if err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
if len(events) != 1 || events[0].Type != activityshared.EventSessionStarted {
|
|
t.Fatalf("events = %#v, want session.started", events)
|
|
}
|
|
if got := asString(events[0].Payload.Metadata["authState"]); got != "auth_required" {
|
|
t.Fatalf("authState = %q, want auth_required", got)
|
|
}
|
|
if requests := appServerRequestParamsList(t, transport.conn, appServerMethodThreadStart); len(requests) != 0 {
|
|
t.Fatalf("thread/start requests = %d, want 0 when auth is required", len(requests))
|
|
}
|
|
state := adapter.SessionState(session)
|
|
if state.AuthState != "auth_required" {
|
|
t.Fatalf("session auth state = %q, want auth_required", state.AuthState)
|
|
}
|
|
if asString(state.RuntimeContext["authMessage"]) == "" {
|
|
t.Fatalf("runtime context missing authMessage: %#v", state.RuntimeContext)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterStartToleratesAccountReadError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newScriptedAppServerTransport()
|
|
transport.server.accountReadError = true
|
|
adapter := NewCodexAppServerAdapter(transport)
|
|
if _, err := adapter.Start(context.Background(), testAppServerSession()); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
if requests := appServerRequestParamsList(t, transport.conn, appServerMethodThreadStart); len(requests) != 1 {
|
|
t.Fatalf("thread/start requests = %d, want 1", len(requests))
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterResume(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newScriptedAppServerTransport()
|
|
adapter := NewCodexAppServerAdapter(transport)
|
|
session := testAppServerSession()
|
|
session.ProviderSessionID = "codex-thread-1"
|
|
|
|
if err := adapter.Resume(context.Background(), session); err != nil {
|
|
t.Fatalf("Resume: %v", err)
|
|
}
|
|
resume := appServerRequestParams(t, transport.conn, appServerMethodThreadResume)
|
|
if asString(resume["threadId"]) != "codex-thread-1" {
|
|
t.Fatalf("thread/resume threadId = %q", resume["threadId"])
|
|
}
|
|
if asString(resume["cwd"]) != "/workspace" {
|
|
t.Fatalf("thread/resume cwd = %q, want /workspace", resume["cwd"])
|
|
}
|
|
if got := asString(resume["model"]); got != "" {
|
|
t.Fatalf("thread/resume model = %q, want existing thread setting", got)
|
|
}
|
|
if config, ok := resume["config"].(map[string]any); ok {
|
|
if got := asString(config["model_reasoning_effort"]); got != "" {
|
|
t.Fatalf("thread/resume reasoning effort = %q, want existing thread setting", got)
|
|
}
|
|
}
|
|
if !adapter.CanResume(session) {
|
|
t.Fatalf("CanResume = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterResumeEmitsCommandSnapshot(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newScriptedAppServerTransport()
|
|
adapter := NewCodexAppServerAdapter(transport)
|
|
var snapshots []AgentSessionCommandSnapshot
|
|
adapter.SetCommandSnapshotSink(func(snapshot AgentSessionCommandSnapshot) {
|
|
snapshots = append(snapshots, snapshot)
|
|
})
|
|
session := testAppServerSession()
|
|
session.ProviderSessionID = "codex-thread-1"
|
|
|
|
if err := adapter.Resume(context.Background(), session); err != nil {
|
|
t.Fatalf("Resume: %v", err)
|
|
}
|
|
if len(snapshots) == 0 {
|
|
t.Fatalf("Resume emitted no command snapshot; review/undo would be missing on resumed sessions")
|
|
}
|
|
names := agentSessionCommandNames(snapshots[len(snapshots)-1].Commands)
|
|
for _, want := range []string{"review", "compact", "undo"} {
|
|
if !containsString(names, want) {
|
|
t.Fatalf("resume snapshot commands = %#v, want %q", names, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterResumeRetainsReplayedContextUsage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newScriptedAppServerTransport()
|
|
transport.server.replayTokenUsageOnResume = true
|
|
adapter := NewCodexAppServerAdapter(transport)
|
|
session := testAppServerSession()
|
|
session.ProviderSessionID = "codex-thread-1"
|
|
|
|
if err := adapter.Resume(context.Background(), session); err != nil {
|
|
t.Fatalf("Resume: %v", err)
|
|
}
|
|
|
|
state := adapter.SessionState(session)
|
|
usage, _ := state.RuntimeContext["usage"].(map[string]any)
|
|
contextWindow, _ := usage["contextWindow"].(map[string]any)
|
|
|
|
if contextWindow == nil {
|
|
t.Fatalf("resume dropped replayed token usage: usage=%#v", usage)
|
|
}
|
|
if got, _ := int64Value(contextWindow["usedTokens"]); got != 20453 {
|
|
t.Fatalf("usedTokens = %d, want 20453", got)
|
|
}
|
|
if got, _ := int64Value(contextWindow["totalTokens"]); got != 258400 {
|
|
t.Fatalf("totalTokens = %d, want 258400", got)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterResumeRequiresProviderSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter := NewCodexAppServerAdapter(newScriptedAppServerTransport())
|
|
session := testAppServerSession()
|
|
session.ProviderSessionID = ""
|
|
if err := adapter.Resume(context.Background(), session); err == nil {
|
|
t.Fatalf("Resume without provider session id should fail")
|
|
}
|
|
if adapter.CanResume(session) {
|
|
t.Fatalf("CanResume = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterReleaseLiveSessionClosesClientAndKeepsProviderSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newScriptedAppServerTransport()
|
|
adapter := NewCodexAppServerAdapter(transport)
|
|
session := testAppServerSession()
|
|
events, err := adapter.Start(context.Background(), session)
|
|
if err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
session = applySessionEvents(session, events)
|
|
if session.ProviderSessionID == "" {
|
|
t.Fatalf("provider session id was not assigned")
|
|
}
|
|
if !adapter.HasLiveSession(session) {
|
|
t.Fatalf("HasLiveSession = false, want true before release")
|
|
}
|
|
|
|
if err := adapter.ReleaseLiveSession(context.Background(), session); err != nil {
|
|
t.Fatalf("ReleaseLiveSession: %v", err)
|
|
}
|
|
if adapter.HasLiveSession(session) {
|
|
t.Fatalf("HasLiveSession = true, want false after release")
|
|
}
|
|
if session.ProviderSessionID != "codex-thread-1" {
|
|
t.Fatalf("provider session id = %q, want preserved caller session", session.ProviderSessionID)
|
|
}
|
|
transport.conn.mu.Lock()
|
|
closeCount := transport.conn.closeCount
|
|
transport.conn.mu.Unlock()
|
|
if closeCount == 0 {
|
|
t.Fatalf("connection close count = 0, want client closed")
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterDisconnectLiveSessionClosesClientAndKeepsProviderSession(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newScriptedAppServerTransport()
|
|
adapter := NewCodexAppServerAdapter(transport)
|
|
session := testAppServerSession()
|
|
events, err := adapter.Start(context.Background(), session)
|
|
if err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
session = applySessionEvents(session, events)
|
|
providerSessionID := session.ProviderSessionID
|
|
if err := adapter.DisconnectLiveSession(context.Background(), session); err != nil {
|
|
t.Fatalf("DisconnectLiveSession: %v", err)
|
|
}
|
|
if adapter.HasLiveSession(session) {
|
|
t.Fatal("HasLiveSession = true after disconnect")
|
|
}
|
|
if session.ProviderSessionID != providerSessionID || providerSessionID == "" {
|
|
t.Fatalf("provider session id=%q, want preserved %q", session.ProviderSessionID, providerSessionID)
|
|
}
|
|
transport.conn.mu.Lock()
|
|
closeCount := transport.conn.closeCount
|
|
transport.conn.mu.Unlock()
|
|
if closeCount == 0 {
|
|
t.Fatal("connection was not closed")
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterDisconnectLiveSessionRetriesCloseFailedHandle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transport := newScriptedAppServerTransport()
|
|
adapter := NewCodexAppServerAdapter(transport)
|
|
session := testAppServerSession()
|
|
events, err := adapter.Start(context.Background(), session)
|
|
if err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
session = applySessionEvents(session, events)
|
|
transport.conn.mu.Lock()
|
|
transport.conn.closeFailures = 1
|
|
transport.conn.mu.Unlock()
|
|
if err := adapter.DisconnectLiveSession(context.Background(), session); err == nil {
|
|
t.Fatal("first DisconnectLiveSession error=nil, want close failure")
|
|
}
|
|
if adapter.getSession(session.AgentSessionID) == nil {
|
|
t.Fatal("close-failed app-server handle lost ownership")
|
|
}
|
|
if err := adapter.DisconnectLiveSession(context.Background(), session); err != nil {
|
|
t.Fatalf("retry DisconnectLiveSession: %v", err)
|
|
}
|
|
if adapter.getSession(session.AgentSessionID) != nil {
|
|
t.Fatal("app-server handle remained after successful retry")
|
|
}
|
|
transport.conn.mu.Lock()
|
|
closeCount := transport.conn.closeCount
|
|
transport.conn.mu.Unlock()
|
|
if closeCount != 2 {
|
|
t.Fatalf("transport close calls=%d, want 2", closeCount)
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterReleaseLiveSessionSkipsPendingRequests(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter, transport, session := startedAppServerAdapter(t)
|
|
transport.server.commandApproval = true
|
|
execDone := make(chan struct{})
|
|
go func() {
|
|
_, _ = adapter.Exec(context.Background(), session, []PromptContentBlock{{
|
|
Type: "text", Text: "clean the build dir",
|
|
}}, "", "turn-local-1", nil, nil)
|
|
close(execDone)
|
|
}()
|
|
waitForCondition(t, func() bool {
|
|
return adapter.getPendingRequest(session.AgentSessionID, "turn-local-1", "approval-1") != nil
|
|
})
|
|
|
|
err := adapter.ReleaseLiveSession(context.Background(), session)
|
|
if !errors.Is(err, ErrLiveSessionBusy) {
|
|
t.Fatalf("ReleaseLiveSession error = %v, want ErrLiveSessionBusy", err)
|
|
}
|
|
if !adapter.HasLiveSession(session) {
|
|
t.Fatalf("HasLiveSession = false, want pending request to keep live session")
|
|
}
|
|
if _, err := adapter.SubmitInteractive(context.Background(), session, SubmitInteractiveInput{
|
|
TurnID: "turn-local-1",
|
|
RequestID: "approval-1",
|
|
OptionID: "deny",
|
|
}); err != nil {
|
|
t.Fatalf("SubmitInteractive after busy release: %v", err)
|
|
}
|
|
select {
|
|
case <-execDone:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("exec did not finish after resolving pending approval")
|
|
}
|
|
}
|
|
|
|
func TestCodexAppServerAdapterDisconnectLiveSessionSettlesPendingRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
adapter, transport, session := startedAppServerAdapter(t)
|
|
transport.server.commandApproval = true
|
|
execDone := make(chan struct{})
|
|
go func() {
|
|
_, _ = adapter.Exec(context.Background(), session, []PromptContentBlock{{
|
|
Type: "text", Text: "clean the build dir",
|
|
}}, "", "turn-local-1", nil, nil)
|
|
close(execDone)
|
|
}()
|
|
var pending *pendingInteractiveRequest
|
|
waitForCondition(t, func() bool {
|
|
pending = adapter.getPendingRequest(session.AgentSessionID, "turn-local-1", "approval-1")
|
|
return pending != nil
|
|
})
|
|
|
|
if err := adapter.DisconnectLiveSession(context.Background(), session); err != nil {
|
|
t.Fatalf("DisconnectLiveSession: %v", err)
|
|
}
|
|
select {
|
|
case <-execDone:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("exec did not settle after workspace runtime disconnect")
|
|
}
|
|
if state := pending.disposition(); state != pendingInteractiveRequestStateInterrupted {
|
|
t.Fatalf("pending request state=%q, want interrupted", state)
|
|
}
|
|
if adapter.HasLiveSession(session) {
|
|
t.Fatal("HasLiveSession = true after pending request disconnect")
|
|
}
|
|
}
|