diff --git a/internal/executor/backend.go b/internal/executor/backend.go index 73201a3e..037ef492 100644 --- a/internal/executor/backend.go +++ b/internal/executor/backend.go @@ -643,6 +643,24 @@ type OpenCodeConfig struct { // ServerCommand is the command to start the server (default: "opencode serve") ServerCommand string `yaml:"server_command,omitempty"` + + // RequestTimeout is the maximum time to wait for OpenCode HTTP responses. + // Applies to session creation, message send, and SSE response headers. + // Default: "10m" + RequestTimeout string `yaml:"request_timeout,omitempty"` +} + +// EffectiveRequestTimeout returns the OpenCode HTTP request timeout. +// Falls back to 10m when empty or invalid. +func (c *OpenCodeConfig) EffectiveRequestTimeout() time.Duration { + if c == nil || c.RequestTimeout == "" { + return 10 * time.Minute + } + d, err := time.ParseDuration(c.RequestTimeout) + if err != nil || d <= 0 { + return 10 * time.Minute + } + return d } // DefaultBackendConfig returns default backend configuration. @@ -669,6 +687,7 @@ func DefaultBackendConfig() *BackendConfig { Provider: "anthropic", AutoStartServer: true, ServerCommand: "opencode serve", + RequestTimeout: "10m", }, ModelRouting: DefaultModelRoutingConfig(), Timeout: DefaultTimeoutConfig(), diff --git a/internal/executor/backend_opencode.go b/internal/executor/backend_opencode.go index d5f42f73..919b86a5 100644 --- a/internal/executor/backend_opencode.go +++ b/internal/executor/backend_opencode.go @@ -51,7 +51,7 @@ func NewOpenCodeBackend(config *OpenCodeConfig) *OpenCodeBackend { config: config, log: logging.WithComponent("executor.opencode"), httpClient: &http.Client{ - Timeout: 10 * time.Minute, // Long timeout for AI operations + Timeout: config.EffectiveRequestTimeout(), }, } } diff --git a/internal/executor/backend_opencode_test.go b/internal/executor/backend_opencode_test.go index e4a752c2..85a05076 100644 --- a/internal/executor/backend_opencode_test.go +++ b/internal/executor/backend_opencode_test.go @@ -19,12 +19,14 @@ func TestNewOpenCodeBackend(t *testing.T) { config *OpenCodeConfig expectServerURL string expectModel string + expectTimeout string }{ { name: "nil config uses defaults", config: nil, expectServerURL: "http://127.0.0.1:4096", expectModel: "anthropic/claude-sonnet-4", + expectTimeout: "10m0s", }, { name: "empty server URL uses default", @@ -34,15 +36,18 @@ func TestNewOpenCodeBackend(t *testing.T) { }, expectServerURL: "http://127.0.0.1:4096", expectModel: "custom-model", + expectTimeout: "10m0s", }, { name: "custom config", config: &OpenCodeConfig{ - ServerURL: "http://localhost:5000", - Model: "anthropic/claude-opus-4", + ServerURL: "http://localhost:5000", + Model: "anthropic/claude-opus-4", + RequestTimeout: "20m", }, expectServerURL: "http://localhost:5000", expectModel: "anthropic/claude-opus-4", + expectTimeout: "20m0s", }, { name: "empty model uses default", @@ -52,6 +57,7 @@ func TestNewOpenCodeBackend(t *testing.T) { }, expectServerURL: "http://localhost:4096", expectModel: "anthropic/claude-sonnet-4", + expectTimeout: "10m0s", }, } @@ -67,6 +73,9 @@ func TestNewOpenCodeBackend(t *testing.T) { if backend.config.Model != tt.expectModel { t.Errorf("Model = %q, want %q", backend.config.Model, tt.expectModel) } + if backend.httpClient.Timeout.String() != tt.expectTimeout { + t.Errorf("http timeout = %q, want %q", backend.httpClient.Timeout.String(), tt.expectTimeout) + } }) } } diff --git a/internal/executor/backend_test.go b/internal/executor/backend_test.go index d9488122..57da3f3c 100644 --- a/internal/executor/backend_test.go +++ b/internal/executor/backend_test.go @@ -116,6 +116,7 @@ func TestOpenCodeConfig(t *testing.T) { Provider: "anthropic", AutoStartServer: true, ServerCommand: "opencode serve --port 5000", + RequestTimeout: "20m", } if config.ServerURL != "http://localhost:5000" { @@ -127,6 +128,32 @@ func TestOpenCodeConfig(t *testing.T) { if !config.AutoStartServer { t.Error("AutoStartServer should be true") } + if config.RequestTimeout != "20m" { + t.Errorf("RequestTimeout = %q, want 20m", config.RequestTimeout) + } +} + +func TestOpenCodeConfigEffectiveRequestTimeout(t *testing.T) { + tests := []struct { + name string + config *OpenCodeConfig + want string + }{ + {name: "nil config fallback", config: nil, want: "10m0s"}, + {name: "empty timeout fallback", config: &OpenCodeConfig{}, want: "10m0s"}, + {name: "configured timeout used", config: &OpenCodeConfig{RequestTimeout: "20m"}, want: "20m0s"}, + {name: "invalid timeout falls back", config: &OpenCodeConfig{RequestTimeout: "nope"}, want: "10m0s"}, + {name: "zero timeout falls back", config: &OpenCodeConfig{RequestTimeout: "0s"}, want: "10m0s"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.config.EffectiveRequestTimeout().String() + if got != tt.want { + t.Fatalf("EffectiveRequestTimeout() = %q, want %q", got, tt.want) + } + }) + } } func TestResolveModel(t *testing.T) {