From 630e040ce6c9713e81746c8d2ed7e5add103d6db Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Thu, 13 Aug 2026 00:27:08 +0700 Subject: [PATCH 01/41] Define model-aware reasoning capabilities Co-authored-by: Cursor --- internal/llm/reasoning.go | 107 +++++++++++++++++++++++++ internal/llm/reasoning_catalog.go | 82 +++++++++++++++++++ internal/llm/reasoning_test.go | 129 ++++++++++++++++++++++++++++++ internal/llm/types.go | 54 +++++++------ 4 files changed, 346 insertions(+), 26 deletions(-) create mode 100644 internal/llm/reasoning.go create mode 100644 internal/llm/reasoning_catalog.go create mode 100644 internal/llm/reasoning_test.go diff --git a/internal/llm/reasoning.go b/internal/llm/reasoning.go new file mode 100644 index 0000000..e71a220 --- /dev/null +++ b/internal/llm/reasoning.go @@ -0,0 +1,107 @@ +package llm + +import ( + "fmt" +) + +// ReasoningValueKind describes special behavior associated with a reasoning +// effort value. +type ReasoningValueKind string + +const ReasoningValueDisable ReasoningValueKind = "disable" + +// ReasoningValue is one provider-defined reasoning effort. Value is opaque: +// callers must retain its spelling and case when sending it to a provider. +type ReasoningValue struct { + Value string `json:"value"` + Label string `json:"label"` + Kind ReasoningValueKind `json:"kind,omitempty"` +} + +// ReasoningCapabilitySource identifies how the capability was obtained. +type ReasoningCapabilitySource string + +const ( + ReasoningCapabilityLive ReasoningCapabilitySource = "live" + ReasoningCapabilityStatic ReasoningCapabilitySource = "static" +) + +// ReasoningCapability describes the effort values supported by one model. +type ReasoningCapability struct { + Values []ReasoningValue `json:"values"` + Default string `json:"default,omitempty"` + Mandatory bool `json:"mandatory"` + CanDisable bool `json:"can_disable"` + Source ReasoningCapabilitySource `json:"source"` +} + +// NewReasoningCapability constructs a valid immutable-by-convention capability. +func NewReasoningCapability(values []ReasoningValue, defaultValue string, mandatory bool, source ReasoningCapabilitySource) (*ReasoningCapability, error) { + allowed := make(map[string]struct{}, len(values)) + disableCount := 0 + for _, value := range values { + if value.Value == "" { + return nil, fmt.Errorf("reasoning capability contains an empty value") + } + if _, exists := allowed[value.Value]; exists { + return nil, fmt.Errorf("reasoning capability contains duplicate value %q", value.Value) + } + allowed[value.Value] = struct{}{} + switch value.Kind { + case "": + case ReasoningValueDisable: + disableCount++ + default: + return nil, fmt.Errorf("reasoning capability value %q has unknown kind %q", value.Value, value.Kind) + } + } + if disableCount > 1 { + return nil, fmt.Errorf("reasoning capability contains multiple disable values") + } + if mandatory && disableCount != 0 { + return nil, fmt.Errorf("mandatory reasoning capability cannot include a disable value") + } + if defaultValue == "" { + return nil, fmt.Errorf("reasoning capability default is required") + } + if _, ok := allowed[defaultValue]; !ok { + return nil, fmt.Errorf("reasoning capability default %q is not allowed", defaultValue) + } + + return &ReasoningCapability{ + Values: append([]ReasoningValue(nil), values...), + Default: defaultValue, + Mandatory: mandatory, + CanDisable: disableCount == 1, + Source: source, + }, nil +} + +// UnsupportedReasoningEffortError reports an override not advertised by a model. +type UnsupportedReasoningEffortError struct { + Model string + Effort string + Allowed []string +} + +func (e *UnsupportedReasoningEffortError) Error() string { + return fmt.Sprintf("reasoning effort %q is not supported by model %q (allowed: %v)", e.Effort, e.Model, e.Allowed) +} + +// ValidateReasoningEffort accepts Auto (an empty effort) for every model and +// otherwise requires an exact, advertised opaque value. +func ValidateReasoningEffort(model string, capability *ReasoningCapability, effort string) error { + if effort == "" { + return nil + } + allowed := make([]string, 0) + if capability != nil { + for _, value := range capability.Values { + allowed = append(allowed, value.Value) + if effort == value.Value { + return nil + } + } + } + return &UnsupportedReasoningEffortError{Model: model, Effort: effort, Allowed: allowed} +} diff --git a/internal/llm/reasoning_catalog.go b/internal/llm/reasoning_catalog.go new file mode 100644 index 0000000..abee854 --- /dev/null +++ b/internal/llm/reasoning_catalog.go @@ -0,0 +1,82 @@ +package llm + +import ( + "strings" + "time" +) + +type reasoningCapabilityEntry struct { + model string + values []ReasoningValue +} + +var openAIReasoningCapabilities = []reasoningCapabilityEntry{ + // https://platform.openai.com/docs/guides/reasoning + {model: "gpt-5", values: reasoningValuesFor("minimal", "low", "medium", "high")}, +} + +var codexReasoningCapabilities = []reasoningCapabilityEntry{ + // https://platform.openai.com/docs/models/gpt-5.3-codex + {model: "gpt-5.3-codex", values: reasoningValuesFor("low", "medium", "high", "xhigh")}, +} + +var anthropicReasoningCapabilities = []reasoningCapabilityEntry{ + // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking + {model: "claude-sonnet-5", values: reasoningValuesFor("low", "medium", "high", "xhigh", "max")}, +} + +var geminiReasoningCapabilities = []reasoningCapabilityEntry{ + // https://ai.google.dev/gemini-api/docs/thinking + {model: "gemini-3.6-flash", values: reasoningValuesFor("minimal", "low", "medium", "high")}, +} + +func reasoningValuesFor(values ...string) []ReasoningValue { + out := make([]ReasoningValue, 0, len(values)) + for _, value := range values { + out = append(out, ReasoningValue{Value: value, Label: strings.ToUpper(value)}) + } + return out +} + +// StaticReasoningCapability resolves documented model capabilities when a +// provider cannot supply live metadata. It deliberately recognises only direct +// vendor adapters and explicit model families; OpenAI-compatible endpoints +// remain Auto-only because their upstream capabilities are unknowable. +func StaticReasoningCapability(kind, provider, baseURL, model string) *ReasoningCapability { + var entries []reasoningCapabilityEntry + switch { + case kind == "openai" && provider == "openai" && baseURL == "https://api.openai.com/v1": + entries = openAIReasoningCapabilities + case kind == "codex" && provider == "openai" && baseURL == "https://api.openai.com/v1": + entries = codexReasoningCapabilities + case kind == "anthropic" && provider == "anthropic" && baseURL == "https://api.anthropic.com": + entries = anthropicReasoningCapabilities + case kind == "gemini" && provider == "google" && baseURL == "https://generativelanguage.googleapis.com/v1beta": + entries = geminiReasoningCapabilities + default: + return nil + } + + for _, entry := range entries { + if exactModelOrDatedSnapshot(entry.model, model) { + capability, err := NewReasoningCapability(entry.values, entry.values[0].Value, false, ReasoningCapabilityStatic) + if err != nil { + panic(err) + } + return capability + } + } + return nil +} + +func exactModelOrDatedSnapshot(family, model string) bool { + if model == family { + return true + } + snapshot, ok := strings.CutPrefix(model, family+"-") + if !ok { + return false + } + _, err := time.Parse("2006-01-02", snapshot) + return err == nil +} diff --git a/internal/llm/reasoning_test.go b/internal/llm/reasoning_test.go new file mode 100644 index 0000000..2722007 --- /dev/null +++ b/internal/llm/reasoning_test.go @@ -0,0 +1,129 @@ +package llm + +import ( + "errors" + "slices" + "strings" + "testing" +) + +func TestReasoningCapabilityRejectsInconsistentDisableMetadata(t *testing.T) { + _, err := NewReasoningCapability( + []ReasoningValue{{Value: "none", Label: "Off", Kind: ReasoningValueDisable}}, + "", true, ReasoningCapabilityStatic, + ) + if err == nil || !strings.Contains(err.Error(), "mandatory") { + t.Fatalf("err = %v, want mandatory/disable conflict", err) + } +} + +func TestValidateReasoningEffortPreservesOpaqueValues(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{ + {Value: "extra-high", Label: "Extra High"}, + {Value: "xhigh", Label: "Extra High (new)"}, + }, + "extra-high", false, ReasoningCapabilityLive, + ) + if err != nil { + t.Fatal(err) + } + if err := ValidateReasoningEffort("gpt-example", cap, "extra-high"); err != nil { + t.Fatal(err) + } + if err := ValidateReasoningEffort("gpt-example", cap, "EXTRA-HIGH"); err == nil { + t.Fatal("case-normalized value was accepted") + } +} + +func TestValidateReasoningEffortAcceptsAuto(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } + if err := ValidateReasoningEffort("gpt-example", cap, ""); err != nil { + t.Fatalf("err = %v, want Auto accepted", err) + } +} + +func TestValidateReasoningEffortRejectsUnknownOverride(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } + err = ValidateReasoningEffort("gpt-example", cap, "high") + var unsupported *UnsupportedReasoningEffortError + if !errors.As(err, &unsupported) { + t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err) + } + if unsupported.Model != "gpt-example" || unsupported.Effort != "high" || !slices.Equal(unsupported.Allowed, []string{"low"}) { + t.Fatalf("error = %#v", unsupported) + } +} + +func TestReasoningCapabilityRequiresUniqueValues(t *testing.T) { + _, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}, {Value: "low", Label: "Low again"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("err = %v, want duplicate value error", err) + } +} + +func TestReasoningCapabilityDefaultMustBeAllowed(t *testing.T) { + _, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}}, + "high", false, ReasoningCapabilityStatic, + ) + if err == nil || !strings.Contains(err.Error(), "default") { + t.Fatalf("err = %v, want unsupported default error", err) + } +} + +func TestStaticReasoningCapabilityRepresentativeFamilies(t *testing.T) { + tests := []struct { + kind, provider, baseURL, model string + want []string + disable bool + }{ + {"openai", "openai", "https://api.openai.com/v1", "gpt-5", []string{"minimal", "low", "medium", "high"}, false}, + {"codex", "openai", "https://api.openai.com/v1", "gpt-5.3-codex", []string{"low", "medium", "high", "xhigh"}, false}, + {"anthropic", "anthropic", "https://api.anthropic.com", "claude-sonnet-5", []string{"low", "medium", "high", "xhigh", "max"}, false}, + {"gemini", "google", "https://generativelanguage.googleapis.com/v1beta", "gemini-3.6-flash", []string{"minimal", "low", "medium", "high"}, false}, + } + for _, tt := range tests { + cap := StaticReasoningCapability(tt.kind, tt.provider, tt.baseURL, tt.model) + if got := reasoningValues(cap); !slices.Equal(got, tt.want) { + t.Errorf("%s: got %v, want %v", tt.model, got, tt.want) + } + if cap == nil { + t.Errorf("%s: got nil capability", tt.model) + } else if cap.CanDisable != tt.disable { + t.Errorf("%s: can_disable=%v", tt.model, cap.CanDisable) + } + } +} + +func TestStaticReasoningCapabilityDoesNotGuessUnknownCompatibleModels(t *testing.T) { + if got := StaticReasoningCapability("openai-compatible", "custom", "https://example.test/v1", "gpt-5"); got != nil { + t.Fatalf("got %#v, want Auto-only", got) + } +} + +func reasoningValues(cap *ReasoningCapability) []string { + if cap == nil { + return nil + } + out := make([]string, 0, len(cap.Values)) + for _, value := range cap.Values { + out = append(out, value.Value) + } + return out +} diff --git a/internal/llm/types.go b/internal/llm/types.go index f36a426..6522174 100644 --- a/internal/llm/types.go +++ b/internal/llm/types.go @@ -68,19 +68,20 @@ type Tool struct { // Request is a normalised completion request. type Request struct { - Model string - System string - Messages []Message - Tools []Tool - ToolChoice string // auto|none|required| - Temperature float64 - TopP float64 - MaxTokens int - StopSequences []string - ReasoningEffort string // none|low|medium|high - ParallelToolCalls bool - PromptCache bool - Extra map[string]any + Model string + System string + Messages []Message + Tools []Tool + ToolChoice string // auto|none|required| + Temperature float64 + TopP float64 + MaxTokens int + StopSequences []string + ReasoningEffort string // provider-defined opaque value; empty means Auto + ReasoningCapability *ReasoningCapability + ParallelToolCalls bool + PromptCache bool + Extra map[string]any } // Usage reports token accounting for one call. @@ -113,9 +114,9 @@ func (u Usage) ContextSize() int { // Response is the final result of a completion. type Response struct { - Content string `json:"content"` - Reasoning string `json:"reasoning,omitempty"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` + Content string `json:"content"` + Reasoning string `json:"reasoning,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` // ThoughtSignature is Gemini part-level metadata for the final text turn. // Agent history must copy this onto the assistant Message for multi-turn // continuity when the model is not making tool calls. @@ -153,16 +154,17 @@ type Event struct { // ModelInfo describes one model offered by a provider. type ModelInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Provider string `json:"provider"` - ContextWindow int `json:"context_window"` - MaxOutput int `json:"max_output"` - InputCost float64 `json:"input_cost"` // USD per 1M tokens - OutputCost float64 `json:"output_cost"` // USD per 1M tokens - Vision bool `json:"vision"` - Tools bool `json:"tools"` - Reasoning bool `json:"reasoning"` + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + ContextWindow int `json:"context_window"` + MaxOutput int `json:"max_output"` + InputCost float64 `json:"input_cost"` // USD per 1M tokens + OutputCost float64 `json:"output_cost"` // USD per 1M tokens + Vision bool `json:"vision"` + Tools bool `json:"tools"` + Reasoning bool `json:"reasoning"` + ReasoningCapability *ReasoningCapability `json:"reasoning_capability,omitempty"` } // Client is a provider adapter. From 4388c43341740490befeda9d90d9eeb53b4d5981 Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Thu, 13 Aug 2026 00:31:38 +0700 Subject: [PATCH 02/41] Redact unsupported reasoning overrides Co-authored-by: Cursor --- internal/llm/reasoning.go | 5 ++-- internal/llm/reasoning_test.go | 42 +++++++++++++++++++++++++++++++++- internal/llm/types.go | 10 ++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/internal/llm/reasoning.go b/internal/llm/reasoning.go index e71a220..add7031 100644 --- a/internal/llm/reasoning.go +++ b/internal/llm/reasoning.go @@ -80,12 +80,11 @@ func NewReasoningCapability(values []ReasoningValue, defaultValue string, mandat // UnsupportedReasoningEffortError reports an override not advertised by a model. type UnsupportedReasoningEffortError struct { Model string - Effort string Allowed []string } func (e *UnsupportedReasoningEffortError) Error() string { - return fmt.Sprintf("reasoning effort %q is not supported by model %q (allowed: %v)", e.Effort, e.Model, e.Allowed) + return fmt.Sprintf("unsupported reasoning override for model %q (allowed: %v)", e.Model, e.Allowed) } // ValidateReasoningEffort accepts Auto (an empty effort) for every model and @@ -103,5 +102,5 @@ func ValidateReasoningEffort(model string, capability *ReasoningCapability, effo } } } - return &UnsupportedReasoningEffortError{Model: model, Effort: effort, Allowed: allowed} + return &UnsupportedReasoningEffortError{Model: model, Allowed: allowed} } diff --git a/internal/llm/reasoning_test.go b/internal/llm/reasoning_test.go index 2722007..67fd76e 100644 --- a/internal/llm/reasoning_test.go +++ b/internal/llm/reasoning_test.go @@ -2,6 +2,7 @@ package llm import ( "errors" + "reflect" "slices" "strings" "testing" @@ -62,11 +63,50 @@ func TestValidateReasoningEffortRejectsUnknownOverride(t *testing.T) { if !errors.As(err, &unsupported) { t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err) } - if unsupported.Model != "gpt-example" || unsupported.Effort != "high" || !slices.Equal(unsupported.Allowed, []string{"low"}) { + if unsupported.Model != "gpt-example" || !slices.Equal(unsupported.Allowed, []string{"low"}) { t.Fatalf("error = %#v", unsupported) } } +func TestValidateReasoningEffortDoesNotExposeSubmittedOverride(t *testing.T) { + const submitted = "reasoning-override-secret" + cap, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } + err = ValidateReasoningEffort("gpt-example", cap, submitted) + var unsupported *UnsupportedReasoningEffortError + if !errors.As(err, &unsupported) { + t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err) + } + if strings.Contains(err.Error(), submitted) { + t.Fatalf("error leaks submitted override: %q", err) + } + if _, found := reflect.TypeOf(*unsupported).FieldByName("Effort"); found { + t.Fatal("UnsupportedReasoningEffortError exposes the submitted override") + } +} + +func TestModelInfoWithReasoningCapabilityEnablesReasoning(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } + info := (ModelInfo{ID: "gpt-example"}).WithReasoningCapability(cap) + if !info.Reasoning { + t.Fatal("Reasoning = false, want true when capability is attached") + } + if info.ReasoningCapability != cap { + t.Fatalf("ReasoningCapability = %#v, want %#v", info.ReasoningCapability, cap) + } +} + func TestReasoningCapabilityRequiresUniqueValues(t *testing.T) { _, err := NewReasoningCapability( []ReasoningValue{{Value: "low", Label: "Low"}, {Value: "low", Label: "Low again"}}, diff --git a/internal/llm/types.go b/internal/llm/types.go index 6522174..c485c95 100644 --- a/internal/llm/types.go +++ b/internal/llm/types.go @@ -167,6 +167,16 @@ type ModelInfo struct { ReasoningCapability *ReasoningCapability `json:"reasoning_capability,omitempty"` } +// WithReasoningCapability returns a copy enriched with model-specific +// reasoning metadata. A capability implies the legacy Reasoning marker. +func (m ModelInfo) WithReasoningCapability(capability *ReasoningCapability) ModelInfo { + m.ReasoningCapability = capability + if capability != nil { + m.Reasoning = true + } + return m +} + // Client is a provider adapter. type Client interface { // Kind reports the adapter family (openai, anthropic, gemini, ...). From 690310475c133a1357cbbb6fb317101fae01cd49 Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Thu, 13 Aug 2026 00:48:22 +0700 Subject: [PATCH 03/41] Honor model-specific reasoning controls Co-authored-by: Cursor --- internal/llm/anthropic.go | 76 +++++++-- internal/llm/codex.go | 7 +- internal/llm/gemini.go | 23 ++- internal/llm/gemini_test.go | 83 ++++++++- internal/llm/openai.go | 32 +++- internal/llm/reasoning_request.go | 94 +++++++++++ internal/llm/reasoning_request_test.go | 223 +++++++++++++++++++++++++ 7 files changed, 510 insertions(+), 28 deletions(-) create mode 100644 internal/llm/reasoning_request.go create mode 100644 internal/llm/reasoning_request_test.go diff --git a/internal/llm/anthropic.go b/internal/llm/anthropic.go index f112fde..92e5202 100644 --- a/internal/llm/anthropic.go +++ b/internal/llm/anthropic.go @@ -133,6 +133,39 @@ func toAnthropic(req Request) []antMessage { return out } +// anthropicSupportsAdaptiveThinking reports whether model belongs to the +// adaptive-thinking generation (Claude Sonnet 5 and later dated snapshots), +// which uses "thinking":{"type":"adaptive"} plus output_config.effort instead +// of a fixed token budget. +func anthropicSupportsAdaptiveThinking(model string) bool { + return exactModelOrDatedSnapshot("claude-sonnet-5", model) +} + +// anthropicLegacyThinkingBudgets lists pre-adaptive extended-thinking model +// families and the token budgets published for their effort ladder. +// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking +var anthropicLegacyThinkingBudgets = []struct { + family string + budgets map[string]int +}{ + {family: "claude-3-7-sonnet", budgets: map[string]int{"low": 2048, "medium": 8192, "high": 16384}}, +} + +// anthropicLegacyBudget resolves the fixed token budget for a catalogued +// legacy model and effort value. It never guesses a budget for an +// unrecognised model or an effort value outside that model's documented +// ladder. +func anthropicLegacyBudget(model, effort string) (int, bool) { + for _, entry := range anthropicLegacyThinkingBudgets { + if !exactModelOrDatedSnapshot(entry.family, model) { + continue + } + budget, ok := entry.budgets[effort] + return budget, ok + } + return 0, false +} + func (c *anthropicClient) buildBody(req Request, stream bool) map[string]any { maxTokens := req.MaxTokens if maxTokens <= 0 { @@ -194,21 +227,34 @@ func (c *anthropicClient) buildBody(req Request, stream bool) map[string]any { } } } - switch strings.ToLower(req.ReasoningEffort) { - case "low": - body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": 2048} - case "medium": - body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": 8192} - case "high": - body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": 16384} - } - if _, ok := body["thinking"]; ok { - // Thinking requires headroom beyond the budget. - if maxTokens < 16384 { - body["max_tokens"] = 16384 + if value, err := reasoningValue(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL); err == nil && value != "" { + capability := resolvedReasoningCapability(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL) + switch { + case anthropicSupportsAdaptiveThinking(req.Model): + if disable := reasoningDisableValue(capability); disable != "" && value == disable { + body["thinking"] = map[string]any{"type": "disabled"} + } else { + body["thinking"] = map[string]any{"type": "adaptive"} + body["output_config"] = map[string]any{"effort": value} + } + default: + // Pre-adaptive models only understand fixed token budgets, and + // only for the catalogued legacy families; an unrecognised model + // gets no thinking override rather than a guessed budget. + if budget, ok := anthropicLegacyBudget(req.Model, value); ok { + body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": budget} + } } + } + if th, ok := body["thinking"].(map[string]any); ok { + // Thinking (adaptive or fixed-budget) is incompatible with + // temperature/top_p sampling controls. delete(body, "top_p") delete(body, "temperature") + if _, hasBudget := th["budget_tokens"]; hasBudget && maxTokens < 16384 { + // Fixed-budget thinking requires headroom beyond the budget. + body["max_tokens"] = 16384 + } } for k, v := range req.Extra { body[k] = v @@ -233,6 +279,9 @@ type antResponse struct { } func (c *anthropicClient) Chat(ctx context.Context, req Request) (*Response, error) { + if _, err := reasoningValue(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL); err != nil { + return nil, err + } var raw antResponse if err := c.opts.doJSON(ctx, "POST", c.opts.BaseURL+"/messages", c.buildBody(req, false), c.headers(), &raw); err != nil { return nil, err @@ -275,6 +324,9 @@ func (c *anthropicClient) fromResponse(raw *antResponse) (*Response, error) { } func (c *anthropicClient) Stream(ctx context.Context, req Request, emit func(Event) error) (*Response, error) { + if _, err := reasoningValue(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL); err != nil { + return nil, err + } httpResp, err := c.opts.doStream(ctx, "POST", c.opts.BaseURL+"/messages", c.buildBody(req, true), c.headers()) if err != nil { return nil, err diff --git a/internal/llm/codex.go b/internal/llm/codex.go index ba07727..9ba317d 100644 --- a/internal/llm/codex.go +++ b/internal/llm/codex.go @@ -42,8 +42,8 @@ func (c *codexClient) buildBody(req Request, stream bool) map[string]any { if req.Temperature > 0 { body["temperature"] = req.Temperature } - if e := strings.ToLower(req.ReasoningEffort); e != "" && e != "none" { - body["reasoning"] = map[string]any{"effort": e} + if value, err := reasoningValue(req, "codex", c.opts.ProviderID, c.opts.BaseURL); err == nil && value != "" { + body["reasoning"] = map[string]any{"effort": value} } if len(req.Tools) > 0 { tools := make([]map[string]any, 0, len(req.Tools)) @@ -143,6 +143,9 @@ type responsesReply struct { } func (c *codexClient) Chat(ctx context.Context, req Request) (*Response, error) { + if _, err := reasoningValue(req, "codex", c.opts.ProviderID, c.opts.BaseURL); err != nil { + return nil, err + } var raw responsesReply if err := c.opts.doJSON(ctx, "POST", c.opts.BaseURL+"/responses", c.buildBody(req, false), c.headers(), &raw); err != nil { return nil, err diff --git a/internal/llm/gemini.go b/internal/llm/gemini.go index 14ccb18..1a27283 100644 --- a/internal/llm/gemini.go +++ b/internal/llm/gemini.go @@ -306,8 +306,10 @@ func (c *geminiClient) buildBody(req Request) map[string]any { if len(req.StopSequences) > 0 { gen["stopSequences"] = req.StopSequences } - if tc := geminiThinkingConfig(req.Model, req.ReasoningEffort); tc != nil { - gen["thinkingConfig"] = tc + if value, err := reasoningValue(req, c.Kind(), c.opts.ProviderID, c.opts.BaseURL); err == nil { + if tc := geminiThinkingConfig(req.Model, value); tc != nil { + gen["thinkingConfig"] = tc + } } if len(gen) > 0 { body["generationConfig"] = gen @@ -374,6 +376,10 @@ func (c *geminiClient) endpoint(model, method string, stream bool) string { // Gemini 3 series prefer thinkingLevel (MINIMAL/LOW/MEDIUM/HIGH); 2.5 series // use thinkingBudget token counts. includeThoughts requests thought summaries // when the endpoint exposes them (not all reverse proxies return thought text). +// +// Minimal is a real, distinct thinking level for Gemini 3 — not an Off +// synonym. Gemini 3 has no true Off; its static capability never advertises +// "none", so the "none" case below only guards a stray legacy value. func geminiThinkingConfig(model, effort string) map[string]any { e := strings.ToLower(strings.TrimSpace(effort)) if e == "" { @@ -383,9 +389,14 @@ func geminiThinkingConfig(model, effort string) map[string]any { switch e { case "none": if useLevel { - return map[string]any{"thinkingLevel": "MINIMAL", "includeThoughts": false} + return nil } return map[string]any{"thinkingBudget": 0} + case "minimal": + if !useLevel { + return nil + } + return map[string]any{"thinkingLevel": "MINIMAL", "includeThoughts": true} case "low": if useLevel { return map[string]any{"thinkingLevel": "LOW", "includeThoughts": true} @@ -452,6 +463,9 @@ func parseGeminiParts(parts []gemPart) (content, reasoning string, calls []ToolC } func (c *geminiClient) Chat(ctx context.Context, req Request) (*Response, error) { + if _, err := reasoningValue(req, c.Kind(), c.opts.ProviderID, c.opts.BaseURL); err != nil { + return nil, err + } // Prefer stream collection: some Gemini-compatible reverse proxies aggregate // non-stream generateContent by keeping only the final STOP chunk, which is // often empty text after a functionCall chunk. streamGenerateContent preserves @@ -485,6 +499,9 @@ func (c *geminiClient) Chat(ctx context.Context, req Request) (*Response, error) } func (c *geminiClient) Stream(ctx context.Context, req Request, emit func(Event) error) (*Response, error) { + if _, err := reasoningValue(req, c.Kind(), c.opts.ProviderID, c.opts.BaseURL); err != nil { + return nil, err + } httpResp, err := c.opts.doStream(ctx, "POST", c.endpoint(req.Model, "streamGenerateContent", true), c.buildBody(req), c.headers()) if err != nil { return nil, err diff --git a/internal/llm/gemini_test.go b/internal/llm/gemini_test.go index cbd122b..cda0fbc 100644 --- a/internal/llm/gemini_test.go +++ b/internal/llm/gemini_test.go @@ -1,7 +1,10 @@ package llm import ( + "context" "encoding/json" + "errors" + "reflect" "strings" "testing" ) @@ -35,6 +38,64 @@ func TestGeminiThinkingConfigUsesBudgetFor25(t *testing.T) { } } +func TestGemini3MinimalIsNotDisable(t *testing.T) { + got := geminiThinkingConfig("gemini-3.6-flash", "minimal") + want := map[string]any{"thinkingLevel": "MINIMAL", "includeThoughts": true} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } +} + +func TestGeminiThinkingConfigGemini3HasNoTrueOff(t *testing.T) { + if got := geminiThinkingConfig("gemini-3.6-flash", "none"); got != nil { + t.Fatalf("got %#v, want nil (Gemini 3 has no true Off, only Minimal)", got) + } +} + +func TestGeminiLegacyBudgetZeroWhenOffSupported(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{ + {Value: "none", Label: "Off", Kind: ReasoningValueDisable}, + {Value: "low", Label: "Low"}, + {Value: "medium", Label: "Medium"}, + {Value: "high", Label: "High"}, + }, + "medium", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } + c := &geminiClient{} + body := c.buildBody(Request{ + Model: "gemini-2.5-flash", ReasoningEffort: "none", ReasoningCapability: cap, + Messages: []Message{{Role: RoleUser, Content: "hi"}}, + }) + gen, _ := body["generationConfig"].(map[string]any) + tc, _ := gen["thinkingConfig"].(map[string]any) + want := map[string]any{"thinkingBudget": 0} + if !reflect.DeepEqual(tc, want) { + t.Fatalf("thinkingConfig = %#v, want %#v", tc, want) + } +} + +func TestGeminiRejectsUnsupportedOffBeforeRequest(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}, {Value: "high", Label: "High"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } + c := &geminiClient{} + _, err = c.Chat(context.Background(), Request{ + Model: "gemini-2.5-flash", ReasoningEffort: "none", ReasoningCapability: cap, + }) + var unsupported *UnsupportedReasoningEffortError + if !errors.As(err, &unsupported) { + t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err) + } +} + func TestToGeminiPreservesThoughtSignatureOnFunctionCall(t *testing.T) { req := Request{ Messages: []Message{{ @@ -108,11 +169,19 @@ func TestParseGeminiPartsCapturesSignature(t *testing.T) { } func TestBuildBodyGemini3HighThinking(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}, {Value: "high", Label: "High"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } c := &geminiClient{} body := c.buildBody(Request{ - Model: "gemini-3.6-flash-high", - ReasoningEffort: "high", - Messages: []Message{{Role: RoleUser, Content: "hi"}}, + Model: "gemini-3.6-flash-high", + ReasoningEffort: "high", + ReasoningCapability: cap, + Messages: []Message{{Role: RoleUser, Content: "hi"}}, }) gen, _ := body["generationConfig"].(map[string]any) tc, _ := gen["thinkingConfig"].(map[string]any) @@ -124,11 +193,11 @@ func TestBuildBodyGemini3HighThinking(t *testing.T) { func TestNormalizeGeminiBaseURLMatchesCLI(t *testing.T) { cases := map[string]string{ - "http://127.0.0.1:8080/antigravity": "http://127.0.0.1:8080/antigravity/v1beta", - "http://127.0.0.1:8080/antigravity/": "http://127.0.0.1:8080/antigravity/v1beta", - "http://127.0.0.1:8080/antigravity/v1beta": "http://127.0.0.1:8080/antigravity/v1beta", + "http://127.0.0.1:8080/antigravity": "http://127.0.0.1:8080/antigravity/v1beta", + "http://127.0.0.1:8080/antigravity/": "http://127.0.0.1:8080/antigravity/v1beta", + "http://127.0.0.1:8080/antigravity/v1beta": "http://127.0.0.1:8080/antigravity/v1beta", "https://generativelanguage.googleapis.com/v1beta": "https://generativelanguage.googleapis.com/v1beta", - "http://localhost:8080/v1": "http://localhost:8080/v1", + "http://localhost:8080/v1": "http://localhost:8080/v1", } for in, want := range cases { if got := normalizeGeminiBaseURL(in); got != want { diff --git a/internal/llm/openai.go b/internal/llm/openai.go index 410e329..0819ebd 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -19,6 +19,17 @@ type openAIClient struct { func (c *openAIClient) Kind() string { return "openai" } +// reasoningKind reports the static-catalogue key for this vendor. Only the +// direct OpenAI API has a documented per-model effort ladder; every other +// vendor (compat, Azure, Copilot, OpenRouter) is Auto-only unless the caller +// attaches a resolved capability explicitly. +func (c *openAIClient) reasoningKind() string { + if c.vendor == "openai" { + return "openai" + } + return "openai-compatible" +} + func (c *openAIClient) headers() map[string]string { if c.vendor == "copilot" { // Copilot needs a freshly-exchanged token plus editor headers. @@ -189,12 +200,15 @@ func (c *openAIClient) buildBody(req Request, stream bool) map[string]any { body["parallel_tool_calls"] = false } } - if e := strings.ToLower(req.ReasoningEffort); e != "" && e != "none" { + if value, err := reasoningValue(req, c.reasoningKind(), c.opts.ProviderID, c.opts.BaseURL); err == nil && value != "" { // OpenAI uses reasoning_effort; OpenRouter accepts a reasoning object. - body["reasoning_effort"] = e + // Every validated value is sent as-is, including a marked disable + // value: omitting it would leave reasoning enabled at the model's + // default instead of honoring the user's explicit Off choice. if strings.Contains(c.opts.BaseURL, "openrouter.ai") { - delete(body, "reasoning_effort") - body["reasoning"] = map[string]any{"effort": e} + body["reasoning"] = map[string]any{"effort": value} + } else { + body["reasoning_effort"] = value } } for k, v := range req.Extra { @@ -254,6 +268,9 @@ func (u *oaUsage) normalise() Usage { } func (c *openAIClient) Chat(ctx context.Context, req Request) (*Response, error) { + if _, err := reasoningValue(req, c.reasoningKind(), c.opts.ProviderID, c.opts.BaseURL); err != nil { + return nil, err + } var raw oaResponse if err := c.opts.doJSON(ctx, "POST", c.endpoint("/chat/completions", req.Model), c.buildBody(req, false), c.headers(), &raw); err != nil { return nil, err @@ -283,6 +300,9 @@ func (c *openAIClient) Chat(ctx context.Context, req Request) (*Response, error) } func (c *openAIClient) Stream(ctx context.Context, req Request, emit func(Event) error) (*Response, error) { + if _, err := reasoningValue(req, c.reasoningKind(), c.opts.ProviderID, c.opts.BaseURL); err != nil { + return nil, err + } httpResp, err := c.opts.doStream(ctx, "POST", c.endpoint("/chat/completions", req.Model), c.buildBody(req, true), c.headers()) if err != nil { return nil, err @@ -418,6 +438,7 @@ func (c *openAIClient) Models(ctx context.Context) ([]ModelInfo, error) { Architecture *struct { InputModalities []string `json:"input_modalities"` } `json:"architecture"` + Reasoning *openRouterReasoningMetadata `json:"reasoning"` } `json:"data"` } if err := c.opts.doJSON(ctx, "GET", c.opts.BaseURL+"/models", nil, c.headers(), &raw); err != nil { @@ -443,6 +464,9 @@ func (c *openAIClient) Models(ctx context.Context) ([]ModelInfo, error) { } } } + if capability := openRouterReasoningCapability(m.Reasoning); capability != nil { + info = info.WithReasoningCapability(capability) + } out = append(out, info) } return out, nil diff --git a/internal/llm/reasoning_request.go b/internal/llm/reasoning_request.go new file mode 100644 index 0000000..594f4e9 --- /dev/null +++ b/internal/llm/reasoning_request.go @@ -0,0 +1,94 @@ +package llm + +import "strings" + +// reasoningValue resolves and validates the effort value to send upstream. +// Auto (an empty ReasoningEffort) is always valid and produces no override. A +// non-empty value is validated against the request's attached capability, +// falling back to the static catalogue when the caller did not attach one. +// Callers wired through the agent layer normally attach an already-resolved +// capability; the fallback is defense in depth for direct/test callers. +func reasoningValue(req Request, kind, providerID, baseURL string) (string, error) { + value := req.ReasoningEffort + if value == "" { + return "", nil + } + capability := req.ReasoningCapability + if capability == nil { + capability = StaticReasoningCapability(kind, providerID, baseURL, req.Model) + } + if err := ValidateReasoningEffort(req.Model, capability, value); err != nil { + return "", err + } + return value, nil +} + +// resolvedReasoningCapability mirrors reasoningValue's capability resolution +// so adapters can inspect capability metadata (such as the marked disable +// value) once a value has already been validated. +func resolvedReasoningCapability(req Request, kind, providerID, baseURL string) *ReasoningCapability { + if req.ReasoningCapability != nil { + return req.ReasoningCapability + } + return StaticReasoningCapability(kind, providerID, baseURL, req.Model) +} + +// reasoningDisableValue returns the capability's marked disable value, or "" +// when the capability cannot disable reasoning. +func reasoningDisableValue(capability *ReasoningCapability) string { + if capability == nil { + return "" + } + for _, v := range capability.Values { + if v.Kind == ReasoningValueDisable { + return v.Value + } + } + return "" +} + +// openRouterReasoningMetadata is OpenRouter's per-model reasoning metadata +// returned from GET /models. +// https://openrouter.ai/docs/guides/best-practices/reasoning-tokens +type openRouterReasoningMetadata struct { + SupportedEfforts []string `json:"supported_efforts"` + DefaultEffort string `json:"default_effort"` + DefaultEnabled *bool `json:"default_enabled"` + Mandatory bool `json:"mandatory"` + SupportsMaxTokens bool `json:"supports_max_tokens"` +} + +// openRouterReasoningCapability builds a live capability from OpenRouter's +// documented reasoning metadata. Only the documented "none" value is marked +// as a disable choice. Contradictory metadata — a mandatory model that also +// lists "none", or a shape NewReasoningCapability otherwise rejects — yields +// no capability rather than being silently repaired. +func openRouterReasoningCapability(meta *openRouterReasoningMetadata) *ReasoningCapability { + if meta == nil || len(meta.SupportedEfforts) == 0 { + return nil + } + if meta.Mandatory { + for _, effort := range meta.SupportedEfforts { + if effort == "none" { + return nil + } + } + } + values := make([]ReasoningValue, 0, len(meta.SupportedEfforts)) + for _, effort := range meta.SupportedEfforts { + value := ReasoningValue{Value: effort, Label: strings.ToUpper(effort)} + if effort == "none" { + value.Kind = ReasoningValueDisable + } + values = append(values, value) + } + def := meta.DefaultEffort + if def == "" { + def = meta.SupportedEfforts[0] + } + capability, err := NewReasoningCapability(values, def, meta.Mandatory, ReasoningCapabilityLive) + if err != nil { + return nil + } + return capability +} diff --git a/internal/llm/reasoning_request_test.go b/internal/llm/reasoning_request_test.go new file mode 100644 index 0000000..631718c --- /dev/null +++ b/internal/llm/reasoning_request_test.go @@ -0,0 +1,223 @@ +package llm + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "sync/atomic" + "testing" +) + +func TestOpenRouterReasoningBodySendsExplicitDisable(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{ + {Value: "none", Label: "Off", Kind: ReasoningValueDisable}, + {Value: "high", Label: "High"}, + }, + "high", false, ReasoningCapabilityLive, + ) + if err != nil { + t.Fatal(err) + } + c := &openAIClient{opts: Options{BaseURL: "https://openrouter.ai/api/v1"}} + body := c.buildBody(Request{ + Model: "vendor/model", ReasoningEffort: "none", ReasoningCapability: cap, + }, false) + reasoning, ok := body["reasoning"].(map[string]any) + if !ok || reasoning["effort"] != "none" { + t.Fatalf("reasoning = %#v", body["reasoning"]) + } + if _, ok := body["reasoning_effort"]; ok { + t.Fatalf("unexpected reasoning_effort alongside reasoning: %#v", body["reasoning_effort"]) + } +} + +func TestReasoningAutoOmitsProviderFields(t *testing.T) { + req := Request{Model: "gpt-5", ReasoningEffort: ""} + if body := (&openAIClient{}).buildBody(req, false); body["reasoning_effort"] != nil { + t.Fatalf("OpenAI body = %#v", body) + } + if body := (&codexClient{}).buildBody(req, false); body["reasoning"] != nil { + t.Fatalf("Codex body = %#v", body) + } +} + +func TestOpenAIReasoningEffortBody(t *testing.T) { + cap := StaticReasoningCapability("openai", "openai", "https://api.openai.com/v1", "gpt-5") + if cap == nil { + t.Fatal("expected a static capability for gpt-5") + } + c := &openAIClient{opts: Options{BaseURL: "https://api.openai.com/v1", ProviderID: "openai"}, vendor: "openai"} + body := c.buildBody(Request{Model: "gpt-5", ReasoningEffort: "high", ReasoningCapability: cap}, false) + if body["reasoning_effort"] != "high" { + t.Fatalf("reasoning_effort = %#v", body["reasoning_effort"]) + } + if _, ok := body["reasoning"]; ok { + t.Fatalf("unexpected reasoning field: %#v", body["reasoning"]) + } +} + +func TestOpenAIReasoningEffortFallsBackToStaticCapability(t *testing.T) { + c := &openAIClient{opts: Options{BaseURL: "https://api.openai.com/v1", ProviderID: "openai"}, vendor: "openai"} + body := c.buildBody(Request{Model: "gpt-5", ReasoningEffort: "minimal"}, false) + if body["reasoning_effort"] != "minimal" { + t.Fatalf("reasoning_effort = %#v, want fallback to static capability to validate it", body["reasoning_effort"]) + } +} + +func TestCodexReasoningEffortBody(t *testing.T) { + c := &codexClient{opts: Options{BaseURL: "https://api.openai.com/v1", ProviderID: "openai"}} + body := c.buildBody(Request{Model: "gpt-5.3-codex", ReasoningEffort: "xhigh"}, false) + want := map[string]any{"effort": "xhigh"} + if got := body["reasoning"]; !reflect.DeepEqual(got, want) { + t.Fatalf("reasoning = %#v, want %#v", got, want) + } +} + +func TestAnthropicAdaptiveThinkingBody(t *testing.T) { + // The upstream base URL is corrected to the exact static-catalogue key + // (https://api.anthropic.com) so this capability actually resolves; + // see task-2-report.md for why the brief's literal "" argument is a + // vacuous match against the Task 1 catalogue. + cap := StaticReasoningCapability("anthropic", "anthropic", "https://api.anthropic.com", "claude-sonnet-5") + if cap == nil { + t.Fatal("expected a static capability for claude-sonnet-5") + } + body := (&anthropicClient{}).buildBody(Request{ + Model: "claude-sonnet-5", ReasoningEffort: "xhigh", ReasoningCapability: cap, + }, false) + if got, want := body["thinking"], map[string]any{"type": "adaptive"}; !reflect.DeepEqual(got, want) { + t.Fatalf("thinking = %#v, want %#v", got, want) + } + if got, want := body["output_config"], map[string]any{"effort": "xhigh"}; !reflect.DeepEqual(got, want) { + t.Fatalf("output_config = %#v, want %#v", got, want) + } +} + +func TestAnthropicLegacyThinkingBudgetBody(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}, {Value: "medium", Label: "Medium"}, {Value: "high", Label: "High"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } + body := (&anthropicClient{}).buildBody(Request{ + Model: "claude-3-7-sonnet", ReasoningEffort: "medium", ReasoningCapability: cap, + }, false) + if got, want := body["thinking"], map[string]any{"type": "enabled", "budget_tokens": 8192}; !reflect.DeepEqual(got, want) { + t.Fatalf("thinking = %#v, want %#v", got, want) + } + if _, ok := body["output_config"]; ok { + t.Fatalf("unexpected output_config on a legacy model: %#v", body["output_config"]) + } +} + +func TestAnthropicAdaptiveDisableBody(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{ + {Value: "off", Label: "Off", Kind: ReasoningValueDisable}, + {Value: "low", Label: "Low"}, + }, + "low", false, ReasoningCapabilityLive, + ) + if err != nil { + t.Fatal(err) + } + body := (&anthropicClient{}).buildBody(Request{ + Model: "claude-sonnet-5", ReasoningEffort: "off", ReasoningCapability: cap, + }, false) + if got, want := body["thinking"], map[string]any{"type": "disabled"}; !reflect.DeepEqual(got, want) { + t.Fatalf("thinking = %#v, want %#v", got, want) + } + if _, ok := body["output_config"]; ok { + t.Fatalf("unexpected output_config for a disabled turn: %#v", body["output_config"]) + } +} + +func TestReasoningValidationBlocksRequestBeforeNetworkIO(t *testing.T) { + var count int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&count, 1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + cap, err := NewReasoningCapability([]ReasoningValue{{Value: "low", Label: "Low"}}, "low", false, ReasoningCapabilityStatic) + if err != nil { + t.Fatal(err) + } + req := Request{Model: "test-model", ReasoningEffort: "max", ReasoningCapability: cap} + + clients := map[string]Client{ + "openai": &openAIClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}, vendor: "openai"}, + "codex": &codexClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}}, + "anthropic": &anthropicClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}}, + "gemini": &geminiClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}}, + } + for name, c := range clients { + _, err := c.Chat(context.Background(), req) + var unsupported *UnsupportedReasoningEffortError + if !errors.As(err, &unsupported) { + t.Fatalf("%s: err = %v, want UnsupportedReasoningEffortError", name, err) + } + } + if got := atomic.LoadInt32(&count); got != 0 { + t.Fatalf("requests sent = %d, want 0", got) + } +} + +func TestOpenRouterModelsBuildsLiveReasoningCapability(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":[{"id":"vendor/model","reasoning":{"supported_efforts":["none","low","high"],"default_effort":"high","mandatory":false,"supports_max_tokens":false}}]}`)) + })) + defer srv.Close() + c := &openAIClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}} + models, err := c.Models(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(models) != 1 { + t.Fatalf("models = %+v", models) + } + cap := models[0].ReasoningCapability + if cap == nil { + t.Fatal("expected a live reasoning capability") + } + if cap.Source != ReasoningCapabilityLive || cap.Default != "high" { + t.Fatalf("capability = %#v", cap) + } + if !models[0].Reasoning { + t.Fatal("expected legacy Reasoning boolean to be set") + } + var disableFound bool + for _, v := range cap.Values { + if v.Value == "none" { + disableFound = v.Kind == ReasoningValueDisable + } + } + if !disableFound { + t.Fatal(`expected "none" marked as disable`) + } +} + +func TestOpenRouterModelsRejectsContradictoryReasoningMetadata(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":[{"id":"vendor/model","reasoning":{"supported_efforts":["none","low"],"default_effort":"low","mandatory":true}}]}`)) + })) + defer srv.Close() + c := &openAIClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}} + models, err := c.Models(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(models) != 1 { + t.Fatalf("models = %+v", models) + } + if got := models[0].ReasoningCapability; got != nil { + t.Fatalf("expected no capability for contradictory metadata, got %#v", got) + } +} From 0bf0314031d9a2c55acf8c2a5d9932d47c37faaa Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Thu, 13 Aug 2026 01:10:49 +0700 Subject: [PATCH 04/41] Fix pre-request reasoning validation gaps found in Task 2 review Three blocking issues from review, fixed without weakening validation for newly-explicit invalid values: 1. Default OpenRouter/direct-Anthropic chat failed pre-request. The config default carried a non-empty "medium" reasoning effort with no attached capability, and openai-compatible's static resolution is deliberately nil, so every default chat errored before the network. Direct Anthropic also failed because the runtime default base URL ("https://api.anthropic.com/v1") didn't match the static catalog's bare-host check. Fixed by defaulting reasoning effort to "" (Auto, valid everywhere) and widening the catalog's Anthropic base URL match to an exact two-value allowlist. 2. Bedrock bypassed reasoning validation by calling anthropic buildBody directly, silently dropping invalid explicit values and sending signed requests upstream. bedrockClient.Chat now runs the same validateReasoning gate before signing/sending. 3. A legacy Anthropic model could pass attached-capability validation for a value absent from the hardcoded budget table, silently vanishing from the request. Factored the body-shape decision into anthropicThinkingBody, shared by buildBody and a new validateReasoning check so an unmappable value now fails before any request. Co-authored-by: Cursor --- internal/agent/reasoning_defaults_test.go | 85 +++++++++++++++++++++++ internal/config/defaults.go | 21 +++--- internal/config/defaults_test.go | 20 ++++++ internal/llm/anthropic.go | 75 +++++++++++++++----- internal/llm/bedrock.go | 8 +++ internal/llm/bedrock_test.go | 41 +++++++++++ internal/llm/reasoning_catalog.go | 11 ++- internal/llm/reasoning_request_test.go | 46 ++++++++++++ internal/llm/reasoning_test.go | 12 ++++ 9 files changed, 294 insertions(+), 25 deletions(-) create mode 100644 internal/agent/reasoning_defaults_test.go create mode 100644 internal/config/defaults_test.go diff --git a/internal/agent/reasoning_defaults_test.go b/internal/agent/reasoning_defaults_test.go new file mode 100644 index 0000000..537b72c --- /dev/null +++ b/internal/agent/reasoning_defaults_test.go @@ -0,0 +1,85 @@ +package agent + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/llm" +) + +// TestDefaultConfigReasoningEffortReachesOpenRouterCompatibleProvider guards +// the exact regression a review found in Task 2's provider-adapter validation: +// with the old "medium" default and no attached llm.ReasoningCapability, every +// default chat through an openai-compatible provider (OpenRouter's kind) was +// rejected before any network call, because the static catalog deliberately +// returns nil for unknown compatible endpoints. It reproduces the precedence +// agent.go's Run loop uses (firstNonEmpty(explicit, agent, model)) with the +// real default config against a real openAIClient. +func TestDefaultConfigReasoningEffortReachesOpenRouterCompatibleProvider(t *testing.T) { + cfg := config.Default() + effort := firstNonEmpty("", cfg.Agent.ReasoningEffort, cfg.Model.ReasoningEffort) + + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`)) + })) + defer srv.Close() + + client, err := llm.New(llm.Options{Kind: "openai-compatible", BaseURL: srv.URL, HTTPClient: srv.Client(), ProviderID: "openrouter"}) + if err != nil { + t.Fatalf("llm.New: %v", err) + } + + a := &Agent{} + _, err = a.callModel(context.Background(), client, llm.Request{ + Model: "vendor/model", + Messages: []llm.Message{{Role: llm.RoleUser, Content: "hi"}}, + ReasoningEffort: effort, + }, false, func(Event) error { return nil }) + if err != nil { + t.Fatalf("default config reasoning effort blocked an OpenRouter-shaped chat before it reached the network: %v", err) + } + if got := atomic.LoadInt32(&hits); got != 1 { + t.Fatalf("requests reaching the provider = %d, want 1", got) + } +} + +// TestDefaultConfigReasoningEffortReachesDirectAnthropicProvider is the direct +// Anthropic half of the same finding: even a model the static catalog knows +// (claude-sonnet-5) was previously rejected pre-request because the runtime +// default base URL ("https://api.anthropic.com/v1") didn't match the +// catalog's bare-host check. +func TestDefaultConfigReasoningEffortReachesDirectAnthropicProvider(t *testing.T) { + cfg := config.Default() + effort := firstNonEmpty("", cfg.Agent.ReasoningEffort, cfg.Model.ReasoningEffort) + + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + _, _ = w.Write([]byte(`{"content":[{"type":"text","text":"ok"}]}`)) + })) + defer srv.Close() + + client, err := llm.New(llm.Options{Kind: "anthropic", BaseURL: srv.URL, HTTPClient: srv.Client(), ProviderID: "anthropic"}) + if err != nil { + t.Fatalf("llm.New: %v", err) + } + + a := &Agent{} + _, err = a.callModel(context.Background(), client, llm.Request{ + Model: "claude-sonnet-5", + Messages: []llm.Message{{Role: llm.RoleUser, Content: "hi"}}, + ReasoningEffort: effort, + }, false, func(Event) error { return nil }) + if err != nil { + t.Fatalf("default config reasoning effort blocked a direct-Anthropic chat before it reached the network: %v", err) + } + if got := atomic.LoadInt32(&hits); got != 1 { + t.Fatalf("requests reaching the provider = %d, want 1", got) + } +} diff --git a/internal/config/defaults.go b/internal/config/defaults.go index a4c6f34..633aa74 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -7,13 +7,18 @@ import "path/filepath" func Default() *Config { return &Config{ Model: Model{ - Default: "anthropic/claude-sonnet-4.5", - Provider: "openrouter", - Temperature: 0.7, - TopP: 1.0, - MaxTokens: 8192, - ContextWindow: 200000, - ReasoningEffort: "medium", + Default: "anthropic/claude-sonnet-4.5", + Provider: "openrouter", + Temperature: 0.7, + TopP: 1.0, + MaxTokens: 8192, + ContextWindow: 200000, + // Auto ("") by default: reasoning effort is a provider/model-specific + // opaque value now, and OpenRouter can route to thousands of backing + // models with different (or no) reasoning ladders. A non-empty + // default here would fail pre-request validation for any model + // that doesn't advertise it. + ReasoningEffort: "", ParallelToolCall: true, }, Providers: map[string]Provider{ @@ -60,7 +65,7 @@ func Default() *Config { CORSOrigins: []string{}, }, Agent: Agent{ - MaxTurns: 200, MaxToolCalls: 32, ReasoningEffort: "medium", + MaxTurns: 200, MaxToolCalls: 32, ReasoningEffort: "", Personality: "default", Workspace: "~/antares-workspace", Timezone: "Local", Language: "auto", IdleTimeoutSecs: 900, RepeatLimit: 3, VerifyReplies: false, VerifyMax: 2, GoalMaxIterations: 10, diff --git a/internal/config/defaults_test.go b/internal/config/defaults_test.go new file mode 100644 index 0000000..165605c --- /dev/null +++ b/internal/config/defaults_test.go @@ -0,0 +1,20 @@ +package config + +import "testing" + +// TestDefaultReasoningEffortIsAuto guards against a regression where the +// fresh-install default carried a non-empty reasoning effort ("medium"). +// Reasoning effort is now a provider/model-specific opaque value validated +// pre-request; OpenRouter alone can route to thousands of backing models +// with unknown or absent reasoning ladders, so any non-empty default here +// would fail validation before every default chat reached the network. +// Auto ("") is the only value guaranteed to be valid everywhere. +func TestDefaultReasoningEffortIsAuto(t *testing.T) { + cfg := Default() + if cfg.Agent.ReasoningEffort != "" { + t.Fatalf("Agent.ReasoningEffort = %q, want empty (Auto)", cfg.Agent.ReasoningEffort) + } + if cfg.Model.ReasoningEffort != "" { + t.Fatalf("Model.ReasoningEffort = %q, want empty (Auto)", cfg.Model.ReasoningEffort) + } +} diff --git a/internal/llm/anthropic.go b/internal/llm/anthropic.go index 92e5202..c4a4dcd 100644 --- a/internal/llm/anthropic.go +++ b/internal/llm/anthropic.go @@ -166,6 +166,55 @@ func anthropicLegacyBudget(model, effort string) (int, bool) { return 0, false } +// anthropicThinkingBody resolves the "thinking"/"output_config" fields for a +// validated, non-empty reasoning effort. ok is false when the model cannot +// actually honor this specific value: either it is a pre-adaptive model whose +// hardcoded budget table has no entry for effort (e.g. an attached capability +// advertised a value this local catalogue does not know how to map). Callers +// must treat ok == false as a hard failure rather than silently sending the +// request without the override — see anthropicClient.validateReasoning. +func anthropicThinkingBody(model, effort string, capability *ReasoningCapability) (thinking map[string]any, outputConfig map[string]any, ok bool) { + if anthropicSupportsAdaptiveThinking(model) { + if disable := reasoningDisableValue(capability); disable != "" && effort == disable { + return map[string]any{"type": "disabled"}, nil, true + } + return map[string]any{"type": "adaptive"}, map[string]any{"effort": effort}, true + } + // Pre-adaptive models only understand fixed token budgets, and only for + // the catalogued legacy families and their documented effort ladders. + if budget, ok := anthropicLegacyBudget(model, effort); ok { + return map[string]any{"type": "enabled", "budget_tokens": budget}, nil, true + } + return nil, nil, false +} + +// validateReasoning fails before any network call when the request's +// reasoning effort cannot be honored: either the value itself is not +// advertised by the model's capability (delegated to reasoningValue), or — +// for pre-adaptive Anthropic models — the value is validated by an attached +// capability but has no entry in the local fixed-budget table, which would +// otherwise silently vanish from the outgoing request. +func (c *anthropicClient) validateReasoning(req Request) error { + value, err := reasoningValue(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL) + if err != nil { + return err + } + if value == "" { + return nil + } + capability := resolvedReasoningCapability(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL) + if _, _, ok := anthropicThinkingBody(req.Model, value, capability); !ok { + var allowed []string + if capability != nil { + for _, v := range capability.Values { + allowed = append(allowed, v.Value) + } + } + return &UnsupportedReasoningEffortError{Model: req.Model, Allowed: allowed} + } + return nil +} + func (c *anthropicClient) buildBody(req Request, stream bool) map[string]any { maxTokens := req.MaxTokens if maxTokens <= 0 { @@ -229,20 +278,14 @@ func (c *anthropicClient) buildBody(req Request, stream bool) map[string]any { } if value, err := reasoningValue(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL); err == nil && value != "" { capability := resolvedReasoningCapability(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL) - switch { - case anthropicSupportsAdaptiveThinking(req.Model): - if disable := reasoningDisableValue(capability); disable != "" && value == disable { - body["thinking"] = map[string]any{"type": "disabled"} - } else { - body["thinking"] = map[string]any{"type": "adaptive"} - body["output_config"] = map[string]any{"effort": value} - } - default: - // Pre-adaptive models only understand fixed token budgets, and - // only for the catalogued legacy families; an unrecognised model - // gets no thinking override rather than a guessed budget. - if budget, ok := anthropicLegacyBudget(req.Model, value); ok { - body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": budget} + // A value that cannot be mapped (ok == false) is left out of the body + // here; callers reach this only via Chat/Stream, which fail the + // request first via validateReasoning so that case is unreachable in + // practice. buildBody stays a pure, non-erroring body builder. + if thinking, outputConfig, ok := anthropicThinkingBody(req.Model, value, capability); ok { + body["thinking"] = thinking + if outputConfig != nil { + body["output_config"] = outputConfig } } } @@ -279,7 +322,7 @@ type antResponse struct { } func (c *anthropicClient) Chat(ctx context.Context, req Request) (*Response, error) { - if _, err := reasoningValue(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL); err != nil { + if err := c.validateReasoning(req); err != nil { return nil, err } var raw antResponse @@ -324,7 +367,7 @@ func (c *anthropicClient) fromResponse(raw *antResponse) (*Response, error) { } func (c *anthropicClient) Stream(ctx context.Context, req Request, emit func(Event) error) (*Response, error) { - if _, err := reasoningValue(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL); err != nil { + if err := c.validateReasoning(req); err != nil { return nil, err } httpResp, err := c.opts.doStream(ctx, "POST", c.opts.BaseURL+"/messages", c.buildBody(req, true), c.headers()) diff --git a/internal/llm/bedrock.go b/internal/llm/bedrock.go index 683e04e..389f069 100644 --- a/internal/llm/bedrock.go +++ b/internal/llm/bedrock.go @@ -71,6 +71,14 @@ func (c *bedrockClient) Chat(ctx context.Context, req Request) (*Response, error if req.Model == "" { return nil, errors.New("bedrock needs a model id, e.g. anthropic.claude-3-5-sonnet-20241022-v2:0") } + // Bedrock reuses anthropicClient.buildBody directly rather than going + // through anthropicClient.Chat, so it must run the same pre-request + // reasoning validation itself. Otherwise an invalid explicit effort is + // silently dropped by buildBody and a signed, billable request still goes + // upstream. + if err := c.inner.validateReasoning(req); err != nil { + return nil, err + } payload, err := json.Marshal(c.bedrockBody(req)) if err != nil { return nil, err diff --git a/internal/llm/bedrock_test.go b/internal/llm/bedrock_test.go index cb4c208..ac84f14 100644 --- a/internal/llm/bedrock_test.go +++ b/internal/llm/bedrock_test.go @@ -1,9 +1,13 @@ package llm import ( + "context" "encoding/hex" + "errors" "net/http" + "net/http/httptest" "strings" + "sync/atomic" "testing" "time" ) @@ -54,3 +58,40 @@ func TestNewBedrockNeedsRegion(t *testing.T) { t.Fatalf("expected a bedrock client, got %v %v", c, err) } } + +// TestBedrockReasoningValidationBlocksRequestBeforeSigning guards a bypass: +// bedrockClient.Chat builds its body via anthropicClient.buildBody directly +// rather than anthropicClient.Chat, so it must run the same pre-request +// reasoning validation itself. Without it, an invalid explicit reasoning +// value was silently dropped by buildBody and a signed, billable request +// still went upstream. +func TestBedrockReasoningValidationBlocksRequestBeforeSigning(t *testing.T) { + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + cap, err := NewReasoningCapability([]ReasoningValue{{Value: "low", Label: "Low"}}, "low", false, ReasoningCapabilityStatic) + if err != nil { + t.Fatal(err) + } + + opts := Options{BaseURL: srv.URL, HTTPClient: srv.Client()} + c := &bedrockClient{opts: opts, region: "us-east-1", inner: &anthropicClient{opts: opts}, endpoint: srv.URL} + + _, err = c.Chat(context.Background(), Request{ + Model: "anthropic.claude-3-5-sonnet-20241022-v2:0", + ReasoningEffort: "max", + ReasoningCapability: cap, + }) + var unsupported *UnsupportedReasoningEffortError + if !errors.As(err, &unsupported) { + t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err) + } + if got := atomic.LoadInt32(&hits); got != 0 { + t.Fatalf("requests sent = %d, want 0 (must fail before signing/sending)", got) + } +} diff --git a/internal/llm/reasoning_catalog.go b/internal/llm/reasoning_catalog.go index abee854..7c8c8e2 100644 --- a/internal/llm/reasoning_catalog.go +++ b/internal/llm/reasoning_catalog.go @@ -49,7 +49,7 @@ func StaticReasoningCapability(kind, provider, baseURL, model string) *Reasoning entries = openAIReasoningCapabilities case kind == "codex" && provider == "openai" && baseURL == "https://api.openai.com/v1": entries = codexReasoningCapabilities - case kind == "anthropic" && provider == "anthropic" && baseURL == "https://api.anthropic.com": + case kind == "anthropic" && provider == "anthropic" && isAnthropicDirectBaseURL(baseURL): entries = anthropicReasoningCapabilities case kind == "gemini" && provider == "google" && baseURL == "https://generativelanguage.googleapis.com/v1beta": entries = geminiReasoningCapabilities @@ -69,6 +69,15 @@ func StaticReasoningCapability(kind, provider, baseURL, model string) *Reasoning return nil } +// isAnthropicDirectBaseURL recognises both forms of Anthropic's own base URL +// seen at runtime: the bare host (as documented) and the "/v1" form that this +// codebase's provider defaults actually configure. Both are exact, canonical +// Anthropic hosts — this is not a broad prefix match, so a custom or +// Anthropic-compatible endpoint still falls back to Auto-only. +func isAnthropicDirectBaseURL(baseURL string) bool { + return baseURL == "https://api.anthropic.com" || baseURL == "https://api.anthropic.com/v1" +} + func exactModelOrDatedSnapshot(family, model string) bool { if model == family { return true diff --git a/internal/llm/reasoning_request_test.go b/internal/llm/reasoning_request_test.go index 631718c..cfa2fa5 100644 --- a/internal/llm/reasoning_request_test.go +++ b/internal/llm/reasoning_request_test.go @@ -115,6 +115,52 @@ func TestAnthropicLegacyThinkingBudgetBody(t *testing.T) { } } +// TestAnthropicLegacyUnmappableEffortFailsBeforeRequest guards a silent-drop +// bug: a value can pass validation against an *attached* capability (e.g. a +// hypothetical live capability advertising more values than this codebase's +// hardcoded legacy budget table knows) yet have no entry in +// anthropicLegacyThinkingBudgets for that model family. buildBody previously +// just omitted "thinking" in that case, silently downgrading the turn to +// Auto. Chat/Stream must now fail before any request is sent. +func TestAnthropicLegacyUnmappableEffortFailsBeforeRequest(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}, {Value: "xhigh", Label: "Extra High"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } + + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c := &anthropicClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}} + _, err = c.Chat(context.Background(), Request{ + Model: "claude-3-7-sonnet", ReasoningEffort: "xhigh", ReasoningCapability: cap, + }) + var unsupported *UnsupportedReasoningEffortError + if !errors.As(err, &unsupported) { + t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err) + } + if got := atomic.LoadInt32(&hits); got != 0 { + t.Fatalf("requests sent = %d, want 0", got) + } + + // buildBody alone (bypassing Chat) must also stay silent-safe: it must not + // fabricate a "thinking" override it cannot actually honor. + body := (&anthropicClient{}).buildBody(Request{ + Model: "claude-3-7-sonnet", ReasoningEffort: "xhigh", ReasoningCapability: cap, + }, false) + if _, ok := body["thinking"]; ok { + t.Fatalf("buildBody emitted a thinking override for an unmappable legacy effort: %#v", body["thinking"]) + } +} + func TestAnthropicAdaptiveDisableBody(t *testing.T) { cap, err := NewReasoningCapability( []ReasoningValue{ diff --git a/internal/llm/reasoning_test.go b/internal/llm/reasoning_test.go index 67fd76e..5162826 100644 --- a/internal/llm/reasoning_test.go +++ b/internal/llm/reasoning_test.go @@ -151,6 +151,18 @@ func TestStaticReasoningCapabilityRepresentativeFamilies(t *testing.T) { } } +// TestStaticReasoningCapabilityAcceptsRuntimeAnthropicBaseURL guards against a +// regression where every default-config, direct-Anthropic chat request failed +// pre-request validation: the provider default base URL configured in +// internal/config/defaults.go is "https://api.anthropic.com/v1", but the +// catalog originally matched only the bare "https://api.anthropic.com" host. +func TestStaticReasoningCapabilityAcceptsRuntimeAnthropicBaseURL(t *testing.T) { + cap := StaticReasoningCapability("anthropic", "anthropic", "https://api.anthropic.com/v1", "claude-sonnet-5") + if cap == nil { + t.Fatal("got nil capability for the runtime default Anthropic base URL (with /v1)") + } +} + func TestStaticReasoningCapabilityDoesNotGuessUnknownCompatibleModels(t *testing.T) { if got := StaticReasoningCapability("openai-compatible", "custom", "https://example.test/v1", "gpt-5"); got != nil { t.Fatalf("got %#v, want Auto-only", got) From d8778635508a6e2d1bf58f0643d0abe2eb94d619 Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Thu, 13 Aug 2026 01:15:34 +0700 Subject: [PATCH 05/41] Close two correctness gaps in Gemini reasoning mapping 1. Direct Gemini's static catalogue required provider id "google", but the shipped provider default (internal/config/defaults.go) uses "gemini" as its provider map key, so the static fallback never fired for the real default Gemini provider. Widened the match to an exact two-value allowlist (isGeminiDirectProvider): "google" or "gemini", still rejecting arbitrary compatible/custom provider ids. 2. geminiThinkingConfig returned nil for both "no override" and "cannot map this effort", so a value that passed an attached ReasoningCapability but had no real mapping (unrecognised keyword, or "minimal" on a legacy budget model) silently vanished from the request instead of failing. geminiThinkingConfig now returns an explicit ok bool distinguishing the two; a new geminiClient.validateReasoning fails Chat/Stream before any network call when ok is false, while buildBody stays a pure, non-erroring body builder. Known low/medium/high/minimal/none mappings for both Gemini 3 (thinkingLevel) and 2.5 (thinkingBudget) models are unchanged. Co-authored-by: Cursor --- internal/llm/gemini.go | 69 +++++++++++++++++----- internal/llm/gemini_test.go | 97 ++++++++++++++++++++++++++++--- internal/llm/reasoning_catalog.go | 12 +++- internal/llm/reasoning_test.go | 26 +++++++++ 4 files changed, 179 insertions(+), 25 deletions(-) diff --git a/internal/llm/gemini.go b/internal/llm/gemini.go index 1a27283..fe7eafe 100644 --- a/internal/llm/gemini.go +++ b/internal/llm/gemini.go @@ -307,7 +307,11 @@ func (c *geminiClient) buildBody(req Request) map[string]any { gen["stopSequences"] = req.StopSequences } if value, err := reasoningValue(req, c.Kind(), c.opts.ProviderID, c.opts.BaseURL); err == nil { - if tc := geminiThinkingConfig(req.Model, value); tc != nil { + // An unmappable value (ok == false) is left out of the body here; + // callers reach this only via Chat/Stream, which fail the request + // first via validateReasoning, so that case is unreachable in + // practice. buildBody stays a pure, non-erroring body builder. + if tc, ok := geminiThinkingConfig(req.Model, value); ok && tc != nil { gen["thinkingConfig"] = tc } } @@ -380,41 +384,76 @@ func (c *geminiClient) endpoint(model, method string, stream bool) string { // Minimal is a real, distinct thinking level for Gemini 3 — not an Off // synonym. Gemini 3 has no true Off; its static capability never advertises // "none", so the "none" case below only guards a stray legacy value. -func geminiThinkingConfig(model, effort string) map[string]any { +// +// ok is false when effort cannot actually be honored on model: an +// unrecognised keyword, or "minimal" requested against a legacy +// thinkingBudget model that has no such level. config == nil with ok == true +// is a real, intentional mapping (Auto, or Gemini 3's documented lack of a +// true Off) — callers must not conflate the two. See +// geminiClient.validateReasoning, which fails the request before any network +// call when ok is false rather than silently omitting the override. +func geminiThinkingConfig(model, effort string) (config map[string]any, ok bool) { e := strings.ToLower(strings.TrimSpace(effort)) if e == "" { - return nil + return nil, true } useLevel := geminiModelUsesThinkingLevel(model) switch e { case "none": if useLevel { - return nil + return nil, true } - return map[string]any{"thinkingBudget": 0} + return map[string]any{"thinkingBudget": 0}, true case "minimal": if !useLevel { - return nil + return nil, false } - return map[string]any{"thinkingLevel": "MINIMAL", "includeThoughts": true} + return map[string]any{"thinkingLevel": "MINIMAL", "includeThoughts": true}, true case "low": if useLevel { - return map[string]any{"thinkingLevel": "LOW", "includeThoughts": true} + return map[string]any{"thinkingLevel": "LOW", "includeThoughts": true}, true } - return map[string]any{"thinkingBudget": 2048, "includeThoughts": true} + return map[string]any{"thinkingBudget": 2048, "includeThoughts": true}, true case "medium": if useLevel { - return map[string]any{"thinkingLevel": "MEDIUM", "includeThoughts": true} + return map[string]any{"thinkingLevel": "MEDIUM", "includeThoughts": true}, true } - return map[string]any{"thinkingBudget": 8192, "includeThoughts": true} + return map[string]any{"thinkingBudget": 8192, "includeThoughts": true}, true case "high": if useLevel { - return map[string]any{"thinkingLevel": "HIGH", "includeThoughts": true} + return map[string]any{"thinkingLevel": "HIGH", "includeThoughts": true}, true } - return map[string]any{"thinkingBudget": 24576, "includeThoughts": true} + return map[string]any{"thinkingBudget": 24576, "includeThoughts": true}, true default: + return nil, false + } +} + +// validateReasoning fails before any network call when the request's +// reasoning effort cannot be honored: either the value itself is not +// advertised by the model's capability (delegated to reasoningValue), or the +// value is validated by an attached capability but geminiThinkingConfig has +// no mapping for it on this model — which would otherwise silently vanish +// from the outgoing request (buildBody just omits thinkingConfig). +func (c *geminiClient) validateReasoning(req Request) error { + value, err := reasoningValue(req, c.Kind(), c.opts.ProviderID, c.opts.BaseURL) + if err != nil { + return err + } + if value == "" { return nil } + if _, ok := geminiThinkingConfig(req.Model, value); !ok { + capability := resolvedReasoningCapability(req, c.Kind(), c.opts.ProviderID, c.opts.BaseURL) + var allowed []string + if capability != nil { + for _, v := range capability.Values { + allowed = append(allowed, v.Value) + } + } + return &UnsupportedReasoningEffortError{Model: req.Model, Allowed: allowed} + } + return nil } func geminiModelUsesThinkingLevel(model string) bool { @@ -463,7 +502,7 @@ func parseGeminiParts(parts []gemPart) (content, reasoning string, calls []ToolC } func (c *geminiClient) Chat(ctx context.Context, req Request) (*Response, error) { - if _, err := reasoningValue(req, c.Kind(), c.opts.ProviderID, c.opts.BaseURL); err != nil { + if err := c.validateReasoning(req); err != nil { return nil, err } // Prefer stream collection: some Gemini-compatible reverse proxies aggregate @@ -499,7 +538,7 @@ func (c *geminiClient) Chat(ctx context.Context, req Request) (*Response, error) } func (c *geminiClient) Stream(ctx context.Context, req Request, emit func(Event) error) (*Response, error) { - if _, err := reasoningValue(req, c.Kind(), c.opts.ProviderID, c.opts.BaseURL); err != nil { + if err := c.validateReasoning(req); err != nil { return nil, err } httpResp, err := c.opts.doStream(ctx, "POST", c.endpoint(req.Model, "streamGenerateContent", true), c.buildBody(req), c.headers()) diff --git a/internal/llm/gemini_test.go b/internal/llm/gemini_test.go index cda0fbc..7468688 100644 --- a/internal/llm/gemini_test.go +++ b/internal/llm/gemini_test.go @@ -4,14 +4,17 @@ import ( "context" "encoding/json" "errors" + "net/http" + "net/http/httptest" "reflect" "strings" + "sync/atomic" "testing" ) func TestGeminiThinkingConfigUsesLevelForGemini3(t *testing.T) { - tc := geminiThinkingConfig("gemini-3.6-flash-high", "high") - if tc == nil { + tc, ok := geminiThinkingConfig("gemini-3.6-flash-high", "high") + if !ok || tc == nil { t.Fatal("expected thinkingConfig") } if tc["thinkingLevel"] != "HIGH" { @@ -26,8 +29,8 @@ func TestGeminiThinkingConfigUsesLevelForGemini3(t *testing.T) { } func TestGeminiThinkingConfigUsesBudgetFor25(t *testing.T) { - tc := geminiThinkingConfig("gemini-2.5-flash", "medium") - if tc == nil { + tc, ok := geminiThinkingConfig("gemini-2.5-flash", "medium") + if !ok || tc == nil { t.Fatal("expected thinkingConfig") } if tc["thinkingBudget"] != 8192 { @@ -39,16 +42,42 @@ func TestGeminiThinkingConfigUsesBudgetFor25(t *testing.T) { } func TestGemini3MinimalIsNotDisable(t *testing.T) { - got := geminiThinkingConfig("gemini-3.6-flash", "minimal") + got, ok := geminiThinkingConfig("gemini-3.6-flash", "minimal") want := map[string]any{"thinkingLevel": "MINIMAL", "includeThoughts": true} - if !reflect.DeepEqual(got, want) { - t.Fatalf("got %#v, want %#v", got, want) + if !ok || !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, ok=%v, want %#v, ok=true", got, ok, want) } } func TestGeminiThinkingConfigGemini3HasNoTrueOff(t *testing.T) { - if got := geminiThinkingConfig("gemini-3.6-flash", "none"); got != nil { - t.Fatalf("got %#v, want nil (Gemini 3 has no true Off, only Minimal)", got) + got, ok := geminiThinkingConfig("gemini-3.6-flash", "none") + if !ok || got != nil { + t.Fatalf("got %#v, ok=%v, want nil, ok=true (Gemini 3 has no true Off, only Minimal — that's a valid mapping, not a failure)", got, ok) + } +} + +// TestGeminiThinkingConfigMinimalUnmappableForLegacyBudgetModel guards the +// second half of geminiThinkingConfig's ok contract: "minimal" has no +// documented meaning for a pre-Gemini-3 thinkingBudget model, so it must be +// reported as unmappable (ok == false) rather than silently treated as a +// no-op, matching how an entirely unrecognised keyword is handled. +func TestGeminiThinkingConfigMinimalUnmappableForLegacyBudgetModel(t *testing.T) { + if _, ok := geminiThinkingConfig("gemini-2.5-flash", "minimal"); ok { + t.Fatal("got ok=true, want ok=false: legacy budget models have no minimal thinking level") + } +} + +// TestGeminiThinkingConfigUnrecognisedEffortIsUnmappable guards the general +// case behind the review finding: an effort value this switch has never +// heard of (as could be attached via a live/static ReasoningCapability that +// advertises more values than this local mapping knows) must report +// ok == false rather than silently mapping to no override. +func TestGeminiThinkingConfigUnrecognisedEffortIsUnmappable(t *testing.T) { + if _, ok := geminiThinkingConfig("gemini-2.5-flash", "xhigh"); ok { + t.Fatal("got ok=true, want ok=false for an unrecognised effort keyword") + } + if _, ok := geminiThinkingConfig("gemini-3.6-flash", "xhigh"); ok { + t.Fatal("got ok=true, want ok=false for an unrecognised effort keyword on a Gemini 3 model") } } @@ -96,6 +125,56 @@ func TestGeminiRejectsUnsupportedOffBeforeRequest(t *testing.T) { } } +// TestGeminiUnmappableLegacyEffortFailsBeforeRequest guards a silent-drop +// bug: an effort can pass validation against an *attached* capability (e.g. a +// live capability advertising a value this local geminiThinkingConfig switch +// does not recognise for the model) yet have no mapping at all. +// geminiThinkingConfig previously returned nil in that case and buildBody +// just omitted thinkingConfig, silently downgrading the turn to Auto. Chat +// must now fail before any request is sent. +func TestGeminiUnmappableLegacyEffortFailsBeforeRequest(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}, {Value: "xhigh", Label: "Extra High"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } + + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c := &geminiClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}} + _, err = c.Chat(context.Background(), Request{ + Model: "gemini-2.5-flash", ReasoningEffort: "xhigh", ReasoningCapability: cap, + }) + var unsupported *UnsupportedReasoningEffortError + if !errors.As(err, &unsupported) { + t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err) + } + if got := atomic.LoadInt32(&hits); got != 0 { + t.Fatalf("requests sent = %d, want 0", got) + } + + // buildBody alone (bypassing Chat) must also stay silent-safe: it must + // not fabricate a thinkingConfig it cannot actually honor. + body := (&geminiClient{}).buildBody(Request{ + Model: "gemini-2.5-flash", ReasoningEffort: "xhigh", ReasoningCapability: cap, + Messages: []Message{{Role: RoleUser, Content: "hi"}}, + }) + gen, _ := body["generationConfig"].(map[string]any) + if gen != nil { + if _, ok := gen["thinkingConfig"]; ok { + t.Fatalf("buildBody emitted a thinkingConfig for an unmappable effort: %#v", gen["thinkingConfig"]) + } + } +} + func TestToGeminiPreservesThoughtSignatureOnFunctionCall(t *testing.T) { req := Request{ Messages: []Message{{ diff --git a/internal/llm/reasoning_catalog.go b/internal/llm/reasoning_catalog.go index 7c8c8e2..dc185c4 100644 --- a/internal/llm/reasoning_catalog.go +++ b/internal/llm/reasoning_catalog.go @@ -51,7 +51,7 @@ func StaticReasoningCapability(kind, provider, baseURL, model string) *Reasoning entries = codexReasoningCapabilities case kind == "anthropic" && provider == "anthropic" && isAnthropicDirectBaseURL(baseURL): entries = anthropicReasoningCapabilities - case kind == "gemini" && provider == "google" && baseURL == "https://generativelanguage.googleapis.com/v1beta": + case kind == "gemini" && isGeminiDirectProvider(provider) && baseURL == "https://generativelanguage.googleapis.com/v1beta": entries = geminiReasoningCapabilities default: return nil @@ -78,6 +78,16 @@ func isAnthropicDirectBaseURL(baseURL string) bool { return baseURL == "https://api.anthropic.com" || baseURL == "https://api.anthropic.com/v1" } +// isGeminiDirectProvider recognises both the documented canonical provider id +// ("google") and the shipped provider id this codebase actually configures +// for direct Gemini ("gemini" — internal/config/defaults.go's providers map +// key). This is an exact two-value allowlist, not a broad match: any other +// provider id (a custom or Gemini-compatible reverse proxy) still falls back +// to Auto-only, matching direct-request behavior for other kinds. +func isGeminiDirectProvider(provider string) bool { + return provider == "google" || provider == "gemini" +} + func exactModelOrDatedSnapshot(family, model string) bool { if model == family { return true diff --git a/internal/llm/reasoning_test.go b/internal/llm/reasoning_test.go index 5162826..ca9eac0 100644 --- a/internal/llm/reasoning_test.go +++ b/internal/llm/reasoning_test.go @@ -169,6 +169,32 @@ func TestStaticReasoningCapabilityDoesNotGuessUnknownCompatibleModels(t *testing } } +// TestStaticReasoningCapabilityAcceptsShippedGeminiProviderID guards against a +// regression where direct Gemini's static catalog only matched the documented +// canonical provider id "google", but internal/config/defaults.go's shipped +// provider map key (and therefore the real runtime llm.Options.ProviderID) is +// "gemini". Without this, the static fallback never fired for the actual +// default Gemini provider. +func TestStaticReasoningCapabilityAcceptsShippedGeminiProviderID(t *testing.T) { + for _, provider := range []string{"google", "gemini"} { + cap := StaticReasoningCapability("gemini", provider, "https://generativelanguage.googleapis.com/v1beta", "gemini-3.6-flash") + if cap == nil { + t.Errorf("provider %q: got nil capability, want a match", provider) + } + } +} + +// TestStaticReasoningCapabilityRejectsUnknownGeminiCompatibleProvider ensures +// widening the Gemini provider match to also accept "gemini" stayed an exact +// two-value allowlist rather than a broad match: an arbitrary custom or +// Gemini-compatible reverse-proxy provider id must still resolve to nil +// (Auto-only), exactly like every other kind's unknown-provider case. +func TestStaticReasoningCapabilityRejectsUnknownGeminiCompatibleProvider(t *testing.T) { + if got := StaticReasoningCapability("gemini", "my-gemini-proxy", "https://generativelanguage.googleapis.com/v1beta", "gemini-3.6-flash"); got != nil { + t.Fatalf("got %#v, want Auto-only for an unrecognised Gemini-compatible provider id", got) + } +} + func reasoningValues(cap *ReasoningCapability) []string { if cap == nil { return nil From 48af6fcdc98f48a11d49748100f18cce2f9b60de Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Thu, 13 Aug 2026 01:50:28 +0700 Subject: [PATCH 06/41] Resolve reasoning against the effective model Co-authored-by: Cursor --- cmd/antares/main.go | 6 +- cmd/antares/reasoning_test.go | 67 +++++ internal/agent/agent.go | 48 +++- internal/agent/client.go | 105 ++++++-- internal/agent/harness.go | 14 +- internal/agent/reasoning.go | 165 ++++++++++++ internal/agent/reasoning_test.go | 423 +++++++++++++++++++++++++++++++ internal/llm/fallback.go | 22 +- internal/llm/fallback_test.go | 56 +++- internal/llm/reasoning.go | 8 + 10 files changed, 864 insertions(+), 50 deletions(-) create mode 100644 cmd/antares/reasoning_test.go create mode 100644 internal/agent/reasoning.go create mode 100644 internal/agent/reasoning_test.go diff --git a/cmd/antares/main.go b/cmd/antares/main.go index 2a9bc0b..c23b9e6 100644 --- a/cmd/antares/main.go +++ b/cmd/antares/main.go @@ -422,6 +422,10 @@ func (rt *runtimeServices) messageIsRelevant(ctx context.Context, b *config.Bind "\n\nMessage:\n" + strings.TrimSpace(text) + "\n\nDoes this message fit the criteria and deserve a reply? Answer with exactly one word: YES or NO." + reasoningEffort := "" + if err := rt.agent.ValidateReasoningEffort(ctx, b.Model, "low"); err == nil { + reasoningEffort = "low" + } var out strings.Builder _, err := rt.agent.Run(ctx, agent.Request{ Message: prompt, @@ -429,7 +433,7 @@ func (rt *runtimeServices) messageIsRelevant(ctx context.Context, b *config.Bind Toolset: "minimal", Quiet: true, MaxTurns: 1, - ReasoningEffort: "low", + ReasoningEffort: reasoningEffort, }, func(e agent.Event) error { if e.Type == agent.EventText { out.WriteString(e.Delta) diff --git a/cmd/antares/reasoning_test.go b/cmd/antares/reasoning_test.go new file mode 100644 index 0000000..3672912 --- /dev/null +++ b/cmd/antares/reasoning_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/enowdev/antares/internal/agent" + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/store" + "github.com/enowdev/antares/internal/tools" +) + +func TestMessageIsRelevantUsesAutoWhenLowUnsupported(t *testing.T) { + var chatCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"): + _, _ = w.Write([]byte(`{"data":[{"id":"plain-model","name":"Plain"}]}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"): + chatCalls.Add(1) + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"NO"}}]}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + cfg := config.Default() + cfg.Model.Provider = "router" + cfg.Model.Default = "plain-model" + cfg.Model.MaxRetries = -1 + cfg.Model.ReasoningEffort = "" + cfg.Agent.ReasoningEffort = "" + cfg.Streaming.Enabled = false + cfg.Providers = map[string]config.Provider{ + "router": { + Kind: "openai-compatible", + BaseURL: srv.URL, + Enabled: true, + }, + } + db, err := store.Open(context.Background(), "memory", "", 1, 5000, false) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + rt := &runtimeServices{ + cfg: cfg, + db: db, + agent: agent.New(cfg, db, tools.NewRegistry(), nil, nil), + } + + if got := rt.messageIsRelevant(context.Background(), &config.Binding{ + Model: "plain-model", + RelevanceFilter: "Only answer release announcements.", + }, "How is everyone?"); got { + t.Fatal("messageIsRelevant = true, want classifier's NO response") + } + if got := chatCalls.Load(); got != 1 { + t.Fatalf("classifier chat calls = %d, want one", got) + } +} diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 1f43da8..120e367 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -309,6 +309,7 @@ func (a *Agent) Run(ctx context.Context, req Request, emit Emit) (*Result, error req.Role = stored } } + roleReasoningEffort := a.roleReasoningEffort(req.Role) a.applyRole(&req) if !req.Quiet { if err := emit(Event{Type: EventSession, ID: sess.ID, Title: sess.Title}); err != nil { @@ -327,12 +328,34 @@ func (a *Agent) Run(ctx context.Context, req Request, emit Emit) (*Result, error a.mu.Unlock() }() - client, modelName, providerName, err := a.newClient(req.Model, sess.ID) + client, modelName, providerName, err := a.newClientContext(runCtx, req.Model, sess.ID) if err != nil { _ = emit(Event{Type: EventError, Err: err.Error()}) _ = emit(Event{Type: EventDone}) return nil, err } + reasoning, err := a.resolveReasoning(runCtx, reasoningInput{ + ModelRef: providerName + "/" + modelName, + Explicit: req.ReasoningEffort, + Role: roleReasoningEffort, + Agent: cfg.Agent.ReasoningEffort, + Model: cfg.Model.ReasoningEffort, + }) + if err != nil { + _ = emit(Event{Type: EventError, Err: err.Error()}) + _ = emit(Event{Type: EventDone}) + return nil, err + } + if reasoning.DiscardedLegacy != "" { + _ = emit(Event{ + Type: EventNotice, + Message: fmt.Sprintf( + "configured reasoning effort %q is unsupported by %s and was ignored", + reasoning.DiscardedLegacy, + modelName, + ), + }) + } history, err := a.loadHistory(ctx, sess, req) if err != nil { @@ -441,17 +464,18 @@ func (a *Agent) Run(ctx context.Context, req Request, emit Emit) (*Result, error history = a.maybeCompact(runCtx, history, systemPrompt, modelName, toolSpecs, emit, sess) llmReq := llm.Request{ - Model: modelName, - System: systemPrompt, - Messages: ensureToolResults(history), - Tools: toolSpecs, - Temperature: cfg.Model.Temperature, - TopP: cfg.Model.TopP, - MaxTokens: cfg.Model.MaxTokens, - StopSequences: cfg.Agent.StopSequences, - ReasoningEffort: firstNonEmpty(req.ReasoningEffort, cfg.Agent.ReasoningEffort, cfg.Model.ReasoningEffort), - ParallelToolCalls: cfg.Model.ParallelToolCall, - PromptCache: cfg.PromptCaching.Enabled, + Model: modelName, + System: systemPrompt, + Messages: ensureToolResults(history), + Tools: toolSpecs, + Temperature: cfg.Model.Temperature, + TopP: cfg.Model.TopP, + MaxTokens: cfg.Model.MaxTokens, + StopSequences: cfg.Agent.StopSequences, + ReasoningEffort: reasoning.Value, + ReasoningCapability: reasoning.Capability, + ParallelToolCalls: cfg.Model.ParallelToolCall, + PromptCache: cfg.PromptCaching.Enabled, } resp, err := a.callModel(runCtx, client, llmReq, cfg.Streaming.Enabled, emit) diff --git a/internal/agent/client.go b/internal/agent/client.go index c29e222..6f10655 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -8,6 +8,7 @@ import ( "log/slog" + "github.com/enowdev/antares/internal/config" "github.com/enowdev/antares/internal/llm" ) @@ -16,6 +17,14 @@ import ( // models are configured, the returned client tries each in turn on a hard // failure. sessionID pins gateway sticky routing (Gemini CLI–compatible) when set. func (a *Agent) newClient(modelOverride, sessionID string) (client llm.Client, model, provider string, err error) { + return a.buildClient(context.Background(), modelOverride, sessionID, false) +} + +func (a *Agent) newClientContext(ctx context.Context, modelOverride, sessionID string) (client llm.Client, model, provider string, err error) { + return a.buildClient(ctx, modelOverride, sessionID, true) +} + +func (a *Agent) buildClient(ctx context.Context, modelOverride, sessionID string, withReasoningCapabilities bool) (client llm.Client, model, provider string, err error) { primary, model, provider, err := a.resolveClient(modelOverride, sessionID) if err != nil { return nil, "", "", err @@ -23,24 +32,43 @@ func (a *Agent) newClient(modelOverride, sessionID string) (client llm.Client, m // Only the default path (no explicit override) uses the fallback chain, so // a deliberately chosen model is honoured exactly. - entries := []llm.FallbackEntry{{Client: primary, Model: model}} + entries := []llm.FallbackEntry{{ + Client: primary, + Model: model, + }} if modelOverride == "" { for _, spec := range a.config().Model.Fallback { spec = strings.TrimSpace(spec) if spec == "" { continue } - fc, fm, _, ferr := a.resolveClient(spec, sessionID) + fc, fm, fp, ferr := a.resolveClient(spec, sessionID) if ferr != nil { slog.Debug("fallback model unavailable", "spec", spec, "error", ferr) continue } - entries = append(entries, llm.FallbackEntry{Client: fc, Model: fm}) + if withReasoningCapabilities && len(entries) == 1 { + entries[0].ReasoningCapability = a.reasoningCapabilityForResolved(ctx, provider, model) + } + entry := llm.FallbackEntry{Client: fc, Model: fm} + if withReasoningCapabilities { + entry.ReasoningCapability = a.reasoningCapabilityForResolved(ctx, fp, fm) + } + entries = append(entries, entry) } } return llm.NewFallback(entries), model, provider, nil } +func (a *Agent) reasoningCapabilityForResolved(ctx context.Context, provider, model string) *llm.ReasoningCapability { + capability, err := a.ReasoningCapability(ctx, provider+"/"+model) + if err != nil { + slog.Debug("reasoning metadata unavailable", "provider", provider, "model", model, "error", err) + return nil + } + return capability +} + // resolveClient builds one provider adapter for a model spec. func (a *Agent) resolveClient(modelOverride, sessionID string) (client llm.Client, model, provider string, err error) { cfg := a.config() @@ -132,48 +160,73 @@ func (a *Agent) Probe(ctx context.Context) (bool, string) { // Models lists the models a provider offers. // -// If providers..models is non-empty it is treated as a whitelist: only -// those ids are returned (no live /models merge). This keeps curated local -// gateways (e.g. Sub2API antigravity) from flooding the UI with broken or -// deprecated upstream catalog entries. +// If providers..models is non-empty it is treated as a whitelist: live +// metadata may enrich those ids, but unlisted live models are never appended. +// This keeps curated local gateways (e.g. Sub2API antigravity) from flooding +// the UI with broken or deprecated upstream catalog entries. // // If the list is empty, the provider's /models endpoint is queried live. // A live fetch that fails still yields any manual list rather than nothing. func (a *Agent) Models(ctx context.Context, providerID string) ([]llm.ModelInfo, error) { id, p := a.config().ResolveProvider(providerID) - // Curated whitelist: skip live catalog entirely. - if len(p.Models) > 0 { - out := make([]llm.ModelInfo, 0, len(p.Models)) - seen := make(map[string]bool, len(p.Models)) - for _, mid := range p.Models { - if mid == "" || seen[mid] { - continue - } - seen[mid] = true - out = append(out, llm.ModelInfo{ - ID: mid, - Name: mid, - Provider: id, - ContextWindow: p.ModelMeta[mid].ContextWindow, - }) - } - return out, nil - } - client, err := llm.New(llm.Options{ Kind: p.Kind, BaseURL: p.BaseURL, APIKey: p.APIKey, Headers: p.Headers, ProviderID: id, Timeout: 60 * time.Second, APIVersion: p.APIVersion, Region: p.Region, }) if err != nil { + if len(p.Models) > 0 { + return curatedModelsWithReasoning(id, p, nil, p.Kind), nil + } return nil, err } fetchCtx, cancel := context.WithTimeout(ctx, 45*time.Second) defer cancel() live, ferr := client.Models(fetchCtx) + if len(p.Models) > 0 { + return curatedModelsWithReasoning(id, p, live, client.Kind()), nil + } if ferr != nil && len(live) == 0 { return nil, ferr } + for i := range live { + if live[i].ReasoningCapability != nil { + continue + } + target := reasoningTarget{providerID: id, model: live[i].ID, provider: p} + target.provider.Kind = client.Kind() + live[i] = live[i].WithReasoningCapability(staticReasoningCapability(target)) + } return live, nil } + +func curatedModelsWithReasoning(id string, p config.Provider, live []llm.ModelInfo, kind string) []llm.ModelInfo { + liveByID := make(map[string]llm.ModelInfo, len(live)) + for _, model := range live { + liveByID[model.ID] = model + } + + out := make([]llm.ModelInfo, 0, len(p.Models)) + seen := make(map[string]bool, len(p.Models)) + for _, mid := range p.Models { + if mid == "" || seen[mid] { + continue + } + seen[mid] = true + info := llm.ModelInfo{ + ID: mid, + Name: mid, + Provider: id, + ContextWindow: p.ModelMeta[mid].ContextWindow, + } + capability := liveByID[mid].ReasoningCapability + if capability == nil { + target := reasoningTarget{providerID: id, model: mid, provider: p} + target.provider.Kind = kind + capability = staticReasoningCapability(target) + } + out = append(out, info.WithReasoningCapability(capability)) + } + return out +} diff --git a/internal/agent/harness.go b/internal/agent/harness.go index 94b4803..a430431 100644 --- a/internal/agent/harness.go +++ b/internal/agent/harness.go @@ -783,14 +783,22 @@ func (a *Agent) applyRole(req *Request) { if req.Model == "" && role.Model != "" { req.Model = role.Model } - if req.ReasoningEffort == "" && role.Effort != "" { - req.ReasoningEffort = role.Effort - } if req.MaxTurns == 0 && role.MaxTurns > 0 { req.MaxTurns = role.MaxTurns } } +func (a *Agent) roleReasoningEffort(name string) string { + if a.roles == nil || strings.TrimSpace(name) == "" { + return "" + } + role, ok := a.roles.Get(name) + if !ok { + return "" + } + return role.Effort +} + // roleInfos exposes the roles to the tools layer. func (a *Agent) roleInfos() []tools.RoleInfo { if a.roles == nil { diff --git a/internal/agent/reasoning.go b/internal/agent/reasoning.go new file mode 100644 index 0000000..3c94c61 --- /dev/null +++ b/internal/agent/reasoning.go @@ -0,0 +1,165 @@ +package agent + +import ( + "context" + "strings" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/llm" +) + +type reasoningInput struct { + ModelRef string + Explicit string + Role string + Agent string + Model string +} + +type reasoningResolution struct { + Value string + Capability *llm.ReasoningCapability + DiscardedLegacy string +} + +type reasoningTarget struct { + providerID string + model string + provider config.Provider +} + +// ReasoningCapability returns the best model-specific reasoning metadata the +// configured provider can supply. Documented direct-provider metadata avoids a +// network dependency; dynamic providers are queried through Agent.Models. +func (a *Agent) ReasoningCapability(ctx context.Context, modelRef string) (*llm.ReasoningCapability, error) { + target := a.reasoningTarget(modelRef) + if capability := staticReasoningCapability(target); capability != nil { + return capability, nil + } + + models, err := a.Models(ctx, target.providerID) + if err != nil { + // Model catalogues are optional. Unknown metadata means Auto-only; it + // must not make an otherwise valid chat depend on a /models endpoint. + return nil, nil + } + for _, model := range models { + if model.ID == target.model && model.ReasoningCapability != nil { + return model.ReasoningCapability, nil + } + } + return nil, nil +} + +// ValidateReasoningEffort validates an explicit value without including the +// submitted value in any error. +func (a *Agent) ValidateReasoningEffort(ctx context.Context, modelRef, effort string) error { + capability, err := a.ReasoningCapability(ctx, modelRef) + if err != nil { + return err + } + return llm.ValidateReasoningEffort(a.reasoningTarget(modelRef).model, capability, effort) +} + +// resolveReasoning distinguishes a new explicit override from stored legacy +// values. An invalid explicit override is an error; invalid stored values are +// skipped in role, agent, model order so old configuration degrades to Auto. +func (a *Agent) resolveReasoning(ctx context.Context, in reasoningInput) (reasoningResolution, error) { + capability, err := a.ReasoningCapability(ctx, in.ModelRef) + if err != nil { + return reasoningResolution{}, err + } + resolution := reasoningResolution{Capability: capability} + model := a.reasoningTarget(in.ModelRef).model + + if in.Explicit != "" { + if err := llm.ValidateReasoningEffort(model, capability, in.Explicit); err != nil { + return reasoningResolution{}, err + } + resolution.Value = in.Explicit + return resolution, nil + } + + agentValue := in.Agent + if agentValue == "" { + agentValue = a.config().Agent.ReasoningEffort + } + modelValue := in.Model + if modelValue == "" { + modelValue = a.config().Model.ReasoningEffort + } + for _, stored := range []string{in.Role, agentValue, modelValue} { + if stored == "" { + continue + } + if err := llm.ValidateReasoningEffort(model, capability, stored); err == nil { + resolution.Value = stored + return resolution, nil + } + if resolution.DiscardedLegacy == "" { + resolution.DiscardedLegacy = stored + } + } + return resolution, nil +} + +func (a *Agent) reasoningTarget(modelRef string) reasoningTarget { + cfg := a.config() + providerID := cfg.Model.Provider + model := modelRef + if model == "" { + model = cfg.Model.Default + } + if modelRef != "" { + if candidate, rest, ok := strings.Cut(modelRef, "/"); ok && rest != "" { + if _, configured := cfg.Providers[candidate]; configured { + providerID, model = candidate, rest + } else if candidate == cfg.Model.Provider || candidate == "google" { + // "google/model" is the canonical direct-Gemini reference even + // though the shipped provider map uses the key "gemini". + providerID, model = candidate, rest + } + } + } + + id, provider := cfg.ResolveProvider(providerID) + if id == "google" { + if _, configured := cfg.Providers[id]; !configured { + provider = config.Provider{ + Kind: "gemini", + BaseURL: "https://generativelanguage.googleapis.com/v1beta", + Enabled: true, + } + } + } + return reasoningTarget{providerID: id, model: model, provider: provider} +} + +func staticReasoningCapability(target reasoningTarget) *llm.ReasoningCapability { + kind := strings.ToLower(strings.TrimSpace(target.provider.Kind)) + switch kind { + case "google": + kind = "gemini" + case "claude": + kind = "anthropic" + case "responses", "openai-responses": + kind = "codex" + } + baseURL := strings.TrimRight(strings.TrimSpace(target.provider.BaseURL), "/") + if baseURL == "" { + switch kind { + case "openai", "codex": + baseURL = "https://api.openai.com/v1" + case "anthropic": + baseURL = "https://api.anthropic.com/v1" + case "gemini": + baseURL = "https://generativelanguage.googleapis.com/v1beta" + } + } + return llm.StaticReasoningCapability( + kind, + target.providerID, + baseURL, + target.model, + ) +} diff --git a/internal/agent/reasoning_test.go b/internal/agent/reasoning_test.go new file mode 100644 index 0000000..cd1ee25 --- /dev/null +++ b/internal/agent/reasoning_test.go @@ -0,0 +1,423 @@ +package agent + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/llm" + "github.com/enowdev/antares/internal/store" + "github.com/enowdev/antares/internal/tools" +) + +func TestResolveReasoningExplicitUnsupportedReturnsError(t *testing.T) { + a := agentWithConfig(config.Default()) + _, err := a.resolveReasoning(context.Background(), reasoningInput{ + ModelRef: "google/gemini-3.6-flash", + Explicit: "max", + }) + if err == nil || !llm.IsUnsupportedReasoningEffort(err) { + t.Fatalf("err = %v", err) + } +} + +func TestResolveReasoningUnsupportedStoredValueFallsBackToAuto(t *testing.T) { + cfg := config.Default() + cfg.Model.Provider = "google" + cfg.Model.Default = "gemini-3.6-flash" + cfg.Agent.ReasoningEffort = "max" + a := agentWithConfig(cfg) + got, err := a.resolveReasoning(context.Background(), reasoningInput{ModelRef: cfg.Model.Default}) + if err != nil || got.Value != "" || got.DiscardedLegacy != "max" { + t.Fatalf("got=%+v err=%v", got, err) + } +} + +func TestResolveReasoningUsesRoleAgentModelPrecedence(t *testing.T) { + cfg := config.Default() + cfg.Model.Provider = "gemini" + cfg.Model.Default = "gemini-3.6-flash" + a := agentWithConfig(cfg) + + got, err := a.resolveReasoning(context.Background(), reasoningInput{ + ModelRef: "gemini/gemini-3.6-flash", + Role: "high", + Agent: "medium", + Model: "low", + }) + if err != nil { + t.Fatal(err) + } + if got.Value != "high" || got.DiscardedLegacy != "" { + t.Fatalf("got = %+v, want role value high", got) + } +} + +func TestResolveReasoningSkipsUnsupportedStoredValuesInPrecedenceOrder(t *testing.T) { + cfg := config.Default() + cfg.Model.Provider = "gemini" + cfg.Model.Default = "gemini-3.6-flash" + a := agentWithConfig(cfg) + + got, err := a.resolveReasoning(context.Background(), reasoningInput{ + ModelRef: "gemini/gemini-3.6-flash", + Role: "MAX", + Agent: "medium", + Model: "low", + }) + if err != nil { + t.Fatal(err) + } + if got.Value != "medium" || got.DiscardedLegacy != "MAX" { + t.Fatalf("got = %+v, want agent value medium after discarding exact role value MAX", got) + } +} + +func TestResolveReasoningPreservesOpaqueLiveValueAndCase(t *testing.T) { + srv := newReasoningModelsServer(t, `{ + "data": [ + {"id": "model-a", "reasoning": {"supported_efforts": ["low"], "default_effort": "low"}}, + {"id": "model-b", "reasoning": {"supported_efforts": ["MiXeD"], "default_effort": "MiXeD"}} + ] + }`) + cfg := reasoningTestConfig(srv.URL, nil) + a := agentWithConfig(cfg) + + got, err := a.resolveReasoning(context.Background(), reasoningInput{ + ModelRef: "router/model-b", + Explicit: "MiXeD", + }) + if err != nil { + t.Fatal(err) + } + if got.Value != "MiXeD" || got.Capability == nil || got.Capability.Source != llm.ReasoningCapabilityLive { + t.Fatalf("got = %+v", got) + } + + _, err = a.resolveReasoning(context.Background(), reasoningInput{ + ModelRef: "router/model-b", + Explicit: "mixed", + }) + if err == nil || !llm.IsUnsupportedReasoningEffort(err) { + t.Fatalf("case-changed value err = %v", err) + } +} + +func TestValidateReasoningEffortDoesNotExposeSubmittedValue(t *testing.T) { + cfg := config.Default() + cfg.Model.Provider = "gemini" + a := agentWithConfig(cfg) + const submitted = "secret-invalid-effort" + + err := a.ValidateReasoningEffort(context.Background(), "gemini/gemini-3.6-flash", submitted) + if err == nil || !llm.IsUnsupportedReasoningEffort(err) { + t.Fatalf("err = %v", err) + } + if strings.Contains(err.Error(), submitted) { + t.Fatalf("error exposes submitted value: %v", err) + } +} + +func TestReasoningCapabilityUsesMatchingModelLiveMetadata(t *testing.T) { + srv := newReasoningModelsServer(t, `{ + "data": [ + {"id": "model-a", "reasoning": {"supported_efforts": ["low"], "default_effort": "low"}}, + {"id": "model-b", "reasoning": {"supported_efforts": ["MiXeD"], "default_effort": "MiXeD"}} + ] + }`) + a := agentWithConfig(reasoningTestConfig(srv.URL, nil)) + + capability, err := a.ReasoningCapability(context.Background(), "router/model-b") + if err != nil { + t.Fatal(err) + } + if capability == nil || capability.Source != llm.ReasoningCapabilityLive { + t.Fatalf("capability = %#v", capability) + } + if len(capability.Values) != 1 || capability.Values[0].Value != "MiXeD" { + t.Fatalf("values = %#v, want exact model-b metadata", capability.Values) + } +} + +func TestReasoningCapabilityResolvesInlineActiveProviderModelRef(t *testing.T) { + srv := newReasoningModelsServer(t, `{ + "data": [ + {"id": "model-a", "reasoning": {"supported_efforts": ["Exact"], "default_effort": "Exact"}} + ] + }`) + cfg := config.Default() + cfg.Providers = map[string]config.Provider{} + cfg.Model.Provider = "inline" + cfg.Model.Default = "model-a" + cfg.Model.BaseURL = srv.URL + a := agentWithConfig(cfg) + + capability, err := a.ReasoningCapability(context.Background(), "inline/model-a") + if err != nil { + t.Fatal(err) + } + if capability == nil || len(capability.Values) != 1 || capability.Values[0].Value != "Exact" { + t.Fatalf("capability = %#v", capability) + } +} + +func TestModelsKeepsCuratedWhitelistWhileUsingMatchingLiveCapability(t *testing.T) { + srv := newReasoningModelsServer(t, `{ + "data": [ + {"id": "listed", "reasoning": {"supported_efforts": ["HIGH"], "default_effort": "HIGH"}}, + {"id": "unlisted", "reasoning": {"supported_efforts": ["low"], "default_effort": "low"}} + ] + }`) + cfg := reasoningTestConfig(srv.URL, []string{"listed"}) + a := agentWithConfig(cfg) + + models, err := a.Models(context.Background(), "router") + if err != nil { + t.Fatal(err) + } + if len(models) != 1 || models[0].ID != "listed" { + t.Fatalf("models = %#v, want only curated model", models) + } + capability := models[0].ReasoningCapability + if capability == nil || capability.Source != llm.ReasoningCapabilityLive || + len(capability.Values) != 1 || capability.Values[0].Value != "HIGH" { + t.Fatalf("capability = %#v", capability) + } +} + +func TestModelsFallsBackToStaticCapabilityForCuratedModel(t *testing.T) { + cfg := config.Default() + cfg.Providers["gemini"] = config.Provider{ + Kind: "gemini", + Enabled: true, + Models: []string{"gemini-3.6-flash"}, + } + a := agentWithConfig(cfg) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + models, err := a.Models(ctx, "gemini") + if err != nil { + t.Fatal(err) + } + if len(models) != 1 { + t.Fatalf("models = %#v", models) + } + capability := models[0].ReasoningCapability + if capability == nil || capability.Source != llm.ReasoningCapabilityStatic { + t.Fatalf("capability = %#v, want static fallback", capability) + } +} + +func TestRunResolvesStoredRoleReasoningOnceBeforeTurnLoop(t *testing.T) { + var ( + mu sync.Mutex + chatBodies []map[string]any + chatCalls int + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"): + _, _ = w.Write([]byte(`{ + "data": [ + {"id": "model-a", "reasoning": {"supported_efforts": ["low"], "default_effort": "low"}} + ] + }`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"): + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode chat body: %v", err) + } + mu.Lock() + chatBodies = append(chatBodies, body) + chatCalls++ + call := chatCalls + mu.Unlock() + if call == 1 { + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`)) + return + } + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + cfg := reasoningTestConfig(srv.URL, nil) + cfg.Agent.ReasoningEffort = "low" + cfg.Model.ReasoningEffort = "low" + cfg.Streaming.Enabled = false + a := newReasoningRunAgent(t, cfg) + + var discardedNotices int + result, err := a.Run(context.Background(), Request{ + Message: "test", + Role: "reviewer", + Quiet: true, + MaxTurns: 2, + }, func(event Event) error { + if event.Type == EventNotice && strings.Contains(event.Message, "high") { + discardedNotices++ + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if result.Reply != "ok" { + t.Fatalf("reply = %q", result.Reply) + } + if discardedNotices != 1 { + t.Fatalf("discarded-role notices = %d, want one", discardedNotices) + } + mu.Lock() + defer mu.Unlock() + if len(chatBodies) != 2 { + t.Fatalf("chat calls = %d, want two", len(chatBodies)) + } + for i, body := range chatBodies { + if body["reasoning_effort"] != "low" { + t.Fatalf("chat body %d reasoning_effort = %#v, want low", i+1, body["reasoning_effort"]) + } + } +} + +func TestRunCarriesMatchingReasoningCapabilityThroughFallbackEntries(t *testing.T) { + var primaryChats atomic.Int32 + primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"): + _, _ = w.Write([]byte(`{ + "data": [ + {"id": "primary-model", "reasoning": {"supported_efforts": ["HIGH"], "default_effort": "HIGH"}} + ] + }`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"): + primaryChats.Add(1) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":{"message":"primary unavailable"}}`)) + default: + http.NotFound(w, r) + } + })) + defer primary.Close() + + var ( + mu sync.Mutex + fallbackEffort any + ) + fallback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"): + _, _ = w.Write([]byte(`{ + "data": [ + {"id": "fallback-model", "reasoning": {"supported_efforts": ["HIGH"], "default_effort": "HIGH"}} + ] + }`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"): + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode fallback body: %v", err) + } + mu.Lock() + fallbackEffort = body["reasoning_effort"] + mu.Unlock() + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"fallback ok"}}]}`)) + default: + http.NotFound(w, r) + } + })) + defer fallback.Close() + + cfg := config.Default() + cfg.Model.Provider = "primary" + cfg.Model.Default = "primary-model" + cfg.Model.Fallback = []string{"backup/fallback-model"} + cfg.Model.MaxRetries = -1 + cfg.Model.ReasoningEffort = "" + cfg.Agent.ReasoningEffort = "HIGH" + cfg.Streaming.Enabled = false + cfg.Providers = map[string]config.Provider{ + "primary": { + Kind: "openai-compatible", + BaseURL: primary.URL, + Enabled: true, + }, + "backup": { + Kind: "openai-compatible", + BaseURL: fallback.URL, + Enabled: true, + }, + } + a := newReasoningRunAgent(t, cfg) + + result, err := a.Run(context.Background(), Request{ + Message: "test fallback", + Quiet: true, + MaxTurns: 1, + }, nil) + if err != nil { + t.Fatal(err) + } + if result.Reply != "fallback ok" { + t.Fatalf("reply = %q", result.Reply) + } + if got := primaryChats.Load(); got != 1 { + t.Fatalf("primary chat calls = %d, want one", got) + } + mu.Lock() + defer mu.Unlock() + if fallbackEffort != "HIGH" { + t.Fatalf("fallback reasoning_effort = %#v, want exact live value HIGH", fallbackEffort) + } +} + +func reasoningTestConfig(baseURL string, curated []string) *config.Config { + cfg := config.Default() + cfg.Model.Provider = "router" + cfg.Model.Default = "model-a" + cfg.Model.MaxRetries = -1 + cfg.Model.ReasoningEffort = "" + cfg.Agent.ReasoningEffort = "" + cfg.Providers = map[string]config.Provider{ + "router": { + Kind: "openai-compatible", + BaseURL: baseURL, + Enabled: true, + Models: curated, + }, + } + return cfg +} + +func newReasoningModelsServer(t *testing.T, response string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(response)) + })) +} + +func newReasoningRunAgent(t *testing.T, cfg *config.Config) *Agent { + t.Helper() + db, err := store.Open(context.Background(), "memory", "", 1, 5000, false) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + return New(cfg, db, tools.NewRegistry(), nil, nil) +} diff --git a/internal/llm/fallback.go b/internal/llm/fallback.go index 7f0e219..8a1f627 100644 --- a/internal/llm/fallback.go +++ b/internal/llm/fallback.go @@ -7,8 +7,9 @@ import ( // FallbackEntry is one client and the model to ask it for. type FallbackEntry struct { - Client Client - Model string + Client Client + Model string + ReasoningCapability *ReasoningCapability } // fallbackClient tries each entry in order, moving to the next when one fails @@ -37,9 +38,8 @@ func (c *fallbackClient) Kind() string { func (c *fallbackClient) Chat(ctx context.Context, req Request) (*Response, error) { var lastErr error - for _, e := range c.entries { - r := req - r.Model = e.Model + for i, e := range c.entries { + r := fallbackRequest(req, e, i > 0) resp, err := e.Client.Chat(ctx, r) if err == nil { return resp, nil @@ -62,8 +62,7 @@ func (c *fallbackClient) Stream(ctx context.Context, req Request, emit func(Even emitted = true return emit(ev) } - r := req - r.Model = e.Model + r := fallbackRequest(req, e, i > 0) resp, err := e.Client.Stream(ctx, r, wrapped) if err == nil { return resp, nil @@ -76,6 +75,15 @@ func (c *fallbackClient) Stream(ctx context.Context, req Request, emit func(Even return nil, lastErr } +func fallbackRequest(req Request, entry FallbackEntry, isFallback bool) Request { + req.Model = entry.Model + req.ReasoningCapability = entry.ReasoningCapability + if isFallback && ValidateReasoningEffort(entry.Model, entry.ReasoningCapability, req.ReasoningEffort) != nil { + req.ReasoningEffort = "" + } + return req +} + // Models and Embed use the primary only — a fallback for enumeration or // embeddings would silently change the vector space. func (c *fallbackClient) Models(ctx context.Context) ([]ModelInfo, error) { diff --git a/internal/llm/fallback_test.go b/internal/llm/fallback_test.go index b8c399c..209b355 100644 --- a/internal/llm/fallback_test.go +++ b/internal/llm/fallback_test.go @@ -41,17 +41,71 @@ func TestFallbackOverridesModel(t *testing.T) { } } +func TestFallbackReplacesPrimaryReasoningCapability(t *testing.T) { + primaryCapability, err := NewReasoningCapability( + []ReasoningValue{{Value: "high", Label: "HIGH"}}, + "high", false, ReasoningCapabilityLive, + ) + if err != nil { + t.Fatal(err) + } + fallbackCapability, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "LOW"}}, + "low", false, ReasoningCapabilityLive, + ) + if err != nil { + t.Fatal(err) + } + + rec := &modelRecorder{} + c := NewFallback([]FallbackEntry{ + { + Client: &fakeClient{failN: 10, failErr: &apiError{Status: 500}}, + Model: "primary", + ReasoningCapability: primaryCapability, + }, + { + Client: rec, + Model: "fallback", + ReasoningCapability: fallbackCapability, + }, + }) + _, err = c.Chat(context.Background(), Request{ + Model: "original", + ReasoningEffort: "high", + ReasoningCapability: primaryCapability, + }) + if err != nil { + t.Fatal(err) + } + if rec.gotModel != "fallback" { + t.Fatalf("model = %q, want fallback", rec.gotModel) + } + if rec.gotCapability != fallbackCapability { + t.Fatalf("capability = %#v, want fallback entry capability %#v", rec.gotCapability, fallbackCapability) + } + if rec.gotEffort != "" { + t.Fatalf("reasoning effort = %q, want Auto for unsupported legacy value", rec.gotEffort) + } +} + type modelRecorder struct { - gotModel string + gotModel string + gotEffort string + gotCapability *ReasoningCapability } func (m *modelRecorder) Kind() string { return "rec" } func (m *modelRecorder) Chat(ctx context.Context, req Request) (*Response, error) { m.gotModel = req.Model + m.gotEffort = req.ReasoningEffort + m.gotCapability = req.ReasoningCapability return &Response{Content: "ok"}, nil } func (m *modelRecorder) Stream(ctx context.Context, req Request, emit func(Event) error) (*Response, error) { m.gotModel = req.Model + m.gotEffort = req.ReasoningEffort + m.gotCapability = req.ReasoningCapability return &Response{Content: "ok"}, nil } func (m *modelRecorder) Models(context.Context) ([]ModelInfo, error) { return nil, nil } diff --git a/internal/llm/reasoning.go b/internal/llm/reasoning.go index add7031..8b62da2 100644 --- a/internal/llm/reasoning.go +++ b/internal/llm/reasoning.go @@ -1,6 +1,7 @@ package llm import ( + "errors" "fmt" ) @@ -87,6 +88,13 @@ func (e *UnsupportedReasoningEffortError) Error() string { return fmt.Sprintf("unsupported reasoning override for model %q (allowed: %v)", e.Model, e.Allowed) } +// IsUnsupportedReasoningEffort reports whether err is an unsupported +// reasoning override without requiring callers to inspect or expose its value. +func IsUnsupportedReasoningEffort(err error) bool { + var unsupported *UnsupportedReasoningEffortError + return errors.As(err, &unsupported) +} + // ValidateReasoningEffort accepts Auto (an empty effort) for every model and // otherwise requires an exact, advertised opaque value. func ValidateReasoningEffort(model string, capability *ReasoningCapability, effort string) error { From 46e11b79cc649b71ba349a40565353b43ca007f2 Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Thu, 13 Aug 2026 02:05:57 +0700 Subject: [PATCH 07/41] Fix reasoning metadata caching and outages Reuse scoped catalogues and preserve stale capabilities so interactive reasoning remains deterministic across repeated lookups and transient provider failures. Co-authored-by: Cursor --- internal/agent/agent.go | 24 +- internal/agent/client.go | 35 ++- internal/agent/model_cache.go | 209 +++++++++++++ internal/agent/model_cache_test.go | 468 +++++++++++++++++++++++++++++ internal/agent/reasoning.go | 62 +++- internal/agent/reasoning_test.go | 14 +- 6 files changed, 773 insertions(+), 39 deletions(-) create mode 100644 internal/agent/model_cache.go create mode 100644 internal/agent/model_cache_test.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 120e367..845a7b3 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -194,21 +194,27 @@ type Agent struct { mu sync.Mutex active map[string]context.CancelFunc + + catalogMu sync.Mutex + catalogCache map[providerCatalogScope]*providerCatalogEntry + catalogNow func() time.Time } // New builds an agent. func New(cfg *config.Config, db store.Store, reg *tools.Registry, shell *tools.ShellManager, ragProvider tools.RAGProvider) *Agent { a := &Agent{ db: db, reg: reg, shell: shell, rag: ragProvider, - checks: checkpoint.NewStore(config.Path("checkpoints")), - roles: roles.NewRegistry(nil), - findings: findings.NewStore(config.Path("findings")), - intel: engagement.NewStore(config.Path("intel")), - roleperf: roleperf.NewTracker(config.Path("role-performance.json")), - board: board.New(config.Path("boards")), - bg: newBGManager(), - bgAct: newBgActivity(), - active: map[string]context.CancelFunc{}, + checks: checkpoint.NewStore(config.Path("checkpoints")), + roles: roles.NewRegistry(nil), + findings: findings.NewStore(config.Path("findings")), + intel: engagement.NewStore(config.Path("intel")), + roleperf: roleperf.NewTracker(config.Path("role-performance.json")), + board: board.New(config.Path("boards")), + bg: newBGManager(), + bgAct: newBgActivity(), + active: map[string]context.CancelFunc{}, + catalogCache: make(map[providerCatalogScope]*providerCatalogEntry), + catalogNow: time.Now, } a.cfg.Store(cfg) return a diff --git a/internal/agent/client.go b/internal/agent/client.go index 6f10655..251fd74 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -169,33 +169,38 @@ func (a *Agent) Probe(ctx context.Context) (bool, string) { // A live fetch that fails still yields any manual list rather than nothing. func (a *Agent) Models(ctx context.Context, providerID string) ([]llm.ModelInfo, error) { id, p := a.config().ResolveProvider(providerID) - - client, err := llm.New(llm.Options{ - Kind: p.Kind, BaseURL: p.BaseURL, APIKey: p.APIKey, - Headers: p.Headers, ProviderID: id, Timeout: 60 * time.Second, APIVersion: p.APIVersion, Region: p.Region, - }) - if err != nil { - if len(p.Models) > 0 { - return curatedModelsWithReasoning(id, p, nil, p.Kind), nil - } - return nil, err + models, err := a.modelsForProvider(ctx, id, p) + if err != nil && len(p.Models) > 0 { + return models, nil } - fetchCtx, cancel := context.WithTimeout(ctx, 45*time.Second) - defer cancel() + return models, err +} - live, ferr := client.Models(fetchCtx) +func (a *Agent) modelsForProvider(ctx context.Context, id string, p config.Provider) ([]llm.ModelInfo, error) { + live, ferr := a.cachedProviderCatalog(ctx, id, p, func(fetchCtx context.Context) ([]llm.ModelInfo, error) { + client, err := llm.New(llm.Options{ + Kind: p.Kind, BaseURL: p.BaseURL, APIKey: p.APIKey, + Headers: p.Headers, ProviderID: id, Timeout: 60 * time.Second, APIVersion: p.APIVersion, Region: p.Region, + }) + if err != nil { + return nil, err + } + fetchCtx, cancel := context.WithTimeout(fetchCtx, 45*time.Second) + defer cancel() + return client.Models(fetchCtx) + }) if len(p.Models) > 0 { - return curatedModelsWithReasoning(id, p, live, client.Kind()), nil + return curatedModelsWithReasoning(id, p, live, p.Kind), ferr } if ferr != nil && len(live) == 0 { return nil, ferr } for i := range live { + live[i].Provider = id if live[i].ReasoningCapability != nil { continue } target := reasoningTarget{providerID: id, model: live[i].ID, provider: p} - target.provider.Kind = client.Kind() live[i] = live[i].WithReasoningCapability(staticReasoningCapability(target)) } return live, nil diff --git a/internal/agent/model_cache.go b/internal/agent/model_cache.go new file mode 100644 index 0000000..9fbd5ce --- /dev/null +++ b/internal/agent/model_cache.go @@ -0,0 +1,209 @@ +package agent + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "hash" + "net/url" + "sort" + "strings" + "time" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/llm" +) + +type providerCatalogScope struct { + providerID string + kind string + baseURLFingerprint [sha256.Size]byte + credentialFingerprint [sha256.Size]byte + apiVersion string + region string +} + +type providerCatalogEntry struct { + done chan struct{} + ready bool + hasSuccess bool + expiresAt time.Time + models []llm.ModelInfo + err error +} + +const providerCatalogTTL = 5 * time.Minute + +func (a *Agent) cachedProviderCatalog( + ctx context.Context, + providerID string, + provider config.Provider, + fetch func(context.Context) ([]llm.ModelInfo, error), +) ([]llm.ModelInfo, error) { + scope := providerCatalogScopeFor(providerID, provider) + for { + a.catalogMu.Lock() + if a.catalogCache == nil { + a.catalogCache = make(map[providerCatalogScope]*providerCatalogEntry) + } + if entry, ok := a.catalogCache[scope]; ok { + if entry.ready { + if a.providerCatalogTime().Before(entry.expiresAt) { + models, err := cloneModelInfo(entry.models), entry.err + a.catalogMu.Unlock() + return models, err + } + entry.ready = false + entry.done = make(chan struct{}) + a.catalogMu.Unlock() + return a.refreshProviderCatalog(ctx, entry, fetch) + } + done := entry.done + a.catalogMu.Unlock() + select { + case <-done: + continue + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + entry := &providerCatalogEntry{done: make(chan struct{})} + a.catalogCache[scope] = entry + a.catalogMu.Unlock() + return a.refreshProviderCatalog(ctx, entry, fetch) + } +} + +func (a *Agent) refreshProviderCatalog( + ctx context.Context, + entry *providerCatalogEntry, + fetch func(context.Context) ([]llm.ModelInfo, error), +) ([]llm.ModelInfo, error) { + models, err := fetch(ctx) + + a.catalogMu.Lock() + if err == nil { + entry.models = cloneModelInfo(models) + entry.err = nil + entry.hasSuccess = true + } else if entry.hasSuccess { + models = cloneModelInfo(entry.models) + err = nil + entry.err = nil + } else if len(models) > 0 { + entry.models = cloneModelInfo(models) + entry.err = nil + entry.hasSuccess = true + err = nil + } else { + entry.models = nil + entry.err = err + } + entry.expiresAt = a.providerCatalogTime().Add(providerCatalogTTL) + entry.ready = true + close(entry.done) + a.catalogMu.Unlock() + return cloneModelInfo(models), err +} + +func providerCatalogScopeFor(providerID string, provider config.Provider) providerCatalogScope { + kind := normalizedProviderKind(provider.Kind) + baseURL := normalizedProviderBaseURL(kind, provider.BaseURL) + return providerCatalogScope{ + providerID: strings.ToLower(strings.TrimSpace(providerID)), + kind: kind, + baseURLFingerprint: sha256.Sum256([]byte(baseURL)), + credentialFingerprint: providerCredentialFingerprint(provider), + apiVersion: strings.TrimSpace(provider.APIVersion), + region: strings.ToLower(strings.TrimSpace(provider.Region)), + } +} + +func normalizedProviderKind(kind string) string { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "google": + return "gemini" + case "claude": + return "anthropic" + case "responses", "openai-responses": + return "codex" + default: + return strings.ToLower(strings.TrimSpace(kind)) + } +} + +func normalizedProviderBaseURL(kind, baseURL string) string { + baseURL = strings.TrimSpace(baseURL) + if parsed, err := url.Parse(baseURL); err == nil && parsed.Scheme != "" && parsed.Host != "" { + parsed.Scheme = strings.ToLower(parsed.Scheme) + parsed.Host = strings.ToLower(parsed.Host) + parsed.Path = strings.TrimRight(parsed.Path, "/") + baseURL = parsed.String() + } else { + baseURL = strings.TrimRight(baseURL, "/") + } + if baseURL != "" { + if kind == "gemini" { + lower := strings.ToLower(baseURL) + if (strings.HasSuffix(lower, "/antigravity") || strings.Contains(lower, "/antigravity/")) && + !strings.Contains(lower, "/v1beta") { + return baseURL + "/v1beta" + } + } + return baseURL + } + switch kind { + case "openai", "codex": + return "https://api.openai.com/v1" + case "anthropic": + return "https://api.anthropic.com/v1" + case "gemini": + return "https://generativelanguage.googleapis.com/v1beta" + default: + return "" + } +} + +func providerCredentialFingerprint(provider config.Provider) [sha256.Size]byte { + h := sha256.New() + writeFingerprintValue(h, provider.APIKey) + + keys := make([]string, 0, len(provider.Headers)) + for key := range provider.Headers { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + left, right := strings.ToLower(keys[i]), strings.ToLower(keys[j]) + if left == right { + return keys[i] < keys[j] + } + return left < right + }) + for _, key := range keys { + writeFingerprintValue(h, strings.ToLower(strings.TrimSpace(key))) + writeFingerprintValue(h, provider.Headers[key]) + } + + var fingerprint [sha256.Size]byte + copy(fingerprint[:], h.Sum(nil)) + return fingerprint +} + +func writeFingerprintValue(h hash.Hash, value string) { + var size [8]byte + binary.BigEndian.PutUint64(size[:], uint64(len(value))) + _, _ = h.Write(size[:]) + _, _ = h.Write([]byte(value)) +} + +func cloneModelInfo(models []llm.ModelInfo) []llm.ModelInfo { + return append([]llm.ModelInfo(nil), models...) +} + +func (a *Agent) providerCatalogTime() time.Time { + if a.catalogNow != nil { + return a.catalogNow() + } + return time.Now() +} diff --git a/internal/agent/model_cache_test.go b/internal/agent/model_cache_test.go new file mode 100644 index 0000000..df20409 --- /dev/null +++ b/internal/agent/model_cache_test.go @@ -0,0 +1,468 @@ +package agent + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/llm" +) + +func TestReasoningCapabilityAndModelsShareProviderCatalogueFetch(t *testing.T) { + var fetches atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + fetches.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "data": [ + {"id": "model-a", "reasoning": {"supported_efforts": ["Exact"], "default_effort": "Exact"}} + ] + }`)) + })) + defer srv.Close() + + a := agentWithConfig(reasoningTestConfig(srv.URL, nil)) + if _, err := a.ReasoningCapability(context.Background(), "router/model-a"); err != nil { + t.Fatal(err) + } + if _, err := a.Models(context.Background(), "router"); err != nil { + t.Fatal(err) + } + if _, err := a.ReasoningCapability(context.Background(), "router/model-a"); err != nil { + t.Fatal(err) + } + if got := fetches.Load(); got != 1 { + t.Fatalf("provider catalogue fetches = %d, want one shared fetch", got) + } +} + +func TestModelsCachesCuratedProviderWithoutBroadeningWhitelist(t *testing.T) { + var fetches atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + fetches.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "data": [ + {"id": "listed", "reasoning": {"supported_efforts": ["LOW"], "default_effort": "LOW"}}, + {"id": "unlisted", "reasoning": {"supported_efforts": ["HIGH"], "default_effort": "HIGH"}} + ] + }`)) + })) + defer srv.Close() + + cfg := reasoningTestConfig(srv.URL, []string{"listed"}) + a := agentWithConfig(cfg) + for i := 0; i < 2; i++ { + models, err := a.Models(context.Background(), "router") + if err != nil { + t.Fatal(err) + } + if len(models) != 1 || models[0].ID != "listed" { + t.Fatalf("models = %#v, want only listed", models) + } + } + if got := fetches.Load(); got != 1 { + t.Fatalf("curated provider catalogue fetches = %d, want one", got) + } +} + +func TestModelsConcurrentMissesUseSingleProviderFetch(t *testing.T) { + var fetches atomic.Int32 + arrived := make(chan struct{}, 16) + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + fetches.Add(1) + arrived <- struct{}{} + <-release + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"model-a"}]}`)) + })) + defer srv.Close() + + a := agentWithConfig(reasoningTestConfig(srv.URL, nil)) + const callers = 16 + start := make(chan struct{}) + errs := make(chan error, callers) + var wg sync.WaitGroup + for i := 0; i < callers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, err := a.Models(context.Background(), "router") + errs <- err + }() + } + close(start) + select { + case <-arrived: + case <-time.After(2 * time.Second): + t.Fatal("provider catalogue fetch did not start") + } + time.Sleep(50 * time.Millisecond) + close(release) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + if got := fetches.Load(); got != 1 { + t.Fatalf("concurrent provider catalogue fetches = %d, want one", got) + } +} + +func TestProviderCatalogueCacheDoesNotShareAcrossCredentials(t *testing.T) { + var fetches atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + fetches.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"model-a"}]}`)) + })) + defer srv.Close() + + cfg := reasoningTestConfig(srv.URL, nil) + provider := cfg.Providers["router"] + provider.APIKey = "credential-one" + cfg.Providers["router"] = provider + a := agentWithConfig(cfg) + if _, err := a.Models(context.Background(), "router"); err != nil { + t.Fatal(err) + } + + changed := *cfg + changed.Providers = make(map[string]config.Provider, len(cfg.Providers)) + for id, configured := range cfg.Providers { + changed.Providers[id] = configured + } + provider = changed.Providers["router"] + provider.APIKey = "credential-two" + changed.Providers["router"] = provider + a.SetConfig(&changed) + if _, err := a.Models(context.Background(), "router"); err != nil { + t.Fatal(err) + } + + if got := fetches.Load(); got != 2 { + t.Fatalf("provider catalogue fetches after credential change = %d, want two", got) + } +} + +func TestProviderCatalogueCacheExpiresAfterFiveMinutes(t *testing.T) { + var fetches atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + fetches.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"model-a"}]}`)) + })) + defer srv.Close() + + now := time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC) + a := agentWithConfig(reasoningTestConfig(srv.URL, nil)) + a.catalogNow = func() time.Time { return now } + + if _, err := a.Models(context.Background(), "router"); err != nil { + t.Fatal(err) + } + now = now.Add(4*time.Minute + 59*time.Second) + if _, err := a.Models(context.Background(), "router"); err != nil { + t.Fatal(err) + } + if got := fetches.Load(); got != 1 { + t.Fatalf("provider catalogue fetches before TTL = %d, want one", got) + } + + now = now.Add(2 * time.Second) + if _, err := a.Models(context.Background(), "router"); err != nil { + t.Fatal(err) + } + if got := fetches.Load(); got != 2 { + t.Fatalf("provider catalogue fetches after TTL = %d, want two", got) + } +} + +func TestProviderCatalogueCacheScopesNormalizedBaseURLAndProviderIdentity(t *testing.T) { + var firstFetches, secondFetches atomic.Int32 + newServer := func(fetches *atomic.Int32) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + fetches.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"model-a"}]}`)) + })) + } + first := newServer(&firstFetches) + defer first.Close() + second := newServer(&secondFetches) + defer second.Close() + + cfg := reasoningTestConfig(first.URL, nil) + a := agentWithConfig(cfg) + if _, err := a.Models(context.Background(), "router"); err != nil { + t.Fatal(err) + } + + withTrailingSlash := *cfg + withTrailingSlash.Providers = map[string]config.Provider{} + provider := cfg.Providers["router"] + provider.BaseURL = first.URL + "/" + withTrailingSlash.Providers["router"] = provider + a.SetConfig(&withTrailingSlash) + if _, err := a.Models(context.Background(), "router"); err != nil { + t.Fatal(err) + } + if got := firstFetches.Load(); got != 1 { + t.Fatalf("normalized equivalent base URL fetched %d times, want one", got) + } + + changedBase := withTrailingSlash + changedBase.Providers = map[string]config.Provider{} + provider.BaseURL = second.URL + changedBase.Providers["router"] = provider + a.SetConfig(&changedBase) + if _, err := a.Models(context.Background(), "router"); err != nil { + t.Fatal(err) + } + + changedIdentity := changedBase + changedIdentity.Providers = map[string]config.Provider{ + "alternate": provider, + } + a.SetConfig(&changedIdentity) + if _, err := a.Models(context.Background(), "alternate"); err != nil { + t.Fatal(err) + } + if got := secondFetches.Load(); got != 2 { + t.Fatalf("changed base/identity fetches = %d, want one per distinct scope", got) + } +} + +func TestProviderCatalogueScopeDoesNotRetainRawCredentials(t *testing.T) { + const ( + apiSecret = "RAW-API-SECRET" + headerSecret = "RAW-HEADER-SECRET" + urlSecret = "RAW-URL-SECRET" + ) + scope := providerCatalogScopeFor("router", config.Provider{ + Kind: "openai-compatible", + BaseURL: "https://example.test/v1?token=" + urlSecret, + APIKey: apiSecret, + Headers: map[string]string{"Authorization": "Bearer " + headerSecret}, + }) + rendered := fmt.Sprintf("%#v", scope) + for _, secret := range []string{apiSecret, headerSecret, urlSecret} { + if strings.Contains(rendered, secret) { + t.Fatalf("cache scope retained raw credential %q", secret) + } + } +} + +func TestReasoningCapabilityUsesStaleCatalogueWhenRefreshFails(t *testing.T) { + var ( + fetches atomic.Int32 + outage atomic.Bool + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + fetches.Add(1) + w.Header().Set("Content-Type", "application/json") + if outage.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error":{"message":"temporary catalogue outage"}}`)) + return + } + _, _ = w.Write([]byte(`{ + "data": [ + {"id": "model-a", "reasoning": {"supported_efforts": ["MiXeD"], "default_effort": "MiXeD"}} + ] + }`)) + })) + defer srv.Close() + + now := time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC) + a := agentWithConfig(reasoningTestConfig(srv.URL, nil)) + a.catalogNow = func() time.Time { return now } + + if err := a.ValidateReasoningEffort(context.Background(), "router/model-a", "MiXeD"); err != nil { + t.Fatal(err) + } + now = now.Add(5*time.Minute + time.Second) + outage.Store(true) + if err := a.ValidateReasoningEffort(context.Background(), "router/model-a", "MiXeD"); err != nil { + t.Fatalf("stale live value rejected after refresh outage: %v", err) + } + if err := a.ValidateReasoningEffort(context.Background(), "router/model-a", "MiXeD"); err != nil { + t.Fatalf("cached stale live value rejected: %v", err) + } + if got := fetches.Load(); got != 2 { + t.Fatalf("provider catalogue fetches = %d, want initial load plus one failed refresh", got) + } +} + +type metadataUnavailableMarker interface { + ReasoningMetadataUnavailable() bool +} + +func TestExplicitReasoningReturnsDistinctBoundedErrorOnFirstCatalogueOutage(t *testing.T) { + for _, curated := range []bool{false, true} { + t.Run(map[bool]string{false: "live", true: "curated"}[curated], func(t *testing.T) { + var fetches atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + fetches.Add(1) + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error":{"message":"SECRET-UPSTREAM-DIAGNOSTIC"}}`)) + })) + defer srv.Close() + + var models []string + if curated { + models = []string{"model-a"} + } + cfg := reasoningTestConfig(srv.URL, models) + cfg.Agent.ReasoningEffort = "LEGACY-STORED" + a := agentWithConfig(cfg) + + const submitted = "SECRET-SUBMITTED-EFFORT" + for i := 0; i < 2; i++ { + err := a.ValidateReasoningEffort(context.Background(), "router/model-a", submitted) + if err == nil { + t.Fatal("expected metadata-unavailable error") + } + if llm.IsUnsupportedReasoningEffort(err) { + t.Fatalf("first catalogue outage misreported as unsupported: %v", err) + } + var unavailable metadataUnavailableMarker + if !errors.As(err, &unavailable) || !unavailable.ReasoningMetadataUnavailable() { + t.Fatalf("error = %T %v, want distinct metadata-unavailable error", err, err) + } + if len(err.Error()) > 200 { + t.Fatalf("metadata error is unbounded (%d bytes)", len(err.Error())) + } + if strings.Contains(err.Error(), submitted) || strings.Contains(err.Error(), "SECRET-UPSTREAM-DIAGNOSTIC") { + t.Fatalf("metadata error exposes submitted or upstream value: %v", err) + } + } + if got := fetches.Load(); got != 1 { + t.Fatalf("first-outage provider catalogue fetches = %d, want one cached failure", got) + } + + got, err := a.resolveReasoning(context.Background(), reasoningInput{ModelRef: "router/model-a"}) + if err != nil { + t.Fatalf("stored legacy value returned metadata error: %v", err) + } + if got.Value != "" || got.DiscardedLegacy != "LEGACY-STORED" { + t.Fatalf("stored resolution = %+v, want Auto with legacy notice", got) + } + }) + } +} + +func TestRunFirstCatalogueOutageRejectsExplicitButAllowsStoredAutoFallback(t *testing.T) { + var ( + catalogFetches atomic.Int32 + chatCalls atomic.Int32 + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"): + catalogFetches.Add(1) + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error":{"message":"SECRET-UPSTREAM-DIAGNOSTIC"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"): + chatCalls.Add(1) + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + cfg := reasoningTestConfig(srv.URL, nil) + cfg.Agent.ReasoningEffort = "LEGACY-STORED" + cfg.Streaming.Enabled = false + a := newReasoningRunAgent(t, cfg) + + const submitted = "SECRET-SUBMITTED-EFFORT" + _, err := a.Run(context.Background(), Request{ + Message: "explicit", + Quiet: true, + MaxTurns: 1, + ReasoningEffort: submitted, + }, nil) + if err == nil || !IsReasoningMetadataUnavailable(err) { + t.Fatalf("explicit run error = %T %v, want metadata unavailable", err, err) + } + if strings.Contains(err.Error(), submitted) || strings.Contains(err.Error(), "SECRET-UPSTREAM-DIAGNOSTIC") { + t.Fatalf("explicit run error exposes submitted or upstream value: %v", err) + } + if got := chatCalls.Load(); got != 0 { + t.Fatalf("chat calls after explicit metadata outage = %d, want zero", got) + } + + var discardedNotices int + result, err := a.Run(context.Background(), Request{ + Message: "stored", + Quiet: true, + MaxTurns: 1, + }, func(event Event) error { + if event.Type == EventNotice && strings.Contains(event.Message, "LEGACY-STORED") { + discardedNotices++ + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if result.Reply != "ok" { + t.Fatalf("stored fallback reply = %q", result.Reply) + } + if discardedNotices != 1 { + t.Fatalf("stored fallback notices = %d, want one", discardedNotices) + } + if got := chatCalls.Load(); got != 1 { + t.Fatalf("chat calls after stored metadata outage = %d, want one Auto request", got) + } + if got := catalogFetches.Load(); got != 1 { + t.Fatalf("catalogue fetches across explicit and stored runs = %d, want one cached failure", got) + } +} diff --git a/internal/agent/reasoning.go b/internal/agent/reasoning.go index 3c94c61..f43730e 100644 --- a/internal/agent/reasoning.go +++ b/internal/agent/reasoning.go @@ -2,6 +2,7 @@ package agent import ( "context" + "errors" "strings" "github.com/enowdev/antares/internal/config" @@ -22,6 +23,23 @@ type reasoningResolution struct { DiscardedLegacy string } +// ReasoningMetadataUnavailableError distinguishes an unavailable provider +// catalogue from a model that is known not to support a submitted value. Its +// message is deliberately constant and bounded: upstream diagnostics and the +// submitted value are never exposed. +type ReasoningMetadataUnavailableError struct{} + +func (*ReasoningMetadataUnavailableError) Error() string { + return "reasoning metadata is temporarily unavailable; use Auto or retry" +} + +func (*ReasoningMetadataUnavailableError) ReasoningMetadataUnavailable() bool { return true } + +func IsReasoningMetadataUnavailable(err error) bool { + var unavailable *ReasoningMetadataUnavailableError + return errors.As(err, &unavailable) +} + type reasoningTarget struct { providerID string model string @@ -30,18 +48,19 @@ type reasoningTarget struct { // ReasoningCapability returns the best model-specific reasoning metadata the // configured provider can supply. Documented direct-provider metadata avoids a -// network dependency; dynamic providers are queried through Agent.Models. +// network dependency; dynamic providers use the Agent-owned cached catalogue. func (a *Agent) ReasoningCapability(ctx context.Context, modelRef string) (*llm.ReasoningCapability, error) { target := a.reasoningTarget(modelRef) if capability := staticReasoningCapability(target); capability != nil { return capability, nil } - models, err := a.Models(ctx, target.providerID) + models, err := a.modelsForProvider(ctx, target.providerID, target.provider) if err != nil { - // Model catalogues are optional. Unknown metadata means Auto-only; it - // must not make an otherwise valid chat depend on a /models endpoint. - return nil, nil + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, &ReasoningMetadataUnavailableError{} } for _, model := range models { if model.ID == target.model && model.ReasoningCapability != nil { @@ -54,6 +73,9 @@ func (a *Agent) ReasoningCapability(ctx context.Context, modelRef string) (*llm. // ValidateReasoningEffort validates an explicit value without including the // submitted value in any error. func (a *Agent) ValidateReasoningEffort(ctx context.Context, modelRef, effort string) error { + if effort == "" { + return nil + } capability, err := a.ReasoningCapability(ctx, modelRef) if err != nil { return err @@ -65,8 +87,28 @@ func (a *Agent) ValidateReasoningEffort(ctx context.Context, modelRef, effort st // values. An invalid explicit override is an error; invalid stored values are // skipped in role, agent, model order so old configuration degrades to Auto. func (a *Agent) resolveReasoning(ctx context.Context, in reasoningInput) (reasoningResolution, error) { + agentValue := in.Agent + if agentValue == "" { + agentValue = a.config().Agent.ReasoningEffort + } + modelValue := in.Model + if modelValue == "" { + modelValue = a.config().Model.ReasoningEffort + } + storedValues := []string{in.Role, agentValue, modelValue} + capability, err := a.ReasoningCapability(ctx, in.ModelRef) if err != nil { + if IsReasoningMetadataUnavailable(err) && in.Explicit == "" { + resolution := reasoningResolution{} + for _, stored := range storedValues { + if stored != "" { + resolution.DiscardedLegacy = stored + break + } + } + return resolution, nil + } return reasoningResolution{}, err } resolution := reasoningResolution{Capability: capability} @@ -80,15 +122,7 @@ func (a *Agent) resolveReasoning(ctx context.Context, in reasoningInput) (reason return resolution, nil } - agentValue := in.Agent - if agentValue == "" { - agentValue = a.config().Agent.ReasoningEffort - } - modelValue := in.Model - if modelValue == "" { - modelValue = a.config().Model.ReasoningEffort - } - for _, stored := range []string{in.Role, agentValue, modelValue} { + for _, stored := range storedValues { if stored == "" { continue } diff --git a/internal/agent/reasoning_test.go b/internal/agent/reasoning_test.go index cd1ee25..30b1272 100644 --- a/internal/agent/reasoning_test.go +++ b/internal/agent/reasoning_test.go @@ -291,11 +291,15 @@ func TestRunResolvesStoredRoleReasoningOnceBeforeTurnLoop(t *testing.T) { } func TestRunCarriesMatchingReasoningCapabilityThroughFallbackEntries(t *testing.T) { - var primaryChats atomic.Int32 + var ( + primaryModels atomic.Int32 + primaryChats atomic.Int32 + ) primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"): + primaryModels.Add(1) _, _ = w.Write([]byte(`{ "data": [ {"id": "primary-model", "reasoning": {"supported_efforts": ["HIGH"], "default_effort": "HIGH"}} @@ -314,11 +318,13 @@ func TestRunCarriesMatchingReasoningCapabilityThroughFallbackEntries(t *testing. var ( mu sync.Mutex fallbackEffort any + fallbackModels atomic.Int32 ) fallback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"): + fallbackModels.Add(1) _, _ = w.Write([]byte(`{ "data": [ {"id": "fallback-model", "reasoning": {"supported_efforts": ["HIGH"], "default_effort": "HIGH"}} @@ -375,6 +381,12 @@ func TestRunCarriesMatchingReasoningCapabilityThroughFallbackEntries(t *testing. if got := primaryChats.Load(); got != 1 { t.Fatalf("primary chat calls = %d, want one", got) } + if got := primaryModels.Load(); got != 1 { + t.Fatalf("primary catalogue fetches = %d, want one shared by fallback setup and run resolution", got) + } + if got := fallbackModels.Load(); got != 1 { + t.Fatalf("fallback catalogue fetches = %d, want one", got) + } mu.Lock() defer mu.Unlock() if fallbackEffort != "HIGH" { From 516a12cf4614b16bb0394b3fcc5aa67f3319c24c Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Thu, 13 Aug 2026 02:21:57 +0700 Subject: [PATCH 08/41] Fix provider cache cancellation and adapter families Keep catalogue fetches independent of individual waiters and retain resolved adapter identity without widening explicit Codex capabilities. Co-authored-by: Cursor --- internal/agent/client.go | 26 +++-- internal/agent/model_cache.go | 65 ++++++++---- internal/agent/model_cache_test.go | 88 ++++++++++++++++ internal/agent/reasoning_test.go | 157 +++++++++++++++++++++++++++++ 4 files changed, 310 insertions(+), 26 deletions(-) diff --git a/internal/agent/client.go b/internal/agent/client.go index 251fd74..b1de216 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -177,20 +177,20 @@ func (a *Agent) Models(ctx context.Context, providerID string) ([]llm.ModelInfo, } func (a *Agent) modelsForProvider(ctx context.Context, id string, p config.Provider) ([]llm.ModelInfo, error) { - live, ferr := a.cachedProviderCatalog(ctx, id, p, func(fetchCtx context.Context) ([]llm.ModelInfo, error) { + live, adapterKind, ferr := a.cachedProviderCatalog(ctx, id, p, func(fetchCtx context.Context) ([]llm.ModelInfo, string, error) { client, err := llm.New(llm.Options{ Kind: p.Kind, BaseURL: p.BaseURL, APIKey: p.APIKey, Headers: p.Headers, ProviderID: id, Timeout: 60 * time.Second, APIVersion: p.APIVersion, Region: p.Region, }) if err != nil { - return nil, err + return nil, "", err } - fetchCtx, cancel := context.WithTimeout(fetchCtx, 45*time.Second) - defer cancel() - return client.Models(fetchCtx) + models, err := client.Models(fetchCtx) + return models, client.Kind(), err }) + reasoningKind := reasoningFamilyForAdapter(p.Kind, adapterKind) if len(p.Models) > 0 { - return curatedModelsWithReasoning(id, p, live, p.Kind), ferr + return curatedModelsWithReasoning(id, p, live, reasoningKind), ferr } if ferr != nil && len(live) == 0 { return nil, ferr @@ -201,11 +201,25 @@ func (a *Agent) modelsForProvider(ctx context.Context, id string, p config.Provi continue } target := reasoningTarget{providerID: id, model: live[i].ID, provider: p} + target.provider.Kind = reasoningKind live[i] = live[i].WithReasoningCapability(staticReasoningCapability(target)) } return live, nil } +func reasoningFamilyForAdapter(configuredKind, adapterKind string) string { + // Codex/Responses intentionally owns a narrower static table even though + // codexClient.Kind reports "openai" for its shared transport family. + switch strings.ToLower(strings.TrimSpace(configuredKind)) { + case "codex", "responses", "openai-responses": + return "codex" + } + if adapterKind == "" { + return normalizedProviderKind(configuredKind) + } + return normalizedProviderKind(adapterKind) +} + func curatedModelsWithReasoning(id string, p config.Provider, live []llm.ModelInfo, kind string) []llm.ModelInfo { liveByID := make(map[string]llm.ModelInfo, len(live)) for _, model := range live { diff --git a/internal/agent/model_cache.go b/internal/agent/model_cache.go index 9fbd5ce..f25c8c2 100644 --- a/internal/agent/model_cache.go +++ b/internal/agent/model_cache.go @@ -24,24 +24,33 @@ type providerCatalogScope struct { } type providerCatalogEntry struct { - done chan struct{} - ready bool - hasSuccess bool - expiresAt time.Time - models []llm.ModelInfo - err error + done chan struct{} + ready bool + hasSuccess bool + expiresAt time.Time + models []llm.ModelInfo + adapterKind string + err error } -const providerCatalogTTL = 5 * time.Minute +const ( + providerCatalogTTL = 5 * time.Minute + providerCatalogFetchTimeout = 45 * time.Second +) func (a *Agent) cachedProviderCatalog( ctx context.Context, providerID string, provider config.Provider, - fetch func(context.Context) ([]llm.ModelInfo, error), -) ([]llm.ModelInfo, error) { + fetch func(context.Context) ([]llm.ModelInfo, string, error), +) ([]llm.ModelInfo, string, error) { scope := providerCatalogScopeFor(providerID, provider) for { + if err := ctx.Err(); err != nil { + return nil, "", err + } + + var startFetch bool a.catalogMu.Lock() if a.catalogCache == nil { a.catalogCache = make(map[providerCatalogScope]*providerCatalogEntry) @@ -49,62 +58,78 @@ func (a *Agent) cachedProviderCatalog( if entry, ok := a.catalogCache[scope]; ok { if entry.ready { if a.providerCatalogTime().Before(entry.expiresAt) { - models, err := cloneModelInfo(entry.models), entry.err + models, adapterKind, err := cloneModelInfo(entry.models), entry.adapterKind, entry.err a.catalogMu.Unlock() - return models, err + return models, adapterKind, err } entry.ready = false entry.done = make(chan struct{}) - a.catalogMu.Unlock() - return a.refreshProviderCatalog(ctx, entry, fetch) + startFetch = true } done := entry.done a.catalogMu.Unlock() + if startFetch { + go a.refreshProviderCatalog(entry, fetch) + } select { case <-done: continue case <-ctx.Done(): - return nil, ctx.Err() + return nil, "", ctx.Err() } } entry := &providerCatalogEntry{done: make(chan struct{})} a.catalogCache[scope] = entry + done := entry.done a.catalogMu.Unlock() - return a.refreshProviderCatalog(ctx, entry, fetch) + go a.refreshProviderCatalog(entry, fetch) + select { + case <-done: + continue + case <-ctx.Done(): + return nil, "", ctx.Err() + } } } func (a *Agent) refreshProviderCatalog( - ctx context.Context, entry *providerCatalogEntry, - fetch func(context.Context) ([]llm.ModelInfo, error), -) ([]llm.ModelInfo, error) { - models, err := fetch(ctx) + fetch func(context.Context) ([]llm.ModelInfo, string, error), +) { + // The shared fetch belongs to the cache entry, not to whichever caller won + // the miss race. Individual waiters may cancel without aborting or poisoning + // the provider scope; this independent context bounds orphaned work. + ctx, cancel := context.WithTimeout(context.Background(), providerCatalogFetchTimeout) + defer cancel() + models, adapterKind, err := fetch(ctx) a.catalogMu.Lock() if err == nil { entry.models = cloneModelInfo(models) + entry.adapterKind = adapterKind entry.err = nil entry.hasSuccess = true } else if entry.hasSuccess { models = cloneModelInfo(entry.models) + adapterKind = entry.adapterKind err = nil entry.err = nil } else if len(models) > 0 { entry.models = cloneModelInfo(models) + entry.adapterKind = adapterKind entry.err = nil entry.hasSuccess = true err = nil } else { entry.models = nil + entry.adapterKind = adapterKind entry.err = err } entry.expiresAt = a.providerCatalogTime().Add(providerCatalogTTL) entry.ready = true close(entry.done) a.catalogMu.Unlock() - return cloneModelInfo(models), err } func providerCatalogScopeFor(providerID string, provider config.Provider) providerCatalogScope { diff --git a/internal/agent/model_cache_test.go b/internal/agent/model_cache_test.go index df20409..f470c3f 100644 --- a/internal/agent/model_cache_test.go +++ b/internal/agent/model_cache_test.go @@ -133,6 +133,94 @@ func TestModelsConcurrentMissesUseSingleProviderFetch(t *testing.T) { } } +func TestProviderCatalogueLeaderCancellationDoesNotPoisonSharedFetch(t *testing.T) { + var ( + fetches atomic.Int32 + startedOnce sync.Once + releaseOnce sync.Once + ) + started := make(chan struct{}) + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + fetches.Add(1) + startedOnce.Do(func() { close(started) }) + select { + case <-release: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"model-a"}]}`)) + case <-r.Context().Done(): + } + })) + defer srv.Close() + defer releaseOnce.Do(func() { close(release) }) + + a := agentWithConfig(reasoningTestConfig(srv.URL, nil)) + leaderCtx, cancelLeader := context.WithCancel(context.Background()) + leaderResult := make(chan error, 1) + go func() { + _, err := a.Models(leaderCtx, "router") + leaderResult <- err + }() + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("leader provider catalogue fetch did not start") + } + + waiterCalling := make(chan struct{}) + waiterResult := make(chan struct { + models []llm.ModelInfo + err error + }, 1) + go func() { + close(waiterCalling) + models, err := a.Models(context.Background(), "router") + waiterResult <- struct { + models []llm.ModelInfo + err error + }{models: models, err: err} + }() + <-waiterCalling + + cancelLeader() + select { + case err := <-leaderResult: + if !errors.Is(err, context.Canceled) { + t.Fatalf("leader error = %v, want context.Canceled", err) + } + case <-time.After(time.Second): + t.Fatal("canceled leader did not return promptly") + } + + releaseOnce.Do(func() { close(release) }) + select { + case got := <-waiterResult: + if got.err != nil { + t.Fatalf("healthy waiter inherited leader cancellation: %v", got.err) + } + if len(got.models) != 1 || got.models[0].ID != "model-a" { + t.Fatalf("healthy waiter models = %#v, want model-a", got.models) + } + case <-time.After(2 * time.Second): + t.Fatal("healthy waiter did not receive shared catalogue") + } + + models, err := a.Models(context.Background(), "router") + if err != nil { + t.Fatalf("healthy successor inherited leader cancellation: %v", err) + } + if len(models) != 1 || models[0].ID != "model-a" { + t.Fatalf("healthy successor models = %#v, want cached model-a", models) + } + if got := fetches.Load(); got != 1 { + t.Fatalf("provider catalogue fetches = %d, want one shared fetch", got) + } +} + func TestProviderCatalogueCacheDoesNotShareAcrossCredentials(t *testing.T) { var fetches atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/agent/reasoning_test.go b/internal/agent/reasoning_test.go index 30b1272..370624d 100644 --- a/internal/agent/reasoning_test.go +++ b/internal/agent/reasoning_test.go @@ -3,6 +3,7 @@ package agent import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" "strings" @@ -215,6 +216,162 @@ func TestModelsFallsBackToStaticCapabilityForCuratedModel(t *testing.T) { } } +type modelCatalogueRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f modelCatalogueRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func installStaticFamilyModelCatalogue(t *testing.T) { + t.Helper() + previous := http.DefaultTransport + http.DefaultTransport = modelCatalogueRoundTripFunc(func(req *http.Request) (*http.Response, error) { + status := http.StatusOK + body := `{"data":[{"id":"gpt-5"},{"id":"gpt-5.3-codex"}]}` + if req.Method != http.MethodGet || !strings.HasSuffix(req.URL.Path, "/models") { + status = http.StatusNotFound + body = `{"error":{"message":"not found"}}` + } + return &http.Response{ + StatusCode: status, + Status: http.StatusText(status), + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + }) + t.Cleanup(func() { + http.DefaultTransport = previous + }) +} + +func assertReasoningValues(t *testing.T, capability *llm.ReasoningCapability, want ...string) { + t.Helper() + if capability == nil { + t.Fatalf("capability = nil, want values %v", want) + } + if len(capability.Values) != len(want) { + t.Fatalf("values = %#v, want %v", capability.Values, want) + } + for i, value := range capability.Values { + if value.Value != want[i] { + t.Fatalf("values = %#v, want %v", capability.Values, want) + } + } +} + +func modelInfoByID(t *testing.T, models []llm.ModelInfo, id string) llm.ModelInfo { + t.Helper() + for _, model := range models { + if model.ID == id { + return model + } + } + t.Fatalf("models = %#v, want %q", models, id) + return llm.ModelInfo{} +} + +func TestModelsUsesResolvedOpenAIAdapterFamilyForDirectOpenAI(t *testing.T) { + installStaticFamilyModelCatalogue(t) + for _, configuredKind := range []string{"openai", "openai-compatible"} { + t.Run(configuredKind, func(t *testing.T) { + cfg := config.Default() + cfg.Model.Provider = "openai" + cfg.Model.Default = "gpt-5" + cfg.Providers = map[string]config.Provider{ + "openai": { + Kind: configuredKind, + BaseURL: "https://api.openai.com/v1", + Enabled: true, + }, + } + + a := agentWithConfig(cfg) + models, err := a.Models(context.Background(), "openai") + if err != nil { + t.Fatal(err) + } + model := modelInfoByID(t, models, "gpt-5") + assertReasoningValues(t, model.ReasoningCapability, "minimal", "low", "medium", "high") + capability, err := a.ReasoningCapability(context.Background(), "openai/gpt-5") + if err != nil { + t.Fatal(err) + } + assertReasoningValues(t, capability, "minimal", "low", "medium", "high") + }) + } +} + +func TestModelsKeepsExplicitCodexFamilyForResponsesAlias(t *testing.T) { + installStaticFamilyModelCatalogue(t) + for _, configuredKind := range []string{"codex", "responses", "openai-responses"} { + t.Run(configuredKind, func(t *testing.T) { + cfg := config.Default() + cfg.Model.Provider = "openai" + cfg.Model.Default = "gpt-5.3-codex" + cfg.Providers = map[string]config.Provider{ + "openai": { + Kind: configuredKind, + BaseURL: "https://api.openai.com/v1", + Enabled: true, + Models: []string{"gpt-5.3-codex", "gpt-5"}, + }, + } + + a := agentWithConfig(cfg) + models, err := a.Models(context.Background(), "openai") + if err != nil { + t.Fatal(err) + } + if len(models) != 2 { + t.Fatalf("models = %#v, want two curated models", models) + } + codex := modelInfoByID(t, models, "gpt-5.3-codex") + assertReasoningValues(t, codex.ReasoningCapability, "low", "medium", "high", "xhigh") + openAI := modelInfoByID(t, models, "gpt-5") + if openAI.ReasoningCapability != nil { + t.Fatalf("%s alias broadened gpt-5 to OpenAI capability: %#v", configuredKind, openAI.ReasoningCapability) + } + capability, err := a.ReasoningCapability(context.Background(), "openai/gpt-5.3-codex") + if err != nil { + t.Fatal(err) + } + assertReasoningValues(t, capability, "low", "medium", "high", "xhigh") + }) + } +} + +func TestModelsKeepsUnknownCompatibleEndpointAutoOnly(t *testing.T) { + installStaticFamilyModelCatalogue(t) + cfg := config.Default() + cfg.Model.Provider = "custom" + cfg.Model.Default = "gpt-5" + cfg.Providers = map[string]config.Provider{ + "custom": { + Kind: "custom", + BaseURL: "https://gateway.example.test/v1", + Enabled: true, + }, + } + + a := agentWithConfig(cfg) + models, err := a.Models(context.Background(), "custom") + if err != nil { + t.Fatal(err) + } + model := modelInfoByID(t, models, "gpt-5") + if model.ReasoningCapability != nil { + t.Fatalf("capability = %#v, want Auto-only", model.ReasoningCapability) + } + capability, err := a.ReasoningCapability(context.Background(), "custom/gpt-5") + if err != nil { + t.Fatal(err) + } + if capability != nil { + t.Fatalf("resolved capability = %#v, want Auto-only", capability) + } +} + func TestRunResolvesStoredRoleReasoningOnceBeforeTurnLoop(t *testing.T) { var ( mu sync.Mutex From e3e2869e415a57642de2160e94b6a23e778dd2bb Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Thu, 13 Aug 2026 02:35:34 +0700 Subject: [PATCH 09/41] Validate reasoning at configuration boundaries Co-authored-by: Cursor --- internal/config/load.go | 15 +- internal/config/schema.go | 148 +++++----- internal/config/schema_reasoning_test.go | 58 ++++ internal/server/handlers_chat.go | 6 + internal/server/handlers_config.go | 29 +- internal/server/handlers_providers.go | 16 +- internal/server/handlers_roles.go | 16 +- internal/server/reasoning.go | 60 ++++ internal/server/reasoning_test.go | 347 +++++++++++++++++++++++ 9 files changed, 614 insertions(+), 81 deletions(-) create mode 100644 internal/config/schema_reasoning_test.go create mode 100644 internal/server/reasoning.go create mode 100644 internal/server/reasoning_test.go diff --git a/internal/config/load.go b/internal/config/load.go index 955be60..d50d91e 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -107,11 +107,20 @@ func Raw() (string, error) { return string(b), err } -// SaveRaw validates then writes YAML text supplied by the dashboard editor. -func SaveRaw(text string) error { +// ParseRaw validates YAML text supplied by the dashboard editor without +// changing either the active config file or the in-memory config cache. +func ParseRaw(text string) (*Config, error) { cfg := Default() if err := yaml.Unmarshal([]byte(text), cfg); err != nil { - return fmt.Errorf("invalid YAML: %w", err) + return nil, fmt.Errorf("invalid YAML: %w", err) + } + return cfg, nil +} + +// SaveRaw validates then writes YAML text supplied by the dashboard editor. +func SaveRaw(text string) error { + if _, err := ParseRaw(text); err != nil { + return err } if err := os.MkdirAll(filepath.Dir(ConfigFile()), 0o700); err != nil { return err diff --git a/internal/config/schema.go b/internal/config/schema.go index 6a98964..7dc1b33 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -21,7 +21,11 @@ type Field struct { Default any `json:"default"` Secret bool `json:"secret"` Enum []string `json:"enum,omitempty"` - Help string `json:"help,omitempty"` + // OptionsSource names dynamic option metadata supplied outside the static + // schema. Reasoning values are model-specific, so the dashboard resolves + // them from the selected model's capability instead of a fixed enum. + OptionsSource string `json:"options_source,omitempty"` + Help string `json:"help,omitempty"` } // Tiers a field can belong to. @@ -48,43 +52,43 @@ var essential = map[string]bool{ // common holds the settings people actually revisit. Everything not listed // here or above is treated as advanced. var common = map[string]bool{ - "model.temperature": true, - "model.max_tokens": true, - "model.context_window": true, - "model.reasoning_effort": true, - "model.auxiliary": true, - "agent.max_turns": true, - "agent.personality": true, - "agent.system_prompt_extra": true, - "agent.timezone": true, - "tools.approval_mode": true, - "tools.web_search.provider": true, - "tools.web_search.api_key": true, - "terminal.backend": true, - "terminal.cwd": true, - "terminal.timeout": true, - "memory.memory_enabled": true, - "memory.user_profile_enabled": true, - "rag.embed_model": true, - "rag.embed_provider": true, - "rag.rerank_mode": true, - "rag.per_user": true, - "skills.enabled": true, - "skills.auto_create": true, - "cron.enabled": true, - "cron.timezone": true, - "gateway.enabled": true, - "gateway.telegram.enabled": true, - "gateway.discord.enabled": true, - "mcp.enabled": true, - "compression.enabled": true, - "streaming.enabled": true, - "delegation.enabled": true, - "display.show_reasoning": true, - "display.tool_progress": true, - "display.max_live_reasoning_chars": true, - "logging.level": true, - "server.host": true, + "model.temperature": true, + "model.max_tokens": true, + "model.context_window": true, + "model.reasoning_effort": true, + "model.auxiliary": true, + "agent.max_turns": true, + "agent.personality": true, + "agent.system_prompt_extra": true, + "agent.timezone": true, + "tools.approval_mode": true, + "tools.web_search.provider": true, + "tools.web_search.api_key": true, + "terminal.backend": true, + "terminal.cwd": true, + "terminal.timeout": true, + "memory.memory_enabled": true, + "memory.user_profile_enabled": true, + "rag.embed_model": true, + "rag.embed_provider": true, + "rag.rerank_mode": true, + "rag.per_user": true, + "skills.enabled": true, + "skills.auto_create": true, + "cron.enabled": true, + "cron.timezone": true, + "gateway.enabled": true, + "gateway.telegram.enabled": true, + "gateway.discord.enabled": true, + "mcp.enabled": true, + "compression.enabled": true, + "streaming.enabled": true, + "delegation.enabled": true, + "display.show_reasoning": true, + "display.tool_progress": true, + "display.max_live_reasoning_chars": true, + "logging.level": true, + "server.host": true, } func tierFor(path string) string { @@ -115,34 +119,37 @@ var enums = map[string][]string{ "session_reset.mode": {"never", "idle", "daily"}, "display.theme": {"system", "light", "dark"}, "logging.level": {"debug", "info", "warn", "error"}, - "agent.reasoning_effort": {"none", "low", "medium", "high"}, - "model.reasoning_effort": {"none", "low", "medium", "high"}, "tools.web_search.provider": {"browser", "brave", "tavily", "searxng", "none"}, } +var optionsSources = map[string]string{ + "agent.reasoning_effort": "reasoning_capability", + "model.reasoning_effort": "reasoning_capability", +} + var help = map[string]string{ - "model.default": "Model id as your provider spells it, e.g. anthropic/claude-sonnet-4.5.", - "model.provider": "Which entry under providers to call.", - "model.auxiliary": "Cheaper model used for summarising and other background work.", - "model.context_window": "Used to decide when to compact; set it to match your model.", - "database.driver": "sqlite for a single node, postgres when you share state.", - "database.dsn": "sqlite: a file path. postgres: postgres://user:pass@host:5432/db?sslmode=disable", - "server.auth_token": "Leave empty to keep the dashboard open — sensible behind a private network.", - "server.host": "0.0.0.0 exposes it on every interface; 127.0.0.1 keeps it local.", - "agent.workspace": "The only directory file tools may read or write.", - "agent.system_prompt_extra": "Appended to the system prompt on every turn.", - "tools.toolset": "Preset deciding which tools reach the model.", - "tools.approval_mode": "auto runs mutating tools directly; deny blocks them.", - "rag.rerank_mode": "How to reorder results: llm (an auxiliary model scores them), api (an external reranker), or off.", - "rag.embed_model": "The embedding model for indexing and search, e.g. text-embedding-3-small.", - "rag.per_user": "Keep a separate memory per chat user (Discord/Telegram), so the agent can recall topics and facts about each specific person. Stores cross-conversation data about individuals; off by default.", - "compression.threshold": "Fraction of the context window that triggers automatic compaction.", - "terminal.backend": "local runs on this machine; docker and ssh sandbox it elsewhere.", - "memory.memory_enabled": "Lets the agent store durable facts between sessions.", - "skills.auto_create": "Allows the agent to write new skills on its own.", - "osint.google_cookie": "Optional. A logged-in Google Cookie header enables osint_google to resolve an email to its public profile. ToS-sensitive; uses your own session. Leave empty to disable.", - "display.show_reasoning": "Stream and show model reasoning/thinking in the dashboard (and TUI). Off skips emitting reasoning events so long thinking traces never hit the UI.", - "display.tool_progress": "Show live tool progress lines while a tool runs.", + "model.default": "Model id as your provider spells it, e.g. anthropic/claude-sonnet-4.5.", + "model.provider": "Which entry under providers to call.", + "model.auxiliary": "Cheaper model used for summarising and other background work.", + "model.context_window": "Used to decide when to compact; set it to match your model.", + "database.driver": "sqlite for a single node, postgres when you share state.", + "database.dsn": "sqlite: a file path. postgres: postgres://user:pass@host:5432/db?sslmode=disable", + "server.auth_token": "Leave empty to keep the dashboard open — sensible behind a private network.", + "server.host": "0.0.0.0 exposes it on every interface; 127.0.0.1 keeps it local.", + "agent.workspace": "The only directory file tools may read or write.", + "agent.system_prompt_extra": "Appended to the system prompt on every turn.", + "tools.toolset": "Preset deciding which tools reach the model.", + "tools.approval_mode": "auto runs mutating tools directly; deny blocks them.", + "rag.rerank_mode": "How to reorder results: llm (an auxiliary model scores them), api (an external reranker), or off.", + "rag.embed_model": "The embedding model for indexing and search, e.g. text-embedding-3-small.", + "rag.per_user": "Keep a separate memory per chat user (Discord/Telegram), so the agent can recall topics and facts about each specific person. Stores cross-conversation data about individuals; off by default.", + "compression.threshold": "Fraction of the context window that triggers automatic compaction.", + "terminal.backend": "local runs on this machine; docker and ssh sandbox it elsewhere.", + "memory.memory_enabled": "Lets the agent store durable facts between sessions.", + "skills.auto_create": "Allows the agent to write new skills on its own.", + "osint.google_cookie": "Optional. A logged-in Google Cookie header enables osint_google to resolve an email to its public profile. ToS-sensitive; uses your own session. Leave empty to disable.", + "display.show_reasoning": "Stream and show model reasoning/thinking in the dashboard (and TUI). Off skips emitting reasoning events so long thinking traces never hit the UI.", + "display.tool_progress": "Show live tool progress lines while a tool runs.", "display.max_live_reasoning_chars": "Max characters of reasoning kept in the browser while a turn streams (trailing window). Prevents tab freezes on long thinking. Default 48000. 0 = unlimited. Full text is still saved server-side and restored after the turn.", } @@ -220,14 +227,15 @@ func walk(v reflect.Value, prefix, group string, out *[]Field) { } f := Field{ - Path: path, - Label: humanize(name), - Group: grp, - Tier: tierFor(path), - Default: fv.Interface(), - Secret: secretKey(path), - Enum: enums[path], - Help: help[path], + Path: path, + Label: humanize(name), + Group: grp, + Tier: tierFor(path), + Default: fv.Interface(), + Secret: secretKey(path), + Enum: enums[path], + OptionsSource: optionsSources[path], + Help: help[path], } switch fv.Kind() { case reflect.Bool: diff --git a/internal/config/schema_reasoning_test.go b/internal/config/schema_reasoning_test.go new file mode 100644 index 0000000..cf72857 --- /dev/null +++ b/internal/config/schema_reasoning_test.go @@ -0,0 +1,58 @@ +package config + +import ( + "os" + "testing" +) + +func TestSchemaMarksReasoningFieldsModelAware(t *testing.T) { + schema := Schema() + for _, path := range []string{"agent.reasoning_effort", "model.reasoning_effort"} { + field := fieldByPath(t, schema, path) + if len(field.Enum) != 0 || field.OptionsSource != "reasoning_capability" { + t.Fatalf("%s = %+v", path, field) + } + } +} + +func TestParseRawDoesNotWriteConfiguration(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + t.Setenv("ANTARES_CONFIG", "") + t.Setenv("ANTARES_PROFILE", "default") + if err := Save(Default()); err != nil { + t.Fatal(err) + } + before := mustReadConfigFile(t) + if _, err := ParseRaw("model:\n default: gpt-5\n"); err != nil { + t.Fatal(err) + } + if after := mustReadConfigFile(t); after != before { + t.Fatal("ParseRaw changed the config file") + } +} + +func fieldByPath(t *testing.T, fields []Field, path string) Field { + t.Helper() + for _, field := range fields { + if field.Path == path { + return field + } + } + t.Fatalf("field %q not found", path) + return Field{} +} + +func mustReadConfigFile(t *testing.T) string { + t.Helper() + path := ConfigFile() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Errorf("restore config: %v", err) + } + }) + return string(raw) +} diff --git a/internal/server/handlers_chat.go b/internal/server/handlers_chat.go index d8649ad..5cc4033 100644 --- a/internal/server/handlers_chat.go +++ b/internal/server/handlers_chat.go @@ -120,6 +120,12 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, errors.New("message is required")) return } + if err := s.validateExplicitReasoning( + r.Context(), s.config(), req.Model, req.ReasoningEffort, + ); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } // Persist the picked role against the session so a reload reflects it. The // session id is only known once the run assigns one, so a brand-new diff --git a/internal/server/handlers_config.go b/internal/server/handlers_config.go index 70935bf..d1dcda8 100644 --- a/internal/server/handlers_config.go +++ b/internal/server/handlers_config.go @@ -61,23 +61,32 @@ func (s *Server) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { } sort.Strings(paths) + next := *cfg + invalidateDashSessions := false for _, path := range paths { value := body.Updates[path] // A redacted secret coming back unchanged means "leave it alone". if str, ok := value.(string); ok && strings.Contains(str, "••••") { continue } - if err := cfg.SetPath(path, value); err != nil { + if err := next.SetPath(path, value); err != nil { writeError(w, http.StatusBadRequest, err) return } // Changing (or clearing) the dashboard password must not leave old // logins valid. if path == "server.dashboard_password_hash" { - s.invalidateDashSessions() + invalidateDashSessions = true } } - if err := config.Save(cfg); err != nil { + if err := s.validateChangedReasoning(r.Context(), cfg, &next); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if invalidateDashSessions { + s.invalidateDashSessions() + } + if err := config.Save(&next); err != nil { writeError(w, http.StatusInternalServerError, err) return } @@ -108,6 +117,20 @@ func (s *Server) handleSaveRawConfig(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err) return } + next, err := config.ParseRaw(body.YAML) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + current, err := config.Reload() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if err := s.validateChangedReasoning(r.Context(), current, next); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } if err := config.SaveRaw(body.YAML); err != nil { writeError(w, http.StatusBadRequest, err) return diff --git a/internal/server/handlers_providers.go b/internal/server/handlers_providers.go index a289680..600c21b 100644 --- a/internal/server/handlers_providers.go +++ b/internal/server/handlers_providers.go @@ -19,7 +19,12 @@ import ( // found:false — the caller then asks the user for the value. func (s *Server) handleProviderModelInfo(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - modelID := strings.TrimSpace(r.URL.Query().Get("id")) + modelID := strings.TrimSpace(r.URL.Query().Get("model")) + if modelID == "" { + // Keep accepting the earlier dashboard parameter while model is rolled + // out; model is the canonical API name. + modelID = strings.TrimSpace(r.URL.Query().Get("id")) + } if modelID == "" { writeError(w, http.StatusBadRequest, errors.New("a model id is required")) return @@ -40,9 +45,12 @@ func (s *Server) handleProviderModelInfo(w http.ResponseWriter, r *http.Request) for _, m := range models { if m.ID == modelID { writeJSON(w, http.StatusOK, map[string]any{ - "found": true, - "context_window": m.ContextWindow, - "name": m.Name, + "found": true, + "id": m.ID, + "context_window": m.ContextWindow, + "name": m.Name, + "reasoning": m.Reasoning, + "reasoning_capability": m.ReasoningCapability, }) return } diff --git a/internal/server/handlers_roles.go b/internal/server/handlers_roles.go index 466cfd0..1f9eb18 100644 --- a/internal/server/handlers_roles.go +++ b/internal/server/handlers_roles.go @@ -3,6 +3,7 @@ package server import ( "errors" "net/http" + "strings" "github.com/enowdev/antares/internal/roles" "github.com/enowdev/antares/internal/tools" @@ -82,9 +83,22 @@ func (s *Server) handleSaveRole(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err) return } + effort := strings.TrimSpace(b.Effort) + previousEffort := "" + if previous, ok := reg.Get(b.Name); ok { + previousEffort = previous.Effort + } + if effort != previousEffort { + if err := s.validateExplicitReasoning( + r.Context(), s.config(), strings.TrimSpace(b.Model), effort, + ); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + } saved, err := reg.Save(roles.Role{ Name: b.Name, Title: b.Title, Summary: b.Summary, Category: b.Category, - Toolset: b.Toolset, Model: b.Model, Effort: b.Effort, MaxTurns: b.MaxTurns, + Toolset: b.Toolset, Model: b.Model, Effort: effort, MaxTurns: b.MaxTurns, Tags: b.Tags, Danger: b.Danger, Subrole: b.Subrole, Parent: b.Parent, Prompt: b.Body, }) diff --git a/internal/server/reasoning.go b/internal/server/reasoning.go new file mode 100644 index 0000000..b56f967 --- /dev/null +++ b/internal/server/reasoning.go @@ -0,0 +1,60 @@ +package server + +import ( + "context" + + "github.com/enowdev/antares/internal/config" +) + +// validateExplicitReasoning rejects a newly submitted override before the +// caller opens a stream or mutates persistent state. Empty means Auto and is +// valid for every model. +func (s *Server) validateExplicitReasoning( + ctx context.Context, + cfg *config.Config, + modelRef string, + effort string, +) error { + if effort == "" { + return nil + } + if modelRef == "" { + modelRef = reasoningModelRef(cfg) + } + return s.agent.ValidateReasoningEffort(ctx, modelRef, effort) +} + +// validateChangedReasoning validates only values changed by a mutation. +// Persisted values from older releases remain loadable and are handled as +// legacy values by the agent at runtime. +func (s *Server) validateChangedReasoning( + ctx context.Context, + before *config.Config, + after *config.Config, +) error { + modelRef := reasoningModelRef(after) + if before.Agent.ReasoningEffort != after.Agent.ReasoningEffort { + if err := s.validateExplicitReasoning(ctx, after, modelRef, after.Agent.ReasoningEffort); err != nil { + return err + } + } + if before.Model.ReasoningEffort != after.Model.ReasoningEffort { + if err := s.validateExplicitReasoning(ctx, after, modelRef, after.Model.ReasoningEffort); err != nil { + return err + } + } + return nil +} + +func reasoningModelRef(cfg *config.Config) string { + if cfg == nil { + return "" + } + if cfg.Model.Provider == "" || cfg.Model.Default == "" { + return cfg.Model.Default + } + // Qualifying with the configured provider preserves aggregator model ids: + // openrouter + anthropic/claude becomes openrouter/anthropic/claude, which + // Agent resolves back to provider=openrouter and model=anthropic/claude. + return cfg.Model.Provider + "/" + cfg.Model.Default +} diff --git a/internal/server/reasoning_test.go b/internal/server/reasoning_test.go new file mode 100644 index 0000000..901f0b4 --- /dev/null +++ b/internal/server/reasoning_test.go @@ -0,0 +1,347 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/enowdev/antares/internal/agent" + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/llm" + "github.com/enowdev/antares/internal/roles" + "github.com/enowdev/antares/internal/store" + "github.com/enowdev/antares/internal/tools" +) + +func TestHandleModelListAllIncludesReasoningCapability(t *testing.T) { + catalog := newServerReasoningCatalog(t, nil) + s, _, _ := newReasoningBoundaryServer(t, func(cfg *config.Config) { + cfg.Model.Provider = "router" + cfg.Model.Default = "model-a" + cfg.Providers = map[string]config.Provider{ + "router": { + Kind: "openai-compatible", + BaseURL: catalog.URL, + Enabled: true, + }, + } + }) + + req := httptest.NewRequest(http.MethodGet, "/api/model/list-all", nil) + rec := httptest.NewRecorder() + s.handleModelListAll(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + + var body struct { + Models []llm.ModelInfo `json:"models"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Models) != 1 || body.Models[0].ID != "model-a" { + t.Fatalf("models = %#v", body.Models) + } + assertServerReasoningCapability(t, body.Models[0]) +} + +func TestHandleProviderModelInfoReadsModelQueryAndIncludesCapability(t *testing.T) { + catalog := newServerReasoningCatalog(t, nil) + s, _, _ := newReasoningBoundaryServer(t, func(cfg *config.Config) { + cfg.Providers = map[string]config.Provider{ + "router": { + Kind: "openai-compatible", + BaseURL: catalog.URL, + Enabled: true, + }, + } + }) + + req := httptest.NewRequest(http.MethodGet, "/api/providers/router/model-info?model=model-a", nil) + req.SetPathValue("id", "router") + rec := httptest.NewRecorder() + s.handleProviderModelInfo(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + + var body struct { + Found bool `json:"found"` + ID string `json:"id"` + Reasoning bool `json:"reasoning"` + ReasoningCapability *llm.ReasoningCapability `json:"reasoning_capability"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if !body.Found || body.ID != "model-a" { + t.Fatalf("response = %#v", body) + } + assertServerReasoningCapability(t, llm.ModelInfo{ + ID: body.ID, + Reasoning: body.Reasoning, + ReasoningCapability: body.ReasoningCapability, + }) +} + +func TestHandleChatRejectsUnsupportedReasoningBeforeChatRequest(t *testing.T) { + var chatRequests atomic.Int32 + catalog := newServerReasoningCatalog(t, &chatRequests) + s, _, _ := newReasoningBoundaryServer(t, func(cfg *config.Config) { + cfg.Model.Provider = "router" + cfg.Model.Default = "model-a" + cfg.Providers = map[string]config.Provider{ + "router": { + Kind: "openai-compatible", + BaseURL: catalog.URL, + Enabled: true, + }, + } + }) + + req := httptest.NewRequest(http.MethodPost, "/api/chat", + strings.NewReader(`{"message":"hello","reasoning_effort":"unsupported"}`)) + rec := httptest.NewRecorder() + s.handleChat(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if strings.HasPrefix(rec.Header().Get("Content-Type"), "text/event-stream") { + t.Fatalf("unsupported request opened SSE: %q", rec.Header().Get("Content-Type")) + } + if got := chatRequests.Load(); got != 0 { + t.Fatalf("chat requests = %d, want 0", got) + } +} + +func TestHandleUpdateConfigRejectsChangedUnsupportedReasoningWithoutSaving(t *testing.T) { + s, _, configPath := newReasoningBoundaryServer(t, nil) + before := mustReadServerFile(t, configPath) + + req := httptest.NewRequest(http.MethodPost, "/api/config", + strings.NewReader(`{"updates":{"model.reasoning_effort":"unsupported"}}`)) + rec := httptest.NewRecorder() + s.handleUpdateConfig(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if after := mustReadServerFile(t, configPath); !bytes.Equal(after, before) { + t.Fatal("rejected config update changed the config file") + } + if got := s.config().Model.ReasoningEffort; got != "" { + t.Fatalf("rejected config update mutated in-memory effort to %q", got) + } +} + +func TestHandleUpdateConfigAllowsUnrelatedEditWithLegacyUnsupportedReasoning(t *testing.T) { + s, _, _ := newReasoningBoundaryServer(t, func(cfg *config.Config) { + cfg.Model.ReasoningEffort = "legacy-unsupported" + }) + + req := httptest.NewRequest(http.MethodPost, "/api/config", + strings.NewReader(`{"updates":{"display.theme":"dark"}}`)) + rec := httptest.NewRecorder() + s.handleUpdateConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + saved, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if saved.Model.ReasoningEffort != "legacy-unsupported" { + t.Fatalf("legacy reasoning effort = %q", saved.Model.ReasoningEffort) + } + if saved.Display.Theme != "dark" { + t.Fatalf("theme = %q, want dark", saved.Display.Theme) + } +} + +func TestHandleSaveRawConfigRejectsNewUnsupportedReasoningWithoutSaving(t *testing.T) { + s, _, configPath := newReasoningBoundaryServer(t, nil) + before := mustReadServerFile(t, configPath) + raw := "model:\n provider: openai\n default: gpt-5\n reasoning_effort: unsupported\n" + body, err := json.Marshal(map[string]string{"yaml": raw}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body)) + rec := httptest.NewRecorder() + s.handleSaveRawConfig(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if after := mustReadServerFile(t, configPath); !bytes.Equal(after, before) { + t.Fatal("rejected raw config changed the config file") + } +} + +func TestHandleSaveRoleRejectsExplicitUnsupportedReasoning(t *testing.T) { + var roleDir string + s, a, _ := newReasoningBoundaryServer(t, func(cfg *config.Config) { + roleDir = filepath.Join(config.Home(), "roles") + cfg.Roles.Dirs = []string{roleDir} + }) + reg := roles.NewRegistry([]string{roleDir}) + if _, err := reg.Save(roles.Role{ + Name: "custom-reviewer", Title: "Custom Reviewer", Model: "openai/gpt-5", + Effort: "low", Prompt: "before", + }); err != nil { + t.Fatal(err) + } + a.SetRoles(reg) + rolePath := filepath.Join(roleDir, "custom-reviewer.md") + before := mustReadServerFile(t, rolePath) + + req := httptest.NewRequest(http.MethodPost, "/api/roles", strings.NewReader( + `{"name":"custom-reviewer","title":"Custom Reviewer","model":"openai/gpt-5",`+ + `"effort":"unsupported","body":"after"}`, + )) + rec := httptest.NewRecorder() + s.handleSaveRole(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if after := mustReadServerFile(t, rolePath); !bytes.Equal(after, before) { + t.Fatal("rejected role save changed the role file") + } + if role, ok := reg.Get("custom-reviewer"); !ok || role.Effort != "low" { + t.Fatalf("rejected role save mutated registry: %#v, found = %v", role, ok) + } +} + +func newReasoningBoundaryServer( + t *testing.T, + seed func(*config.Config), +) (*Server, *agent.Agent, string) { + t.Helper() + t.Setenv("ANTARES_HOME", t.TempDir()) + t.Setenv("ANTARES_CONFIG", "") + t.Setenv("ANTARES_PROFILE", "default") + t.Setenv("ANTARES_MODEL", "") + t.Setenv("ANTARES_PROVIDER", "") + t.Setenv("ANTARES_BASE_URL", "") + t.Setenv("ANTARES_API_KEY", "") + for _, key := range []string{ + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "CURSOR_API_KEY", + } { + t.Setenv(key, "") + } + + cfg := config.Default() + cfg.Server.DashboardPasswordHash = "test-hash" + cfg.Model.Provider = "openai" + cfg.Model.Default = "gpt-5" + cfg.Providers = map[string]config.Provider{ + "openai": { + Kind: "openai", + BaseURL: "https://api.openai.com/v1", + Enabled: true, + }, + } + if seed != nil { + seed(cfg) + } + configPath := config.ConfigFile() + if err := config.SaveAt(configPath, cfg); err != nil { + t.Fatal(err) + } + reloaded, err := config.Reload() + if err != nil { + t.Fatal(err) + } + // Reload overlays YAML onto fresh defaults, including default provider map + // entries absent from the test fixture. Keep only the explicitly seeded + // providers so list-all can never probe real or developer-local endpoints. + reloaded.Providers = make(map[string]config.Provider, len(cfg.Providers)) + for id, provider := range cfg.Providers { + reloaded.Providers[id] = provider + } + db, err := store.Open(context.Background(), "memory", "", 1, 5000, false) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + a := agent.New(reloaded, db, tools.NewRegistry(), nil, nil) + s := New(Options{ + Config: reloaded, + Agent: a, + Store: db, + Reload: func() error { + next, err := config.Reload() + if err == nil { + a.SetConfig(next) + } + return err + }, + }) + return s, a, configPath +} + +func newServerReasoningCatalog(t *testing.T, chatRequests *atomic.Int32) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "data": [{ + "id": "model-a", + "name": "Model A", + "context_length": 128000, + "reasoning": { + "supported_efforts": ["low"], + "default_effort": "low" + } + }] + }`)) + case r.Method == http.MethodPost: + if chatRequests != nil { + chatRequests.Add(1) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"unexpected"}}]}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func assertServerReasoningCapability(t *testing.T, model llm.ModelInfo) { + t.Helper() + if !model.Reasoning { + t.Fatal("legacy reasoning flag = false") + } + capability := model.ReasoningCapability + if capability == nil || capability.Source != llm.ReasoningCapabilityLive { + t.Fatalf("reasoning capability = %#v", capability) + } + if capability.Default != "low" || len(capability.Values) != 1 || + capability.Values[0].Value != "low" { + t.Fatalf("reasoning capability = %#v", capability) + } +} + +func mustReadServerFile(t *testing.T, path string) []byte { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return raw +} From 17bd879fb52b2f8445a9b1ee76894600c4d5e918 Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Thu, 13 Aug 2026 02:47:45 +0700 Subject: [PATCH 10/41] Fix reasoning validation write boundaries Validate raw edits against their candidate provider map without touching live config, and preserve repairability while distinguishing unavailable metadata from unsupported values. Co-authored-by: Cursor --- internal/agent/reasoning.go | 33 +++- internal/server/handlers_chat.go | 2 +- internal/server/handlers_config.go | 13 +- internal/server/handlers_roles.go | 2 +- internal/server/reasoning.go | 12 +- internal/server/reasoning_test.go | 295 +++++++++++++++++++++++++++++ 6 files changed, 344 insertions(+), 13 deletions(-) diff --git a/internal/agent/reasoning.go b/internal/agent/reasoning.go index f43730e..1c7f404 100644 --- a/internal/agent/reasoning.go +++ b/internal/agent/reasoning.go @@ -50,7 +50,15 @@ type reasoningTarget struct { // configured provider can supply. Documented direct-provider metadata avoids a // network dependency; dynamic providers use the Agent-owned cached catalogue. func (a *Agent) ReasoningCapability(ctx context.Context, modelRef string) (*llm.ReasoningCapability, error) { - target := a.reasoningTarget(modelRef) + return a.reasoningCapabilityForConfig(ctx, a.config(), modelRef) +} + +func (a *Agent) reasoningCapabilityForConfig( + ctx context.Context, + cfg *config.Config, + modelRef string, +) (*llm.ReasoningCapability, error) { + target := reasoningTargetForConfig(cfg, modelRef) if capability := staticReasoningCapability(target); capability != nil { return capability, nil } @@ -73,14 +81,26 @@ func (a *Agent) ReasoningCapability(ctx context.Context, modelRef string) (*llm. // ValidateReasoningEffort validates an explicit value without including the // submitted value in any error. func (a *Agent) ValidateReasoningEffort(ctx context.Context, modelRef, effort string) error { + return a.ValidateReasoningEffortForConfig(ctx, a.config(), modelRef, effort) +} + +// ValidateReasoningEffortForConfig validates against an immutable candidate +// config while retaining the Agent-owned provider catalogue cache. It does not +// publish the candidate as the live Agent configuration. +func (a *Agent) ValidateReasoningEffortForConfig( + ctx context.Context, + cfg *config.Config, + modelRef string, + effort string, +) error { if effort == "" { return nil } - capability, err := a.ReasoningCapability(ctx, modelRef) + capability, err := a.reasoningCapabilityForConfig(ctx, cfg, modelRef) if err != nil { return err } - return llm.ValidateReasoningEffort(a.reasoningTarget(modelRef).model, capability, effort) + return llm.ValidateReasoningEffort(reasoningTargetForConfig(cfg, modelRef).model, capability, effort) } // resolveReasoning distinguishes a new explicit override from stored legacy @@ -139,6 +159,13 @@ func (a *Agent) resolveReasoning(ctx context.Context, in reasoningInput) (reason func (a *Agent) reasoningTarget(modelRef string) reasoningTarget { cfg := a.config() + return reasoningTargetForConfig(cfg, modelRef) +} + +func reasoningTargetForConfig(cfg *config.Config, modelRef string) reasoningTarget { + if cfg == nil { + return reasoningTarget{model: modelRef} + } providerID := cfg.Model.Provider model := modelRef if model == "" { diff --git a/internal/server/handlers_chat.go b/internal/server/handlers_chat.go index 5cc4033..4aab63a 100644 --- a/internal/server/handlers_chat.go +++ b/internal/server/handlers_chat.go @@ -123,7 +123,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { if err := s.validateExplicitReasoning( r.Context(), s.config(), req.Model, req.ReasoningEffort, ); err != nil { - writeError(w, http.StatusBadRequest, err) + writeReasoningValidationError(w, err) return } diff --git a/internal/server/handlers_config.go b/internal/server/handlers_config.go index d1dcda8..3caaeff 100644 --- a/internal/server/handlers_config.go +++ b/internal/server/handlers_config.go @@ -80,7 +80,7 @@ func (s *Server) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { } } if err := s.validateChangedReasoning(r.Context(), cfg, &next); err != nil { - writeError(w, http.StatusBadRequest, err) + writeReasoningValidationError(w, err) return } if invalidateDashSessions { @@ -122,13 +122,12 @@ func (s *Server) handleSaveRawConfig(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err) return } - current, err := config.Reload() - if err != nil { - writeError(w, http.StatusInternalServerError, err) - return - } + // Compare against the last valid in-memory snapshot. Re-reading here would + // prevent the raw editor from repairing malformed on-disk YAML, and Reload's + // first-run behavior could create a file before a rejected submission. + current := s.config() if err := s.validateChangedReasoning(r.Context(), current, next); err != nil { - writeError(w, http.StatusBadRequest, err) + writeReasoningValidationError(w, err) return } if err := config.SaveRaw(body.YAML); err != nil { diff --git a/internal/server/handlers_roles.go b/internal/server/handlers_roles.go index 1f9eb18..428aac3 100644 --- a/internal/server/handlers_roles.go +++ b/internal/server/handlers_roles.go @@ -92,7 +92,7 @@ func (s *Server) handleSaveRole(w http.ResponseWriter, r *http.Request) { if err := s.validateExplicitReasoning( r.Context(), s.config(), strings.TrimSpace(b.Model), effort, ); err != nil { - writeError(w, http.StatusBadRequest, err) + writeReasoningValidationError(w, err) return } } diff --git a/internal/server/reasoning.go b/internal/server/reasoning.go index b56f967..d6c8630 100644 --- a/internal/server/reasoning.go +++ b/internal/server/reasoning.go @@ -2,7 +2,9 @@ package server import ( "context" + "net/http" + "github.com/enowdev/antares/internal/agent" "github.com/enowdev/antares/internal/config" ) @@ -21,7 +23,7 @@ func (s *Server) validateExplicitReasoning( if modelRef == "" { modelRef = reasoningModelRef(cfg) } - return s.agent.ValidateReasoningEffort(ctx, modelRef, effort) + return s.agent.ValidateReasoningEffortForConfig(ctx, cfg, modelRef, effort) } // validateChangedReasoning validates only values changed by a mutation. @@ -46,6 +48,14 @@ func (s *Server) validateChangedReasoning( return nil } +func writeReasoningValidationError(w http.ResponseWriter, err error) { + status := http.StatusBadRequest + if agent.IsReasoningMetadataUnavailable(err) { + status = http.StatusServiceUnavailable + } + writeError(w, status, err) +} + func reasoningModelRef(cfg *config.Config) string { if cfg == nil { return "" diff --git a/internal/server/reasoning_test.go b/internal/server/reasoning_test.go index 901f0b4..731d59b 100644 --- a/internal/server/reasoning_test.go +++ b/internal/server/reasoning_test.go @@ -185,6 +185,278 @@ func TestHandleSaveRawConfigRejectsNewUnsupportedReasoningWithoutSaving(t *testi } } +func TestHandleSaveRawConfigRepairsMalformedExistingYAML(t *testing.T) { + s, _, configPath := newReasoningBoundaryServer(t, nil) + if err := os.WriteFile(configPath, []byte("model: [\n"), 0o600); err != nil { + t.Fatal(err) + } + raw := "model:\n provider: openai\n default: gpt-5\n" + body, err := json.Marshal(map[string]string{"yaml": raw}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body)) + rec := httptest.NewRecorder() + s.handleSaveRawConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if after := mustReadServerFile(t, configPath); string(after) != raw { + t.Fatalf("saved config = %q, want submitted repair %q", after, raw) + } +} + +func TestHandleSaveRawConfigRejectedSubmissionDoesNotCreateMissingFile(t *testing.T) { + tests := []struct { + name string + raw string + }{ + {name: "malformed YAML", raw: "model: [\n"}, + { + name: "unsupported reasoning", + raw: "model:\n provider: openai\n default: gpt-5\n reasoning_effort: unsupported\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, a, configPath := newReasoningBoundaryServer(t, nil) + if err := os.Remove(configPath); err != nil { + t.Fatal(err) + } + serverBefore := s.config() + agentBefore := a.Config() + body, err := json.Marshal(map[string]string{"yaml": tt.raw}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body)) + rec := httptest.NewRecorder() + s.handleSaveRawConfig(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(configPath); !os.IsNotExist(err) { + t.Fatalf("rejected raw save created %s: %v", configPath, err) + } + if s.config() != serverBefore { + t.Fatal("rejected raw save replaced the live server config") + } + if a.Config() != agentBefore { + t.Fatal("rejected raw save replaced the live agent config") + } + }) + } +} + +func TestHandleSaveRawConfigValidatesNewProviderAgainstCandidateConfig(t *testing.T) { + var oldFetches, candidateFetches atomic.Int32 + oldCatalog := newServerCatalogFixture(t, http.StatusOK, `{ + "data": [{ + "id": "model-old", + "reasoning": {"supported_efforts": ["low"], "default_effort": "low"} + }] + }`, &oldFetches) + candidateCatalog := newServerCatalogFixture(t, http.StatusOK, `{ + "data": [{ + "id": "model-a", + "reasoning": {"supported_efforts": ["MiXeD"], "default_effort": "MiXeD"} + }] + }`, &candidateFetches) + s, a, configPath := newReasoningBoundaryServer(t, func(cfg *config.Config) { + cfg.Model.Provider = "old-router" + cfg.Model.Default = "model-old" + cfg.Providers = map[string]config.Provider{ + "old-router": { + Kind: "openai-compatible", + BaseURL: oldCatalog.URL, + Enabled: true, + }, + } + }) + raw := "model:\n" + + " provider: candidate-router\n" + + " default: model-a\n" + + " reasoning_effort: MiXeD\n" + + "providers:\n" + + " candidate-router:\n" + + " kind: openai-compatible\n" + + " base_url: " + candidateCatalog.URL + "\n" + + " enabled: true\n" + body, err := json.Marshal(map[string]string{"yaml": raw}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body)) + rec := httptest.NewRecorder() + s.handleSaveRawConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if got := oldFetches.Load(); got != 0 { + t.Fatalf("old live provider catalogue fetches = %d, want 0", got) + } + if got := candidateFetches.Load(); got != 1 { + t.Fatalf("candidate provider catalogue fetches = %d, want 1", got) + } + if after := mustReadServerFile(t, configPath); string(after) != raw { + t.Fatalf("saved config = %q, want candidate text %q", after, raw) + } + if got := s.config(); got.Model.Provider != "candidate-router" || + got.Model.Default != "model-a" || got.Model.ReasoningEffort != "MiXeD" { + t.Fatalf("live server config = %+v", got.Model) + } + if got := a.Config(); got.Model.Provider != "candidate-router" || + got.Model.Default != "model-a" || got.Model.ReasoningEffort != "MiXeD" { + t.Fatalf("live agent config = %+v", got.Model) + } +} + +func TestHandleSaveRawConfigRejectsInvalidEffortAgainstCandidateWithoutMutation(t *testing.T) { + var oldFetches, candidateFetches atomic.Int32 + oldCatalog := newServerCatalogFixture(t, http.StatusOK, `{ + "data": [{ + "id": "model-old", + "reasoning": {"supported_efforts": ["low"], "default_effort": "low"} + }] + }`, &oldFetches) + candidateCatalog := newServerCatalogFixture(t, http.StatusOK, `{ + "data": [{ + "id": "model-a", + "reasoning": {"supported_efforts": ["MiXeD"], "default_effort": "MiXeD"} + }] + }`, &candidateFetches) + s, a, configPath := newReasoningBoundaryServer(t, func(cfg *config.Config) { + cfg.Model.Provider = "old-router" + cfg.Model.Default = "model-old" + cfg.Providers = map[string]config.Provider{ + "old-router": { + Kind: "openai-compatible", + BaseURL: oldCatalog.URL, + Enabled: true, + }, + } + }) + fileBefore := mustReadServerFile(t, configPath) + serverBefore := s.config() + agentBefore := a.Config() + raw := "model:\n" + + " provider: candidate-router\n" + + " default: model-a\n" + + " reasoning_effort: unsupported\n" + + "providers:\n" + + " candidate-router:\n" + + " kind: openai-compatible\n" + + " base_url: " + candidateCatalog.URL + "\n" + + " enabled: true\n" + body, err := json.Marshal(map[string]string{"yaml": raw}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body)) + rec := httptest.NewRecorder() + s.handleSaveRawConfig(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if got := oldFetches.Load(); got != 0 { + t.Fatalf("old live provider catalogue fetches = %d, want 0", got) + } + if got := candidateFetches.Load(); got != 1 { + t.Fatalf("candidate provider catalogue fetches = %d, want 1", got) + } + if after := mustReadServerFile(t, configPath); !bytes.Equal(after, fileBefore) { + t.Fatal("rejected candidate config changed the config file") + } + if s.config() != serverBefore { + t.Fatal("rejected candidate config replaced the live server config") + } + if a.Config() != agentBefore { + t.Fatal("rejected candidate config replaced the live agent config") + } +} + +func TestHandleSaveRawConfigDistinguishesUnavailableMetadataFromAutoOnlyModel(t *testing.T) { + tests := []struct { + name string + catalogStatus int + catalogBody string + wantStatus int + wantError string + forbiddenError string + }{ + { + name: "known Auto-only model", + catalogStatus: http.StatusOK, + catalogBody: `{"data":[{"id":"model-a"}]}`, + wantStatus: http.StatusBadRequest, + wantError: "unsupported reasoning override", + }, + { + name: "metadata unavailable", + catalogStatus: http.StatusServiceUnavailable, + catalogBody: `{"error":{"message":"SECRET-UPSTREAM-DIAGNOSTIC"}}`, + wantStatus: http.StatusServiceUnavailable, + wantError: "use Auto or retry", + forbiddenError: "SECRET-UPSTREAM-DIAGNOSTIC", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var fetches atomic.Int32 + catalog := newServerCatalogFixture( + t, tt.catalogStatus, tt.catalogBody, &fetches, + ) + s, a, configPath := newReasoningBoundaryServer(t, nil) + fileBefore := mustReadServerFile(t, configPath) + serverBefore := s.config() + agentBefore := a.Config() + raw := "model:\n" + + " provider: candidate-router\n" + + " default: model-a\n" + + " reasoning_effort: high\n" + + "providers:\n" + + " candidate-router:\n" + + " kind: openai-compatible\n" + + " base_url: " + catalog.URL + "\n" + + " enabled: true\n" + body, err := json.Marshal(map[string]string{"yaml": raw}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body)) + rec := httptest.NewRecorder() + s.handleSaveRawConfig(rec, req) + if rec.Code != tt.wantStatus { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), tt.wantError) { + t.Fatalf("body = %s, want %q", rec.Body.String(), tt.wantError) + } + if tt.forbiddenError != "" && strings.Contains(rec.Body.String(), tt.forbiddenError) { + t.Fatalf("response leaked upstream diagnostic: %s", rec.Body.String()) + } + if got := fetches.Load(); got != 1 { + t.Fatalf("catalogue fetches = %d, want 1", got) + } + if after := mustReadServerFile(t, configPath); !bytes.Equal(after, fileBefore) { + t.Fatal("rejected config changed the config file") + } + if s.config() != serverBefore { + t.Fatal("rejected config replaced the live server config") + } + if a.Config() != agentBefore { + t.Fatal("rejected config replaced the live agent config") + } + }) + } +} + func TestHandleSaveRoleRejectsExplicitUnsupportedReasoning(t *testing.T) { var roleDir string s, a, _ := newReasoningBoundaryServer(t, func(cfg *config.Config) { @@ -322,6 +594,29 @@ func newServerReasoningCatalog(t *testing.T, chatRequests *atomic.Int32) *httpte return srv } +func newServerCatalogFixture( + t *testing.T, + status int, + response string, + fetches *atomic.Int32, +) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + if fetches != nil { + fetches.Add(1) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(response)) + })) + t.Cleanup(srv.Close) + return srv +} + func assertServerReasoningCapability(t *testing.T, model llm.ModelInfo) { t.Helper() if !model.Reasoning { From 052747015196556d96bbdfcbe655c1c44ad00a74 Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Thu, 13 Aug 2026 03:01:52 +0700 Subject: [PATCH 11/41] Validate raw candidates with environment overlays Apply provider credential and endpoint overrides to write-free candidate parsing so capability checks match the eventual reload without persisting derived values. Co-authored-by: Cursor --- internal/config/load.go | 12 ++ internal/config/schema_reasoning_test.go | 60 +++++++ internal/server/handlers_config.go | 2 +- internal/server/reasoning_test.go | 197 +++++++++++++++++++++++ 4 files changed, 270 insertions(+), 1 deletion(-) diff --git a/internal/config/load.go b/internal/config/load.go index d50d91e..806686e 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -117,6 +117,18 @@ func ParseRaw(text string) (*Config, error) { return cfg, nil } +// ParseRawWithEnv returns a write-free validation candidate with the current +// process-environment overlays applied exactly as Reload applies them. It does +// not load dotenv files, update the config cache, or persist derived secrets. +func ParseRawWithEnv(text string) (*Config, error) { + cfg, err := ParseRaw(text) + if err != nil { + return nil, err + } + applyEnv(cfg) + return cfg, nil +} + // SaveRaw validates then writes YAML text supplied by the dashboard editor. func SaveRaw(text string) error { if _, err := ParseRaw(text); err != nil { diff --git a/internal/config/schema_reasoning_test.go b/internal/config/schema_reasoning_test.go index cf72857..c823c4f 100644 --- a/internal/config/schema_reasoning_test.go +++ b/internal/config/schema_reasoning_test.go @@ -2,6 +2,7 @@ package config import ( "os" + "strings" "testing" ) @@ -31,6 +32,65 @@ func TestParseRawDoesNotWriteConfiguration(t *testing.T) { } } +func TestParseRawWithEnvAppliesProviderOverlaysWithoutWriting(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + t.Setenv("ANTARES_CONFIG", "") + t.Setenv("ANTARES_PROFILE", "default") + t.Setenv("ANTARES_MODEL", "") + t.Setenv("ANTARES_PROVIDER", "") + t.Setenv("ANTARES_BASE_URL", "") + t.Setenv("ANTARES_API_KEY", "") + t.Setenv("ROUND2_DECLARED_KEY", "declared-secret") + t.Setenv("ANTARES_PROVIDER_DECLARED_API_KEY", "") + t.Setenv("ANTARES_PROVIDER_DECLARED_BASE_URL", "http://env-declared.example/v1") + t.Setenv("ANTARES_PROVIDER_EXPLICIT_API_KEY", "explicit-secret") + t.Setenv("ANTARES_PROVIDER_EXPLICIT_BASE_URL", "http://env-explicit.example/v1") + + if err := Save(Default()); err != nil { + t.Fatal(err) + } + liveBefore := Get() + fileBefore := mustReadConfigFile(t) + raw := "model:\n" + + " provider: declared\n" + + " default: model-a\n" + + "providers:\n" + + " declared:\n" + + " kind: openai-compatible\n" + + " base_url: http://raw-declared.example/v1\n" + + " api_key_env: ROUND2_DECLARED_KEY\n" + + " enabled: true\n" + + " explicit:\n" + + " kind: openai-compatible\n" + + " base_url: http://raw-explicit.example/v1\n" + + " enabled: true\n" + + candidate, err := ParseRawWithEnv(raw) + if err != nil { + t.Fatal(err) + } + declared := candidate.Providers["declared"] + if declared.APIKey != "declared-secret" || + declared.BaseURL != "http://env-declared.example/v1" { + t.Fatalf("declared provider = %+v", declared) + } + explicit := candidate.Providers["explicit"] + if explicit.APIKey != "explicit-secret" || + explicit.BaseURL != "http://env-explicit.example/v1" { + t.Fatalf("explicit provider = %+v", explicit) + } + if after := mustReadConfigFile(t); after != fileBefore { + t.Fatal("ParseRawWithEnv changed the config file") + } + if Get() != liveBefore { + t.Fatal("ParseRawWithEnv replaced the live config") + } + if strings.Contains(fileBefore, "declared-secret") || + strings.Contains(fileBefore, "explicit-secret") { + t.Fatal("environment credential was persisted") + } +} + func fieldByPath(t *testing.T, fields []Field, path string) Field { t.Helper() for _, field := range fields { diff --git a/internal/server/handlers_config.go b/internal/server/handlers_config.go index 3caaeff..fd454dc 100644 --- a/internal/server/handlers_config.go +++ b/internal/server/handlers_config.go @@ -117,7 +117,7 @@ func (s *Server) handleSaveRawConfig(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err) return } - next, err := config.ParseRaw(body.YAML) + next, err := config.ParseRawWithEnv(body.YAML) if err != nil { writeError(w, http.StatusBadRequest, err) return diff --git a/internal/server/reasoning_test.go b/internal/server/reasoning_test.go index 731d59b..4189774 100644 --- a/internal/server/reasoning_test.go +++ b/internal/server/reasoning_test.go @@ -379,6 +379,165 @@ func TestHandleSaveRawConfigRejectsInvalidEffortAgainstCandidateWithoutMutation( } } +func TestHandleSaveRawConfigAppliesCandidateProviderEnvWithoutPersistingSecrets(t *testing.T) { + const secret = "round2-candidate-secret" + tests := []struct { + name string + declaredEnv string + providerEnv string + wantCredential string + }{ + { + name: "api_key_env", + declaredEnv: secret, + wantCredential: secret, + }, + { + name: "provider-specific API key", + declaredEnv: "wrong-fallback-secret", + providerEnv: secret, + wantCredential: secret, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var accepted, rejected, wrongEndpoint atomic.Int32 + catalog := newAuthenticatedServerCatalogFixture( + t, secret, &accepted, &rejected, + ) + wrongCatalog := newServerCatalogFixture( + t, + http.StatusServiceUnavailable, + `{"error":{"message":"wrong raw endpoint"}}`, + &wrongEndpoint, + ) + s, a, configPath := newReasoningBoundaryServer(t, nil) + t.Setenv("ROUND2_CANDIDATE_API_KEY", tt.declaredEnv) + t.Setenv("ANTARES_PROVIDER_CANDIDATE_ROUTER_API_KEY", tt.providerEnv) + t.Setenv("ANTARES_PROVIDER_CANDIDATE_ROUTER_BASE_URL", catalog.URL) + raw := "model:\n" + + " provider: candidate-router\n" + + " default: model-a\n" + + " reasoning_effort: MiXeD\n" + + "providers:\n" + + " candidate-router:\n" + + " kind: openai-compatible\n" + + " base_url: " + wrongCatalog.URL + "\n" + + " api_key_env: ROUND2_CANDIDATE_API_KEY\n" + + " enabled: true\n" + body, err := json.Marshal(map[string]string{"yaml": raw}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body)) + rec := httptest.NewRecorder() + s.handleSaveRawConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if got := accepted.Load(); got != 1 { + t.Fatalf("authenticated catalogue requests = %d, want 1", got) + } + if got := rejected.Load(); got != 0 { + t.Fatalf("rejected catalogue requests = %d, want 0", got) + } + if got := wrongEndpoint.Load(); got != 0 { + t.Fatalf("raw endpoint requests = %d, want 0", got) + } + saved := mustReadServerFile(t, configPath) + if string(saved) != raw { + t.Fatalf("saved config = %q, want submitted text %q", saved, raw) + } + if bytes.Contains(saved, []byte(secret)) || + bytes.Contains(saved, []byte(catalog.URL)) { + t.Fatal("saved config contains environment-derived provider data") + } + if provider := s.config().Providers["candidate-router"]; provider.APIKey != tt.wantCredential || + provider.BaseURL != catalog.URL { + t.Fatalf("live server provider = %+v", provider) + } + if provider := a.Config().Providers["candidate-router"]; provider.APIKey != tt.wantCredential || + provider.BaseURL != catalog.URL { + t.Fatalf("live agent provider = %+v", provider) + } + }) + } +} + +func TestHandleSaveRawConfigRejectsMissingOrWrongCandidateProviderEnvWithoutMutation(t *testing.T) { + const secret = "round2-candidate-secret" + tests := []struct { + name string + declaredEnv string + }{ + {name: "missing credential"}, + {name: "wrong credential", declaredEnv: "wrong-secret"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var accepted, rejected, wrongEndpoint atomic.Int32 + catalog := newAuthenticatedServerCatalogFixture( + t, secret, &accepted, &rejected, + ) + wrongCatalog := newServerCatalogFixture( + t, + http.StatusServiceUnavailable, + `{"error":{"message":"wrong raw endpoint"}}`, + &wrongEndpoint, + ) + s, a, configPath := newReasoningBoundaryServer(t, nil) + t.Setenv("ROUND2_CANDIDATE_API_KEY", tt.declaredEnv) + t.Setenv("ANTARES_PROVIDER_CANDIDATE_ROUTER_API_KEY", "") + t.Setenv("ANTARES_PROVIDER_CANDIDATE_ROUTER_BASE_URL", catalog.URL) + fileBefore := mustReadServerFile(t, configPath) + serverBefore := s.config() + agentBefore := a.Config() + raw := "model:\n" + + " provider: candidate-router\n" + + " default: model-a\n" + + " reasoning_effort: MiXeD\n" + + "providers:\n" + + " candidate-router:\n" + + " kind: openai-compatible\n" + + " base_url: " + wrongCatalog.URL + "\n" + + " api_key_env: ROUND2_CANDIDATE_API_KEY\n" + + " enabled: true\n" + body, err := json.Marshal(map[string]string{"yaml": raw}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body)) + rec := httptest.NewRecorder() + s.handleSaveRawConfig(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if got := accepted.Load(); got != 0 { + t.Fatalf("authenticated catalogue requests = %d, want 0", got) + } + if got := rejected.Load(); got != 1 { + t.Fatalf("rejected catalogue requests = %d, want 1", got) + } + if got := wrongEndpoint.Load(); got != 0 { + t.Fatalf("raw endpoint requests = %d, want 0", got) + } + if after := mustReadServerFile(t, configPath); !bytes.Equal(after, fileBefore) { + t.Fatal("rejected environment candidate changed the config file") + } + if s.config() != serverBefore { + t.Fatal("rejected environment candidate replaced the live server config") + } + if a.Config() != agentBefore { + t.Fatal("rejected environment candidate replaced the live agent config") + } + }) + } +} + func TestHandleSaveRawConfigDistinguishesUnavailableMetadataFromAutoOnlyModel(t *testing.T) { tests := []struct { name string @@ -617,6 +776,44 @@ func newServerCatalogFixture( return srv } +func newAuthenticatedServerCatalogFixture( + t *testing.T, + apiKey string, + accepted *atomic.Int32, + rejected *atomic.Int32, +) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + if r.Header.Get("Authorization") != "Bearer "+apiKey { + if rejected != nil { + rejected.Add(1) + } + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":{"message":"invalid synthetic credential"}}`)) + return + } + if accepted != nil { + accepted.Add(1) + } + _, _ = w.Write([]byte(`{ + "data": [{ + "id": "model-a", + "reasoning": { + "supported_efforts": ["MiXeD"], + "default_effort": "MiXeD" + } + }] + }`)) + })) + t.Cleanup(srv.Close) + return srv +} + func assertServerReasoningCapability(t *testing.T, model llm.ModelInfo) { t.Helper() if !model.Reasoning { From 1570731aebc54ad4f9718bab9c3a0ae9649feb4b Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Thu, 13 Aug 2026 03:16:53 +0700 Subject: [PATCH 12/41] Adapt reasoning controls to each model Co-authored-by: Cursor --- web/src/components/chat/ModelPicker.tsx | 99 +++++++++++++--- web/src/components/chat/ReasoningPicker.tsx | 74 +++++++----- web/src/lib/i18n.tsx | 25 ++++ web/src/lib/models.ts | 21 ++++ web/src/lib/reasoning.test.mjs | 121 ++++++++++++++++++++ web/src/lib/reasoning.ts | 72 ++++++++++++ web/src/pages/ChatPage.tsx | 83 ++++++++++---- web/src/pages/ConfigPage.tsx | 87 +++++++++++++- web/src/pages/ModelsPage.tsx | 19 ++- web/src/pages/ProvidersPage.tsx | 14 ++- web/src/pages/RolesPage.tsx | 67 ++++++++++- 11 files changed, 605 insertions(+), 77 deletions(-) create mode 100644 web/src/lib/models.ts create mode 100644 web/src/lib/reasoning.test.mjs create mode 100644 web/src/lib/reasoning.ts diff --git a/web/src/components/chat/ModelPicker.tsx b/web/src/components/chat/ModelPicker.tsx index 3bdcc2d..325ad08 100644 --- a/web/src/components/chat/ModelPicker.tsx +++ b/web/src/components/chat/ModelPicker.tsx @@ -8,6 +8,10 @@ import { } from "@phosphor-icons/react"; import { get, isDashboardPasswordRequired, post } from "@/lib/api"; import { useI18n } from "@/lib/i18n"; +import type { + ChatModelSelection, + ReasoningCapability, +} from "@/lib/models"; import { cn } from "@/lib/utils"; interface AllModel { @@ -15,6 +19,7 @@ interface AllModel { name: string; provider: string; provider_label: string; + reasoning_capability?: ReasoningCapability; } interface ListAll { @@ -22,6 +27,28 @@ interface ListAll { models: AllModel[]; } +interface ModelOptions { + active: { model: string; provider: string }; + providers?: Array<{ id: string; label: string }>; +} + +interface ModelInfo { + found: boolean; + id?: string; + name?: string; + reasoning_capability?: ReasoningCapability; +} + +function modelSelection(model: AllModel): ChatModelSelection { + return { + provider: model.provider, + model: model.id, + name: model.name, + providerLabel: model.provider_label, + reasoningCapability: model.reasoning_capability, + }; +} + /** * Switch the active model straight from the composer, without leaving the chat. * Lists every connected provider's models (same source as the Models page) and @@ -30,7 +57,7 @@ interface ListAll { export function ModelPicker({ onModelChange, }: { - onModelChange?: (model: string) => void; + onModelChange?: (selection: ChatModelSelection) => void; }) { const { t } = useI18n(); const [open, setOpen] = useState(false); @@ -41,6 +68,7 @@ export function ModelPicker({ const [pickError, setPickError] = useState(); const [pickGate, setPickGate] = useState(false); const ref = useRef(null); + const resolutionRef = useRef(0); const [activeConfig, setActiveConfig] = useState<{ model: string; @@ -50,27 +78,65 @@ export function ModelPicker({ const load = () => { setLoading(true); return get("/model/list-all") - .then((d) => setData(d)) + .then((d) => { + setData(d); + const activeModel = d.models.find( + (model) => + model.id === d.active?.model && + model.provider === d.active?.provider, + ); + if (activeModel) onModelChange?.(modelSelection(activeModel)); + }) .catch(() => {}) .finally(() => setLoading(false)); }; - // On mount, fetch just the active model from the cheap /model/options endpoint - // (config only, no per-provider probing) so the trigger shows the persisted - // last-used model immediately instead of the "pick a model" placeholder — - // otherwise it looks like the selection resets on every reload. - const loadActive = () => - get<{ active: { model: string; provider: string } }>("/model/options") - .then((d) => { - setActiveConfig(d.active); - if (d.active?.model && d.active?.provider) { - onModelChange?.(`${d.active.provider}/${d.active.model}`); + // The cheap options call identifies the persisted active pair. Resolve that + // one model through model-info so the composer has capability metadata before + // it restores a model-scoped reasoning preference. + useEffect(() => { + let cancelled = false; + const sequence = ++resolutionRef.current; + get("/model/options") + .then(async (options) => { + if (cancelled || sequence !== resolutionRef.current) return; + const active = options.active; + setActiveConfig(active); + if (!active?.model || !active?.provider) return; + + const providerLabel = + options.providers?.find((provider) => provider.id === active.provider) + ?.label ?? active.provider; + const fallback: ChatModelSelection = { + provider: active.provider, + model: active.model, + name: active.model, + providerLabel, + }; + + try { + const info = await get( + `/providers/${encodeURIComponent(active.provider)}/model-info?model=${encodeURIComponent(active.model)}`, + ); + if (cancelled || sequence !== resolutionRef.current) return; + onModelChange?.({ + ...fallback, + name: info.found ? info.name || active.model : active.model, + reasoningCapability: info.found + ? info.reasoning_capability + : undefined, + }); + } catch { + if (!cancelled && sequence === resolutionRef.current) { + onModelChange?.(fallback); + } } }) .catch(() => {}); - useEffect(() => { - loadActive(); - }, []); + return () => { + cancelled = true; + }; + }, [onModelChange]); // The full model list (which probes every provider) is fetched lazily on open, // and refreshed each open so a newly connected provider's models appear. @@ -105,6 +171,7 @@ export function ModelPicker({ }, [data, query]); const pick = async (m: AllModel) => { + ++resolutionRef.current; setSaving(`${m.provider}/${m.id}`); setPickError(undefined); try { @@ -113,7 +180,7 @@ export function ModelPicker({ setData((d) => d ? { ...d, active: { model: m.id, provider: m.provider } } : d, ); - onModelChange?.(`${m.provider}/${m.id}`); + onModelChange?.(modelSelection(m)); setOpen(false); setQuery(""); } catch (e) { diff --git a/web/src/components/chat/ReasoningPicker.tsx b/web/src/components/chat/ReasoningPicker.tsx index 0ece110..e12bc6f 100644 --- a/web/src/components/chat/ReasoningPicker.tsx +++ b/web/src/components/chat/ReasoningPicker.tsx @@ -1,33 +1,29 @@ import { useEffect, useRef, useState } from 'react' import { Brain, CaretDown, Check } from '@phosphor-icons/react' +import { useI18n } from '@/lib/i18n' +import type { ReasoningCapability, ReasoningValue } from '@/lib/models' +import { reasoningOptions } from '@/lib/reasoning' import { cn } from '@/lib/utils' -// Reasoning effort options. Empty value means "use the configured default" -// (agent.reasoning_effort, then model.reasoning_effort). The rest map to the -// provider's thinking budget: none disables thinking, low/medium/high raise it. -const OPTIONS: { value: string; label: string; hint: string }[] = [ - { value: '', label: 'Default', hint: 'Use the configured effort' }, - { value: 'none', label: 'Off', hint: 'No reasoning' }, - { value: 'low', label: 'Low', hint: 'Brief reasoning' }, - { value: 'medium', label: 'Medium', hint: 'Balanced reasoning' }, - { value: 'high', label: 'High', hint: 'Deep reasoning' }, -] +export interface ReasoningPickerProps { + value: string + capability?: ReasoningCapability + onChange(value: string): void + compact?: boolean +} /** - * Pick the reasoning effort for the next turn straight from the composer. The - * choice rides on the message body (reasoning_effort) and overrides the - * configured default for that turn only; it is remembered in localStorage so it - * survives a reload. Mirrors RolePicker's compact chip style. + * Present the exact reasoning values supported by the selected model. Storage + * and model changes are owned by the composer; this component only renders the + * supplied value and capability. */ export function ReasoningPicker({ value, + capability, onChange, compact = false, -}: { - value: string - onChange: (effort: string) => void - compact?: boolean -}) { +}: ReasoningPickerProps) { + const { t } = useI18n() const [open, setOpen] = useState(false) const ref = useRef(null) @@ -40,18 +36,26 @@ export function ReasoningPicker({ return () => document.removeEventListener('mousedown', onClick) }, [open]) - const current = OPTIONS.find((o) => o.value === value) ?? OPTIONS[0] + const options = reasoningOptions(capability) + const current = options.find((option) => option.value === value) ?? options[0] + const label = (option: ReasoningValue) => + option.value === '' ? t('reasoning.auto') : option.label const pick = (v: string) => { onChange(v) setOpen(false) } + // Unknown/Auto-only models expose no trustworthy override values. Auto is + // still the behavior, but there is no useful composer control to show. + if (!capability) return null + return (
{open ? (
- {OPTIONS.map((o) => ( + {options.map((option) => ( ))} + {capability.mandatory || capability.values.length === 0 ? ( +

+ {capability.mandatory + ? t('reasoning.mandatory') + : t('reasoning.providerControlled')} +

+ ) : null}
) : null}
diff --git a/web/src/lib/i18n.tsx b/web/src/lib/i18n.tsx index 6acaaa8..5f37a5f 100644 --- a/web/src/lib/i18n.tsx +++ b/web/src/lib/i18n.tsx @@ -451,6 +451,11 @@ const en = { 'ask.add': 'Add answer', 'chat.nothingToCopy': 'There is no reply to copy yet.', 'chat.reasoning': 'Reasoning', + 'reasoning.auto': 'Auto', + 'reasoning.unsupported': 'Unsupported legacy value: {value}. Choose Auto to replace it.', + 'reasoning.autoHint': 'Auto keeps reasoning adaptive or uses the model/provider default.', + 'reasoning.mandatory': 'This model always reasons; Auto keeps its required behavior.', + 'reasoning.providerControlled': 'This model exposes no reasoning overrides; the provider controls it.', 'chat.working': 'Working…', 'chat.attachAuthFailed': 'Dashboard login expired — refresh and sign in again.', 'chat.waitingAnswer': 'Paused — waiting for your answer', @@ -1469,6 +1474,11 @@ const id: Dict = { 'ask.add': 'Tambah jawaban', 'chat.nothingToCopy': 'Belum ada balasan untuk disalin.', 'chat.reasoning': 'Penalaran', + 'reasoning.auto': 'Otomatis', + 'reasoning.unsupported': 'Nilai lama tidak didukung: {value}. Pilih Otomatis untuk menggantinya.', + 'reasoning.autoHint': 'Otomatis mempertahankan penalaran adaptif atau memakai default model/provider.', + 'reasoning.mandatory': 'Model ini selalu memakai penalaran; Otomatis mempertahankan perilaku wajibnya.', + 'reasoning.providerControlled': 'Model ini tidak menyediakan override penalaran; provider yang mengaturnya.', 'chat.working': 'Sedang bekerja…', 'chat.attachAuthFailed': 'Login dashboard kedaluwarsa — muat ulang dan masuk lagi.', 'chat.waitingAnswer': 'Dijeda — menunggu jawabanmu', @@ -2254,6 +2264,11 @@ const ja: Dict = { 'approval.expired': 'その要求はすでに期限切れです。', 'chat.nothingToCopy': 'コピーできる返信がまだありません。', 'chat.reasoning': '推論', + 'reasoning.auto': '自動', + 'reasoning.unsupported': '未対応の従来値: {value}。置き換えるには「自動」を選んでください。', + 'reasoning.autoHint': '自動では、適応型推論またはモデル/プロバイダーの既定値を使用します。', + 'reasoning.mandatory': 'このモデルでは推論が必須です。「自動」は必須の動作を維持します。', + 'reasoning.providerControlled': 'このモデルは推論の上書きを公開していません。プロバイダーが制御します。', 'chat.tokensOut': '出力 {n} トークン', 'chat.welcomeTitle': '会話を始める', 'chat.welcomeDesc': @@ -2956,6 +2971,11 @@ const zh: Dict = { 'approval.expired': '该请求已经超时,不再等待。', 'chat.nothingToCopy': '还没有可复制的回复。', 'chat.reasoning': '推理过程', + 'reasoning.auto': '自动', + 'reasoning.unsupported': '不支持的旧值:{value}。请选择“自动”进行替换。', + 'reasoning.autoHint': '自动会保留自适应推理,或使用模型/提供商的默认值。', + 'reasoning.mandatory': '此模型必须进行推理;自动会保留其必需行为。', + 'reasoning.providerControlled': '此模型未提供推理覆盖项;由提供商控制。', 'chat.tokensOut': '输出 {n} 个 token', 'chat.welcomeTitle': '开始对话', 'chat.welcomeDesc': 'Antares 可以访问文件、终端、网页搜索、长期记忆和 RAG 索引。', @@ -3656,6 +3676,11 @@ const ru: Dict = { 'approval.expired': 'Этот запрос больше не ждёт — истёк срок.', 'chat.nothingToCopy': 'Копировать пока нечего.', 'chat.reasoning': 'Рассуждение', + 'reasoning.auto': 'Авто', + 'reasoning.unsupported': 'Неподдерживаемое устаревшее значение: {value}. Выберите «Авто», чтобы заменить его.', + 'reasoning.autoHint': 'Авто оставляет адаптивное рассуждение или использует значение по умолчанию модели/провайдера.', + 'reasoning.mandatory': 'Для этой модели рассуждение обязательно; «Авто» сохраняет это поведение.', + 'reasoning.providerControlled': 'Эта модель не предоставляет переопределения рассуждения; им управляет провайдер.', 'chat.tokensOut': '{n} токенов на выходе', 'chat.welcomeTitle': 'Начните разговор', 'chat.welcomeDesc': diff --git a/web/src/lib/models.ts b/web/src/lib/models.ts new file mode 100644 index 0000000..394d01d --- /dev/null +++ b/web/src/lib/models.ts @@ -0,0 +1,21 @@ +export interface ReasoningValue { + value: string + label: string + kind?: 'disable' +} + +export interface ReasoningCapability { + values: ReasoningValue[] + default?: string + mandatory: boolean + can_disable: boolean + source: 'live' | 'static' +} + +export interface ChatModelSelection { + provider: string + model: string + name: string + providerLabel: string + reasoningCapability?: ReasoningCapability +} diff --git a/web/src/lib/reasoning.test.mjs b/web/src/lib/reasoning.test.mjs new file mode 100644 index 0000000..e574bda --- /dev/null +++ b/web/src/lib/reasoning.test.mjs @@ -0,0 +1,121 @@ +import { describe, expect, test } from 'bun:test' +import { + loadReasoningPreference, + reasoningOptions, + reasoningPreferenceKey, + saveReasoningPreference, +} from './reasoning.ts' + +describe('reasoning options', () => { + test('preserve opaque values and mark only explicit disable', () => { + const cap = { + values: [ + { value: 'none', label: 'Off', kind: 'disable' }, + { value: 'extra-high', label: 'Extra High' }, + ], + default: 'extra-high', + mandatory: false, + can_disable: true, + source: 'live', + } + + expect(reasoningOptions(cap).map((x) => x.value)).toEqual(['', 'none', 'extra-high']) + }) + + test('offer only Auto when a model has no capability metadata', () => { + expect(reasoningOptions(undefined).map((x) => x.value)).toEqual(['']) + }) + + test('omit explicit disable values for mandatory models', () => { + const cap = { + values: [ + { value: 'none', label: 'Off', kind: 'disable' }, + { value: 'MiXeD', label: 'Mixed' }, + ], + mandatory: true, + can_disable: false, + source: 'static', + } + + expect(reasoningOptions(cap).map((x) => x.value)).toEqual(['', 'MiXeD']) + }) +}) + +describe('reasoning preferences', () => { + test('migrate a legacy preference once only when valid', () => { + const storage = memoryStorage({ 'antares:reasoning': 'high' }) + const cap = capability(['low', 'high']) + + expect(loadReasoningPreference(storage, 'openai', 'gpt-5', cap)).toEqual({ + value: 'high', + migrated: true, + }) + expect(storage.getItem('antares:reasoning')).toBeNull() + expect(storage.getItem(reasoningPreferenceKey('openai', 'gpt-5'))).toBe('high') + }) + + test('remove an invalid legacy preference after its single migration attempt', () => { + const storage = memoryStorage({ 'antares:reasoning': 'HIGH' }) + + expect(loadReasoningPreference(storage, 'openai', 'gpt-5', capability(['high']))).toEqual({ + value: '', + migrated: false, + }) + expect(storage.getItem('antares:reasoning')).toBeNull() + expect(storage.getItem(reasoningPreferenceKey('openai', 'gpt-5'))).toBeNull() + }) + + test('sanitize an invalid scoped value without changing case', () => { + const key = reasoningPreferenceKey('openai', 'gpt-5') + const storage = memoryStorage({ [key]: 'HIGH' }) + + expect(loadReasoningPreference(storage, 'openai', 'gpt-5', capability(['high']))).toEqual({ + value: '', + migrated: false, + }) + expect(storage.getItem(key)).toBeNull() + }) + + test('isolate encoded provider and model storage keys', () => { + const storage = memoryStorage() + const first = reasoningPreferenceKey('provider/one', 'model:alpha') + const second = reasoningPreferenceKey('provider', 'one/model:alpha') + + expect(first).toBe('antares:reasoning:v2:provider%2Fone:model%3Aalpha') + expect(second).toBe('antares:reasoning:v2:provider:one%2Fmodel%3Aalpha') + expect(first).not.toBe(second) + + saveReasoningPreference(storage, 'provider/one', 'model:alpha', 'MiXeD') + saveReasoningPreference(storage, 'provider', 'one/model:alpha', 'extra-high') + + expect(storage.getItem(first)).toBe('MiXeD') + expect(storage.getItem(second)).toBe('extra-high') + }) + + test('store Auto as no explicit scoped override', () => { + const key = reasoningPreferenceKey('openai', 'gpt-5') + const storage = memoryStorage({ [key]: 'high' }) + + saveReasoningPreference(storage, 'openai', 'gpt-5', '') + + expect(storage.getItem(key)).toBeNull() + }) +}) + +function memoryStorage(initial = {}) { + const values = new Map(Object.entries(initial)) + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + removeItem: (key) => values.delete(key), + } +} + +function capability(values) { + return { + values: values.map((value) => ({ value, label: value })), + mandatory: false, + can_disable: false, + source: 'static', + } +} diff --git a/web/src/lib/reasoning.ts b/web/src/lib/reasoning.ts new file mode 100644 index 0000000..78bf703 --- /dev/null +++ b/web/src/lib/reasoning.ts @@ -0,0 +1,72 @@ +import type { ReasoningCapability, ReasoningValue } from '@/lib/models' + +export interface StorageLike { + getItem(key: string): string | null + setItem(key: string, value: string): void + removeItem(key: string): void +} + +const LEGACY_REASONING_KEY = 'antares:reasoning' +const REASONING_KEY_PREFIX = 'antares:reasoning:v2' + +export function reasoningOptions(capability?: ReasoningCapability): ReasoningValue[] { + if (!capability) return [{ value: '', label: 'Auto' }] + const values = capability.values.filter( + (option) => + option.kind !== 'disable' || + (capability.can_disable && !capability.mandatory), + ) + return [{ value: '', label: 'Auto' }, ...values] +} + +export function reasoningPreferenceKey(provider: string, model: string): string { + return `${REASONING_KEY_PREFIX}:${encodeURIComponent(provider)}:${encodeURIComponent(model)}` +} + +export function loadReasoningPreference( + storage: StorageLike, + provider: string, + model: string, + capability?: ReasoningCapability, +): { value: string; migrated: boolean } { + const key = reasoningPreferenceKey(provider, model) + const allowed = new Set(reasoningOptions(capability).map((option) => option.value)) + + try { + const scoped = storage.getItem(key) + const legacy = storage.getItem(LEGACY_REASONING_KEY) + if (legacy !== null) storage.removeItem(LEGACY_REASONING_KEY) + + if (scoped !== null) { + if (scoped && allowed.has(scoped)) { + return { value: scoped, migrated: false } + } + storage.removeItem(key) + return { value: '', migrated: false } + } + + if (legacy && allowed.has(legacy)) { + storage.setItem(key, legacy) + return { value: legacy, migrated: true } + } + } catch { + // Storage is best-effort (private browsing and quotas may reject access). + } + + return { value: '', migrated: false } +} + +export function saveReasoningPreference( + storage: StorageLike, + provider: string, + model: string, + value: string, +): void { + const key = reasoningPreferenceKey(provider, model) + try { + if (value) storage.setItem(key, value) + else storage.removeItem(key) + } catch { + // Preference persistence must never prevent composing a message. + } +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 0dcfefd..afd3962 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -26,6 +26,12 @@ import { } from '@/lib/chatStreamQueue' import { copyText } from '@/lib/clipboard' import { useI18n, useTimeAgo, type MessageKey } from '@/lib/i18n' +import type { ChatModelSelection, ReasoningCapability } from '@/lib/models' +import { + loadReasoningPreference, + reasoningOptions, + saveReasoningPreference, +} from '@/lib/reasoning' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/primitives' @@ -363,24 +369,45 @@ export default function ChatPage() { if (r) localStorage.setItem('antares:last-role', r) else localStorage.removeItem('antares:last-role') }, []) - // Per-turn reasoning effort picked in the composer. Empty means "use the - // configured default" (agent.reasoning_effort, then model). Persisted so the - // choice survives a reload, mirroring the role picker. - const [reasoning, setReasoning] = useState( - () => localStorage.getItem('antares:reasoning') ?? '', - ) - const pickReasoning = useCallback((r: string) => { - setReasoning(r) - if (r) localStorage.setItem('antares:reasoning', r) - else localStorage.removeItem('antares:reasoning') + // The model, its capability, and its scoped reasoning value move together. + // Updating the ref synchronously prevents a send immediately after switching + // models from carrying the previous model's override. + const [modelSelection, setModelSelection] = useState() + const [reasoning, setReasoning] = useState('') + const composerReasoningRef = useRef<{ + selection: ChatModelSelection + capability?: ReasoningCapability + value: string + } | null>(null) + const selectModel = useCallback((selection: ChatModelSelection) => { + const capability = selection.reasoningCapability + const { value } = loadReasoningPreference( + localStorage, + selection.provider, + selection.model, + capability, + ) + composerReasoningRef.current = { selection, capability, value } + setModelSelection(selection) + setReasoning(value) + }, []) + const pickReasoning = useCallback((value: string) => { + const current = composerReasoningRef.current + if (!current) return + const next = reasoningOptions(current.capability).some( + (option) => option.value === value, + ) + ? value + : '' + saveReasoningPreference( + localStorage, + current.selection.provider, + current.selection.model, + next, + ) + composerReasoningRef.current = { ...current, value: next } + setReasoning(next) }, []) - // Per-chat model override, chosen via the picker in the composer. Kept in a - // ref so the stream request closure always reads the latest selection. - const [activeModel, setActiveModel] = useState('') - const activeModelRef = useRef('') - useEffect(() => { - activeModelRef.current = activeModel - }, [activeModel]) // Project session: the folder this chat is bound to. Chosen on a NEW chat and // sent with the first message; once the session exists it is fixed (locked). const [projectDir, setProjectDir] = useState('') @@ -1086,6 +1113,7 @@ export default function ChatPage() { setStreaming(true) setLive({ turn: 1 }) + const composerReasoning = composerReasoningRef.current abortRef.current = streamPost( '/chat', { @@ -1095,10 +1123,16 @@ export default function ChatPage() { role, // Per-chat model override; omitted when unset so the server falls // back to the configured default. - ...(activeModelRef.current ? { model: activeModelRef.current } : {}), + ...(composerReasoning + ? { + model: `${composerReasoning.selection.provider}/${composerReasoning.selection.model}`, + } + : {}), // Per-turn reasoning override; omitted when unset so the server falls // back to the configured default. - ...(reasoning ? { reasoning_effort: reasoning } : {}), + ...(composerReasoning?.value + ? { reasoning_effort: composerReasoning.value } + : {}), // Only meaningful when starting a new session; the server ignores it once // the session exists. Read from the ref so an auto-analyze turn fired // right after binding still carries the project. @@ -1170,7 +1204,7 @@ export default function ChatPage() { }, ) }, - [role, reasoning, projectDir, streaming, sessionId, navigate, runCommand, applyEvent, drainPatches, t], + [role, projectDir, streaming, sessionId, navigate, runCommand, applyEvent, drainPatches, t], ) const send = useCallback(() => { @@ -1446,8 +1480,13 @@ export default function ChatPage() { roleSlot={
- - + + { diff --git a/web/src/pages/ConfigPage.tsx b/web/src/pages/ConfigPage.tsx index 8e894cf..cbcaa7b 100644 --- a/web/src/pages/ConfigPage.tsx +++ b/web/src/pages/ConfigPage.tsx @@ -15,6 +15,8 @@ import { import { post } from '@/lib/api' import { useApi } from '@/lib/hooks' import { useI18n } from '@/lib/i18n' +import type { ReasoningCapability } from '@/lib/models' +import { reasoningOptions } from '@/lib/reasoning' import { cn } from '@/lib/utils' import { usePageActions } from '@/components/layout/PageChrome' import { Button } from '@/components/ui/button' @@ -44,6 +46,7 @@ interface Field { default: unknown secret: boolean enum?: string[] + options_source?: string help?: string } @@ -52,6 +55,15 @@ interface ConfigResponse { schema: Field[] } +interface ReasoningModelsResponse { + active: { model: string; provider: string } + models: Array<{ + id: string + provider: string + reasoning_capability?: ReasoningCapability + }> +} + const ESSENTIALS = '__essentials' const YAML = '__yaml' @@ -82,6 +94,7 @@ export default function ConfigPage() { const { t } = useI18n() const { data, loading, reload } = useApi('/config') const rawState = useApi<{ yaml: string }>('/config/raw') + const modelsState = useApi('/model/list-all') const [edits, setEdits] = useState>({}) const [saving, setSaving] = useState(false) @@ -93,6 +106,25 @@ export default function ConfigPage() { const [section, setSection] = useState(ESSENTIALS) const [showAdvanced, setShowAdvanced] = useState(false) + const configuredProvider = String( + edits['model.provider'] ?? + (data ? readPath(data.values, 'model.provider') : '') ?? + '', + ) + const configuredModel = String( + edits['model.default'] ?? + (data ? readPath(data.values, 'model.default') : '') ?? + '', + ) + const reasoningCapability = useMemo( + () => + modelsState.data?.models.find( + (model) => + model.provider === configuredProvider && model.id === configuredModel, + )?.reasoning_capability, + [configuredModel, configuredProvider, modelsState.data], + ) + const query = filter.trim().toLowerCase() const searching = query.length > 0 const dirty = Object.keys(edits).length @@ -205,6 +237,7 @@ export default function ConfigPage() { field={f} showGroup={withGroup} value={valueOf(f)} + reasoningCapability={reasoningCapability} dirty={f.path in edits} revealed={!!revealed[f.path]} onReveal={() => setRevealed((r) => ({ ...r, [f.path]: !r[f.path] }))} @@ -411,6 +444,7 @@ function SectionRail({ function FieldRow({ field, value, + reasoningCapability, dirty, revealed, showGroup, @@ -419,6 +453,7 @@ function FieldRow({ }: { field: Field value: unknown + reasoningCapability?: ReasoningCapability dirty: boolean revealed: boolean showGroup?: boolean @@ -452,7 +487,13 @@ function FieldRow({
- {field.enum ? ( + {field.options_source === 'reasoning_capability' ? ( + + ) : field.enum ? ( onChange(event.target.value)} + className="h-9 w-full rounded-[var(--radius-sm)] border border-input bg-background px-3 text-sm" + > + {unsupported ? ( + + ) : null} + {options.map((option) => ( + + ))} + +

{hint}

+
+ ) +} + /** * A comma-separated list editor. Keeps the RAW text you type as its own state * so typing is never interrupted — the previous version reparsed to an array on diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx index c3c1311..cd89727 100644 --- a/web/src/pages/ModelsPage.tsx +++ b/web/src/pages/ModelsPage.tsx @@ -3,6 +3,7 @@ import { Eye, Lightning, MagnifyingGlass, Wrench } from '@phosphor-icons/react' import { post } from '@/lib/api' import { useApi } from '@/lib/hooks' import { useI18n } from '@/lib/i18n' +import type { ReasoningCapability } from '@/lib/models' import { cn } from '@/lib/utils' import { PageLayout } from '@/components/layout/PageLayout' import { Pagination } from '@/components/ui/Pagination' @@ -21,6 +22,7 @@ interface ModelInfo { vision: boolean tools: boolean reasoning: boolean + reasoning_capability?: ReasoningCapability } interface ProviderInfo { @@ -229,8 +231,21 @@ function AllModelsView({ ) : null} {m.reasoning ? ( - - {t('models.reasoning')} + value.label) + .join(', ') || t('reasoning.providerControlled') + : t('reasoning.providerControlled') + } + > + {' '} + {m.reasoning_capability + ? t('models.reasoning') + : t('reasoning.providerControlled')} ) : null} diff --git a/web/src/pages/ProvidersPage.tsx b/web/src/pages/ProvidersPage.tsx index aa69b8b..ff9981d 100644 --- a/web/src/pages/ProvidersPage.tsx +++ b/web/src/pages/ProvidersPage.tsx @@ -7,6 +7,7 @@ import { Eye, EyeSlash, Key, + Lightning, Plugs, ShieldCheck, Trash, @@ -15,6 +16,7 @@ import { del, get, post } from '@/lib/api' import { agentModelsErrorText, isAgentProvider, providerModelsPath, type ProviderCapability } from '@/lib/providerCapabilities' import { useApi } from '@/lib/hooks' import { useI18n } from '@/lib/i18n' +import type { ReasoningCapability } from '@/lib/models' import { cn } from '@/lib/utils' import { PageLayout } from '@/components/layout/PageLayout' import { Button } from '@/components/ui/button' @@ -244,6 +246,8 @@ interface AllModel { provider: string provider_label: string context_window: number + reasoning: boolean + reasoning_capability?: ReasoningCapability } interface AgentModel { @@ -338,7 +342,7 @@ function ProviderModal({ if (!q) return try { const r = await get<{ found: boolean; context_window?: number }>( - `/providers/${encodeURIComponent(p.id)}/model-info?id=${encodeURIComponent(q)}`, + `/providers/${encodeURIComponent(p.id)}/model-info?model=${encodeURIComponent(q)}`, ) if (r.found && r.context_window) { setNewCtx(String(r.context_window)) @@ -538,6 +542,14 @@ function ProviderModal({ {t('models.ctx', { n: Math.round(m.context_window / 1000) })}

) : null} + {m.reasoning ? ( +

+ + {m.reasoning_capability + ? t('models.reasoning') + : t('reasoning.providerControlled')} +

+ ) : null} + + {open ? ( +
+
+

{value.model.name}

+

+ {value.model.id} +

+
+ + {reasoning ? ( + pickDimension(reasoning, option)} + disabled={disabled} + /> + ) : null} + {others.map((dimension) => ( + pickDimension(dimension, option)} + disabled={disabled} + /> + ))} + +
+ +
+ {(['agent', 'plan'] as CursorMode[]).map((mode) => ( + onChange({ ...value, mode })} + label={mode === 'agent' ? t('cursor.modeAgent') : t('cursor.modePlan')} + /> + ))} +
+

+ {t('cursor.modeHint')} +

+
+ +
+ + onChange({ ...value, repositoryUrl: e.target.value })} + className="h-8 text-xs" + autoComplete="off" + spellCheck={false} + /> + + onChange({ ...value, startingRef: e.target.value })} + className="h-8 text-xs" + autoComplete="off" + spellCheck={false} + /> + {value.repositoryUrl !== null || value.startingRef !== null ? ( + + ) : ( +

+ {projectDir ? t('cursor.repositoryAuto') : t('cursor.repositoryNoProject')} +

+ )} +
+ + + + {warnings.length > 0 ? ( +
+ {warnings.map((warning) => ( +

+ + {warning} +

+ ))} +
+ ) : null} + + {newAgent ? ( +

+ {t('cursor.newAgentNotice')} +

+ ) : null} +
+ ) : null} + + ) +} + +function DimensionRow({ + dimension, + selected, + onPick, + disabled, +}: { + dimension: CursorDimension + selected?: string + onPick: (value: string) => void + disabled?: boolean +}) { + return ( +
+ +
+ {dimension.values.map((option) => ( + onPick(option.value)} + label={option.label} + /> + ))} +
+
+ ) +} + +function OptionChip({ + active, + label, + onClick, + disabled, +}: { + active: boolean + label: string + onClick: () => void + disabled?: boolean +}) { + return ( + + ) +} diff --git a/web/src/components/chat/ModelPicker.tsx b/web/src/components/chat/ModelPicker.tsx index 325ad08..f7d1402 100644 --- a/web/src/components/chat/ModelPicker.tsx +++ b/web/src/components/chat/ModelPicker.tsx @@ -1,30 +1,39 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { CaretDown, CircleNotch, + Cloud, Cpu, MagnifyingGlass, } from "@phosphor-icons/react"; import { get, isDashboardPasswordRequired, post } from "@/lib/api"; +import { + chatTargetFromModel, + composerTargetKey, + composerTargetLabel, + cursorCatalogueState, + cursorTargetFromModel, + searchComposerTargets, + type ChatCatalogueModel, + type ChatTarget, + type ComposerTarget, +} from "@/lib/composerTargets"; +import type { CursorModel } from "@/lib/cursorModels"; +import { cursorVariantSummary, defaultCursorVariant } from "@/lib/cursorModels"; import { useI18n } from "@/lib/i18n"; -import type { - ChatModelSelection, - ReasoningCapability, -} from "@/lib/models"; +import type { ReasoningCapability } from "@/lib/models"; import { cn } from "@/lib/utils"; -interface AllModel { - id: string; - name: string; - provider: string; - provider_label: string; - reasoning_capability?: ReasoningCapability; -} - interface ListAll { active: { model: string; provider: string }; - models: AllModel[]; + models: ChatCatalogueModel[]; +} + +interface CursorCatalogue { + models: CursorModel[]; + needs_key?: boolean; + error?: string; } interface ModelOptions { @@ -39,30 +48,25 @@ interface ModelInfo { reasoning_capability?: ReasoningCapability; } -function modelSelection(model: AllModel): ChatModelSelection { - return { - provider: model.provider, - model: model.id, - name: model.name, - providerLabel: model.provider_label, - reasoningCapability: model.reasoning_capability, - }; -} - /** - * Switch the active model straight from the composer, without leaving the chat. - * Lists every connected provider's models (same source as the Models page) and - * sets provider+model together, since a model always knows its provider. + * Pick where the next message runs, without leaving the chat. Chat models and + * Cursor Cloud Agents are searched together but stay separate targets: picking + * a chat model sets the active Antares model, while picking a Cursor model only + * routes this conversation's turns to Cursor and never touches `/model/set`. */ export function ModelPicker({ - onModelChange, + value, + onChange, }: { - onModelChange?: (selection: ChatModelSelection) => void; + value: ComposerTarget | null; + onChange: (target: ComposerTarget) => void; }) { const { t } = useI18n(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [data, setData] = useState(null); + const [cursorData, setCursorData] = useState(); + const [cursorError, setCursorError] = useState(); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(""); const [pickError, setPickError] = useState(); @@ -75,9 +79,21 @@ export function ModelPicker({ provider: string; } | null>(null); + // Read the current target through a ref: the mount resolution below must not + // overwrite a Cursor selection the user already made, and must not re-run + // (and re-fetch) every time the composer hands down a new target object. + const valueRef = useRef(value); + valueRef.current = value; + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + const adoptChatDefault = useCallback((target: ChatTarget) => { + if (valueRef.current) return; + onChangeRef.current(target); + }, []); + const load = () => { setLoading(true); - return get("/model/list-all") + const chat = get("/model/list-all") .then((d) => { setData(d); const activeModel = d.models.find( @@ -85,10 +101,21 @@ export function ModelPicker({ model.id === d.active?.model && model.provider === d.active?.provider, ); - if (activeModel) onModelChange?.(modelSelection(activeModel)); + if (activeModel) adoptChatDefault(chatTargetFromModel(activeModel)); }) - .catch(() => {}) - .finally(() => setLoading(false)); + .catch(() => {}); + // Cursor's catalogue is a separate call on purpose: it is never merged into + // the chat model list, and a Cursor failure must not hide chat models. + const cursor = get("/providers/cursor/models") + .then((d) => { + setCursorData(d); + setCursorError(undefined); + }) + .catch((e: Error) => { + setCursorData(undefined); + setCursorError(e); + }); + return Promise.all([chat, cursor]).finally(() => setLoading(false)); }; // The cheap options call identifies the persisted active pair. Resolve that @@ -107,7 +134,8 @@ export function ModelPicker({ const providerLabel = options.providers?.find((provider) => provider.id === active.provider) ?.label ?? active.provider; - const fallback: ChatModelSelection = { + const fallback: ChatTarget = { + kind: "chat", provider: active.provider, model: active.model, name: active.model, @@ -119,7 +147,7 @@ export function ModelPicker({ `/providers/${encodeURIComponent(active.provider)}/model-info?model=${encodeURIComponent(active.model)}`, ); if (cancelled || sequence !== resolutionRef.current) return; - onModelChange?.({ + adoptChatDefault({ ...fallback, name: info.found ? info.name || active.model : active.model, reasoningCapability: info.found @@ -128,7 +156,7 @@ export function ModelPicker({ }); } catch { if (!cancelled && sequence === resolutionRef.current) { - onModelChange?.(fallback); + adoptChatDefault(fallback); } } }) @@ -136,7 +164,7 @@ export function ModelPicker({ return () => { cancelled = true; }; - }, [onModelChange]); + }, [adoptChatDefault]); // The full model list (which probes every provider) is fetched lazily on open, // and refreshed each open so a newly connected provider's models appear. @@ -156,31 +184,38 @@ export function ModelPicker({ // Prefer the freshly-probed list's active; fall back to the cheap mount fetch. const active = data?.active ?? activeConfig; - const activeLabel = active?.model || t("models.pickModel"); + const chipLabel = + composerTargetLabel(value) || active?.model || t("models.pickModel"); + const cursorState = cursorCatalogueState(cursorData, cursorError); + const cursorMessage = cursorData?.error ?? cursorError?.message; - const shown = useMemo(() => { - const list = data?.models ?? []; - const q = query.trim().toLowerCase(); - if (!q) return list; - return list.filter( - (m) => - m.id.toLowerCase().includes(q) || - m.name.toLowerCase().includes(q) || - m.provider_label.toLowerCase().includes(q), - ); - }, [data, query]); + const shown = useMemo( + () => + searchComposerTargets({ + chatModels: data?.models ?? [], + cursorModels: cursorData?.models ?? [], + query, + }), + [data, cursorData, query], + ); + const selectedKey = value ? composerTargetKey(value) : ""; - const pick = async (m: AllModel) => { + const pickChat = async (target: ChatTarget) => { ++resolutionRef.current; - setSaving(`${m.provider}/${m.id}`); + setSaving(composerTargetKey(target)); setPickError(undefined); try { - await post("/model/set", { model: m.id, provider: m.provider }); - setActiveConfig({ model: m.id, provider: m.provider }); + await post("/model/set", { + model: target.model, + provider: target.provider, + }); + setActiveConfig({ model: target.model, provider: target.provider }); setData((d) => - d ? { ...d, active: { model: m.id, provider: m.provider } } : d, + d + ? { ...d, active: { model: target.model, provider: target.provider } } + : d, ); - onModelChange?.(modelSelection(m)); + onChange(target); setOpen(false); setQuery(""); } catch (e) { @@ -198,6 +233,18 @@ export function ModelPicker({ } }; + // Cursor is an execution target, not a chat provider: selecting one changes + // only this composer, so there is nothing to save and nothing to fail. + const pickCursor = (model: CursorModel) => { + setPickError(undefined); + onChange(cursorTargetFromModel(model)); + setOpen(false); + setQuery(""); + }; + + const empty = + shown.chat.length === 0 && shown.cursor.length === 0 && cursorState !== "connect"; + return (
{open ? ( -
+
setQuery(e.target.value)} placeholder={t("models.searchAll")} + aria-label={t("models.searchAll")} className="h-8 w-full rounded-[var(--radius-sm)] border border-border bg-background pl-8 pr-2 text-xs outline-none focus:border-ring" />
@@ -244,49 +300,132 @@ export function ModelPicker({

) : null}
- {shown.length === 0 && loading ? ( + {empty && loading ? (
{t("models.loading")}
- ) : shown.length === 0 ? ( + ) : empty ? (

{t("models.none")}

- ) : ( - shown.map((m) => { - const isActive = - m.id === active?.model && m.provider === active?.provider; - return ( - + ); + })} + + {cursorState === "connect" || shown.cursor.length > 0 ? ( + } + label={t("target.cursorGroup")} + /> + ) : null} + {cursorState === "connect" ? ( +
+

{t("target.cursorNeedsKey")}

+ setOpen(false)} + className="mt-1 inline-block font-medium text-primary underline underline-offset-2" + > + {t("target.cursorConnect")} + +
+ ) : null} + {cursorState === "error" && cursorMessage ? ( +

+ {cursorMessage} +

+ ) : null} + {shown.cursor.map((target) => { + const key = composerTargetKey(target); + const summary = cursorVariantSummary( + target.model, + defaultCursorVariant(target.model), + ); + return ( + - ); - }) - )} + + + ); + })}
) : null}
); } + +function GroupHeading({ + icon, + label, +}: { + icon: React.ReactNode; + label: string; +}) { + return ( +
+ {icon} + {label} +
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ff97042..e11a13a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,8 +1,15 @@ /** Typed client for the Antares HTTP API. */ -/** True when an error is the "set a dashboard password first" 428 gate. */ +/** + * True when an error is the "set a dashboard password first" gate. The marker + * in the body distinguishes it from other 428 answers — Cursor reports a + * missing integration credential with the same status but its own message. + */ export function isDashboardPasswordRequired(e: unknown): boolean { - return e instanceof ApiError && e.status === 428 + if (!(e instanceof ApiError) || e.status !== 428) return false + const body = e.body + if (typeof body !== 'object' || body === null || !('error' in body)) return true + return (body as { error: unknown }).error === 'dashboard_password_required' } export class ApiError extends Error { @@ -33,6 +40,30 @@ function authHeaders(): Record { return token ? { Authorization: `Bearer ${token}` } : {} } +/** + * Turn a non-2xx response into an ApiError that keeps the parsed body. The + * status and the server's own `error` string are what the UI needs to explain a + * 409 busy session, a 429 with retry-after, an auth failure, or a stale model. + */ +async function responseError(res: Response): Promise { + const text = await res.text().catch(() => '') + let body: unknown = text + if (text) { + try { + body = JSON.parse(text) + } catch { + /* keep raw text */ + } + } + const message = + typeof body === 'object' && body !== null && 'error' in body + ? String((body as { error: unknown }).error) + : typeof body === 'string' && body.trim() !== '' + ? body + : res.statusText || `HTTP ${res.status}` + return new ApiError(res.status, message, body) +} + export async function api(path: string, init: RequestInit = {}): Promise { const res = await fetch(`/api${path}`, { ...init, @@ -53,6 +84,8 @@ export async function api(path: string, init: RequestInit = {}): Promise { } } + if (!res.ok) throw await responseError(res) + const text = await res.text() let body: unknown = text if (text) { @@ -62,14 +95,6 @@ export async function api(path: string, init: RequestInit = {}): Promise { /* keep raw text */ } } - - if (!res.ok) { - const msg = - typeof body === 'object' && body !== null && 'error' in body - ? String((body as { error: unknown }).error) - : res.statusText || `HTTP ${res.status}` - throw new ApiError(res.status, msg, body) - } return body as T } @@ -138,10 +163,11 @@ export function streamPost( body: JSON.stringify(data), signal: controller.signal, }) - if (!res.ok || !res.body) { - const text = await res.text().catch(() => '') - throw new ApiError(res.status, text || res.statusText) - } + // A refused turn answers with the same JSON error envelope as `api`, so + // the composer can tell a busy session from a rate limit or a stale + // model instead of showing a bare status line. + if (!res.ok) throw await responseError(res) + if (!res.body) throw new ApiError(res.status, 'the response had no body') const reader = res.body.getReader() const decoder = new TextDecoder() @@ -211,10 +237,8 @@ export function streamGet( headers: { ...authHeaders(), Accept: 'text/event-stream' }, signal: controller.signal, }) - if (!res.ok || !res.body) { - const text = await res.text().catch(() => '') - throw new ApiError(res.status, text || res.statusText) - } + if (!res.ok) throw await responseError(res) + if (!res.body) throw new ApiError(res.status, 'the response had no body') const reader = res.body.getReader() const decoder = new TextDecoder() diff --git a/web/src/lib/chatEvents.test.mjs b/web/src/lib/chatEvents.test.mjs new file mode 100644 index 0000000..2c183fe --- /dev/null +++ b/web/src/lib/chatEvents.test.mjs @@ -0,0 +1,212 @@ +import { describe, expect, test } from 'bun:test' +import { + approvalFromEvent, + cursorSessionHydration, + mergeApprovals, + parseCursorApproval, + pendingApprovalsForSession, + shouldReconnectAttach, + stopBehavior, +} from './chatEvents.ts' + +const startArguments = JSON.stringify({ + operation: 'start', + kind: 'new_agent', + model: { + id: 'gpt-5.6-sol', + params: [ + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'off' }, + ], + }, + repository_url: 'https://github.com/acme/repo', + repository_source: 'auto', + starting_ref: 'main', + worktree_dirty: true, + local_only_commits: 2, + remote_ref_known: false, + warnings: ['Local uncommitted changes are absent from the Cursor cloud VM.'], + mode: 'agent', + auto_create_pr: false, + prompt_preview: 'ship the release', + image_count: 1, +}) + +describe('approval events', () => { + test('an approval event becomes a card view', () => { + expect( + approvalFromEvent({ + type: 'approval', + id: 'apr_1', + name: 'cursor_direct', + arguments: startArguments, + message: 'Start Cursor Cloud Agent run', + }), + ).toEqual({ + id: 'apr_1', + tool: 'cursor_direct', + arguments: startArguments, + message: 'Start Cursor Cloud Agent run', + }) + }) + + test('non-approval and id-less events are ignored', () => { + expect(approvalFromEvent({ type: 'text', delta: 'hi' })).toBeNull() + expect(approvalFromEvent({ type: 'approval', name: 'cursor_direct' })).toBeNull() + }) + + test('the same approval never appears twice and keeps its decision', () => { + const first = { id: 'apr_1', tool: 'cursor_direct', arguments: '{}', message: 'Start' } + const decided = mergeApprovals( + mergeApprovals([], first).map((a) => ({ ...a, decided: 'allowed' })), + { ...first, message: 'Start again' }, + ) + expect(decided).toHaveLength(1) + expect(decided[0].decided).toBe('allowed') + expect(mergeApprovals(decided, { id: 'apr_2', tool: 'cursor_direct_cancel', arguments: '{}', message: 'Cancel' })).toHaveLength(2) + }) + + test('pending approvals are scoped to the open session and de-duplicated', () => { + const existing = [{ id: 'apr_1', tool: 'cursor_direct', arguments: '{}', message: 'Start', decided: 'allowed' }] + const merged = pendingApprovalsForSession( + existing, + [ + { id: 'apr_1', session_id: 'ses_1', tool: 'cursor_direct', arguments: '{}', message: 'Start' }, + { id: 'apr_2', session_id: 'ses_1', tool: 'cursor_direct_cancel', arguments: '{}', message: 'Cancel' }, + { id: 'apr_3', session_id: 'ses_2', tool: 'terminal', arguments: '{}', message: 'Other session' }, + ], + 'ses_1', + ) + expect(merged.map((a) => a.id)).toEqual(['apr_1', 'apr_2']) + expect(merged[0].decided).toBe('allowed') + }) + + test('no open session shows no pending approvals', () => { + expect( + pendingApprovalsForSession([], [{ id: 'apr_1', session_id: 'ses_1', tool: 'x', arguments: '{}', message: '' }], undefined), + ).toEqual([]) + }) +}) + +describe('Cursor approval details', () => { + test('parses the immutable Cursor projection', () => { + const details = parseCursorApproval({ + id: 'apr_1', + tool: 'cursor_direct', + arguments: startArguments, + message: 'Start Cursor Cloud Agent run', + }) + expect(details).toEqual({ + operation: 'start', + newAgent: true, + model: 'gpt-5.6-sol', + params: [ + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'off' }, + ], + repositoryUrl: 'https://github.com/acme/repo', + repositorySource: 'auto', + startingRef: 'main', + worktreeDirty: true, + localOnlyCommits: 2, + remoteRefKnown: false, + warnings: ['Local uncommitted changes are absent from the Cursor cloud VM.'], + mode: 'agent', + autoCreatePR: false, + promptPreview: 'ship the release', + imageCount: 1, + agentId: '', + runId: '', + }) + }) + + test('a follow-up is not a new agent', () => { + const details = parseCursorApproval({ + id: 'apr_2', + tool: 'cursor_direct', + arguments: JSON.stringify({ operation: 'follow_up', kind: 'follow_up', model: { id: 'x', params: [] } }), + message: 'Continue', + }) + expect(details?.newAgent).toBe(false) + expect(details?.operation).toBe('follow_up') + }) + + test('a cancellation names the remote run and carries no model params', () => { + const details = parseCursorApproval({ + id: 'apr_3', + tool: 'cursor_direct_cancel', + arguments: JSON.stringify({ operation: 'cancel', agent_id: 'bc-1', run_id: 'run-1' }), + message: 'Cancel Cursor Cloud Agent run', + }) + expect(details?.operation).toBe('cancel') + expect(details?.params).toEqual([]) + expect(details?.agentId).toBe('bc-1') + expect(details?.runId).toBe('run-1') + }) + + test('other tools and malformed payloads have no Cursor details', () => { + expect(parseCursorApproval({ id: 'a', tool: 'terminal', arguments: startArguments, message: '' })).toBeNull() + expect(parseCursorApproval({ id: 'a', tool: 'cursor_direct', arguments: 'not json', message: '' })).toBeNull() + }) +}) + +describe('stream lifecycle', () => { + test('Cursor Stop detaches locally while chat Stop interrupts the turn', () => { + expect(stopBehavior('cursor')).toEqual({ interrupt: false, detach: true }) + expect(stopBehavior('chat')).toEqual({ interrupt: true, detach: false }) + }) + + test('an intentional detach stops the standing attach loop from reconnecting', () => { + expect(shouldReconnectAttach({ alive: true, detached: false })).toBe(true) + expect(shouldReconnectAttach({ alive: true, detached: true })).toBe(false) + expect(shouldReconnectAttach({ alive: false, detached: false })).toBe(false) + }) +}) + +describe('Cursor session hydration', () => { + test('restores the Cursor target, status, and branches from persisted messages', () => { + const state = cursorSessionHydration([ + { id: 'm1', role: 'user', content: 'hi', meta: { cursor_image_count: 1 } }, + { + id: 'm2', + role: 'assistant', + content: 'done', + model: 'gpt-5.6-sol', + meta: { + cursor_remote_status: 'FINISHED', + cursor_git_state: JSON.stringify({ + branches: [ + { repoUrl: 'https://github.com/acme/repo', branch: 'cursor/x', prUrl: 'https://github.com/acme/repo/pull/7' }, + ], + }), + }, + }, + ]) + expect(state).toEqual({ + active: true, + modelId: 'gpt-5.6-sol', + remoteStatus: 'FINISHED', + branches: [ + { + repoUrl: 'https://github.com/acme/repo', + branch: 'cursor/x', + prUrl: 'https://github.com/acme/repo/pull/7', + }, + ], + }) + }) + + test('an ordinary chat session is not a Cursor session', () => { + expect( + cursorSessionHydration([{ id: 'm1', role: 'assistant', content: 'hi', model: 'gpt-5.6' }]), + ).toEqual({ active: false, branches: [] }) + }) + + test('malformed Cursor git state never breaks hydration', () => { + expect( + cursorSessionHydration([ + { id: 'm1', role: 'assistant', content: 'x', model: 'sol', meta: { cursor_remote_status: 'ERROR', cursor_git_state: '{' } }, + ]), + ).toEqual({ active: true, modelId: 'sol', remoteStatus: 'ERROR', branches: [] }) + }) +}) diff --git a/web/src/lib/chatEvents.ts b/web/src/lib/chatEvents.ts new file mode 100644 index 0000000..7c92783 --- /dev/null +++ b/web/src/lib/chatEvents.ts @@ -0,0 +1,222 @@ +/** + * Pure stream-lifecycle and approval helpers shared by the chat page. + * + * The approval payload the server publishes is an immutable display + * projection: the pending operation itself stays on the server behind an + * opaque id, so nothing parsed here can change what a decision executes. + */ + +export interface ApprovalView { + id: string + tool: string + arguments: string + message: string + /** Set once answered, so the card shows the outcome instead of buttons. */ + decided?: 'allowed' | 'refused' | 'expired' +} + +export interface PendingApproval { + id: string + session_id: string + tool: string + arguments: string + message?: string +} + +export const CURSOR_APPROVAL_TOOLS = ['cursor_direct', 'cursor_direct_cancel'] as const + +/** An `approval` stream event as a card view, or null for anything else. */ +export function approvalFromEvent( + event: Record, +): ApprovalView | null { + if (event.type !== 'approval') return null + const id = typeof event.id === 'string' ? event.id : '' + if (!id) return null + return { + id, + tool: String(event.name ?? ''), + arguments: String(event.arguments ?? ''), + message: String(event.message ?? ''), + } +} + +/** Add an approval once. A decision already shown to the user is never reset. */ +export function mergeApprovals( + current: ApprovalView[], + incoming: ApprovalView, +): ApprovalView[] { + if (current.some((approval) => approval.id === incoming.id)) return current + return [...current, incoming] +} + +/** + * The approvals waiting on this session, merged into what is already on screen. + * Used after (re)opening a session, where the `approval` event that announced a + * still-pending decision was published before this page attached. + */ +export function pendingApprovalsForSession( + current: ApprovalView[], + pending: PendingApproval[], + sessionId: string | undefined, +): ApprovalView[] { + if (!sessionId) return current + let merged = current + for (const request of pending ?? []) { + if (request.session_id !== sessionId) continue + merged = mergeApprovals(merged, { + id: request.id, + tool: request.tool, + arguments: request.arguments, + message: request.message ?? '', + }) + } + return merged +} + +export interface CursorApprovalDetails { + operation: string + newAgent: boolean + model: string + params: Array<{ id: string; value: string }> + repositoryUrl: string + repositorySource: string + startingRef: string + worktreeDirty: boolean + localOnlyCommits: number + remoteRefKnown: boolean + warnings: string[] + mode: string + autoCreatePR: boolean + promptPreview: string + imageCount: number + /** Populated for a cancellation, which names the run it would stop. */ + agentId: string + runId: string +} + +/** The Cursor projection behind an approval, or null for any other tool. */ +export function parseCursorApproval( + approval: Pick, +): CursorApprovalDetails | null { + if (!CURSOR_APPROVAL_TOOLS.includes(approval.tool as (typeof CURSOR_APPROVAL_TOOLS)[number])) { + return null + } + let parsed: Record + try { + const decoded: unknown = JSON.parse(approval.arguments) + if (typeof decoded !== 'object' || decoded === null) return null + parsed = decoded as Record + } catch { + return null + } + + const model = (parsed.model ?? {}) as { id?: unknown; params?: unknown } + const params = Array.isArray(model.params) + ? (model.params as Array>).map((param) => ({ + id: String(param.id ?? ''), + value: String(param.value ?? ''), + })) + : [] + return { + operation: String(parsed.operation ?? ''), + newAgent: parsed.kind === 'new_agent', + model: String(model.id ?? ''), + params, + repositoryUrl: String(parsed.repository_url ?? ''), + repositorySource: String(parsed.repository_source ?? ''), + startingRef: String(parsed.starting_ref ?? ''), + worktreeDirty: parsed.worktree_dirty === true, + localOnlyCommits: Number(parsed.local_only_commits ?? 0), + remoteRefKnown: parsed.remote_ref_known === true, + warnings: Array.isArray(parsed.warnings) ? parsed.warnings.map(String) : [], + mode: String(parsed.mode ?? ''), + autoCreatePR: parsed.auto_create_pr === true, + promptPreview: String(parsed.prompt_preview ?? ''), + imageCount: Number(parsed.image_count ?? 0), + agentId: String(parsed.agent_id ?? ''), + runId: String(parsed.run_id ?? ''), + } +} + +/** + * What the composer's Stop button does. A Cursor run lives on Cursor's side, so + * Stop only closes this browser's stream; cancelling it remotely is a separate, + * approved action. + */ +export function stopBehavior(kind: 'chat' | 'cursor'): { + interrupt: boolean + detach: boolean +} { + return kind === 'cursor' + ? { interrupt: false, detach: true } + : { interrupt: true, detach: false } +} + +/** + * Whether the standing attach loop may reconnect. After an intentional detach + * it must not, or Stop would immediately re-follow the run it just left. + */ +export function shouldReconnectAttach(state: { + alive: boolean + detached: boolean +}): boolean { + return state.alive && !state.detached +} + +export interface CursorBranch { + repoUrl: string + branch: string + prUrl: string +} + +export interface CursorSessionHydration { + active: boolean + modelId?: string + remoteStatus?: string + branches: CursorBranch[] +} + +interface HydrationMessage { + role: string + model?: string + meta?: Record | null +} + +/** + * Recover the Cursor side of a persisted session: only a Cursor turn records a + * remote status, so the newest one identifies the model, its outcome, and the + * branches or pull requests the run produced. + */ +export function cursorSessionHydration( + messages: HydrationMessage[], +): CursorSessionHydration { + for (let i = (messages ?? []).length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role !== 'assistant') continue + const status = message.meta?.cursor_remote_status + if (typeof status !== 'string' || !status) continue + return { + active: true, + modelId: message.model || undefined, + remoteStatus: status, + branches: parseCursorBranches(message.meta?.cursor_git_state), + } + } + return { active: false, branches: [] } +} + +function parseCursorBranches(raw: unknown): CursorBranch[] { + if (typeof raw !== 'string' || !raw) return [] + try { + const parsed: unknown = JSON.parse(raw) + const branches = (parsed as { branches?: unknown })?.branches + if (!Array.isArray(branches)) return [] + return branches.map((branch: Record) => ({ + repoUrl: String(branch.repoUrl ?? ''), + branch: String(branch.branch ?? ''), + prUrl: String(branch.prUrl ?? ''), + })) + } catch { + return [] + } +} diff --git a/web/src/lib/composerTargets.test.mjs b/web/src/lib/composerTargets.test.mjs new file mode 100644 index 0000000..d7950d8 --- /dev/null +++ b/web/src/lib/composerTargets.test.mjs @@ -0,0 +1,234 @@ +import { describe, expect, test } from 'bun:test' +import { + chatTargetFromModel, + composerTargetKey, + cursorCatalogueState, + cursorChatRequest, + cursorTargetFromModel, + isCursorTarget, + searchComposerTargets, + startsNewCursorAgent, +} from './composerTargets.ts' + +const chatModels = [ + { + id: 'gpt-5.6', + name: 'GPT 5.6', + provider: 'openai', + provider_label: 'OpenAI', + reasoning_capability: { values: [], mandatory: false, can_disable: false, source: 'live' }, + }, + { + id: 'claude-opus-4-6', + name: 'Claude Opus 4.6', + provider: 'anthropic', + provider_label: 'Anthropic', + }, +] + +const cursorModels = [ + { + id: 'gpt-5.6-sol', + name: 'GPT 5.6 Sol', + aliases: ['sol'], + parameters: [{ id: 'reasoning', values: [{ value: 'low' }, { value: 'max' }] }], + variants: [ + { + params: [ + { id: 'reasoning', value: 'low' }, + { id: 'internal', value: 'off' }, + ], + displayName: 'GPT 5.6 Sol', + isDefault: true, + }, + { + params: [ + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'off' }, + ], + displayName: 'GPT 5.6 Sol (max)', + }, + ], + }, + { + id: 'auto-smart', + name: 'Auto (smart)', + aliases: ['auto'], + parameters: [], + variants: [], + }, +] + +describe('composer targets', () => { + test('a chat target carries provider metadata and reasoning capability', () => { + const target = chatTargetFromModel(chatModels[0]) + expect(target).toEqual({ + kind: 'chat', + provider: 'openai', + model: 'gpt-5.6', + name: 'GPT 5.6', + providerLabel: 'OpenAI', + reasoningCapability: chatModels[0].reasoning_capability, + }) + expect(isCursorTarget(target)).toBe(false) + }) + + test('a Cursor target starts from the upstream default variant', () => { + const target = cursorTargetFromModel(cursorModels[0]) + expect(target.kind).toBe('cursor') + expect(target.variant).toBe(cursorModels[0].variants[0]) + expect(isCursorTarget(target)).toBe(true) + }) + + test('target keys separate the two execution surfaces', () => { + expect(composerTargetKey(chatTargetFromModel(chatModels[0]))).toBe('chat:openai/gpt-5.6') + expect(composerTargetKey(cursorTargetFromModel(cursorModels[0]))).toBe('cursor:gpt-5.6-sol') + }) +}) + +describe('grouped target search', () => { + test('searches chat id, name, and provider label', () => { + expect(searchComposerTargets({ chatModels, cursorModels, query: 'anthropic' }).chat).toHaveLength(1) + expect(searchComposerTargets({ chatModels, cursorModels, query: 'opus' }).chat[0].model).toBe( + 'claude-opus-4-6', + ) + expect(searchComposerTargets({ chatModels, cursorModels, query: 'gpt-5.6' }).chat[0].model).toBe( + 'gpt-5.6', + ) + }) + + test('searches Cursor id, name, and alias without mixing the groups', () => { + const bySlug = searchComposerTargets({ chatModels, cursorModels, query: 'sol' }) + expect(bySlug.cursor.map((t) => t.model.id)).toEqual(['gpt-5.6-sol']) + expect(bySlug.chat).toHaveLength(0) + + const byAlias = searchComposerTargets({ chatModels, cursorModels, query: 'auto' }) + expect(byAlias.cursor.map((t) => t.model.id)).toEqual(['auto-smart']) + }) + + test('the Cursor group answers a Cursor provider search', () => { + const found = searchComposerTargets({ chatModels, cursorModels, query: 'cursor' }) + expect(found.cursor).toHaveLength(2) + expect(found.chat).toHaveLength(0) + }) + + test('an empty query keeps both catalogues intact', () => { + const all = searchComposerTargets({ chatModels, cursorModels, query: '' }) + expect(all.chat).toHaveLength(2) + expect(all.cursor).toHaveLength(2) + }) +}) + +describe('Cursor catalogue state', () => { + test('a missing key asks for the Connect action instead of an error', () => { + expect(cursorCatalogueState({ needs_key: true, models: [] })).toBe('connect') + }) + + test('a catalogue error is reported as an error', () => { + expect(cursorCatalogueState({ models: [], error: 'Cursor API key expired' })).toBe('error') + expect(cursorCatalogueState(undefined, new Error('Network unavailable'))).toBe('error') + }) + + test('a connected but empty catalogue is empty, not disconnected', () => { + expect(cursorCatalogueState({ models: [] })).toBe('empty') + expect(cursorCatalogueState({ models: cursorModels })).toBe('ready') + }) +}) + +describe('Cursor run identity', () => { + const base = { + model: cursorModels[0], + variant: cursorModels[0].variants[0], + mode: 'agent', + repositoryUrl: 'https://github.com/acme/repo', + startingRef: 'main', + autoCreatePR: false, + } + + test('mode-only changes continue the same agent', () => { + expect(startsNewCursorAgent(base, { ...base, mode: 'plan' })).toBe(false) + }) + + test('model, variant, repository, ref, and auto-PR changes start a new agent', () => { + expect( + startsNewCursorAgent(base, { ...base, variant: cursorModels[0].variants[1] }), + ).toBe(true) + expect( + startsNewCursorAgent(base, { ...base, model: cursorModels[1], variant: { params: [] } }), + ).toBe(true) + expect(startsNewCursorAgent(base, { ...base, repositoryUrl: '' })).toBe(true) + expect(startsNewCursorAgent(base, { ...base, startingRef: 'release' })).toBe(true) + expect(startsNewCursorAgent(base, { ...base, autoCreatePR: true })).toBe(true) + }) + + test('no previous run never warns about a new agent', () => { + expect(startsNewCursorAgent(null, base)).toBe(false) + }) +}) + +describe('Cursor chat request', () => { + const value = { + model: cursorModels[0], + variant: cursorModels[0].variants[1], + mode: 'plan', + repositoryUrl: null, + startingRef: null, + autoCreatePR: false, + } + + test('sends the exact upstream variant params', () => { + const request = cursorChatRequest(value, { + sessionId: 'ses_1', + message: 'ship it', + images: ['data:image/png;base64,AAAA'], + projectDir: '/home/me/project', + }) + expect(request.model).toEqual({ + id: 'gpt-5.6-sol', + params: [ + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'off' }, + ], + }) + expect(request.mode).toBe('plan') + expect(request.session_id).toBe('ses_1') + expect(request.images).toEqual(['data:image/png;base64,AAAA']) + expect(request.project_dir).toBe('/home/me/project') + expect(request.auto_create_pr).toBe(false) + }) + + test('omits repository overrides so the server discovers the project repo', () => { + const request = cursorChatRequest(value, { sessionId: '', message: 'hi' }) + expect('repository_url' in request).toBe(false) + expect('starting_ref' in request).toBe(false) + expect('project_dir' in request).toBe(false) + }) + + test('an edited repository and ref are sent verbatim, including a cleared repository', () => { + const edited = cursorChatRequest( + { ...value, repositoryUrl: 'https://github.com/acme/repo', startingRef: 'main' }, + { sessionId: 'ses_1', message: 'hi' }, + ) + expect(edited.repository_url).toBe('https://github.com/acme/repo') + expect(edited.starting_ref).toBe('main') + + const cleared = cursorChatRequest( + { ...value, repositoryUrl: '', startingRef: '' }, + { sessionId: 'ses_1', message: 'hi' }, + ) + expect(cleared.repository_url).toBe('') + expect(cleared.starting_ref).toBe('') + }) + + test('the request never carries composer-only fields', () => { + const request = cursorChatRequest(value, { sessionId: 'ses_1', message: 'hi' }) + expect(Object.keys(request).sort()).toEqual([ + 'auto_create_pr', + 'images', + 'message', + 'mode', + 'model', + 'session_id', + ]) + }) +}) diff --git a/web/src/lib/composerTargets.ts b/web/src/lib/composerTargets.ts new file mode 100644 index 0000000..2592450 --- /dev/null +++ b/web/src/lib/composerTargets.ts @@ -0,0 +1,205 @@ +/** + * The composer's execution target. A chat target runs through `/api/chat` and + * the active Antares provider; a Cursor target runs through `/api/chat/cursor` + * and never becomes the active chat provider. + */ + +import { + cursorModelMatches, + defaultCursorVariant, + type CursorModel, + type CursorVariant, +} from '@/lib/cursorModels' +import type { ReasoningCapability } from '@/lib/models' + +export interface ChatCatalogueModel { + id: string + name: string + provider: string + provider_label: string + reasoning_capability?: ReasoningCapability +} + +export interface ChatTarget { + kind: 'chat' + provider: string + model: string + name: string + providerLabel: string + reasoningCapability?: ReasoningCapability +} + +export interface CursorTarget { + kind: 'cursor' + model: CursorModel + variant: CursorVariant +} + +export type ComposerTarget = ChatTarget | CursorTarget + +export type CursorMode = 'agent' | 'plan' + +/** + * Everything one Cursor turn needs. `repositoryUrl`/`startingRef` are null + * until the user edits them, so the server keeps discovering the project's own + * repository; an empty string is an explicit "no repository". + */ +export interface CursorOptionsValue { + model: CursorModel + variant: CursorVariant + mode: CursorMode + repositoryUrl: string | null + startingRef: string | null + autoCreatePR: boolean +} + +export function isCursorTarget(target: ComposerTarget | null): target is CursorTarget { + return target?.kind === 'cursor' +} + +export function isChatTarget(target: ComposerTarget | null): target is ChatTarget { + return target?.kind === 'chat' +} + +export function chatTargetFromModel(model: ChatCatalogueModel): ChatTarget { + return { + kind: 'chat', + provider: model.provider, + model: model.id, + name: model.name, + providerLabel: model.provider_label, + reasoningCapability: model.reasoning_capability, + } +} + +export function cursorTargetFromModel( + model: CursorModel, + variant: CursorVariant = defaultCursorVariant(model), +): CursorTarget { + return { kind: 'cursor', model, variant } +} + +export function composerTargetKey(target: ComposerTarget): string { + return target.kind === 'cursor' + ? `cursor:${target.model.id}` + : `chat:${target.provider}/${target.model}` +} + +/** The composer chip label: the chat model id, or `Cursor · `. */ +export function composerTargetLabel(target: ComposerTarget | null): string { + if (!target) return '' + return target.kind === 'cursor' + ? `Cursor · ${target.model.name || target.model.id}` + : target.model +} + +function chatModelMatches(model: ChatCatalogueModel, query: string): boolean { + const q = query.trim().toLowerCase() + if (!q) return true + return [model.id, model.name, model.provider, model.provider_label].some((entry) => + entry.toLowerCase().includes(q), + ) +} + +/** + * One search over both catalogues, presented as two groups. The catalogues stay + * separate: a Cursor hit is never offered as a chat model. + */ +export function searchComposerTargets(input: { + chatModels: ChatCatalogueModel[] + cursorModels: CursorModel[] + query: string +}): { chat: ChatTarget[]; cursor: CursorTarget[] } { + const { chatModels = [], cursorModels = [], query } = input + return { + chat: chatModels + .filter((model) => chatModelMatches(model, query)) + .map(chatTargetFromModel), + cursor: cursorModels + .filter((model) => cursorModelMatches(model, query)) + .map((model) => cursorTargetFromModel(model)), + } +} + +export type CursorCatalogueState = 'connect' | 'error' | 'empty' | 'ready' + +/** + * What the Cursor section should show. A missing credential is an invitation to + * connect, not a failure. + */ +export function cursorCatalogueState( + response: { models?: CursorModel[]; needs_key?: boolean; error?: string } | undefined, + requestError?: Error, +): CursorCatalogueState { + if (response?.needs_key) return 'connect' + if (response?.error || requestError) return 'error' + if ((response?.models ?? []).length === 0) return 'empty' + return 'ready' +} + +/** + * The identity Cursor follow-up reuse depends on. Conversation mode is absent + * on purpose: Create Run accepts a mode override, so switching Agent/Plan + * continues the same remote agent. + */ +export function cursorRunIdentity(value: CursorOptionsValue): string { + return JSON.stringify({ + model: value.model.id, + params: (value.variant.params ?? []).map((param) => [param.id, param.value]), + repository: value.repositoryUrl, + ref: value.startingRef, + autoCreatePR: value.autoCreatePR, + }) +} + +/** Whether sending now would start a new Cursor agent instead of following up. */ +export function startsNewCursorAgent( + previous: CursorOptionsValue | null, + next: CursorOptionsValue, +): boolean { + if (!previous) return false + return cursorRunIdentity(previous) !== cursorRunIdentity(next) +} + +export interface CursorChatRequest { + session_id: string + message: string + images: string[] + model: { id: string; params: Array<{ id: string; value: string }> } + mode: CursorMode + auto_create_pr: boolean + project_dir?: string + repository_url?: string + starting_ref?: string +} + +/** The exact `POST /api/chat/cursor` body for one turn. */ +export function cursorChatRequest( + value: CursorOptionsValue, + turn: { + sessionId: string + message: string + images?: string[] + projectDir?: string + }, +): CursorChatRequest { + const request: CursorChatRequest = { + session_id: turn.sessionId, + message: turn.message, + images: [...(turn.images ?? [])], + model: { + id: value.model.id, + // The whole upstream variant, hidden params included. + params: (value.variant.params ?? []).map((param) => ({ + id: param.id, + value: param.value, + })), + }, + mode: value.mode, + auto_create_pr: value.autoCreatePR, + } + if (turn.projectDir) request.project_dir = turn.projectDir + if (value.repositoryUrl !== null) request.repository_url = value.repositoryUrl + if (value.startingRef !== null) request.starting_ref = value.startingRef + return request +} diff --git a/web/src/lib/cursorAttachments.test.mjs b/web/src/lib/cursorAttachments.test.mjs new file mode 100644 index 0000000..1a77d13 --- /dev/null +++ b/web/src/lib/cursorAttachments.test.mjs @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test' +import { + CURSOR_MAX_IMAGES, + composerImageLimit, + dataUrlByteLength, + dataUrlMimeType, + validateCursorAttachments, +} from './cursorAttachments.ts' + +const png = (bytes = 3) => `data:image/png;base64,${'A'.repeat(Math.ceil(bytes / 3) * 4)}` + +describe('Cursor attachment preflight', () => { + test('local documents are rejected, never silently dropped', () => { + const issue = validateCursorAttachments({ + images: [], + docs: [ + { path: '/tmp/a.pdf', name: 'a.pdf' }, + { path: '/tmp/b.csv', name: 'b.csv' }, + ], + }) + expect(issue).toEqual({ code: 'documents', values: { names: 'a.pdf, b.csv' } }) + }) + + test('documents are reported before any image problem', () => { + const issue = validateCursorAttachments({ + images: Array.from({ length: 9 }, () => png()), + docs: [{ path: '/tmp/a.pdf', name: 'a.pdf' }], + }) + expect(issue?.code).toBe('documents') + }) + + test('five images are accepted and a sixth is refused', () => { + expect(CURSOR_MAX_IMAGES).toBe(5) + expect( + validateCursorAttachments({ images: Array.from({ length: 5 }, () => png()), docs: [] }), + ).toBeNull() + expect( + validateCursorAttachments({ images: Array.from({ length: 6 }, () => png()), docs: [] }), + ).toEqual({ code: 'imageCount', values: { max: 5, n: 6 } }) + }) + + test('only the MIME types Cursor accepts pass', () => { + for (const mime of ['image/png', 'image/jpeg', 'image/gif', 'image/webp']) { + expect( + validateCursorAttachments({ images: [`data:${mime};base64,AAAA`], docs: [] }), + ).toBeNull() + } + expect( + validateCursorAttachments({ images: ['data:image/svg+xml;base64,AAAA'], docs: [] }), + ).toEqual({ code: 'imageType', values: { n: 1, type: 'image/svg+xml' } }) + }) + + test('an entry that is not a base64 data URL is refused', () => { + expect( + validateCursorAttachments({ images: ['https://example.com/a.png'], docs: [] }), + ).toEqual({ code: 'imageType', values: { n: 1, type: '' } }) + }) + + test('an image over the 15 MiB decoded limit is refused before approval', () => { + const oversized = png(15 * 1024 * 1024 + 3) + expect(validateCursorAttachments({ images: [oversized], docs: [] })).toEqual({ + code: 'imageSize', + values: { n: 1, max: 15 }, + }) + }) + + test('composer image limits follow the execution target', () => { + expect(composerImageLimit('cursor')).toBe(5) + expect(composerImageLimit('chat')).toBe(4) + }) + + test('data URL helpers read the declared type and decoded size', () => { + expect(dataUrlMimeType('data:image/webp;base64,AAAA')).toBe('image/webp') + expect(dataUrlMimeType('nonsense')).toBe('') + expect(dataUrlByteLength('data:image/png;base64,AAAA')).toBe(3) + expect(dataUrlByteLength('data:image/png;base64,AAA=')).toBe(2) + expect(dataUrlByteLength('data:image/png;base64,AA==')).toBe(1) + }) +}) diff --git a/web/src/lib/cursorAttachments.ts b/web/src/lib/cursorAttachments.ts new file mode 100644 index 0000000..e2b8ed4 --- /dev/null +++ b/web/src/lib/cursorAttachments.ts @@ -0,0 +1,81 @@ +/** + * Cursor's attachment contract, checked in the composer before anything is + * sent. The server validates the same rules authoritatively; this preflight + * exists so a rejection happens before the draft is cleared and long before a + * paid operation is offered for approval. + */ + +export const CURSOR_MAX_IMAGES = 5 +export const CURSOR_MAX_IMAGE_BYTES = 15 << 20 +export const CURSOR_IMAGE_MIME_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', +] as const + +const CHAT_MAX_IMAGES = 4 + +/** How many images the composer accepts for the current execution target. */ +export function composerImageLimit(kind: 'chat' | 'cursor'): number { + return kind === 'cursor' ? CURSOR_MAX_IMAGES : CHAT_MAX_IMAGES +} + +/** The declared MIME type of a base64 data URL, or "" when it is not one. */ +export function dataUrlMimeType(dataUrl: string): string { + const match = /^data:([^;,]+);base64,/.exec(dataUrl.trim()) + return match ? match[1] : '' +} + +/** Decoded size of a base64 data URL, from its payload length alone. */ +export function dataUrlByteLength(dataUrl: string): number { + const payload = dataUrl.slice(dataUrl.indexOf(',') + 1) + if (!payload) return 0 + const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0 + return Math.max(0, Math.floor((payload.length * 3) / 4) - padding) +} + +export interface CursorAttachmentIssue { + code: 'documents' | 'imageCount' | 'imageType' | 'imageSize' + values: Record +} + +/** + * The first reason this turn cannot be sent to Cursor, or null. Local documents + * are reported first: they are rejected outright rather than silently dropped, + * because a Cursor cloud VM cannot read a path on this machine. + */ +export function validateCursorAttachments(input: { + images: string[] + docs: Array<{ name: string }> +}): CursorAttachmentIssue | null { + const docs = input.docs ?? [] + if (docs.length > 0) { + return { + code: 'documents', + values: { names: docs.map((doc) => doc.name).join(', ') }, + } + } + + const images = input.images ?? [] + if (images.length > CURSOR_MAX_IMAGES) { + return { + code: 'imageCount', + values: { max: CURSOR_MAX_IMAGES, n: images.length }, + } + } + + for (let i = 0; i < images.length; i++) { + const mimeType = dataUrlMimeType(images[i]) + if (!CURSOR_IMAGE_MIME_TYPES.includes(mimeType as (typeof CURSOR_IMAGE_MIME_TYPES)[number])) { + return { code: 'imageType', values: { n: i + 1, type: mimeType } } + } + if (dataUrlByteLength(images[i]) > CURSOR_MAX_IMAGE_BYTES) { + return { + code: 'imageSize', + values: { n: i + 1, max: CURSOR_MAX_IMAGE_BYTES >> 20 }, + } + } + } + return null +} diff --git a/web/src/lib/cursorModels.test.mjs b/web/src/lib/cursorModels.test.mjs new file mode 100644 index 0000000..4b89631 --- /dev/null +++ b/web/src/lib/cursorModels.test.mjs @@ -0,0 +1,251 @@ +import { describe, expect, test } from 'bun:test' +import { + applyCursorDimension, + cursorModelMatches, + cursorReasoningDimension, + cursorVariantDimensions, + cursorVariantSummary, + defaultCursorVariant, + matchingCursorVariants, + selectExactVariant, + variantSelection, +} from './cursorModels.ts' + +const modelFixture = { + id: 'gpt-test', + name: 'GPT Test', + aliases: [], + parameters: [ + { id: 'context', values: [{ value: '272k' }, { value: '1m' }] }, + { id: 'reasoning', values: [{ value: 'low' }, { value: 'max' }] }, + { id: 'fast', values: [{ value: 'false' }, { value: 'true' }] }, + ], + variants: [ + { + params: [ + { id: 'context', value: '272k' }, + { id: 'reasoning', value: 'max' }, + { id: 'fast', value: 'true' }, + ], + displayName: 'GPT Test', + isDefault: true, + }, + ], +} + +// Two reachable context values, each with its own reasoning ladder, so a filter +// can be proven to land on a real upstream variant instead of a synthesized one. +const multiVariantFixture = { + id: 'gpt-5.6-sol', + name: 'GPT 5.6 Sol', + aliases: ['sol'], + parameters: [ + { + id: 'context', + displayName: 'Context', + values: [ + { value: '272k', displayName: '272K' }, + { value: '1m', displayName: '1M' }, + ], + }, + { + id: 'reasoning', + displayName: 'Reasoning', + values: [{ value: 'low' }, { value: 'max' }], + }, + ], + variants: [ + { + params: [ + { id: 'context', value: '272k' }, + { id: 'reasoning', value: 'low' }, + { id: 'internal', value: 'off' }, + ], + displayName: 'GPT 5.6 Sol', + isDefault: true, + }, + { + params: [ + { id: 'context', value: '272k' }, + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'off' }, + ], + displayName: 'GPT 5.6 Sol (max)', + }, + { + params: [ + { id: 'context', value: '1m' }, + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'on' }, + ], + displayName: 'GPT 5.6 Sol (1M)', + }, + ], +} + +const autoSmartFixture = { + id: 'auto-smart', + name: 'Auto (smart)', + aliases: ['auto'], + parameters: [ + { + id: 'optimize_for', + displayName: 'Optimize for', + values: [ + { value: 'speed', displayName: 'Speed' }, + { value: 'quality', displayName: 'Quality' }, + ], + }, + ], + variants: [ + { + params: [{ id: 'optimize_for', value: 'speed' }], + displayName: 'Auto (speed)', + isDefault: true, + }, + { + params: [{ id: 'optimize_for', value: 'quality' }], + displayName: 'Auto (quality)', + }, + ], +} + +describe('exact Cursor variants', () => { + test('default variant keeps hidden params', () => { + const model = { + id: 'claude-opus-5', + name: 'Claude Opus 5', + aliases: [], + parameters: [{ id: 'effort', values: [{ value: 'max' }] }], + variants: [ + { + params: [ + { id: 'cyber', value: 'false' }, + { id: 'effort', value: 'max' }, + ], + displayName: 'Claude Opus 5', + isDefault: true, + }, + ], + } + expect(defaultCursorVariant(model).params).toEqual(model.variants[0].params) + }) + + test('filters never synthesize a missing combination', () => { + expect( + selectExactVariant(modelFixture, { context: '1m', reasoning: 'max', fast: 'true' }), + ).toBeNull() + }) + + test('prefers the upstream default variant, then the first one', () => { + expect(defaultCursorVariant(multiVariantFixture)).toBe(multiVariantFixture.variants[0]) + const noDefault = { ...multiVariantFixture, variants: multiVariantFixture.variants.slice(1) } + expect(defaultCursorVariant(noDefault)).toBe(noDefault.variants[0]) + }) + + test('a model without variants selects an empty parameter list', () => { + const bare = { id: 'bare', name: 'Bare', aliases: [], parameters: [], variants: [] } + expect(defaultCursorVariant(bare)).toEqual({ params: [], displayName: 'Bare' }) + expect(selectExactVariant(bare, {})).toEqual({ params: [], displayName: 'Bare' }) + }) + + test('an exact match returns the upstream variant object itself', () => { + const variant = selectExactVariant(multiVariantFixture, { context: '1m', reasoning: 'max' }) + expect(variant).toBe(multiVariantFixture.variants[2]) + expect(variant.params).toEqual([ + { id: 'context', value: '1m' }, + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'on' }, + ]) + }) + + test('an ambiguous filter commits nothing', () => { + expect(matchingCursorVariants(multiVariantFixture, { context: '272k' })).toHaveLength(2) + expect(selectExactVariant(multiVariantFixture, { context: '272k' })).toBeNull() + }) + + test('changing one dimension lands on a concrete variant with its hidden params', () => { + const from = multiVariantFixture.variants[0] + const next = applyCursorDimension(multiVariantFixture, from, 'reasoning', 'max') + expect(next).toBe(multiVariantFixture.variants[1]) + + const wider = applyCursorDimension(multiVariantFixture, from, 'context', '1m') + // 1M has no low-reasoning variant upstream, so the only real 1M variant wins + // and carries its own hidden internal flag. + expect(wider).toBe(multiVariantFixture.variants[2]) + expect(variantSelection(wider)).toEqual({ + context: '1m', + reasoning: 'max', + internal: 'on', + }) + }) + + test('an unreachable dimension value commits nothing', () => { + expect( + applyCursorDimension(modelFixture, modelFixture.variants[0], 'context', '1m'), + ).toBeNull() + }) +}) + +describe('Cursor variant dimensions', () => { + test('exposes only declared parameters that real variants use', () => { + expect(cursorVariantDimensions(multiVariantFixture).map((d) => d.id)).toEqual([ + 'context', + 'reasoning', + ]) + }) + + test('drops declared values no variant offers', () => { + const dimensions = cursorVariantDimensions(modelFixture) + expect(dimensions.map((d) => d.id)).toEqual(['context', 'reasoning', 'fast']) + expect(dimensions[0].values).toEqual([{ value: '272k', label: '272k' }]) + }) + + test('uses catalogue display names for dimensions and values', () => { + const [context] = cursorVariantDimensions(multiVariantFixture) + expect(context.label).toBe('Context') + expect(context.values).toEqual([ + { value: '272k', label: '272K' }, + { value: '1m', label: '1M' }, + ]) + }) + + test('optimize_for appears only when the connected catalogue returns it', () => { + expect(cursorVariantDimensions(autoSmartFixture).map((d) => d.id)).toEqual(['optimize_for']) + expect(cursorVariantDimensions(multiVariantFixture).map((d) => d.id)).not.toContain( + 'optimize_for', + ) + }) + + test('finds the reasoning-like axis and leaves the rest as plain dimensions', () => { + expect(cursorReasoningDimension(multiVariantFixture)?.id).toBe('reasoning') + expect(cursorReasoningDimension(autoSmartFixture)).toBeNull() + expect( + cursorReasoningDimension({ + ...autoSmartFixture, + parameters: [{ id: 'effort', values: [{ value: 'max' }] }], + variants: [{ params: [{ id: 'effort', value: 'max' }], displayName: 'e' }], + })?.id, + ).toBe('effort') + }) + + test('summarizes a variant from its visible params', () => { + expect(cursorVariantSummary(multiVariantFixture, multiVariantFixture.variants[2])).toBe( + 'Context 1M · Reasoning max', + ) + }) +}) + +describe('Cursor model search', () => { + test('matches id, display name, alias, and the Cursor provider label', () => { + expect(cursorModelMatches(multiVariantFixture, 'sol')).toBe(true) + expect(cursorModelMatches(multiVariantFixture, 'GPT 5.6')).toBe(true) + expect(cursorModelMatches(multiVariantFixture, 'gpt-5.6-SOL')).toBe(true) + expect(cursorModelMatches(multiVariantFixture, 'cursor')).toBe(true) + expect(cursorModelMatches(multiVariantFixture, 'claude')).toBe(false) + }) + + test('an empty query matches everything', () => { + expect(cursorModelMatches(autoSmartFixture, ' ')).toBe(true) + }) +}) diff --git a/web/src/lib/cursorModels.ts b/web/src/lib/cursorModels.ts new file mode 100644 index 0000000..20c8dc5 --- /dev/null +++ b/web/src/lib/cursorModels.ts @@ -0,0 +1,224 @@ +/** + * Pure helpers over the Cursor model catalogue. + * + * Cursor returns whole variants, and a variant's `params` array is the only + * shape the API accepts. Every helper here therefore hands back a concrete + * upstream variant (including params Cursor never lists in `parameters`) or + * nothing at all — a combination Cursor did not return is never assembled from + * the individual parameter values. + */ + +export interface CursorParameterValue { + value: string + displayName?: string +} + +export interface CursorParameter { + id: string + displayName?: string + values: CursorParameterValue[] +} + +export interface CursorVariantParam { + id: string + value: string +} + +export interface CursorVariant { + params: CursorVariantParam[] + displayName: string + description?: string + isDefault?: boolean +} + +export interface CursorModel { + id: string + name: string + description?: string + aliases: string[] + parameters: CursorParameter[] + variants: CursorVariant[] +} + +export interface CursorDimensionValue { + value: string + label: string +} + +export interface CursorDimension { + id: string + label: string + values: CursorDimensionValue[] +} + +/** Parameter ids Cursor uses for the reasoning-like axis, in priority order. */ +export const REASONING_DIMENSION_IDS = ['reasoning', 'effort', 'thinking'] as const + +/** The upstream default variant, or an empty selection for a variant-less model. */ +export function defaultCursorVariant(model: CursorModel): CursorVariant { + const variants = model.variants ?? [] + const preferred = variants.find((variant) => variant.isDefault) ?? variants[0] + if (preferred) return preferred + return { params: [], displayName: model.name } +} + +/** A variant's params as an id → value map, hidden params included. */ +export function variantSelection(variant: CursorVariant): Record { + const selection: Record = {} + for (const param of variant.params ?? []) selection[param.id] = param.value + return selection +} + +export function variantParamValue( + variant: CursorVariant, + id: string, +): string | undefined { + return (variant.params ?? []).find((param) => param.id === id)?.value +} + +/** Every upstream variant whose params satisfy the given filter. */ +export function matchingCursorVariants( + model: CursorModel, + selection: Record, +): CursorVariant[] { + const entries = Object.entries(selection) + return (model.variants ?? []).filter((variant) => { + const params = variantSelection(variant) + return entries.every(([id, value]) => params[id] === value) + }) +} + +/** + * The single upstream variant a filter resolves to. Zero matches and ambiguous + * matches both commit nothing, so a control can only ever apply a real variant. + */ +export function selectExactVariant( + model: CursorModel, + selection: Record, +): CursorVariant | null { + if ((model.variants ?? []).length === 0) { + return Object.keys(selection).length === 0 + ? { params: [], displayName: model.name } + : null + } + const matches = matchingCursorVariants(model, selection) + return matches.length === 1 ? matches[0] : null +} + +/** + * Move one dimension while keeping as much of the current variant as Cursor + * actually offers. The result is always an upstream variant, so hidden params + * belong to the variant that was chosen rather than the one left behind. + */ +export function applyCursorDimension( + model: CursorModel, + current: CursorVariant, + dimensionId: string, + value: string, +): CursorVariant | null { + const candidates = matchingCursorVariants(model, { [dimensionId]: value }) + if (candidates.length === 0) return null + + const previous = variantSelection(current) + const dimensions = cursorVariantDimensions(model) + .map((dimension) => dimension.id) + .filter((id) => id !== dimensionId) + + let best = candidates[0] + let bestScore = -1 + for (const candidate of candidates) { + const params = variantSelection(candidate) + let score = dimensions.reduce( + (total, id) => total + (params[id] === previous[id] ? 1 : 0), + 0, + ) + if (candidate.isDefault) score += 0.5 + if (score > bestScore) { + best = candidate + bestScore = score + } + } + return best +} + +/** + * The controls a model can offer: catalogue-declared parameters, narrowed to + * the values real variants use. Params a variant carries but the catalogue does + * not declare stay hidden and travel with the variant. + */ +export function cursorVariantDimensions(model: CursorModel): CursorDimension[] { + const used = new Map>() + for (const variant of model.variants ?? []) { + for (const param of variant.params ?? []) { + const values = used.get(param.id) ?? new Set() + values.add(param.value) + used.set(param.id, values) + } + } + + const dimensions: CursorDimension[] = [] + for (const parameter of model.parameters ?? []) { + const available = used.get(parameter.id) + if (!available) continue + const values = (parameter.values ?? []) + .filter((value) => available.has(value.value)) + .map((value) => ({ value: value.value, label: value.displayName || value.value })) + if (values.length === 0) continue + dimensions.push({ + id: parameter.id, + label: parameter.displayName || parameter.id, + values, + }) + } + return dimensions +} + +/** The reasoning-like axis, when the connected catalogue exposes one. */ +export function cursorReasoningDimension(model: CursorModel): CursorDimension | null { + const dimensions = cursorVariantDimensions(model) + for (const id of REASONING_DIMENSION_IDS) { + const found = dimensions.find((dimension) => dimension.id === id) + if (found) return found + } + return null +} + +/** Every dimension except the reasoning-like one (Context, Fast, …). */ +export function cursorOtherDimensions(model: CursorModel): CursorDimension[] { + const reasoning = cursorReasoningDimension(model) + return cursorVariantDimensions(model).filter( + (dimension) => dimension.id !== reasoning?.id, + ) +} + +/** A compact "Context 1M · Reasoning max" line for the visible dimensions. */ +export function cursorVariantSummary( + model: CursorModel, + variant: CursorVariant, +): string { + const selection = variantSelection(variant) + return cursorVariantDimensions(model) + .map((dimension) => { + const value = selection[dimension.id] + if (value === undefined) return '' + const label = + dimension.values.find((option) => option.value === value)?.label ?? value + return `${dimension.label} ${label}` + }) + .filter(Boolean) + .join(' · ') +} + +/** Search a Cursor model by id, display name, alias, or its provider label. */ +export function cursorModelMatches(model: CursorModel, query: string): boolean { + const q = query.trim().toLowerCase() + if (!q) return true + const haystack = [ + model.id, + model.name, + ...(model.aliases ?? []), + 'cursor', + 'cursor cloud agent', + ] + return haystack.some((entry) => entry.toLowerCase().includes(q)) +} diff --git a/web/src/lib/i18n.tsx b/web/src/lib/i18n.tsx index f5a7c49..fccb749 100644 --- a/web/src/lib/i18n.tsx +++ b/web/src/lib/i18n.tsx @@ -458,6 +458,59 @@ const en = { 'reasoning.unavailable': 'Reasoning options are unavailable; the current value is preserved.', 'reasoning.mandatory': 'This model always reasons; Auto keeps its required behavior.', 'reasoning.providerControlled': 'This model exposes no reasoning overrides; the provider controls it.', + 'common.yes': 'Yes', + 'common.no': 'No', + 'target.chatGroup': 'Chat models', + 'target.cursorGroup': 'Cursor Cloud Agents', + 'target.cursorRow': 'Cursor Cloud Agent', + 'target.cursorNeedsKey': 'Connect a Cursor API key to run Cloud Agents from the composer.', + 'target.cursorConnect': 'Connect Cursor', + 'cursor.options': 'Cursor options', + 'cursor.mode': 'Conversation mode', + 'cursor.modeAgent': 'Agent', + 'cursor.modePlan': 'Plan', + 'cursor.modeHint': 'Changing only the mode keeps following up on the same Cursor agent.', + 'cursor.repository': 'Repository', + 'cursor.repositoryNone': 'No repository', + 'cursor.repositoryAuto': 'Discovered from the project folder.', + 'cursor.repositoryNoProject': 'No project folder is bound, so the run starts without a repository.', + 'cursor.repositoryReset': 'Use the discovered repository again', + 'cursor.startingRef': 'Starting ref', + 'cursor.startingRefAuto': 'Default branch', + 'cursor.autoPR': 'Open a pull request', + 'cursor.autoPRHint': 'Cursor opens a pull request when the run finishes.', + 'cursor.newAgentNotice': 'Sending now starts a new Cursor agent; the current one keeps its own history.', + 'cursor.warnDirty': 'Uncommitted local changes are not present in the Cursor cloud VM.', + 'cursor.warnLocalOnly': '{n} local commit(s) are missing from the remote ref, so the cloud VM will not have them.', + 'cursor.warnRemoteUnknown': 'The remote-tracking ref is unavailable, so Antares cannot tell which local commits the cloud VM has.', + 'cursor.warnUnsupportedOrigin': 'This project’s origin is not a credential-free GitHub repository. Enter one, or run without a repository.', + 'cursor.runLabel': 'Cursor Cloud Agent', + 'cursor.detachedNotice': 'Stopped following this run. It may still be running in Cursor.', + 'cursor.reattach': 'Follow again', + 'cursor.cancel': 'Cancel run', + 'cursor.cancelHint': 'Asks Cursor to cancel the remote run, once you approve it.', + 'cursorAttach.documents': 'Cursor runs in a cloud VM and cannot read local files: {names}. Remove them, or paste their content into the message.', + 'cursorAttach.imageCount': 'Cursor accepts at most {max} images, but {n} are attached.', + 'cursorAttach.imageType': 'Image {n} is not one of the PNG, JPEG, GIF, or WebP images Cursor accepts.', + 'cursorAttach.imageSize': 'Image {n} is larger than {max} MiB once decoded.', + 'cursorApproval.operation': 'Operation', + 'cursorApproval.start': 'Start a new Cursor agent', + 'cursorApproval.followUp': 'Follow up on the current Cursor agent', + 'cursorApproval.cancel': 'Cancel the Cursor run', + 'cursorApproval.model': 'Model', + 'cursorApproval.params': 'Variant', + 'cursorApproval.repository': 'Repository', + 'cursorApproval.noRepository': 'No repository', + 'cursorApproval.startingRef': 'Starting ref', + 'cursorApproval.mode': 'Mode', + 'cursorApproval.autoPR': 'Pull request', + 'cursorApproval.images': 'Images', + 'cursorApproval.agent': 'Agent', + 'cursorApproval.run': 'Run', + 'providers.searchModels': 'Search models…', + 'providers.aliases': 'Aliases: {list}', + 'providers.variantCount': '{n} variant(s)', + 'providers.defaultVariant': 'default: {summary}', 'chat.working': 'Working…', 'chat.attachAuthFailed': 'Dashboard login expired — refresh and sign in again.', 'chat.waitingAnswer': 'Paused — waiting for your answer', @@ -1483,6 +1536,59 @@ const id: Dict = { 'reasoning.unavailable': 'Opsi penalaran tidak tersedia; nilai saat ini tetap dipertahankan.', 'reasoning.mandatory': 'Model ini selalu memakai penalaran; Otomatis mempertahankan perilaku wajibnya.', 'reasoning.providerControlled': 'Model ini tidak menyediakan override penalaran; provider yang mengaturnya.', + 'common.yes': 'Ya', + 'common.no': 'Tidak', + 'target.chatGroup': 'Model obrolan', + 'target.cursorGroup': 'Cursor Cloud Agent', + 'target.cursorRow': 'Cursor Cloud Agent', + 'target.cursorNeedsKey': 'Hubungkan API key Cursor untuk menjalankan Cloud Agent dari kolom pesan.', + 'target.cursorConnect': 'Hubungkan Cursor', + 'cursor.options': 'Opsi Cursor', + 'cursor.mode': 'Mode percakapan', + 'cursor.modeAgent': 'Agent', + 'cursor.modePlan': 'Plan', + 'cursor.modeHint': 'Mengubah mode saja tetap melanjutkan agent Cursor yang sama.', + 'cursor.repository': 'Repositori', + 'cursor.repositoryNone': 'Tanpa repositori', + 'cursor.repositoryAuto': 'Ditemukan dari folder proyek.', + 'cursor.repositoryNoProject': 'Tidak ada folder proyek yang terikat, jadi run dimulai tanpa repositori.', + 'cursor.repositoryReset': 'Pakai lagi repositori hasil deteksi', + 'cursor.startingRef': 'Ref awal', + 'cursor.startingRefAuto': 'Branch default', + 'cursor.autoPR': 'Buka pull request', + 'cursor.autoPRHint': 'Cursor membuka pull request setelah run selesai.', + 'cursor.newAgentNotice': 'Mengirim sekarang memulai agent Cursor baru; agent saat ini menyimpan riwayatnya sendiri.', + 'cursor.warnDirty': 'Perubahan lokal yang belum di-commit tidak ada di VM cloud Cursor.', + 'cursor.warnLocalOnly': '{n} commit lokal belum ada di ref remote, jadi VM cloud tidak memilikinya.', + 'cursor.warnRemoteUnknown': 'Ref remote-tracking tidak tersedia, jadi Antares tidak bisa memastikan commit lokal mana yang ada di VM cloud.', + 'cursor.warnUnsupportedOrigin': 'Origin proyek ini bukan repositori GitHub tanpa kredensial. Isi satu repositori, atau jalankan tanpa repositori.', + 'cursor.runLabel': 'Cursor Cloud Agent', + 'cursor.detachedNotice': 'Berhenti mengikuti run ini. Run mungkin masih berjalan di Cursor.', + 'cursor.reattach': 'Ikuti lagi', + 'cursor.cancel': 'Batalkan run', + 'cursor.cancelHint': 'Meminta Cursor membatalkan run jarak jauh setelah kamu menyetujuinya.', + 'cursorAttach.documents': 'Cursor berjalan di VM cloud dan tidak bisa membaca berkas lokal: {names}. Hapus berkasnya, atau tempel isinya ke pesan.', + 'cursorAttach.imageCount': 'Cursor menerima maksimal {max} gambar, tetapi ada {n} terlampir.', + 'cursorAttach.imageType': 'Gambar {n} bukan PNG, JPEG, GIF, atau WebP yang diterima Cursor.', + 'cursorAttach.imageSize': 'Gambar {n} lebih besar dari {max} MiB setelah didekode.', + 'cursorApproval.operation': 'Operasi', + 'cursorApproval.start': 'Mulai agent Cursor baru', + 'cursorApproval.followUp': 'Lanjutkan agent Cursor saat ini', + 'cursorApproval.cancel': 'Batalkan run Cursor', + 'cursorApproval.model': 'Model', + 'cursorApproval.params': 'Varian', + 'cursorApproval.repository': 'Repositori', + 'cursorApproval.noRepository': 'Tanpa repositori', + 'cursorApproval.startingRef': 'Ref awal', + 'cursorApproval.mode': 'Mode', + 'cursorApproval.autoPR': 'Pull request', + 'cursorApproval.images': 'Gambar', + 'cursorApproval.agent': 'Agent', + 'cursorApproval.run': 'Run', + 'providers.searchModels': 'Cari model…', + 'providers.aliases': 'Alias: {list}', + 'providers.variantCount': '{n} varian', + 'providers.defaultVariant': 'default: {summary}', 'chat.working': 'Sedang bekerja…', 'chat.attachAuthFailed': 'Login dashboard kedaluwarsa — muat ulang dan masuk lagi.', 'chat.waitingAnswer': 'Dijeda — menunggu jawabanmu', @@ -2275,6 +2381,59 @@ const ja: Dict = { 'reasoning.unavailable': '推論オプションを取得できません。現在の値は保持されます。', 'reasoning.mandatory': 'このモデルでは推論が必須です。「自動」は必須の動作を維持します。', 'reasoning.providerControlled': 'このモデルは推論の上書きを公開していません。プロバイダーが制御します。', + 'common.yes': 'はい', + 'common.no': 'いいえ', + 'target.chatGroup': 'チャットモデル', + 'target.cursorGroup': 'Cursor Cloud Agent', + 'target.cursorRow': 'Cursor Cloud Agent', + 'target.cursorNeedsKey': 'Cursor の API キーを接続すると、入力欄から Cloud Agent を実行できます。', + 'target.cursorConnect': 'Cursor を接続', + 'cursor.options': 'Cursor のオプション', + 'cursor.mode': '会話モード', + 'cursor.modeAgent': 'Agent', + 'cursor.modePlan': 'Plan', + 'cursor.modeHint': 'モードだけを変えた場合は、同じ Cursor エージェントを継続します。', + 'cursor.repository': 'リポジトリ', + 'cursor.repositoryNone': 'リポジトリなし', + 'cursor.repositoryAuto': 'プロジェクトフォルダーから検出しました。', + 'cursor.repositoryNoProject': 'プロジェクトフォルダーが紐づいていないため、リポジトリなしで実行します。', + 'cursor.repositoryReset': '検出したリポジトリに戻す', + 'cursor.startingRef': '開始 ref', + 'cursor.startingRefAuto': '既定のブランチ', + 'cursor.autoPR': 'プルリクエストを作成', + 'cursor.autoPRHint': '実行が完了すると Cursor がプルリクエストを作成します。', + 'cursor.newAgentNotice': 'このまま送信すると新しい Cursor エージェントを開始します。現在のエージェントの履歴はそのまま残ります。', + 'cursor.warnDirty': '未コミットのローカル変更は Cursor のクラウド VM にはありません。', + 'cursor.warnLocalOnly': 'ローカルの {n} 件のコミットがリモート ref に無いため、クラウド VM にも存在しません。', + 'cursor.warnRemoteUnknown': 'リモート追跡 ref を取得できないため、どのローカルコミットがクラウド VM にあるか確認できません。', + 'cursor.warnUnsupportedOrigin': 'このプロジェクトの origin は認証情報を含まない GitHub リポジトリではありません。リポジトリを指定するか、リポジトリなしで実行してください。', + 'cursor.runLabel': 'Cursor Cloud Agent', + 'cursor.detachedNotice': 'この実行の追従を停止しました。Cursor 側ではまだ実行中の可能性があります。', + 'cursor.reattach': '再び追従する', + 'cursor.cancel': '実行をキャンセル', + 'cursor.cancelHint': '承認後に、リモート実行のキャンセルを Cursor に要求します。', + 'cursorAttach.documents': 'Cursor はクラウド VM で動作するため、ローカルファイルを読めません: {names}。取り外すか、内容をメッセージに貼り付けてください。', + 'cursorAttach.imageCount': 'Cursor が受け付ける画像は最大 {max} 枚ですが、{n} 枚添付されています。', + 'cursorAttach.imageType': '画像 {n} は Cursor が受け付ける PNG・JPEG・GIF・WebP ではありません。', + 'cursorAttach.imageSize': '画像 {n} はデコード後に {max} MiB を超えています。', + 'cursorApproval.operation': '操作', + 'cursorApproval.start': '新しい Cursor エージェントを開始', + 'cursorApproval.followUp': '現在の Cursor エージェントを継続', + 'cursorApproval.cancel': 'Cursor の実行をキャンセル', + 'cursorApproval.model': 'モデル', + 'cursorApproval.params': 'バリアント', + 'cursorApproval.repository': 'リポジトリ', + 'cursorApproval.noRepository': 'リポジトリなし', + 'cursorApproval.startingRef': '開始 ref', + 'cursorApproval.mode': 'モード', + 'cursorApproval.autoPR': 'プルリクエスト', + 'cursorApproval.images': '画像', + 'cursorApproval.agent': 'エージェント', + 'cursorApproval.run': '実行', + 'providers.searchModels': 'モデルを検索…', + 'providers.aliases': 'エイリアス: {list}', + 'providers.variantCount': 'バリアント {n} 件', + 'providers.defaultVariant': '既定: {summary}', 'chat.tokensOut': '出力 {n} トークン', 'chat.welcomeTitle': '会話を始める', 'chat.welcomeDesc': @@ -2984,6 +3143,59 @@ const zh: Dict = { 'reasoning.unavailable': '推理选项暂不可用;当前值将被保留。', 'reasoning.mandatory': '此模型必须进行推理;自动会保留其必需行为。', 'reasoning.providerControlled': '此模型未提供推理覆盖项;由提供商控制。', + 'common.yes': '是', + 'common.no': '否', + 'target.chatGroup': '聊天模型', + 'target.cursorGroup': 'Cursor 云端 Agent', + 'target.cursorRow': 'Cursor 云端 Agent', + 'target.cursorNeedsKey': '连接 Cursor API key 后即可在输入框中运行云端 Agent。', + 'target.cursorConnect': '连接 Cursor', + 'cursor.options': 'Cursor 选项', + 'cursor.mode': '对话模式', + 'cursor.modeAgent': 'Agent', + 'cursor.modePlan': 'Plan', + 'cursor.modeHint': '只更改模式会继续沿用同一个 Cursor agent。', + 'cursor.repository': '仓库', + 'cursor.repositoryNone': '不使用仓库', + 'cursor.repositoryAuto': '来自项目文件夹的自动识别结果。', + 'cursor.repositoryNoProject': '没有绑定项目文件夹,因此本次运行不带仓库。', + 'cursor.repositoryReset': '恢复使用识别到的仓库', + 'cursor.startingRef': '起始 ref', + 'cursor.startingRefAuto': '默认分支', + 'cursor.autoPR': '创建 Pull Request', + 'cursor.autoPRHint': '运行结束后由 Cursor 创建 Pull Request。', + 'cursor.newAgentNotice': '现在发送会启动一个新的 Cursor agent;当前 agent 的历史会保留。', + 'cursor.warnDirty': '未提交的本地改动不会出现在 Cursor 云端虚拟机中。', + 'cursor.warnLocalOnly': '有 {n} 个本地提交不在远程 ref 上,云端虚拟机也不会有它们。', + 'cursor.warnRemoteUnknown': '无法获取远程跟踪 ref,因此 Antares 无法确认云端虚拟机拥有哪些本地提交。', + 'cursor.warnUnsupportedOrigin': '该项目的 origin 不是免凭据的 GitHub 仓库。请填写一个仓库,或不带仓库运行。', + 'cursor.runLabel': 'Cursor 云端 Agent', + 'cursor.detachedNotice': '已停止跟随这次运行,它可能仍在 Cursor 中执行。', + 'cursor.reattach': '重新跟随', + 'cursor.cancel': '取消运行', + 'cursor.cancelHint': '在你批准后,请求 Cursor 取消远端运行。', + 'cursorAttach.documents': 'Cursor 运行在云端虚拟机中,无法读取本地文件:{names}。请移除它们,或把内容粘贴到消息里。', + 'cursorAttach.imageCount': 'Cursor 最多接受 {max} 张图片,当前附加了 {n} 张。', + 'cursorAttach.imageType': '图片 {n} 不是 Cursor 接受的 PNG、JPEG、GIF 或 WebP。', + 'cursorAttach.imageSize': '图片 {n} 解码后超过 {max} MiB。', + 'cursorApproval.operation': '操作', + 'cursorApproval.start': '启动新的 Cursor agent', + 'cursorApproval.followUp': '继续当前的 Cursor agent', + 'cursorApproval.cancel': '取消该 Cursor 运行', + 'cursorApproval.model': '模型', + 'cursorApproval.params': '变体', + 'cursorApproval.repository': '仓库', + 'cursorApproval.noRepository': '不使用仓库', + 'cursorApproval.startingRef': '起始 ref', + 'cursorApproval.mode': '模式', + 'cursorApproval.autoPR': 'Pull Request', + 'cursorApproval.images': '图片', + 'cursorApproval.agent': 'Agent', + 'cursorApproval.run': '运行', + 'providers.searchModels': '搜索模型…', + 'providers.aliases': '别名:{list}', + 'providers.variantCount': '{n} 个变体', + 'providers.defaultVariant': '默认:{summary}', 'chat.tokensOut': '输出 {n} 个 token', 'chat.welcomeTitle': '开始对话', 'chat.welcomeDesc': 'Antares 可以访问文件、终端、网页搜索、长期记忆和 RAG 索引。', @@ -3691,6 +3903,59 @@ const ru: Dict = { 'reasoning.unavailable': 'Варианты рассуждения недоступны; текущее значение сохранено.', 'reasoning.mandatory': 'Для этой модели рассуждение обязательно; «Авто» сохраняет это поведение.', 'reasoning.providerControlled': 'Эта модель не предоставляет переопределения рассуждения; им управляет провайдер.', + 'common.yes': 'Да', + 'common.no': 'Нет', + 'target.chatGroup': 'Чат-модели', + 'target.cursorGroup': 'Облачные агенты Cursor', + 'target.cursorRow': 'Облачный агент Cursor', + 'target.cursorNeedsKey': 'Подключите API-ключ Cursor, чтобы запускать облачных агентов прямо из поля ввода.', + 'target.cursorConnect': 'Подключить Cursor', + 'cursor.options': 'Параметры Cursor', + 'cursor.mode': 'Режим разговора', + 'cursor.modeAgent': 'Agent', + 'cursor.modePlan': 'Plan', + 'cursor.modeHint': 'Смена только режима продолжает работу того же агента Cursor.', + 'cursor.repository': 'Репозиторий', + 'cursor.repositoryNone': 'Без репозитория', + 'cursor.repositoryAuto': 'Определён по папке проекта.', + 'cursor.repositoryNoProject': 'Папка проекта не привязана, поэтому запуск идёт без репозитория.', + 'cursor.repositoryReset': 'Снова использовать найденный репозиторий', + 'cursor.startingRef': 'Начальный ref', + 'cursor.startingRefAuto': 'Ветка по умолчанию', + 'cursor.autoPR': 'Создать pull request', + 'cursor.autoPRHint': 'Cursor создаст pull request после завершения запуска.', + 'cursor.newAgentNotice': 'Отправка сейчас запустит нового агента Cursor; у текущего останется своя история.', + 'cursor.warnDirty': 'Незакоммиченные локальные изменения отсутствуют в облачной ВМ Cursor.', + 'cursor.warnLocalOnly': 'Локальных коммитов вне удалённого ref: {n}; в облачной ВМ их не будет.', + 'cursor.warnRemoteUnknown': 'Удалённый отслеживаемый ref недоступен, поэтому Antares не может определить, какие локальные коммиты есть в облачной ВМ.', + 'cursor.warnUnsupportedOrigin': 'Origin этого проекта не является GitHub-репозиторием без учётных данных. Укажите репозиторий или запустите без него.', + 'cursor.runLabel': 'Облачный агент Cursor', + 'cursor.detachedNotice': 'Слежение за этим запуском остановлено. В Cursor он может продолжаться.', + 'cursor.reattach': 'Следить снова', + 'cursor.cancel': 'Отменить запуск', + 'cursor.cancelHint': 'После вашего подтверждения попросит Cursor отменить удалённый запуск.', + 'cursorAttach.documents': 'Cursor работает в облачной ВМ и не может читать локальные файлы: {names}. Удалите их или вставьте содержимое в сообщение.', + 'cursorAttach.imageCount': 'Cursor принимает не более {max} изображений, а приложено {n}.', + 'cursorAttach.imageType': 'Изображение {n} не относится к принимаемым Cursor форматам PNG, JPEG, GIF или WebP.', + 'cursorAttach.imageSize': 'Изображение {n} после декодирования превышает {max} МиБ.', + 'cursorApproval.operation': 'Операция', + 'cursorApproval.start': 'Запустить нового агента Cursor', + 'cursorApproval.followUp': 'Продолжить работу текущего агента Cursor', + 'cursorApproval.cancel': 'Отменить запуск Cursor', + 'cursorApproval.model': 'Модель', + 'cursorApproval.params': 'Вариант', + 'cursorApproval.repository': 'Репозиторий', + 'cursorApproval.noRepository': 'Без репозитория', + 'cursorApproval.startingRef': 'Начальный ref', + 'cursorApproval.mode': 'Режим', + 'cursorApproval.autoPR': 'Pull request', + 'cursorApproval.images': 'Изображения', + 'cursorApproval.agent': 'Агент', + 'cursorApproval.run': 'Запуск', + 'providers.searchModels': 'Поиск моделей…', + 'providers.aliases': 'Псевдонимы: {list}', + 'providers.variantCount': 'вариантов: {n}', + 'providers.defaultVariant': 'по умолчанию: {summary}', 'chat.tokensOut': '{n} токенов на выходе', 'chat.welcomeTitle': 'Начните разговор', 'chat.welcomeDesc': diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index afd3962..bed7cbc 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -6,18 +6,39 @@ import { Brain, CaretDown, Check, + Cloud, Copy, FileText, + GitBranch, Paperclip, PencilSimple, Plus, + Prohibit, SidebarSimple, Stop, Terminal, Warning, X, } from '@phosphor-icons/react' -import { ApiError, get, post, streamGet, streamPost, type StreamEvent } from '@/lib/api' +import { + ApiError, + get, + isDashboardPasswordRequired, + post, + streamGet, + streamPost, + type StreamEvent, +} from '@/lib/api' +import { + approvalFromEvent, + cursorSessionHydration, + mergeApprovals, + pendingApprovalsForSession, + shouldReconnectAttach, + stopBehavior, + type CursorSessionHydration, + type PendingApproval, +} from '@/lib/chatEvents' import { groupStreamPatches, queueStreamDelta, @@ -25,8 +46,19 @@ import { type QueuedStreamPatch, } from '@/lib/chatStreamQueue' import { copyText } from '@/lib/clipboard' +import { + cursorChatRequest, + cursorTargetFromModel, + isCursorTarget, + type ChatTarget, + type ComposerTarget, + type CursorMode, + type CursorOptionsValue, +} from '@/lib/composerTargets' +import { composerImageLimit, validateCursorAttachments } from '@/lib/cursorAttachments' +import type { CursorModel } from '@/lib/cursorModels' import { useI18n, useTimeAgo, type MessageKey } from '@/lib/i18n' -import type { ChatModelSelection, ReasoningCapability } from '@/lib/models' +import type { ReasoningCapability } from '@/lib/models' import { loadReasoningPreference, reasoningOptions, @@ -43,6 +75,7 @@ import { ApprovalCard, type ApprovalView } from '@/components/chat/ApprovalCard' import { AskUserCard } from '@/components/chat/AskUserCard' import { RolePicker } from '@/components/chat/RolePicker' import { ModelPicker } from '@/components/chat/ModelPicker' +import { CursorOptions } from '@/components/chat/CursorOptions' import { ReasoningPicker } from '@/components/chat/ReasoningPicker' import { ProjectPicker } from '@/components/chat/ProjectPicker' import { ProjectSidebar } from '@/components/chat/ProjectSidebar' @@ -179,6 +212,8 @@ interface SessionDetail { tokens_in: number tokens_out: number hidden?: boolean + model?: string + meta?: Record | null }> } @@ -369,17 +404,54 @@ export default function ChatPage() { if (r) localStorage.setItem('antares:last-role', r) else localStorage.removeItem('antares:last-role') }, []) + // Where the next message runs: an Antares chat model, or a Cursor Cloud + // Agent. Only a chat target has an adaptive reasoning override — Cursor's own + // variant controls take that role in Cursor mode. + const [target, setTarget] = useState(null) + const targetRef = useRef(null) + targetRef.current = target + const cursorMode = isCursorTarget(target) + // The non-model half of a Cursor turn, kept while switching Cursor models. + const [cursorSettings, setCursorSettings] = useState<{ + mode: CursorMode + repositoryUrl: string | null + startingRef: string | null + autoCreatePR: boolean + }>({ mode: 'agent', repositoryUrl: null, startingRef: null, autoCreatePR: false }) + const cursorOptions = useMemo( + () => + isCursorTarget(target) + ? { model: target.model, variant: target.variant, ...cursorSettings } + : null, + [target, cursorSettings], + ) + const cursorOptionsRef = useRef(null) + cursorOptionsRef.current = cursorOptions + // The identity of the Cursor run this session last started, so the options + // popover can warn that sending now starts a new agent instead of following + // up on the existing one. + const [lastCursorRun, setLastCursorRun] = useState(null) + // What a reopened Cursor session already produced: remote status, branches, + // and pull requests from the persisted transcript. + const [cursorState, setCursorState] = useState(null) + // Local Stop detaches from a Cursor run that keeps going remotely. The ref is + // what the standing attach loop reads, so it never re-follows immediately. + const [detached, setDetached] = useState(false) + const detachedRef = useRef(false) + const [cancelling, setCancelling] = useState(false) + // The model, its capability, and its scoped reasoning value move together. // Updating the ref synchronously prevents a send immediately after switching // models from carrying the previous model's override. - const [modelSelection, setModelSelection] = useState() const [reasoning, setReasoning] = useState('') const composerReasoningRef = useRef<{ - selection: ChatModelSelection + selection: ChatTarget capability?: ReasoningCapability value: string } | null>(null) - const selectModel = useCallback((selection: ChatModelSelection) => { + const selectTarget = useCallback((selection: ComposerTarget) => { + setTarget(selection) + if (selection.kind !== 'chat') return const capability = selection.reasoningCapability const { value } = loadReasoningPreference( localStorage, @@ -388,9 +460,39 @@ export default function ChatPage() { capability, ) composerReasoningRef.current = { selection, capability, value } - setModelSelection(selection) setReasoning(value) }, []) + const changeCursorOptions = useCallback((next: CursorOptionsValue) => { + setTarget({ kind: 'cursor', model: next.model, variant: next.variant }) + setCursorSettings({ + mode: next.mode, + repositoryUrl: next.repositoryUrl, + startingRef: next.startingRef, + autoCreatePR: next.autoCreatePR, + }) + }, []) + /** + * Point the composer back at the Cursor model a reopened session used. Only + * the catalogue knows that model's variants, so the target is restored from + * it rather than reconstructed from the transcript. + */ + const restoreCursorTarget = useCallback( + (modelId: string) => { + const before = targetRef.current + void get<{ models?: CursorModel[] }>('/providers/cursor/models') + .then((d) => { + // Never overwrite a target the user chose while this was in flight. + if (targetRef.current !== before) return + const model = (d.models ?? []).find( + (candidate) => + candidate.id === modelId || (candidate.aliases ?? []).includes(modelId), + ) + if (model) setTarget(cursorTargetFromModel(model)) + }) + .catch(() => {}) + }, + [], + ) const pickReasoning = useCallback((value: string) => { const current = composerReasoningRef.current if (!current) return @@ -668,10 +770,18 @@ export default function ChatPage() { }, [input]) const stop = useCallback(() => { + // A Cursor run lives in Cursor's cloud: Stop may only close this browser's + // stream. Interrupting the turn, or cancelling it remotely, is never + // implied by leaving — cancellation is a separate, approved action. + const behavior = stopBehavior(isCursorTarget(targetRef.current) ? 'cursor' : 'chat') abortRef.current?.() abortRef.current = null setStreaming(false) - if (sessionId) { + if (behavior.detach) { + detachedRef.current = true + setDetached(true) + } + if (behavior.interrupt && sessionId) { void post<{ interrupted: boolean }>('/chat/interrupt', { session_id: sessionId }).catch(() => { // The stream is already closed locally. A failed interrupt will surface // when the user reattaches instead of leaving the stop button stuck. @@ -679,6 +789,61 @@ export default function ChatPage() { } }, [sessionId]) + /** + * Follow this session's Cursor run again after an intentional detach. The + * attach stream replays the run from its first event, so the half-finished + * bubble this browser was writing is dropped first — otherwise the replay + * would render the same answer a second time until the turn ends. + */ + const reattach = useCallback(() => { + setMessages((prev) => { + const last = prev[prev.length - 1] + const optimistic = + last?.role === 'assistant' && last.id.startsWith('local_') && last.id.endsWith('_a') + return optimistic ? prev.slice(0, -1) : prev + }) + detachedRef.current = false + setDetached(false) + }, []) + + const refreshApprovals = useCallback((sessionOverride?: string) => { + const sid = sessionOverride ?? sessionIdRef.current + if (!sid) return Promise.resolve() + return get<{ approvals?: PendingApproval[] }>('/approvals') + .then((d) => { + setApprovals((prev) => pendingApprovalsForSession(prev, d.approvals ?? [], sid)) + }) + .catch(() => {}) + }, []) + + /** + * Ask Cursor to cancel the remote run. The server holds the request until the + * approval card is answered, so the pending list is polled while it waits — + * the card must be reachable even when this browser has detached. + */ + const cancelCursorRun = useCallback(async () => { + const sid = sessionIdRef.current + if (!sid || cancelling) return + setCancelling(true) + setError(undefined) + const poll = window.setInterval(() => void refreshApprovals(), 2000) + try { + await post('/chat/cursor/cancel', { session_id: sid }) + } catch (e) { + setError( + isDashboardPasswordRequired(e) + ? t('sensitive.needPasswordDesc') + : e instanceof Error + ? e.message + : String(e), + ) + } finally { + window.clearInterval(poll) + setCancelling(false) + void refreshApprovals() + } + }, [cancelling, refreshApprovals, t]) + // Apply one stream event to the named assistant message. Shared by a fresh // send and a reattach, so both render a turn identically. Session handling // differs between the two (navigate vs. title-only), so it is delegated. @@ -761,6 +926,14 @@ export default function ChatPage() { setAskId(String(event.id ?? '')) setLive((s) => ({ ...s, tool: undefined, waiting: true, notice: undefined })) break + case 'approval': { + // The run is blocked on a decision. Replay and a reconnect both + // deliver the same id, so the card is added exactly once and keeps + // any decision already shown. + const view = approvalFromEvent(event) + if (view) setApprovals((prev) => mergeApprovals(prev, view)) + break + } case 'usage': patchAssistant((m) => ({ ...m, @@ -833,8 +1006,15 @@ export default function ChatPage() { if (!alive) return // Never run the standing attach while a foreground send is streaming: // that turn already renders via streamPost, and a second follower would - // double-render it. Retry shortly instead. - if (abortRef.current) { + // double-render it. An intentional Cursor detach holds the loop open + // but idle in the same way, so Stop does not instantly re-follow the + // run it just left. Retry shortly instead. + if ( + !shouldReconnectAttach({ + alive, + detached: abortRef.current !== null || detachedRef.current, + }) + ) { window.setTimeout(connect, 1500) return } @@ -908,6 +1088,14 @@ export default function ChatPage() { ) useEffect(() => { + // Approvals, Cursor recovery state, and the detach flag all belong to one + // conversation; carrying them into another session would show a decision + // that no longer blocks anything. + setApprovals([]) + setCursorState(null) + setLastCursorRun(null) + detachedRef.current = false + setDetached(false) if (!sessionId) { setMessages([]) setTitle('') @@ -947,6 +1135,15 @@ export default function ChatPage() { } } setError(undefined) + // Only a Cursor turn persists a remote status, so the transcript itself + // says whether this conversation runs on Cursor, which model it used, + // and which branches or pull requests it produced. + const cursor = cursorSessionHydration(d.messages) + setCursorState(cursor.active ? cursor : null) + if (cursor.active && cursor.modelId) restoreCursorTarget(cursor.modelId) + // A decision published before this page attached is still blocking the + // run; the pending list is the only place left to find it. + void refreshApprovals(sessionId) // Once the persisted history is on screen, reconnect to any turn still // in flight for this session so streaming continues where it left off. closeAttach = attachLive(sessionId) @@ -981,7 +1178,7 @@ export default function ChatPage() { cancelled = true closeAttach?.() } - }, [sessionId, t, attachLive]) + }, [sessionId, t, attachLive, refreshApprovals, restoreCursorTarget]) /** Append a locally-produced message without touching the server. */ const pushSystem = useCallback((content: string) => { @@ -1079,6 +1276,19 @@ export default function ChatPage() { return } + // Cursor runs in its own cloud VM. Everything it cannot accept is rejected + // here, before the draft and its attachments are cleared, so nothing is + // silently dropped and no paid operation is ever offered for a turn that + // could not have been sent. + const cursor = cursorOptionsRef.current + if (cursor) { + const issue = validateCursorAttachments({ images: attached, docs: attachedDocs }) + if (issue) { + setError(t(`cursorAttach.${issue.code}`, issue.values)) + return + } + } + // Non-image attachments live in a temp dir; the model can't see them until // it reads them. Tell it they're there and how — read_document by path. let message = text @@ -1114,32 +1324,46 @@ export default function ChatPage() { setLive({ turn: 1 }) const composerReasoning = composerReasoningRef.current + // Sending is a deliberate re-attachment: whatever was detached before, this + // session is being followed again. + detachedRef.current = false + setDetached(false) + if (cursor) setLastCursorRun(cursor) abortRef.current = streamPost( - '/chat', - { - session_id: sessionIdRef.current ?? '', - message, - images: attached, - role, - // Per-chat model override; omitted when unset so the server falls - // back to the configured default. - ...(composerReasoning - ? { - model: `${composerReasoning.selection.provider}/${composerReasoning.selection.model}`, - } - : {}), - // Per-turn reasoning override; omitted when unset so the server falls - // back to the configured default. - ...(composerReasoning?.value - ? { reasoning_effort: composerReasoning.value } - : {}), - // Only meaningful when starting a new session; the server ignores it once - // the session exists. Read from the ref so an auto-analyze turn fired - // right after binding still carries the project. - ...(projectDirRef.current && !sessionIdRef.current - ? { project_dir: projectDirRef.current, index_rag: indexRagRef.current } - : {}), - }, + cursor ? '/chat/cursor' : '/chat', + cursor + ? cursorChatRequest(cursor, { + sessionId: sessionIdRef.current ?? '', + message, + images: attached, + // Only meaningful when starting a new session; the server binds the + // project once and discovers its repository from there. + projectDir: sessionIdRef.current ? undefined : projectDirRef.current, + }) + : { + session_id: sessionIdRef.current ?? '', + message, + images: attached, + role, + // Per-chat model override; omitted when unset so the server falls + // back to the configured default. + ...(composerReasoning + ? { + model: `${composerReasoning.selection.provider}/${composerReasoning.selection.model}`, + } + : {}), + // Per-turn reasoning override; omitted when unset so the server falls + // back to the configured default. + ...(composerReasoning?.value + ? { reasoning_effort: composerReasoning.value } + : {}), + // Only meaningful when starting a new session; the server ignores it once + // the session exists. Read from the ref so an auto-analyze turn fired + // right after binding still carries the project. + ...(projectDirRef.current && !sessionIdRef.current + ? { project_dir: projectDirRef.current, index_rag: indexRagRef.current } + : {}), + }, (event: StreamEvent) => { // End-of-turn: stop streaming immediately rather than waiting for the // socket to close. A detached run keeps the connection open past the @@ -1165,6 +1389,8 @@ export default function ChatPage() { .then((d) => { setMessages(hydrate(d)) setTitle(d.session.title || t('chat.conversation')) + const state = cursorSessionHydration(d.messages) + if (state.active) setCursorState(state) }) .catch(() => {}) } @@ -1190,7 +1416,12 @@ export default function ChatPage() { }, (err) => { drainPatches() - setError(err.message) + // A refused turn carries the server's own explanation (busy session, + // rate limit, stale Cursor selection); only the password gate answers + // with a marker instead of a sentence. + setError( + isDashboardPasswordRequired(err) ? t('sensitive.needPasswordDesc') : err.message, + ) setStreaming(false) abortRef.current = null // The turn is persisted now, so a later revisit should hydrate fresh. @@ -1297,10 +1528,21 @@ export default function ChatPage() { const all = Array.from(files) const imgs = all.filter((f) => f.type.startsWith('image/')) const others = all.filter((f) => !f.type.startsWith('image/')) + const cursor = isCursorTarget(targetRef.current) + const limit = composerImageLimit(cursor ? 'cursor' : 'chat') if (imgs.length > 0) { - const read = await Promise.all(imgs.slice(0, 4).map(readDataURL)) - setImages((prev) => [...prev, ...read].slice(0, 4)) + const read = await Promise.all(imgs.slice(0, limit).map(readDataURL)) + setImages((prev) => [...prev, ...read].slice(0, limit)) + } + + // A Cursor cloud VM cannot read a path on this machine, so a document is + // refused at the point it is attached rather than uploaded and ignored. + if (cursor && others.length > 0) { + setError( + t('cursorAttach.documents', { names: others.map((file) => file.name).join(', ') }), + ) + return } for (const file of others.slice(0, 4)) { @@ -1316,7 +1558,7 @@ export default function ChatPage() { setError((e as Error).message) } } - }, []) + }, [t]) // Pasting a screenshot is the fastest way to show the agent something. const onPaste = (e: React.ClipboardEvent) => { @@ -1442,6 +1684,13 @@ export default function ChatPage() { lastSession.clear() setMessages([]) setTitle('') + setApprovals([]) + // A new Antares chat is a new Cursor conversation too: the next Cursor turn + // starts a fresh agent rather than following up on the previous one. + setCursorState(null) + setLastCursorRun(null) + detachedRef.current = false + setDetached(false) // Keep the remembered role for the new chat instead of resetting to default. setRole(localStorage.getItem('antares:last-role') ?? '') // A project binding belongs to one session; a new chat starts unbound. @@ -1479,14 +1728,28 @@ export default function ChatPage() { attachLabel={t('chat.attach')} roleSlot={
- - - + {/* A Cursor run has no Antares role and no generic reasoning + override — its own variant controls take that place. */} + {cursorMode ? null : } + + {cursorMode && cursorOptions ? ( + + ) : ( + + )} { @@ -1703,6 +1966,19 @@ export default function ChatPage() { />
) : null} + {cursorMode || cursorState ? ( +
+ +
+ ) : null} {composerCard(true)}
@@ -2028,6 +2304,82 @@ function ContextBar({ used, window }: { used: number; window: number }) { ) } +/** + * The Cursor run's own controls. Stop (in the composer) only closes this + * browser's stream, so this bar is where the run's remote state, a way back to + * it, and the separate approved cancellation live. + */ +function CursorRunBar({ + streaming, + detached, + cancelling, + canCancel, + state, + onCancel, + onReattach, +}: { + streaming: boolean + detached: boolean + cancelling: boolean + canCancel: boolean + state: CursorSessionHydration | null + onCancel: () => void + onReattach: () => void +}) { + const { t } = useI18n() + const branches = (state?.branches ?? []).filter((b) => b.branch || b.prUrl) + + return ( +
+ + + {t('cursor.runLabel')} + + {state?.remoteStatus ? ( + {state.remoteStatus} + ) : null} + {detached ? {t('cursor.detachedNotice')} : null} + {branches.map((branch) => ( + + + {branch.prUrl ? ( + + {branch.branch || branch.prUrl} + + ) : ( + {branch.branch} + )} + + ))} +
+ {detached ? ( + + ) : null} + {canCancel && (streaming || detached) ? ( + + ) : null} +
+
+ ) +} + function ErrorBanner({ message, className }: { message: string; className?: string }) { return (
+

{model.id}

+

{model.name}

+ {model.description ? ( +

{model.description}

+ ) : null} + {(model.aliases ?? []).length > 0 ? ( +

+ {t('providers.aliases', { list: (model.aliases ?? []).join(', ') })} +

+ ) : null} + {dimensions.map((dimension) => ( +

+ {dimension.label}:{' '} + {dimension.values.map((value) => value.label).join(', ')} +

+ ))} + {variants.length > 0 ? ( +

+ {t('providers.variantCount', { n: variants.length })} + {summary ? ` · ${t('providers.defaultVariant', { summary })}` : ''} +

+ ) : null} +
+ ) +} + /** * Manage one provider in a modal: credentials, its models (add/remove with an * auto-fetched context window), and advanced settings. Each section saves to @@ -299,6 +338,14 @@ function ProviderModal({ const llmModelsState = useApi<{ models: AllModel[] }>(agentOnly ? null : '/model/list-all') const myModels = (llmModelsState.data?.models ?? []).filter((m) => m.provider === p.id) const agentModelsError = agentModelsErrorText(agentModelsState.data, agentModelsState.error) + const [agentQuery, setAgentQuery] = useState('') + const agentModels = useMemo( + () => + (agentModelsState.data?.models ?? []).filter((model) => + cursorModelMatches(model, agentQuery), + ), + [agentModelsState.data, agentQuery], + ) const [newModel, setNewModel] = useState('') const [newCtx, setNewCtx] = useState('') const [ctxAuto, setCtxAuto] = useState(false) @@ -487,15 +534,24 @@ function ProviderModal({ ) : (agentModelsState.data?.models ?? []).length === 0 ? (

{t('models.none')}

) : ( -
- {(agentModelsState.data?.models ?? []).map((m) => ( -
-

{m.id}

-

{m.name}

- {m.description ?

{m.description}

: null} + <> + setAgentQuery(e.target.value)} + placeholder={t('providers.searchModels')} + aria-label={t('providers.searchModels')} + className="h-8 text-xs" + /> + {agentModels.length === 0 ? ( +

{t('models.none')}

+ ) : ( +
+ {agentModels.map((m) => ( + + ))}
- ))} -
+ )} + )} ) : ( From 0de2d926efedf8c0cba13bbfdae6519c4243ac65 Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Thu, 13 Aug 2026 12:56:48 +0700 Subject: [PATCH 34/41] Hydrate Cursor sessions from durable state Co-authored-by: Cursor --- internal/server/cursor_session_view.go | 183 ++++++++++ internal/server/handlers_chat.go | 12 +- .../server/handlers_cursor_session_test.go | 329 ++++++++++++++++++ web/src/components/chat/CursorOptions.tsx | 6 +- web/src/lib/chatEvents.test.mjs | 150 ++++++++ web/src/lib/chatEvents.ts | 110 ++++++ web/src/lib/composerTargets.test.mjs | 18 +- web/src/lib/composerTargets.ts | 17 +- web/src/lib/cursorModels.test.mjs | 60 ++++ web/src/lib/cursorModels.ts | 43 +++ web/src/lib/i18n.tsx | 5 + web/src/pages/ChatPage.tsx | 212 ++++++++--- 12 files changed, 1093 insertions(+), 52 deletions(-) create mode 100644 internal/server/cursor_session_view.go create mode 100644 internal/server/handlers_cursor_session_test.go diff --git a/internal/server/cursor_session_view.go b/internal/server/cursor_session_view.go new file mode 100644 index 0000000..4d3ca4f --- /dev/null +++ b/internal/server/cursor_session_view.go @@ -0,0 +1,183 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + + "github.com/enowdev/antares/internal/cursor" + "github.com/enowdev/antares/internal/store" +) + +const ( + maxCursorProjectionStatusRunes = 512 + maxCursorProjectionBranches = 16 + maxCursorProjectionParams = 64 +) + +type cursorBranchProjection struct { + RepoURL string `json:"repo_url"` + Branch string `json:"branch"` + PRURL string `json:"pr_url"` +} + +type cursorGitProjection struct { + Branches []cursorBranchProjection `json:"branches"` +} + +// cursorSessionProjection is the composer-facing view of durable Cursor state: +// enough to restore the execution target and describe the run's outcome, and +// nothing else. Revision, partial prompt and answer text, recovery identifiers +// (agent, run, last event), and internal message IDs deliberately stay on the +// server — they are recovery machinery, not composer state, and some of them +// carry user content. +type cursorSessionProjection struct { + TargetActive bool `json:"target_active"` + ReuseValid bool `json:"reuse_valid"` + // ModelID and ModelParams are populated together or not at all: a partially + // understood selection must never become a different one. + ModelID string `json:"model_id"` + ModelParams []cursor.ModelParameterSelection `json:"model_params"` + // RepositoryURL is null when the run discovered its repository (or ran with + // none) rather than being given one, so restoring it reproduces the same + // run identity instead of pinning an explicit empty repository. + RepositoryURL *string `json:"repository_url"` + StartingRef string `json:"starting_ref"` + Mode string `json:"mode"` + AutoCreatePR bool `json:"auto_create_pr"` + RemoteStatus string `json:"remote_status"` + OperationState string `json:"operation_state"` + Git cursorGitProjection `json:"git"` +} + +// cursorSessionView loads the durable Cursor state for one session. A session +// that never ran Cursor yields nil; a store failure is returned as an error, so +// a read problem can never be presented as "this session has no Cursor state". +func (s *Server) cursorSessionView( + ctx context.Context, + sessionID string, +) (*cursorSessionProjection, error) { + if s.db == nil { + return nil, nil + } + state, err := s.db.GetCursorSessionState(ctx, sessionID) + if errors.Is(err, store.ErrNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.projectCursorState(state), nil +} + +func (s *Server) projectCursorState( + state *store.CursorSessionState, +) *cursorSessionProjection { + if state == nil { + return nil + } + view := &cursorSessionProjection{ + TargetActive: state.TargetActive, + ReuseValid: state.ReuseValid, + ModelParams: []cursor.ModelParameterSelection{}, + StartingRef: truncateCursorRunes( + s.redactCursorString(state.StartingRef), maxCursorStartingRefRunes, + ), + AutoCreatePR: state.AutoCreatePR, + RemoteStatus: truncateCursorRunes( + s.redactCursorString(state.RemoteStatus), maxCursorProjectionStatusRunes, + ), + OperationState: state.OperationState, + Git: cursorGitProjection{ + Branches: s.projectCursorGitState(state.GitState), + }, + } + // Only the two modes a turn can be prepared with are meaningful to the + // composer; anything else is reported as no mode at all. + if state.Mode == "agent" || state.Mode == "plan" { + view.Mode = state.Mode + } + // The auto-discovery identity is an internal marker, not a repository. + if state.RepositoryURL != cursorAutoNoRepositoryIdentity { + repository := truncateCursorRunes( + s.redactCursorString(state.RepositoryURL), maxCursorRepositoryRunes, + ) + view.RepositoryURL = &repository + } + if params, ok := s.decodeCursorProjectionParams(state.ModelParams); ok { + view.ModelID = truncateCursorRunes( + s.redactCursorString(state.ModelID), maxCursorIdentifierRunes, + ) + view.ModelParams = params + } + return view +} + +// decodeCursorProjectionParams reads a stored selection back into its exact +// ordered parameters. The store only guarantees a JSON array, so a value that +// is not a well-formed, unambiguous parameter list is reported as undecodable +// rather than partially restored. +func (s *Server) decodeCursorProjectionParams( + raw string, +) ([]cursor.ModelParameterSelection, bool) { + params := []cursor.ModelParameterSelection{} + if strings.TrimSpace(raw) == "" { + return params, true + } + decoder := json.NewDecoder(bytes.NewReader([]byte(raw))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(¶ms); err != nil { + return nil, false + } + if len(params) > maxCursorProjectionParams { + return nil, false + } + seen := make(map[string]struct{}, len(params)) + for i := range params { + id := truncateCursorRunes( + s.redactCursorString(params[i].ID), maxCursorIdentifierRunes, + ) + if id == "" { + return nil, false + } + if _, duplicate := seen[id]; duplicate { + return nil, false + } + seen[id] = struct{}{} + params[i].ID = id + params[i].Value = truncateCursorRunes( + s.redactCursorString(params[i].Value), maxCursorIdentifierRunes, + ) + } + return params, true +} + +func (s *Server) projectCursorGitState(raw string) []cursorBranchProjection { + branches := []cursorBranchProjection{} + if strings.TrimSpace(raw) == "" { + return branches + } + var git cursor.GitState + if err := json.Unmarshal([]byte(raw), &git); err != nil { + return branches + } + for _, branch := range git.Branches { + if len(branches) >= maxCursorProjectionBranches { + break + } + branches = append(branches, cursorBranchProjection{ + RepoURL: truncateCursorRunes( + s.redactCursorString(branch.RepoURL), maxCursorRepositoryRunes, + ), + Branch: truncateCursorRunes( + s.redactCursorString(branch.Branch), maxCursorStartingRefRunes, + ), + PRURL: truncateCursorRunes( + s.redactCursorString(branch.PRURL), maxCursorRepositoryRunes, + ), + }) + } + return branches +} diff --git a/internal/server/handlers_chat.go b/internal/server/handlers_chat.go index 40f69cc..796cd56 100644 --- a/internal/server/handlers_chat.go +++ b/internal/server/handlers_chat.go @@ -435,7 +435,17 @@ func (s *Server) handleGetSession(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err) return } - writeJSON(w, http.StatusOK, map[string]any{"session": sess, "messages": messages}) + // The durable Cursor projection is what lets the composer restore the exact + // execution target after a reload. It is null for every ordinary chat, and + // a failed read is reported rather than shown as "no Cursor state". + cursorState, err := s.cursorSessionView(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, s.cursorSafeError(err)) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "session": sess, "messages": messages, "cursor_state": cursorState, + }) } // handleEditPreview lists the files the agent changed at/after a given user diff --git a/internal/server/handlers_cursor_session_test.go b/internal/server/handlers_cursor_session_test.go new file mode 100644 index 0000000..520688e --- /dev/null +++ b/internal/server/handlers_cursor_session_test.go @@ -0,0 +1,329 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/enowdev/antares/internal/agent" + "github.com/enowdev/antares/internal/store" + "github.com/enowdev/antares/internal/tools" +) + +// getSessionDetail reads one session detail document exactly as the dashboard +// does, so the assertions below describe the wire contract rather than an +// internal struct. +func getSessionDetail( + t *testing.T, + fixture *cursorDirectFixture, + sessionID string, +) (int, map[string]any, string) { + t.Helper() + return getSessionDetailAt(t, fixture.http.URL, fixture.http.Client(), sessionID) +} + +func getSessionDetailAt( + t *testing.T, + baseURL string, + client *http.Client, + sessionID string, +) (int, map[string]any, string) { + t.Helper() + request, err := http.NewRequest( + http.MethodGet, + baseURL+"/api/sessions/"+sessionID, + nil, + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer test-token") + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + raw, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + var body map[string]any + if response.StatusCode == http.StatusOK { + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("decode session detail: %v (%s)", err, raw) + } + } + return response.StatusCode, body, string(raw) +} + +func seedCursorSession( + t *testing.T, + fixture *cursorDirectFixture, + sessionID string, + mutate func(*store.CursorSessionState), +) { + t.Helper() + ctx := context.Background() + if err := fixture.db.CreateSession(ctx, &store.Session{ + ID: sessionID, Title: "Cursor session", + }); err != nil { + t.Fatalf("create session: %v", err) + } + state := &store.CursorSessionState{ + SessionID: sessionID, + TargetActive: true, + ReuseValid: true, + ModelID: "gpt-5.6-sol", + ModelParams: `[{"id":"cyber","value":"false"},{"id":"reasoning","value":"max"}]`, + RepositoryURL: "https://github.com/acme/repo", + StartingRef: "main", + Mode: "plan", + AutoCreatePR: true, + AgentID: "bc-secret-agent", + RunID: "run-secret", + RemoteStatus: "RUNNING", + OperationState: store.CursorOperationRunInFlight, + PartialText: "half of a private answer", + UserMessageID: "msg-user-internal", + AssistantMessageID: "msg-assistant-internal", + } + if mutate != nil { + mutate(state) + } + if err := fixture.db.PutCursorSessionState(ctx, state); err != nil { + t.Fatalf("put cursor state: %v", err) + } +} + +func cursorStateOf(t *testing.T, body map[string]any) map[string]any { + t.Helper() + raw, ok := body["cursor_state"] + if !ok { + t.Fatal("session detail has no cursor_state field") + } + if raw == nil { + t.Fatal("cursor_state is null, want a projection") + } + state, ok := raw.(map[string]any) + if !ok { + t.Fatalf("cursor_state = %T, want an object", raw) + } + return state +} + +func TestSessionDetailReportsNoCursorStateAsNull(t *testing.T) { + fixture := newCursorDirectTestServer(t) + if err := fixture.db.CreateSession(context.Background(), &store.Session{ + ID: "ses-plain", Title: "Plain chat", + }); err != nil { + t.Fatal(err) + } + + status, body, raw := getSessionDetail(t, fixture, "ses-plain") + if status != http.StatusOK { + t.Fatalf("status=%d body=%s", status, raw) + } + value, ok := body["cursor_state"] + if !ok { + t.Fatalf("cursor_state is absent for a session without Cursor state: %s", raw) + } + if value != nil { + t.Fatalf("cursor_state = %#v, want null", value) + } +} + +func TestSessionDetailProjectsExactCursorSelection(t *testing.T) { + fixture := newCursorDirectTestServer(t) + seedCursorSession(t, fixture, "ses-active", func(state *store.CursorSessionState) { + state.GitState = `{"branches":[{"repoUrl":"https://github.com/acme/repo","branch":"cursor/x","prUrl":"https://github.com/acme/repo/pull/7"}]}` + }) + + status, body, raw := getSessionDetail(t, fixture, "ses-active") + if status != http.StatusOK { + t.Fatalf("status=%d body=%s", status, raw) + } + state := cursorStateOf(t, body) + + if state["target_active"] != true || state["reuse_valid"] != true { + t.Fatalf("target/reuse = %#v/%#v", state["target_active"], state["reuse_valid"]) + } + if state["model_id"] != "gpt-5.6-sol" { + t.Fatalf("model_id = %#v", state["model_id"]) + } + // The whole stored selection, in order, including a param the catalogue + // never lists as a user-facing dimension. + wantParams := []any{ + map[string]any{"id": "cyber", "value": "false"}, + map[string]any{"id": "reasoning", "value": "max"}, + } + gotParams, _ := state["model_params"].([]any) + if len(gotParams) != len(wantParams) { + t.Fatalf("model_params = %#v, want %#v", state["model_params"], wantParams) + } + for i := range wantParams { + got, _ := gotParams[i].(map[string]any) + want, _ := wantParams[i].(map[string]any) + if got["id"] != want["id"] || got["value"] != want["value"] { + t.Fatalf("model_params[%d] = %#v, want %#v", i, gotParams[i], wantParams[i]) + } + } + if state["repository_url"] != "https://github.com/acme/repo" || + state["starting_ref"] != "main" || state["mode"] != "plan" || + state["auto_create_pr"] != true { + t.Fatalf("repository projection = %#v", state) + } + if state["remote_status"] != "RUNNING" || + state["operation_state"] != store.CursorOperationRunInFlight { + t.Fatalf("status projection = %#v", state) + } + git, _ := state["git"].(map[string]any) + branches, _ := git["branches"].([]any) + if len(branches) != 1 { + t.Fatalf("git projection = %#v", state["git"]) + } + branch, _ := branches[0].(map[string]any) + if branch["repo_url"] != "https://github.com/acme/repo" || + branch["branch"] != "cursor/x" || + branch["pr_url"] != "https://github.com/acme/repo/pull/7" { + t.Fatalf("branch projection = %#v", branch) + } + + // Nothing internal, recoverable-only, or partially generated may travel to + // the browser with the composer's restore data. + for _, forbidden := range []string{ + "revision", "partial_text", "partial_reasoning", "agent_id", "run_id", + "user_message_id", "assistant_message_id", "last_event_id", + "bc-secret-agent", "run-secret", "half of a private answer", + "msg-user-internal", "msg-assistant-internal", + } { + if strings.Contains(raw, forbidden) { + t.Errorf("session detail leaked %q: %s", forbidden, raw) + } + } +} + +func TestSessionDetailKeepsInactiveCursorTargetInactive(t *testing.T) { + fixture := newCursorDirectTestServer(t) + seedCursorSession(t, fixture, "ses-inactive", func(state *store.CursorSessionState) { + state.TargetActive = false + state.ReuseValid = false + state.OperationState = store.CursorOperationCommitted + }) + + _, body, raw := getSessionDetail(t, fixture, "ses-inactive") + state := cursorStateOf(t, body) + if state["target_active"] != false || state["reuse_valid"] != false { + t.Fatalf("inactive projection = %#v (%s)", state, raw) + } + if state["operation_state"] != store.CursorOperationCommitted { + t.Fatalf("operation_state = %#v", state["operation_state"]) + } +} + +func TestSessionDetailNeverExposesAutoNoRepositorySentinel(t *testing.T) { + fixture := newCursorDirectTestServer(t) + seedCursorSession(t, fixture, "ses-auto", func(state *store.CursorSessionState) { + state.RepositoryURL = cursorAutoNoRepositoryIdentity + state.StartingRef = "" + }) + + _, body, raw := getSessionDetail(t, fixture, "ses-auto") + state := cursorStateOf(t, body) + value, ok := state["repository_url"] + if !ok { + t.Fatal("repository_url is absent") + } + // null means "discover it again", which is the identity this run used; an + // empty string would mean the user explicitly chose no repository. + if value != nil { + t.Fatalf("repository_url = %#v, want null for auto-discovery", value) + } + if strings.Contains(raw, "antares://") { + t.Fatalf("session detail leaked the auto-discovery sentinel: %s", raw) + } +} + +func TestSessionDetailDropsUndecodableCursorSelection(t *testing.T) { + fixture := newCursorDirectTestServer(t) + // The store guarantees a JSON array, not that every element is a parameter. + seedCursorSession(t, fixture, "ses-broken", func(state *store.CursorSessionState) { + state.ModelParams = `[{"id":"reasoning","value":42}]` + }) + + _, body, raw := getSessionDetail(t, fixture, "ses-broken") + state := cursorStateOf(t, body) + if state["model_id"] != "" { + t.Fatalf("model_id = %#v, want no selection when its params cannot be decoded (%s)", + state["model_id"], raw) + } + params, _ := state["model_params"].([]any) + if len(params) != 0 { + t.Fatalf("model_params = %#v, want empty", state["model_params"]) + } + if state["target_active"] != true { + t.Fatalf("an undecodable selection must not hide the durable state: %#v", state) + } +} + +func TestSessionDetailRedactsCursorProjectionStrings(t *testing.T) { + fixture := newCursorDirectTestServer(t) + seedCursorSession(t, fixture, "ses-secret", func(state *store.CursorSessionState) { + state.RemoteStatus = "ERROR: authorization: Bearer test-token" + state.GitState = `{"branches":[{"repoUrl":"https://user:test-token@github.com/acme/repo","branch":"main","prUrl":""}]}` + }) + + _, _, raw := getSessionDetail(t, fixture, "ses-secret") + if strings.Contains(raw, "test-token") { + t.Fatalf("session detail leaked a credential: %s", raw) + } + if !strings.Contains(raw, "REDACTED") { + t.Fatalf("session detail did not redact the projection: %s", raw) + } +} + +type cursorStateErrorStore struct { + store.Store + err error +} + +func (s cursorStateErrorStore) GetCursorSessionState( + context.Context, string, +) (*store.CursorSessionState, error) { + return nil, s.err +} + +func TestSessionDetailStoreFailureIsNotReportedAsNoCursorState(t *testing.T) { + fixture := newCursorDirectTestServer(t) + if err := fixture.db.CreateSession(context.Background(), &store.Session{ + ID: "ses-store-error", Title: "Cursor session", + }); err != nil { + t.Fatal(err) + } + failing := cursorStateErrorStore{ + Store: fixture.db, + err: errors.New("cursor state read failed for test-token"), + } + broken := New(Options{ + Config: fixture.cfg, + Agent: agent.New(fixture.cfg, failing, tools.NewRegistry(), nil, nil), + Store: failing, + Cursor: fixture.runner, + }) + brokenHTTP := httptest.NewServer(broken.Handler()) + defer brokenHTTP.Close() + + status, _, raw := getSessionDetailAt( + t, brokenHTTP.URL, brokenHTTP.Client(), "ses-store-error", + ) + if status != http.StatusInternalServerError { + t.Fatalf("status=%d body=%s, want a reported failure rather than no state", status, raw) + } + if strings.Contains(raw, "test-token") { + t.Fatalf("store failure leaked a credential: %s", raw) + } +} diff --git a/web/src/components/chat/CursorOptions.tsx b/web/src/components/chat/CursorOptions.tsx index e9bfbe4..a4a72b3 100644 --- a/web/src/components/chat/CursorOptions.tsx +++ b/web/src/components/chat/CursorOptions.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { CaretDown, Cloud, Warning } from '@phosphor-icons/react' import { get } from '@/lib/api' -import type { CursorMode, CursorOptionsValue } from '@/lib/composerTargets' +import type { CursorMode, CursorOptionsValue, CursorRunBaseline } from '@/lib/composerTargets' import { startsNewCursorAgent } from '@/lib/composerTargets' import { applyCursorDimension, @@ -40,8 +40,8 @@ export function CursorOptions({ value: CursorOptionsValue onChange: (value: CursorOptionsValue) => void projectDir?: string - /** The identity of the run in flight for this session, if any. */ - lastStarted: CursorOptionsValue | null + /** The run a follow-up would continue, if this session has one. */ + lastStarted: CursorRunBaseline | null disabled?: boolean }) { const { t } = useI18n() diff --git a/web/src/lib/chatEvents.test.mjs b/web/src/lib/chatEvents.test.mjs index 2c183fe..835b6bb 100644 --- a/web/src/lib/chatEvents.test.mjs +++ b/web/src/lib/chatEvents.test.mjs @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { approvalFromEvent, + cursorHydrationFromDetail, cursorSessionHydration, mergeApprovals, parseCursorApproval, @@ -210,3 +211,152 @@ describe('Cursor session hydration', () => { ).toEqual({ active: true, modelId: 'sol', remoteStatus: 'ERROR', branches: [] }) }) }) + +const cursorTranscript = [ + { + id: 'm1', + role: 'assistant', + content: 'done', + model: 'gpt-5.6-sol', + meta: { cursor_remote_status: 'FINISHED' }, + }, +] + +const activeProjection = { + target_active: true, + reuse_valid: true, + model_id: 'gpt-5.6-sol', + model_params: [ + { id: 'cyber', value: 'false' }, + { id: 'reasoning', value: 'max' }, + ], + repository_url: 'https://github.com/acme/repo', + starting_ref: 'main', + mode: 'plan', + auto_create_pr: true, + remote_status: 'RUNNING', + operation_state: 'run_in_flight', + git: { + branches: [ + { + repo_url: 'https://github.com/acme/repo', + branch: 'cursor/x', + pr_url: 'https://github.com/acme/repo/pull/7', + }, + ], + }, +} + +describe('durable Cursor hydration', () => { + test('the durable projection restores the exact target and its identity', () => { + expect( + cursorHydrationFromDetail({ cursor_state: activeProjection, messages: cursorTranscript }), + ).toEqual({ + active: true, + modelId: 'gpt-5.6-sol', + params: [ + { id: 'cyber', value: 'false' }, + { id: 'reasoning', value: 'max' }, + ], + mode: 'plan', + repositoryUrl: 'https://github.com/acme/repo', + startingRef: 'main', + autoCreatePR: true, + reuseValid: true, + remoteStatus: 'RUNNING', + operationState: 'run_in_flight', + running: true, + branches: [ + { + repoUrl: 'https://github.com/acme/repo', + branch: 'cursor/x', + prUrl: 'https://github.com/acme/repo/pull/7', + }, + ], + }) + }) + + test('an inactive target is not restored even with old Cursor messages', () => { + const state = cursorHydrationFromDetail({ + cursor_state: { ...activeProjection, target_active: false, reuse_valid: false, operation_state: 'committed' }, + messages: cursorTranscript, + }) + expect(state.active).toBe(false) + expect(state.modelId).toBeUndefined() + expect(state.params).toBeUndefined() + expect(state.running).toBe(false) + // The finished run's outcome is still worth showing. + expect(state.remoteStatus).toBe('RUNNING') + expect(state.branches).toHaveLength(1) + }) + + test('a session the server says has no Cursor state ignores old transcript metadata', () => { + expect( + cursorHydrationFromDetail({ cursor_state: null, messages: cursorTranscript }), + ).toEqual({ active: false, branches: [] }) + }) + + test('a server that omits the projection still hydrates from the transcript', () => { + const state = cursorHydrationFromDetail({ messages: cursorTranscript }) + expect(state.active).toBe(true) + expect(state.modelId).toBe('gpt-5.6-sol') + expect(state.remoteStatus).toBe('FINISHED') + // A transcript cannot prove the exact variant, so nothing claims to know it. + expect(state.params).toBeUndefined() + expect(state.reuseValid).toBe(false) + expect(state.running).toBe(false) + }) + + test('auto-discovery stays auto and an explicit empty repository stays explicit', () => { + expect( + cursorHydrationFromDetail({ + cursor_state: { ...activeProjection, repository_url: null }, + messages: [], + }).repositoryUrl, + ).toBeNull() + expect( + cursorHydrationFromDetail({ + cursor_state: { ...activeProjection, repository_url: '' }, + messages: [], + }).repositoryUrl, + ).toBe('') + }) + + test('a selection the server could not decode restores no model', () => { + const state = cursorHydrationFromDetail({ + cursor_state: { ...activeProjection, model_id: '', model_params: [] }, + messages: cursorTranscript, + }) + expect(state.active).toBe(true) + expect(state.modelId).toBeUndefined() + expect(state.params).toBeUndefined() + }) + + test('an awaiting-approval or creating run counts as running', () => { + for (const operation of ['awaiting_approval', 'create_in_flight', 'run_in_flight']) { + expect( + cursorHydrationFromDetail({ + cursor_state: { ...activeProjection, operation_state: operation }, + messages: [], + }).running, + ).toBe(true) + } + for (const operation of ['idle', 'terminal', 'committed', 'ambiguous']) { + expect( + cursorHydrationFromDetail({ + cursor_state: { ...activeProjection, operation_state: operation }, + messages: [], + }).running, + ).toBe(false) + } + }) + + test('an unknown stored mode never becomes a Cursor mode', () => { + expect( + cursorHydrationFromDetail({ + cursor_state: { ...activeProjection, mode: 'chaos' }, + messages: [], + }).mode, + ).toBeUndefined() + }) +}) diff --git a/web/src/lib/chatEvents.ts b/web/src/lib/chatEvents.ts index 7c92783..d33ffe9 100644 --- a/web/src/lib/chatEvents.ts +++ b/web/src/lib/chatEvents.ts @@ -176,6 +176,116 @@ export interface CursorSessionHydration { branches: CursorBranch[] } +/** The durable Cursor state `GET /api/sessions/{id}` projects for the composer. */ +export interface CursorStateProjection { + target_active: boolean + reuse_valid: boolean + model_id: string + model_params: Array<{ id: string; value: string }> + /** null when the run discovered its repository (or ran without one). */ + repository_url: string | null + starting_ref: string + mode: string + auto_create_pr: boolean + remote_status: string + operation_state: string + git?: { branches?: Array<{ repo_url: string; branch: string; pr_url: string }> } +} + +export interface CursorHydration { + /** Whether this conversation's execution target is still Cursor. */ + active: boolean + modelId?: string + params?: Array<{ id: string; value: string }> + mode?: 'agent' | 'plan' + repositoryUrl?: string | null + startingRef?: string + autoCreatePR?: boolean + reuseValid?: boolean + remoteStatus?: string + operationState?: string + /** A remote run that has not reached a terminal state yet. */ + running?: boolean + branches: CursorBranch[] +} + +/** Operation states in which Cursor still owns unfinished remote work. */ +const CURSOR_RUNNING_OPERATIONS = [ + 'awaiting_approval', + 'create_in_flight', + 'run_in_flight', +] + +/** + * Restore the Cursor half of a session. The durable projection is + * authoritative: when the server reports no state, or a target that is no + * longer Cursor, old transcript metadata must not resurrect Cursor mode. + * Transcript parsing survives only for a server that predates the projection, + * which is the one case where the field is absent rather than null. + */ +export function cursorHydrationFromDetail(detail: { + cursor_state?: CursorStateProjection | null + messages: HydrationMessage[] +}): CursorHydration { + if (detail.cursor_state === undefined) { + const legacy = cursorSessionHydration(detail.messages) + return { + active: legacy.active, + modelId: legacy.modelId, + remoteStatus: legacy.remoteStatus, + // A transcript proves neither the exact variant nor that a follow-up + // would reuse the same agent. + reuseValid: false, + running: false, + branches: legacy.branches, + } + } + + const state = detail.cursor_state + if (!state) return { active: false, branches: [] } + + const branches: CursorBranch[] = (state.git?.branches ?? []).map((branch) => ({ + repoUrl: String(branch.repo_url ?? ''), + branch: String(branch.branch ?? ''), + prUrl: String(branch.pr_url ?? ''), + })) + const remoteStatus = state.remote_status || undefined + const operationState = state.operation_state || undefined + + if (!state.target_active) { + return { + active: false, + reuseValid: false, + running: false, + remoteStatus, + operationState, + branches, + } + } + + const hydration: CursorHydration = { + active: true, + mode: state.mode === 'agent' || state.mode === 'plan' ? state.mode : undefined, + repositoryUrl: state.repository_url ?? null, + startingRef: typeof state.starting_ref === 'string' ? state.starting_ref : '', + autoCreatePR: state.auto_create_pr === true, + reuseValid: state.reuse_valid === true, + remoteStatus, + operationState, + running: CURSOR_RUNNING_OPERATIONS.includes(state.operation_state), + branches, + } + // The server drops both halves of a selection it could not decode exactly. + if (state.model_id) { + hydration.modelId = state.model_id + hydration.params = (state.model_params ?? []).map((param) => ({ + id: String(param.id ?? ''), + value: String(param.value ?? ''), + })) + } + return hydration +} + interface HydrationMessage { role: string model?: string diff --git a/web/src/lib/composerTargets.test.mjs b/web/src/lib/composerTargets.test.mjs index d7950d8..115cd05 100644 --- a/web/src/lib/composerTargets.test.mjs +++ b/web/src/lib/composerTargets.test.mjs @@ -144,21 +144,27 @@ describe('Cursor run identity', () => { startingRef: 'main', autoCreatePR: false, } + const reusable = { options: base, reuseValid: true } test('mode-only changes continue the same agent', () => { - expect(startsNewCursorAgent(base, { ...base, mode: 'plan' })).toBe(false) + expect(startsNewCursorAgent(reusable, { ...base, mode: 'plan' })).toBe(false) }) test('model, variant, repository, ref, and auto-PR changes start a new agent', () => { expect( - startsNewCursorAgent(base, { ...base, variant: cursorModels[0].variants[1] }), + startsNewCursorAgent(reusable, { ...base, variant: cursorModels[0].variants[1] }), ).toBe(true) expect( - startsNewCursorAgent(base, { ...base, model: cursorModels[1], variant: { params: [] } }), + startsNewCursorAgent(reusable, { ...base, model: cursorModels[1], variant: { params: [] } }), ).toBe(true) - expect(startsNewCursorAgent(base, { ...base, repositoryUrl: '' })).toBe(true) - expect(startsNewCursorAgent(base, { ...base, startingRef: 'release' })).toBe(true) - expect(startsNewCursorAgent(base, { ...base, autoCreatePR: true })).toBe(true) + expect(startsNewCursorAgent(reusable, { ...base, repositoryUrl: '' })).toBe(true) + expect(startsNewCursorAgent(reusable, { ...base, repositoryUrl: null })).toBe(true) + expect(startsNewCursorAgent(reusable, { ...base, startingRef: 'release' })).toBe(true) + expect(startsNewCursorAgent(reusable, { ...base, autoCreatePR: true })).toBe(true) + }) + + test('an identical selection whose reuse was invalidated still starts a new agent', () => { + expect(startsNewCursorAgent({ options: base, reuseValid: false }, { ...base })).toBe(true) }) test('no previous run never warns about a new agent', () => { diff --git a/web/src/lib/composerTargets.ts b/web/src/lib/composerTargets.ts index 2592450..6b5d911 100644 --- a/web/src/lib/composerTargets.ts +++ b/web/src/lib/composerTargets.ts @@ -152,13 +152,26 @@ export function cursorRunIdentity(value: CursorOptionsValue): string { }) } +/** + * The run a follow-up would continue: what it was configured with, and whether + * the server still considers that agent reusable. + */ +export interface CursorRunBaseline { + options: CursorOptionsValue + reuseValid: boolean +} + /** Whether sending now would start a new Cursor agent instead of following up. */ export function startsNewCursorAgent( - previous: CursorOptionsValue | null, + previous: CursorRunBaseline | null, next: CursorOptionsValue, ): boolean { if (!previous) return false - return cursorRunIdentity(previous) !== cursorRunIdentity(next) + // An invalidated chain always creates a new agent, even for an identical + // selection — a failed create, a target switch, or an interrupted approval + // all leave nothing to follow up on. + if (!previous.reuseValid) return true + return cursorRunIdentity(previous.options) !== cursorRunIdentity(next) } export interface CursorChatRequest { diff --git a/web/src/lib/cursorModels.test.mjs b/web/src/lib/cursorModels.test.mjs index 4b89631..bf59dad 100644 --- a/web/src/lib/cursorModels.test.mjs +++ b/web/src/lib/cursorModels.test.mjs @@ -7,6 +7,7 @@ import { cursorVariantSummary, defaultCursorVariant, matchingCursorVariants, + resolveCursorVariant, selectExactVariant, variantSelection, } from './cursorModels.ts' @@ -236,6 +237,65 @@ describe('Cursor variant dimensions', () => { }) }) +describe('restoring a stored selection', () => { + test('matches the one variant carrying exactly those params', () => { + const variant = resolveCursorVariant(multiVariantFixture, [ + { id: 'context', value: '1m' }, + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'on' }, + ]) + expect(variant).toBe(multiVariantFixture.variants[2]) + }) + + test('ignores the order the params were stored in', () => { + const variant = resolveCursorVariant(multiVariantFixture, [ + { id: 'internal', value: 'off' }, + { id: 'reasoning', value: 'low' }, + { id: 'context', value: '272k' }, + ]) + expect(variant).toBe(multiVariantFixture.variants[0]) + }) + + test('a partial or unknown selection never falls back to the default variant', () => { + expect( + resolveCursorVariant(multiVariantFixture, [{ id: 'context', value: '272k' }]), + ).toBeNull() + expect(resolveCursorVariant(multiVariantFixture, [])).toBeNull() + expect( + resolveCursorVariant(multiVariantFixture, [ + { id: 'context', value: '1m' }, + { id: 'reasoning', value: 'low' }, + { id: 'internal', value: 'on' }, + ]), + ).toBeNull() + expect( + resolveCursorVariant(multiVariantFixture, [ + { id: 'context', value: '1m' }, + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'on' }, + { id: 'extra', value: 'yes' }, + ]), + ).toBeNull() + }) + + test('a duplicated parameter id is not a resolvable selection', () => { + expect( + resolveCursorVariant(multiVariantFixture, [ + { id: 'context', value: '272k' }, + { id: 'context', value: '1m' }, + { id: 'reasoning', value: 'low' }, + { id: 'internal', value: 'off' }, + ]), + ).toBeNull() + }) + + test('a variant-less model resolves only an empty selection', () => { + const bare = { id: 'bare', name: 'Bare', aliases: [], parameters: [], variants: [] } + expect(resolveCursorVariant(bare, [])).toEqual({ params: [], displayName: 'Bare' }) + expect(resolveCursorVariant(bare, [{ id: 'reasoning', value: 'max' }])).toBeNull() + }) +}) + describe('Cursor model search', () => { test('matches id, display name, alias, and the Cursor provider label', () => { expect(cursorModelMatches(multiVariantFixture, 'sol')).toBe(true) diff --git a/web/src/lib/cursorModels.ts b/web/src/lib/cursorModels.ts index 20c8dc5..1bc45b1 100644 --- a/web/src/lib/cursorModels.ts +++ b/web/src/lib/cursorModels.ts @@ -105,6 +105,49 @@ export function selectExactVariant( return matches.length === 1 ? matches[0] : null } +/** A selection as an id → value map, or null when an id repeats. */ +function canonicalParamMap( + params: CursorVariantParam[], +): Map | null { + const canonical = new Map() + for (const param of params ?? []) { + if (!param.id || canonical.has(param.id)) return null + canonical.set(param.id, param.value) + } + return canonical +} + +/** + * The upstream variant that carries exactly this stored selection, whatever + * order it was stored in. A selection that no longer matches any variant + * resolves to nothing: falling back to the default would silently run a + * different model configuration than the conversation used. + */ +export function resolveCursorVariant( + model: CursorModel, + params: CursorVariantParam[], +): CursorVariant | null { + const wanted = canonicalParamMap(params) + if (!wanted) return null + const variants = model.variants ?? [] + if (variants.length === 0) { + return wanted.size === 0 ? { params: [], displayName: model.name } : null + } + for (const variant of variants) { + const candidate = canonicalParamMap(variant.params ?? []) + if (!candidate || candidate.size !== wanted.size) continue + let equal = true + for (const [id, value] of wanted) { + if (candidate.get(id) !== value) { + equal = false + break + } + } + if (equal) return variant + } + return null +} + /** * Move one dimension while keeping as much of the current variant as Cursor * actually offers. The result is always an upstream variant, so hidden params diff --git a/web/src/lib/i18n.tsx b/web/src/lib/i18n.tsx index fccb749..9463ab8 100644 --- a/web/src/lib/i18n.tsx +++ b/web/src/lib/i18n.tsx @@ -480,6 +480,7 @@ const en = { 'cursor.autoPR': 'Open a pull request', 'cursor.autoPRHint': 'Cursor opens a pull request when the run finishes.', 'cursor.newAgentNotice': 'Sending now starts a new Cursor agent; the current one keeps its own history.', + 'cursor.staleSelection': 'The Cursor model and variant this conversation used are no longer in the catalogue. Choose a model again before sending.', 'cursor.warnDirty': 'Uncommitted local changes are not present in the Cursor cloud VM.', 'cursor.warnLocalOnly': '{n} local commit(s) are missing from the remote ref, so the cloud VM will not have them.', 'cursor.warnRemoteUnknown': 'The remote-tracking ref is unavailable, so Antares cannot tell which local commits the cloud VM has.', @@ -1558,6 +1559,7 @@ const id: Dict = { 'cursor.autoPR': 'Buka pull request', 'cursor.autoPRHint': 'Cursor membuka pull request setelah run selesai.', 'cursor.newAgentNotice': 'Mengirim sekarang memulai agent Cursor baru; agent saat ini menyimpan riwayatnya sendiri.', + 'cursor.staleSelection': 'Model dan varian Cursor yang dipakai percakapan ini sudah tidak ada di katalog. Pilih model lagi sebelum mengirim.', 'cursor.warnDirty': 'Perubahan lokal yang belum di-commit tidak ada di VM cloud Cursor.', 'cursor.warnLocalOnly': '{n} commit lokal belum ada di ref remote, jadi VM cloud tidak memilikinya.', 'cursor.warnRemoteUnknown': 'Ref remote-tracking tidak tersedia, jadi Antares tidak bisa memastikan commit lokal mana yang ada di VM cloud.', @@ -2403,6 +2405,7 @@ const ja: Dict = { 'cursor.autoPR': 'プルリクエストを作成', 'cursor.autoPRHint': '実行が完了すると Cursor がプルリクエストを作成します。', 'cursor.newAgentNotice': 'このまま送信すると新しい Cursor エージェントを開始します。現在のエージェントの履歴はそのまま残ります。', + 'cursor.staleSelection': 'この会話が使っていた Cursor のモデルとバリアントはカタログにありません。送信する前にモデルを選び直してください。', 'cursor.warnDirty': '未コミットのローカル変更は Cursor のクラウド VM にはありません。', 'cursor.warnLocalOnly': 'ローカルの {n} 件のコミットがリモート ref に無いため、クラウド VM にも存在しません。', 'cursor.warnRemoteUnknown': 'リモート追跡 ref を取得できないため、どのローカルコミットがクラウド VM にあるか確認できません。', @@ -3165,6 +3168,7 @@ const zh: Dict = { 'cursor.autoPR': '创建 Pull Request', 'cursor.autoPRHint': '运行结束后由 Cursor 创建 Pull Request。', 'cursor.newAgentNotice': '现在发送会启动一个新的 Cursor agent;当前 agent 的历史会保留。', + 'cursor.staleSelection': '该对话使用的 Cursor 模型和变体已不在目录中。发送前请重新选择模型。', 'cursor.warnDirty': '未提交的本地改动不会出现在 Cursor 云端虚拟机中。', 'cursor.warnLocalOnly': '有 {n} 个本地提交不在远程 ref 上,云端虚拟机也不会有它们。', 'cursor.warnRemoteUnknown': '无法获取远程跟踪 ref,因此 Antares 无法确认云端虚拟机拥有哪些本地提交。', @@ -3925,6 +3929,7 @@ const ru: Dict = { 'cursor.autoPR': 'Создать pull request', 'cursor.autoPRHint': 'Cursor создаст pull request после завершения запуска.', 'cursor.newAgentNotice': 'Отправка сейчас запустит нового агента Cursor; у текущего останется своя история.', + 'cursor.staleSelection': 'Модель и вариант Cursor, которые использовал этот разговор, больше не значатся в каталоге. Выберите модель заново перед отправкой.', 'cursor.warnDirty': 'Незакоммиченные локальные изменения отсутствуют в облачной ВМ Cursor.', 'cursor.warnLocalOnly': 'Локальных коммитов вне удалённого ref: {n}; в облачной ВМ их не будет.', 'cursor.warnRemoteUnknown': 'Удалённый отслеживаемый ref недоступен, поэтому Antares не может определить, какие локальные коммиты есть в облачной ВМ.', diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index bed7cbc..89d7448 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -31,12 +31,13 @@ import { } from '@/lib/api' import { approvalFromEvent, - cursorSessionHydration, + cursorHydrationFromDetail, mergeApprovals, pendingApprovalsForSession, shouldReconnectAttach, stopBehavior, - type CursorSessionHydration, + type CursorHydration, + type CursorStateProjection, type PendingApproval, } from '@/lib/chatEvents' import { @@ -48,15 +49,19 @@ import { import { copyText } from '@/lib/clipboard' import { cursorChatRequest, - cursorTargetFromModel, isCursorTarget, type ChatTarget, type ComposerTarget, type CursorMode, type CursorOptionsValue, + type CursorRunBaseline, } from '@/lib/composerTargets' import { composerImageLimit, validateCursorAttachments } from '@/lib/cursorAttachments' -import type { CursorModel } from '@/lib/cursorModels' +import { + defaultCursorVariant, + resolveCursorVariant, + type CursorModel, +} from '@/lib/cursorModels' import { useI18n, useTimeAgo, type MessageKey } from '@/lib/i18n' import type { ReasoningCapability } from '@/lib/models' import { @@ -215,6 +220,11 @@ interface SessionDetail { model?: string meta?: Record | null }> + /** + * Durable Cursor state. Null for an ordinary chat; absent only on a server + * that predates the projection, where the transcript is the last resort. + */ + cursor_state?: CursorStateProjection | null } /** Rebuild view models from the persisted message log. */ @@ -285,6 +295,14 @@ function hydrate(detail: SessionDetail): ChatMessage[] { return out } +/** The part of a Cursor turn that is not the model and its variant. */ +interface CursorTurnSettings { + mode: CursorMode + repositoryUrl: string | null + startingRef: string | null + autoCreatePR: boolean +} + const SUGGESTION_KEYS: MessageKey[] = [ 'chat.suggest1', 'chat.suggest2', @@ -412,12 +430,12 @@ export default function ChatPage() { targetRef.current = target const cursorMode = isCursorTarget(target) // The non-model half of a Cursor turn, kept while switching Cursor models. - const [cursorSettings, setCursorSettings] = useState<{ - mode: CursorMode - repositoryUrl: string | null - startingRef: string | null - autoCreatePR: boolean - }>({ mode: 'agent', repositoryUrl: null, startingRef: null, autoCreatePR: false }) + const [cursorSettings, setCursorSettings] = useState({ + mode: 'agent', + repositoryUrl: null, + startingRef: null, + autoCreatePR: false, + }) const cursorOptions = useMemo( () => isCursorTarget(target) @@ -427,13 +445,13 @@ export default function ChatPage() { ) const cursorOptionsRef = useRef(null) cursorOptionsRef.current = cursorOptions - // The identity of the Cursor run this session last started, so the options - // popover can warn that sending now starts a new agent instead of following - // up on the existing one. - const [lastCursorRun, setLastCursorRun] = useState(null) - // What a reopened Cursor session already produced: remote status, branches, - // and pull requests from the persisted transcript. - const [cursorState, setCursorState] = useState(null) + // The run a follow-up would continue, and whether the server still considers + // it reusable, so the options popover can warn that sending now starts a new + // agent instead of following up. + const [lastCursorRun, setLastCursorRun] = useState(null) + // What this session's Cursor run is doing or produced: remote status, + // operation state, branches, and pull requests. + const [cursorState, setCursorState] = useState(null) // Local Stop detaches from a Cursor run that keeps going remotely. The ref is // what the standing attach loop reads, so it never re-follows immediately. const [detached, setDetached] = useState(false) @@ -472,26 +490,125 @@ export default function ChatPage() { }) }, []) /** - * Point the composer back at the Cursor model a reopened session used. Only - * the catalogue knows that model's variants, so the target is restored from - * it rather than reconstructed from the transcript. + * Record the run a follow-up would continue, when the composer is already + * pointed at exactly that selection. Reports whether it could. + */ + const applyCursorBaseline = useCallback( + ( + modelId: string, + params: Array<{ id: string; value: string }>, + settings: CursorTurnSettings, + reuseValid: boolean, + ): boolean => { + const current = targetRef.current + if ( + current?.kind !== 'cursor' || + current.model.id !== modelId || + resolveCursorVariant(current.model, params) !== current.variant + ) { + return false + } + setLastCursorRun({ + options: { model: current.model, variant: current.variant, ...settings }, + reuseValid, + }) + return true + }, + [], + ) + /** + * Point the composer back at the exact model and variant a session's durable + * state names. The catalogue is the only place that knows a model's variants, + * and a selection it no longer offers is reported instead of being replaced + * by the default one — that would silently run a different configuration. */ const restoreCursorTarget = useCallback( - (modelId: string) => { - const before = targetRef.current - void get<{ models?: CursorModel[] }>('/providers/cursor/models') + ( + modelId: string, + /** Null when only a transcript named the model, so no exact selection exists. */ + params: Array<{ id: string; value: string }> | null, + settings: CursorTurnSettings, + reuseValid: boolean, + ) => { + // The composer already holds this exact selection; no catalogue lookup + // can tell us anything new. + if (params && applyCursorBaseline(modelId, params, settings, reuseValid)) return + const current = targetRef.current + void get<{ models?: CursorModel[]; needs_key?: boolean }>('/providers/cursor/models') .then((d) => { // Never overwrite a target the user chose while this was in flight. - if (targetRef.current !== before) return + if (targetRef.current !== current) return + if (d.needs_key) { + setError(t('target.cursorNeedsKey')) + return + } const model = (d.models ?? []).find( (candidate) => candidate.id === modelId || (candidate.aliases ?? []).includes(modelId), ) - if (model) setTarget(cursorTargetFromModel(model)) + // With a stored selection only that exact variant may be restored; + // without one (an older server) the model's own default is the + // honest starting point, and nothing claims a run to follow up on. + const variant = model + ? params + ? resolveCursorVariant(model, params) + : defaultCursorVariant(model) + : null + if (!model || !variant) { + setError(t('cursor.staleSelection')) + return + } + setTarget({ kind: 'cursor', model, variant }) + setLastCursorRun( + params ? { options: { model, variant, ...settings }, reuseValid } : null, + ) }) .catch(() => {}) }, - [], + [applyCursorBaseline, t], + ) + /** + * Apply a session's durable Cursor state. Opening a session restores the + * composer from it; a refresh during or after a turn only updates the run's + * status and reuse baseline, so neither an edit made while the turn ran nor a + * model the user has just switched to is overwritten. + */ + const applyCursorHydration = useCallback( + (hydration: CursorHydration, restoreComposer: boolean) => { + const worthShowing = + hydration.active || Boolean(hydration.remoteStatus) || hydration.branches.length > 0 + setCursorState(worthShowing ? hydration : null) + if (!hydration.active) { + setLastCursorRun(null) + return + } + const settings: CursorTurnSettings = { + mode: hydration.mode ?? 'agent', + repositoryUrl: hydration.repositoryUrl ?? null, + // The exact stored ref, so a follow-up reproduces the same run identity. + startingRef: hydration.startingRef ?? null, + autoCreatePR: hydration.autoCreatePR === true, + } + if (!hydration.modelId) { + // The server could not decode an exact selection; restore no target. + if (restoreComposer) setCursorSettings(settings) + setLastCursorRun(null) + return + } + // An absent selection means a server that predates the projection; an + // exact one must be matched exactly. + const params = hydration.params ?? null + const reuseValid = hydration.reuseValid === true + if (!restoreComposer) { + if (!params || !applyCursorBaseline(hydration.modelId, params, settings, reuseValid)) { + setLastCursorRun(null) + } + return + } + setCursorSettings(settings) + restoreCursorTarget(hydration.modelId, params, settings, reuseValid) + }, + [applyCursorBaseline, restoreCursorTarget], ) const pickReasoning = useCallback((value: string) => { const current = composerReasoningRef.current @@ -1050,6 +1167,7 @@ export default function ChatPage() { if (!alive) return setMessages(hydrate(d)) setTitle(d.session.title || t('chat.conversation')) + applyCursorHydration(cursorHydrationFromDetail(d), false) }) .catch(() => {}) : Promise.resolve() @@ -1084,7 +1202,7 @@ export default function ChatPage() { close?.() } }, - [applyEvent, drainPatches, t], + [applyEvent, applyCursorHydration, drainPatches, t], ) useEffect(() => { @@ -1135,12 +1253,9 @@ export default function ChatPage() { } } setError(undefined) - // Only a Cursor turn persists a remote status, so the transcript itself - // says whether this conversation runs on Cursor, which model it used, - // and which branches or pull requests it produced. - const cursor = cursorSessionHydration(d.messages) - setCursorState(cursor.active ? cursor : null) - if (cursor.active && cursor.modelId) restoreCursorTarget(cursor.modelId) + // Durable Cursor state decides whether this conversation still runs on + // Cursor, and with exactly which model, variant, repository, and mode. + applyCursorHydration(cursorHydrationFromDetail(d), true) // A decision published before this page attached is still blocking the // run; the pending list is the only place left to find it. void refreshApprovals(sessionId) @@ -1178,7 +1293,7 @@ export default function ChatPage() { cancelled = true closeAttach?.() } - }, [sessionId, t, attachLive, refreshApprovals, restoreCursorTarget]) + }, [sessionId, t, attachLive, refreshApprovals, applyCursorHydration]) /** Append a locally-produced message without touching the server. */ const pushSystem = useCallback((content: string) => { @@ -1328,7 +1443,10 @@ export default function ChatPage() { // session is being followed again. detachedRef.current = false setDetached(false) - if (cursor) setLastCursorRun(cursor) + // The turn being started is the run a follow-up would continue. Whether the + // server keeps it reusable is only known once it ends, which the durable + // state at end-of-turn corrects. + if (cursor) setLastCursorRun({ options: cursor, reuseValid: true }) abortRef.current = streamPost( cursor ? '/chat/cursor' : '/chat', cursor @@ -1389,8 +1507,9 @@ export default function ChatPage() { .then((d) => { setMessages(hydrate(d)) setTitle(d.session.title || t('chat.conversation')) - const state = cursorSessionHydration(d.messages) - if (state.active) setCursorState(state) + // The turn that just ended decides whether a follow-up can + // reuse its agent; the composer's own edits are left alone. + applyCursorHydration(cursorHydrationFromDetail(d), false) }) .catch(() => {}) } @@ -1435,7 +1554,18 @@ export default function ChatPage() { }, ) }, - [role, projectDir, streaming, sessionId, navigate, runCommand, applyEvent, drainPatches, t], + [ + role, + projectDir, + streaming, + sessionId, + navigate, + runCommand, + applyEvent, + applyCursorHydration, + drainPatches, + t, + ], ) const send = useCallback(() => { @@ -2322,7 +2452,7 @@ function CursorRunBar({ detached: boolean cancelling: boolean canCancel: boolean - state: CursorSessionHydration | null + state: CursorHydration | null onCancel: () => void onReattach: () => void }) { @@ -2362,7 +2492,9 @@ function CursorRunBar({ {t('cursor.reattach')} ) : null} - {canCancel && (streaming || detached) ? ( + {/* A run the server still owns can be cancelled even when this browser + is neither streaming nor detached — after a reload, for example. */} + {canCancel && (streaming || detached || state?.running === true) ? (
- {reasoning ? ( - pickDimension(reasoning, option)} - disabled={disabled} - /> - ) : null} - {others.map((dimension) => ( + {[...(reasoning ? [reasoning] : []), ...others].map((dimension) => ( pickDimension(dimension, option)} disabled={disabled} /> ))} + {unavailable ? ( +

+ {t('cursor.variantUnavailable')} +

+ ) : null}
@@ -265,27 +280,37 @@ export function CursorOptions({ function DimensionRow({ dimension, selected, + available, onPick, disabled, }: { dimension: CursorDimension selected?: string + /** Values that resolve to exactly one variant from the current selection. */ + available: string[] onPick: (value: string) => void disabled?: boolean }) { + const { t } = useI18n() return (
- {dimension.values.map((option) => ( - onPick(option.value)} - label={option.label} - /> - ))} + {dimension.values.map((option) => { + const reachable = available.includes(option.value) + return ( + onPick(option.value)} + label={option.label} + /> + ) + })}
) @@ -296,17 +321,20 @@ function OptionChip({ label, onClick, disabled, + title, }: { active: boolean label: string onClick: () => void disabled?: boolean + title?: string }) { return ( ); diff --git a/web/src/lib/composerRestore.test.mjs b/web/src/lib/composerRestore.test.mjs new file mode 100644 index 0000000..94fd00c --- /dev/null +++ b/web/src/lib/composerRestore.test.mjs @@ -0,0 +1,172 @@ +import { describe, expect, test } from 'bun:test' +import { + baselineAfterSend, + restoreIsCurrent, + shouldAdoptDefaultTarget, + stopStreamKind, + targetAfterCursorHydration, + targetChangeAllowed, +} from './composerRestore.ts' + +const chatTarget = { + kind: 'chat', + provider: 'openai', + model: 'gpt-5.6', + name: 'GPT 5.6', + providerLabel: 'OpenAI', +} + +const otherChatTarget = { ...chatTarget, model: 'gpt-5.5', name: 'GPT 5.5' } + +const cursorTarget = (id) => ({ + kind: 'cursor', + model: { id, name: id, aliases: [], parameters: [], variants: [] }, + variant: { params: [], displayName: id }, +}) + +describe('session-scoped restoration', () => { + test('only the newest hydration may apply its result', () => { + expect(restoreIsCurrent(4, 4)).toBe(true) + // Session A's catalogue answer arriving after session B opened. + expect(restoreIsCurrent(4, 5)).toBe(false) + }) +}) + +describe('automatic chat defaults', () => { + test('a default never lands while a session is still hydrating', () => { + expect(shouldAdoptDefaultTarget({ owner: 'pending', hasTarget: false })).toBe(false) + }) + + test('a default never replaces a restored Cursor target', () => { + expect(shouldAdoptDefaultTarget({ owner: 'restored', hasTarget: true })).toBe(false) + expect(shouldAdoptDefaultTarget({ owner: 'restored', hasTarget: false })).toBe(false) + }) + + test('a default fills an empty composer once hydration is done', () => { + expect(shouldAdoptDefaultTarget({ owner: 'free', hasTarget: false })).toBe(true) + }) + + test('a default never overwrites a target that is already chosen', () => { + expect(shouldAdoptDefaultTarget({ owner: 'free', hasTarget: true })).toBe(false) + }) +}) + +describe('the target a hydrated session should hold', () => { + test('an active Cursor session keeps the composer while its exact variant loads', () => { + expect( + targetAfterCursorHydration({ + active: true, + modelId: 'gpt-5.6-sol', + current: cursorTarget('gpt-5.6-sol'), + pendingDefault: chatTarget, + lastChat: chatTarget, + }), + ).toEqual({ owner: 'restored', action: 'keep' }) + }) + + test('a Cursor target from another session is dropped before its replacement loads', () => { + expect( + targetAfterCursorHydration({ + active: true, + modelId: 'claude-opus-5', + current: cursorTarget('gpt-5.6-sol'), + pendingDefault: null, + lastChat: null, + }), + ).toEqual({ owner: 'restored', action: 'set', target: null }) + }) + + test('an ordinary session replaces a leftover Cursor target with a chat one', () => { + expect( + targetAfterCursorHydration({ + active: false, + modelId: undefined, + current: cursorTarget('gpt-5.6-sol'), + pendingDefault: chatTarget, + lastChat: otherChatTarget, + }), + ).toEqual({ owner: 'free', action: 'set', target: chatTarget }) + }) + + test('the last chat target is used when no default has arrived yet', () => { + expect( + targetAfterCursorHydration({ + active: false, + modelId: undefined, + current: cursorTarget('gpt-5.6-sol'), + pendingDefault: null, + lastChat: otherChatTarget, + }), + ).toEqual({ owner: 'free', action: 'set', target: otherChatTarget }) + }) + + test('with no chat target known the Cursor target is still cleared', () => { + expect( + targetAfterCursorHydration({ + active: false, + modelId: undefined, + current: cursorTarget('gpt-5.6-sol'), + pendingDefault: null, + lastChat: null, + }), + ).toEqual({ owner: 'free', action: 'set', target: null }) + }) + + test('durable state that names no decodable model does not keep Cursor selected', () => { + expect( + targetAfterCursorHydration({ + active: true, + modelId: '', + current: cursorTarget('gpt-5.6-sol'), + pendingDefault: chatTarget, + lastChat: null, + }), + ).toEqual({ owner: 'free', action: 'set', target: chatTarget }) + }) + + test('an ordinary session leaves an existing chat target alone', () => { + expect( + targetAfterCursorHydration({ + active: false, + modelId: undefined, + current: chatTarget, + pendingDefault: otherChatTarget, + lastChat: otherChatTarget, + }), + ).toEqual({ owner: 'free', action: 'keep' }) + }) +}) + +describe('stop semantics', () => { + test('the stream that was started decides, not the picker', () => { + expect(stopStreamKind('cursor', false)).toBe('cursor') + expect(stopStreamKind('chat', true)).toBe('chat') + }) + + test('an attached run falls back to the session durable target', () => { + expect(stopStreamKind(null, true)).toBe('cursor') + expect(stopStreamKind(null, false)).toBe('chat') + }) + + test('the target cannot be changed while a turn is streaming', () => { + expect(targetChangeAllowed(true)).toBe(false) + expect(targetChangeAllowed(false)).toBe(true) + }) +}) + +describe('follow-up baseline', () => { + const options = { model: { id: 'sol' }, variant: { params: [] }, mode: 'agent' } + const previous = { options: { model: { id: 'opus' }, variant: { params: [] } }, reuseValid: true } + + test('a refused request never becomes the run a follow-up would continue', () => { + expect(baselineAfterSend({ previous, attempted: options, accepted: false })).toBe(previous) + expect(baselineAfterSend({ previous: null, attempted: options, accepted: false })).toBeNull() + }) + + test('an accepted stream adopts what it was sent with', () => { + expect(baselineAfterSend({ previous, attempted: options, accepted: true })).toEqual({ + options, + reuseValid: true, + }) + }) +}) diff --git a/web/src/lib/composerRestore.ts b/web/src/lib/composerRestore.ts new file mode 100644 index 0000000..eb472d1 --- /dev/null +++ b/web/src/lib/composerRestore.ts @@ -0,0 +1,107 @@ +/** + * The composer's restoration decisions, kept away from React so the rules that + * protect a send from targeting the wrong place are testable on their own. + * + * Three asynchronous sources race for one execution target: the picker's + * mount-time active-model lookup, a session's durable Cursor state, and the + * user. Only the user always wins; the other two are ordered by which session + * is open and whether that session's state has been read yet. + */ + +import type { ChatTarget, ComposerTarget, CursorOptionsValue, CursorRunBaseline } from '@/lib/composerTargets' + +/** + * Who owns the composer's target right now: + * - `pending`: a session is being hydrated and its state has the final say; + * - `restored`: durable state named the target, so no default may replace it; + * - `free`: nothing owns it, so an automatic chat default may fill it. + */ +export type TargetOwner = 'pending' | 'restored' | 'free' + +/** Whether an asynchronous restoration still belongs to the open session. */ +export function restoreIsCurrent(captured: number, current: number): boolean { + return captured === current +} + +/** + * Whether the picker's automatic active-model default may be adopted. It never + * competes with a session's own state, and never replaces a chosen target. + */ +export function shouldAdoptDefaultTarget(state: { + owner: TargetOwner + hasTarget: boolean +}): boolean { + return state.owner === 'free' && !state.hasTarget +} + +export interface HydrationTargetInput { + /** Whether durable state still points this session at Cursor. */ + active: boolean + /** The model durable state names, if it names a usable one. */ + modelId?: string + current: ComposerTarget | null + /** A picker default that arrived while the session was hydrating. */ + pendingDefault: ChatTarget | null + /** The last chat target this tab used, if any. */ + lastChat: ChatTarget | null +} + +export type HydrationTargetDecision = + | { owner: TargetOwner; action: 'keep' } + | { owner: TargetOwner; action: 'set'; target: ComposerTarget | null } + +/** + * What the composer's target should become when a session's durable state + * arrives. A Cursor target left over from another conversation is dropped + * before the replacement loads: sending in that window must never reach a + * Cursor model this session never used. + */ +export function targetAfterCursorHydration( + input: HydrationTargetInput, +): HydrationTargetDecision { + const { active, modelId, current, pendingDefault, lastChat } = input + if (active && modelId) { + // A restore is on its way for this session's own model. + if (current?.kind === 'cursor' && current.model.id !== modelId) { + return { owner: 'restored', action: 'set', target: null } + } + return { owner: 'restored', action: 'keep' } + } + // This session does not run on Cursor, or names nothing exact enough to run. + if (current?.kind === 'cursor') { + return { owner: 'free', action: 'set', target: pendingDefault ?? lastChat ?? null } + } + return { owner: 'free', action: 'keep' } +} + +/** + * Which semantics Stop must use. The stream that is actually running decides — + * the picker may have moved on since it started — and an attached run falls + * back to what the session's durable state says it is. + */ +export function stopStreamKind( + started: 'chat' | 'cursor' | null, + cursorActive: boolean, +): 'chat' | 'cursor' { + return started ?? (cursorActive ? 'cursor' : 'chat') +} + +/** The target may only change while nothing is streaming. */ +export function targetChangeAllowed(streaming: boolean): boolean { + return !streaming +} + +/** + * The run a follow-up would continue after a send attempt. A request the server + * refused (busy session, rate limit, auth, stale model) started nothing, so the + * previous baseline stands and the new-agent warning stays truthful. + */ +export function baselineAfterSend(input: { + previous: CursorRunBaseline | null + attempted: CursorOptionsValue + accepted: boolean +}): CursorRunBaseline | null { + return input.accepted + ? { options: input.attempted, reuseValid: true } + : input.previous +} diff --git a/web/src/lib/composerTargets.test.mjs b/web/src/lib/composerTargets.test.mjs index 115cd05..0dcf264 100644 --- a/web/src/lib/composerTargets.test.mjs +++ b/web/src/lib/composerTargets.test.mjs @@ -80,6 +80,11 @@ describe('composer targets', () => { expect(isCursorTarget(target)).toBe(true) }) + test('a model with no upstream variant cannot become a target', () => { + // cursorModels[1] is the catalogue's variant-less entry. + expect(cursorTargetFromModel(cursorModels[1])).toBeNull() + }) + test('target keys separate the two execution surfaces', () => { expect(composerTargetKey(chatTargetFromModel(chatModels[0]))).toBe('chat:openai/gpt-5.6') expect(composerTargetKey(cursorTargetFromModel(cursorModels[0]))).toBe('cursor:gpt-5.6-sol') @@ -112,6 +117,14 @@ describe('grouped target search', () => { expect(found.chat).toHaveLength(0) }) + test('a model with no upstream variant is listed but has no target to select', () => { + const found = searchComposerTargets({ chatModels, cursorModels, query: 'auto' }) + expect(found.cursor.map((row) => row.model.id)).toEqual(['auto-smart']) + expect(found.cursor[0].target).toBeNull() + const usable = searchComposerTargets({ chatModels, cursorModels, query: 'sol' }) + expect(usable.cursor[0].target?.variant).toBe(cursorModels[0].variants[0]) + }) + test('an empty query keeps both catalogues intact', () => { const all = searchComposerTargets({ chatModels, cursorModels, query: '' }) expect(all.chat).toHaveLength(2) diff --git a/web/src/lib/composerTargets.ts b/web/src/lib/composerTargets.ts index 6b5d911..0484495 100644 --- a/web/src/lib/composerTargets.ts +++ b/web/src/lib/composerTargets.ts @@ -72,11 +72,16 @@ export function chatTargetFromModel(model: ChatCatalogueModel): ChatTarget { } } +/** + * A Cursor target for this model, or null when the catalogue offers no variant + * to run it with. Inventing an empty parameter list would send a selection + * Cursor never returned. + */ export function cursorTargetFromModel( model: CursorModel, - variant: CursorVariant = defaultCursorVariant(model), -): CursorTarget { - return { kind: 'cursor', model, variant } + variant: CursorVariant | null = defaultCursorVariant(model), +): CursorTarget | null { + return variant ? { kind: 'cursor', model, variant } : null } export function composerTargetKey(target: ComposerTarget): string { @@ -101,15 +106,23 @@ function chatModelMatches(model: ChatCatalogueModel, query: string): boolean { ) } +/** A Cursor search hit. `target` is null when the model cannot be run at all. */ +export interface CursorSearchRow { + model: CursorModel + target: CursorTarget | null +} + /** * One search over both catalogues, presented as two groups. The catalogues stay - * separate: a Cursor hit is never offered as a chat model. + * separate: a Cursor hit is never offered as a chat model. A model the + * catalogue gave no variant for is still listed — with nothing to select — so + * its absence from the composer is explained rather than silent. */ export function searchComposerTargets(input: { chatModels: ChatCatalogueModel[] cursorModels: CursorModel[] query: string -}): { chat: ChatTarget[]; cursor: CursorTarget[] } { +}): { chat: ChatTarget[]; cursor: CursorSearchRow[] } { const { chatModels = [], cursorModels = [], query } = input return { chat: chatModels @@ -117,7 +130,7 @@ export function searchComposerTargets(input: { .map(chatTargetFromModel), cursor: cursorModels .filter((model) => cursorModelMatches(model, query)) - .map((model) => cursorTargetFromModel(model)), + .map((model) => ({ model, target: cursorTargetFromModel(model) })), } } diff --git a/web/src/lib/cursorModels.test.mjs b/web/src/lib/cursorModels.test.mjs index bf59dad..5ab9a1e 100644 --- a/web/src/lib/cursorModels.test.mjs +++ b/web/src/lib/cursorModels.test.mjs @@ -1,7 +1,9 @@ import { describe, expect, test } from 'bun:test' import { applyCursorDimension, + cursorDimensionAvailability, cursorModelMatches, + cursorModelSelectable, cursorReasoningDimension, cursorVariantDimensions, cursorVariantSummary, @@ -144,10 +146,14 @@ describe('exact Cursor variants', () => { expect(defaultCursorVariant(noDefault)).toBe(noDefault.variants[0]) }) - test('a model without variants selects an empty parameter list', () => { + test('a model the catalogue gave no variant for is not selectable', () => { const bare = { id: 'bare', name: 'Bare', aliases: [], parameters: [], variants: [] } - expect(defaultCursorVariant(bare)).toEqual({ params: [], displayName: 'Bare' }) - expect(selectExactVariant(bare, {})).toEqual({ params: [], displayName: 'Bare' }) + // Sending an invented empty params array would be a selection Cursor never + // offered, so there is nothing to select at all. + expect(defaultCursorVariant(bare)).toBeNull() + expect(selectExactVariant(bare, {})).toBeNull() + expect(cursorModelSelectable(bare)).toBe(false) + expect(cursorModelSelectable(multiVariantFixture)).toBe(true) }) test('an exact match returns the upstream variant object itself', () => { @@ -165,20 +171,31 @@ describe('exact Cursor variants', () => { expect(selectExactVariant(multiVariantFixture, { context: '272k' })).toBeNull() }) - test('changing one dimension lands on a concrete variant with its hidden params', () => { + test('changing one dimension keeps the others and carries the hidden params', () => { const from = multiVariantFixture.variants[0] const next = applyCursorDimension(multiVariantFixture, from, 'reasoning', 'max') expect(next).toBe(multiVariantFixture.variants[1]) - - const wider = applyCursorDimension(multiVariantFixture, from, 'context', '1m') - // 1M has no low-reasoning variant upstream, so the only real 1M variant wins - // and carries its own hidden internal flag. - expect(wider).toBe(multiVariantFixture.variants[2]) - expect(variantSelection(wider)).toEqual({ - context: '1m', + expect(variantSelection(next)).toEqual({ + context: '272k', reasoning: 'max', - internal: 'on', + internal: 'off', }) + + const wider = applyCursorDimension( + multiVariantFixture, + multiVariantFixture.variants[1], + 'context', + '1m', + ) + expect(wider).toBe(multiVariantFixture.variants[2]) + }) + + test('a value that would silently change another dimension commits nothing', () => { + // Only 1M + max exists upstream, so moving context while reasoning is low + // must not quietly raise reasoning too. + expect( + applyCursorDimension(multiVariantFixture, multiVariantFixture.variants[0], 'context', '1m'), + ).toBeNull() }) test('an unreachable dimension value commits nothing', () => { @@ -186,6 +203,48 @@ describe('exact Cursor variants', () => { applyCursorDimension(modelFixture, modelFixture.variants[0], 'context', '1m'), ).toBeNull() }) + + test('a tie between variants commits nothing', () => { + // Two variants share every visible dimension and differ only in a hidden + // one, so "fast on" cannot identify a single upstream variant. + const tied = { + id: 'tied', + name: 'Tied', + aliases: [], + parameters: [{ id: 'fast', values: [{ value: 'off' }, { value: 'on' }] }], + variants: [ + { params: [{ id: 'fast', value: 'off' }], displayName: 'off', isDefault: true }, + { + params: [ + { id: 'fast', value: 'on' }, + { id: 'internal', value: 'a' }, + ], + displayName: 'on a', + }, + { + params: [ + { id: 'fast', value: 'on' }, + { id: 'internal', value: 'b' }, + ], + displayName: 'on b', + }, + ], + } + expect(applyCursorDimension(tied, tied.variants[0], 'fast', 'on')).toBeNull() + expect(cursorDimensionAvailability(tied, tied.variants[0], 'fast')).toEqual(['off']) + }) + + test('availability marks exactly the values a control may commit', () => { + expect( + cursorDimensionAvailability(multiVariantFixture, multiVariantFixture.variants[0], 'context'), + ).toEqual(['272k']) + expect( + cursorDimensionAvailability(multiVariantFixture, multiVariantFixture.variants[1], 'context'), + ).toEqual(['272k', '1m']) + expect( + cursorDimensionAvailability(multiVariantFixture, multiVariantFixture.variants[0], 'reasoning'), + ).toEqual(['low', 'max']) + }) }) describe('Cursor variant dimensions', () => { @@ -289,11 +348,21 @@ describe('restoring a stored selection', () => { ).toBeNull() }) - test('a variant-less model resolves only an empty selection', () => { + test('a variant-less model resolves nothing at all', () => { const bare = { id: 'bare', name: 'Bare', aliases: [], parameters: [], variants: [] } - expect(resolveCursorVariant(bare, [])).toEqual({ params: [], displayName: 'Bare' }) + expect(resolveCursorVariant(bare, [])).toBeNull() expect(resolveCursorVariant(bare, [{ id: 'reasoning', value: 'max' }])).toBeNull() }) + + test('stored values are matched case-sensitively', () => { + expect( + resolveCursorVariant(multiVariantFixture, [ + { id: 'context', value: '272K' }, + { id: 'reasoning', value: 'low' }, + { id: 'internal', value: 'off' }, + ]), + ).toBeNull() + }) }) describe('Cursor model search', () => { diff --git a/web/src/lib/cursorModels.ts b/web/src/lib/cursorModels.ts index 1bc45b1..6b98704 100644 --- a/web/src/lib/cursorModels.ts +++ b/web/src/lib/cursorModels.ts @@ -54,12 +54,19 @@ export interface CursorDimension { /** Parameter ids Cursor uses for the reasoning-like axis, in priority order. */ export const REASONING_DIMENSION_IDS = ['reasoning', 'effort', 'thinking'] as const -/** The upstream default variant, or an empty selection for a variant-less model. */ -export function defaultCursorVariant(model: CursorModel): CursorVariant { +/** + * The upstream default variant, or null when the catalogue returned none. An + * empty parameter list is not a substitute: it would be a selection Cursor + * never offered. + */ +export function defaultCursorVariant(model: CursorModel): CursorVariant | null { const variants = model.variants ?? [] - const preferred = variants.find((variant) => variant.isDefault) ?? variants[0] - if (preferred) return preferred - return { params: [], displayName: model.name } + return variants.find((variant) => variant.isDefault) ?? variants[0] ?? null +} + +/** Whether the catalogue gives this model anything that can actually be run. */ +export function cursorModelSelectable(model: CursorModel): boolean { + return defaultCursorVariant(model) !== null } /** A variant's params as an id → value map, hidden params included. */ @@ -96,11 +103,6 @@ export function selectExactVariant( model: CursorModel, selection: Record, ): CursorVariant | null { - if ((model.variants ?? []).length === 0) { - return Object.keys(selection).length === 0 - ? { params: [], displayName: model.name } - : null - } const matches = matchingCursorVariants(model, selection) return matches.length === 1 ? matches[0] : null } @@ -129,11 +131,7 @@ export function resolveCursorVariant( ): CursorVariant | null { const wanted = canonicalParamMap(params) if (!wanted) return null - const variants = model.variants ?? [] - if (variants.length === 0) { - return wanted.size === 0 ? { params: [], displayName: model.name } : null - } - for (const variant of variants) { + for (const variant of model.variants ?? []) { const candidate = canonicalParamMap(variant.params ?? []) if (!candidate || candidate.size !== wanted.size) continue let equal = true @@ -148,10 +146,26 @@ export function resolveCursorVariant( return null } +/** The current variant's values for the dimensions a control can show. */ +function visibleSelection( + model: CursorModel, + variant: CursorVariant, +): Record { + const params = variantSelection(variant) + const selection: Record = {} + for (const dimension of cursorVariantDimensions(model)) { + const value = params[dimension.id] + if (value !== undefined) selection[dimension.id] = value + } + return selection +} + /** - * Move one dimension while keeping as much of the current variant as Cursor - * actually offers. The result is always an upstream variant, so hidden params - * belong to the variant that was chosen rather than the one left behind. + * Move one dimension and keep every other visible one exactly as it was. The + * move commits only when that combination identifies a single upstream variant: + * picking the nearest candidate instead would silently change a dimension the + * user did not touch, or pick between variants that differ only in params the + * catalogue never shows. */ export function applyCursorDimension( model: CursorModel, @@ -159,29 +173,33 @@ export function applyCursorDimension( dimensionId: string, value: string, ): CursorVariant | null { - const candidates = matchingCursorVariants(model, { [dimensionId]: value }) - if (candidates.length === 0) return null - - const previous = variantSelection(current) - const dimensions = cursorVariantDimensions(model) - .map((dimension) => dimension.id) - .filter((id) => id !== dimensionId) + const matches = matchingCursorVariants(model, { + ...visibleSelection(model, current), + [dimensionId]: value, + }) + return matches.length === 1 ? matches[0] : null +} - let best = candidates[0] - let bestScore = -1 - for (const candidate of candidates) { - const params = variantSelection(candidate) - let score = dimensions.reduce( - (total, id) => total + (params[id] === previous[id] ? 1 : 0), - 0, +/** + * The values of one dimension a control may commit from the current variant. + * Anything else would need another dimension to move first, so the UI shows it + * as unavailable rather than silently rewriting the rest of the selection. + */ +export function cursorDimensionAvailability( + model: CursorModel, + current: CursorVariant, + dimensionId: string, +): string[] { + const dimension = cursorVariantDimensions(model).find( + (candidate) => candidate.id === dimensionId, + ) + if (!dimension) return [] + return dimension.values + .filter( + (option) => + applyCursorDimension(model, current, dimensionId, option.value) !== null, ) - if (candidate.isDefault) score += 0.5 - if (score > bestScore) { - best = candidate - bestScore = score - } - } - return best + .map((option) => option.value) } /** diff --git a/web/src/lib/i18n.tsx b/web/src/lib/i18n.tsx index 9463ab8..fb8dad0 100644 --- a/web/src/lib/i18n.tsx +++ b/web/src/lib/i18n.tsx @@ -465,6 +465,9 @@ const en = { 'target.cursorRow': 'Cursor Cloud Agent', 'target.cursorNeedsKey': 'Connect a Cursor API key to run Cloud Agents from the composer.', 'target.cursorConnect': 'Connect Cursor', + 'target.cursorNoVariant': 'Cursor returned no runnable configuration for this model, so it cannot be selected.', + 'target.lockedWhileStreaming': 'The target cannot change while a turn is running.', + 'cursor.variantUnavailable': 'Cursor has no single configuration for that combination. Change another option first.', 'cursor.options': 'Cursor options', 'cursor.mode': 'Conversation mode', 'cursor.modeAgent': 'Agent', @@ -1544,6 +1547,9 @@ const id: Dict = { 'target.cursorRow': 'Cursor Cloud Agent', 'target.cursorNeedsKey': 'Hubungkan API key Cursor untuk menjalankan Cloud Agent dari kolom pesan.', 'target.cursorConnect': 'Hubungkan Cursor', + 'target.cursorNoVariant': 'Cursor tidak mengembalikan konfigurasi yang bisa dijalankan untuk model ini, jadi model ini tidak bisa dipilih.', + 'target.lockedWhileStreaming': 'Target tidak bisa diubah selama satu giliran masih berjalan.', + 'cursor.variantUnavailable': 'Cursor tidak punya satu konfigurasi pun untuk kombinasi itu. Ubah opsi lain dulu.', 'cursor.options': 'Opsi Cursor', 'cursor.mode': 'Mode percakapan', 'cursor.modeAgent': 'Agent', @@ -2390,6 +2396,9 @@ const ja: Dict = { 'target.cursorRow': 'Cursor Cloud Agent', 'target.cursorNeedsKey': 'Cursor の API キーを接続すると、入力欄から Cloud Agent を実行できます。', 'target.cursorConnect': 'Cursor を接続', + 'target.cursorNoVariant': 'Cursor はこのモデルの実行可能な構成を返していないため、選択できません。', + 'target.lockedWhileStreaming': 'ターンの実行中は実行先を変更できません。', + 'cursor.variantUnavailable': 'その組み合わせに対応する構成が Cursor にありません。先に別のオプションを変更してください。', 'cursor.options': 'Cursor のオプション', 'cursor.mode': '会話モード', 'cursor.modeAgent': 'Agent', @@ -3153,6 +3162,9 @@ const zh: Dict = { 'target.cursorRow': 'Cursor 云端 Agent', 'target.cursorNeedsKey': '连接 Cursor API key 后即可在输入框中运行云端 Agent。', 'target.cursorConnect': '连接 Cursor', + 'target.cursorNoVariant': 'Cursor 没有为该模型返回可运行的配置,因此无法选择。', + 'target.lockedWhileStreaming': '回合运行期间无法更改执行目标。', + 'cursor.variantUnavailable': 'Cursor 没有与该组合对应的唯一配置,请先更改其他选项。', 'cursor.options': 'Cursor 选项', 'cursor.mode': '对话模式', 'cursor.modeAgent': 'Agent', @@ -3914,6 +3926,9 @@ const ru: Dict = { 'target.cursorRow': 'Облачный агент Cursor', 'target.cursorNeedsKey': 'Подключите API-ключ Cursor, чтобы запускать облачных агентов прямо из поля ввода.', 'target.cursorConnect': 'Подключить Cursor', + 'target.cursorNoVariant': 'Cursor не вернул для этой модели работоспособной конфигурации, поэтому выбрать её нельзя.', + 'target.lockedWhileStreaming': 'Пока идёт ход, цель выполнения изменить нельзя.', + 'cursor.variantUnavailable': 'У Cursor нет единственной конфигурации для такого сочетания. Сначала измените другой параметр.', 'cursor.options': 'Параметры Cursor', 'cursor.mode': 'Режим разговора', 'cursor.modeAgent': 'Agent', diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 89d7448..3730586 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -56,6 +56,15 @@ import { type CursorOptionsValue, type CursorRunBaseline, } from '@/lib/composerTargets' +import { + baselineAfterSend, + restoreIsCurrent, + shouldAdoptDefaultTarget, + stopStreamKind, + targetAfterCursorHydration, + targetChangeAllowed, + type TargetOwner, +} from '@/lib/composerRestore' import { composerImageLimit, validateCursorAttachments } from '@/lib/cursorAttachments' import { defaultCursorVariant, @@ -385,6 +394,9 @@ export default function ChatPage() { const [messages, setMessages] = useState([]) const [loading, setLoading] = useState(!!sessionId) const [streaming, setStreaming] = useState(false) + // Read by stable callbacks that must not change identity per render. + const streamingRef = useRef(false) + streamingRef.current = streaming // Live status for the streaming indicator: which step, and what tool (if any) // is running right now. Reset at the start of every send. const [live, setLive] = useState<{ @@ -429,6 +441,18 @@ export default function ChatPage() { const targetRef = useRef(null) targetRef.current = target const cursorMode = isCursorTarget(target) + // Who may set the target right now. A session's own durable state outranks + // the picker's automatic active-model default, which is stashed until the + // session turns out not to own the target. + const targetOwnerRef = useRef('free') + const pendingDefaultRef = useRef(null) + // Bumped for every session hydration, so an answer for the session that was + // open a moment ago can never apply to the one open now. + const hydrationRef = useRef(0) + // The kind of stream that is actually running. The picker is locked while a + // turn streams, but an attach can outlive a target change, so Stop asks this + // rather than the composer. + const streamKindRef = useRef<'chat' | 'cursor' | null>(null) // The non-model half of a Cursor turn, kept while switching Cursor models. const [cursorSettings, setCursorSettings] = useState({ mode: 'agent', @@ -449,9 +473,13 @@ export default function ChatPage() { // it reusable, so the options popover can warn that sending now starts a new // agent instead of following up. const [lastCursorRun, setLastCursorRun] = useState(null) + const lastCursorRunRef = useRef(null) + lastCursorRunRef.current = lastCursorRun // What this session's Cursor run is doing or produced: remote status, // operation state, branches, and pull requests. const [cursorState, setCursorState] = useState(null) + const cursorStateRef = useRef(null) + cursorStateRef.current = cursorState // Local Stop detaches from a Cursor run that keeps going remotely. The ref is // what the standing attach loop reads, so it never re-follows immediately. const [detached, setDetached] = useState(false) @@ -467,28 +495,59 @@ export default function ChatPage() { capability?: ReasoningCapability value: string } | null>(null) - const selectTarget = useCallback((selection: ComposerTarget) => { - setTarget(selection) - if (selection.kind !== 'chat') return - const capability = selection.reasoningCapability - const { value } = loadReasoningPreference( - localStorage, - selection.provider, - selection.model, - capability, - ) - composerReasoningRef.current = { selection, capability, value } - setReasoning(value) + /** + * Set the target and its ref together. Restoration decides in one pass, and + * every step of that pass has to see the target the previous step chose + * rather than the one still rendered. + */ + const commitTarget = useCallback((next: ComposerTarget | null) => { + targetRef.current = next + setTarget(next) }, []) + /** + * Point the composer at a target. `default` is the picker's automatic + * active-model lookup, which must never outrank a session being restored or + * a choice already made; `user` is an edit, refused while a turn streams. + */ + const selectTarget = useCallback( + (selection: ComposerTarget, origin: 'user' | 'default' | 'restore' = 'user') => { + if (origin === 'default') { + // Keep it either way: this session may turn out not to own the target. + if (selection.kind === 'chat') pendingDefaultRef.current = selection + if ( + !shouldAdoptDefaultTarget({ + owner: targetOwnerRef.current, + hasTarget: targetRef.current !== null, + }) + ) { + return + } + } + if (origin === 'user' && !targetChangeAllowed(streamingRef.current)) return + commitTarget(selection) + if (selection.kind !== 'chat') return + const capability = selection.reasoningCapability + const { value } = loadReasoningPreference( + localStorage, + selection.provider, + selection.model, + capability, + ) + composerReasoningRef.current = { selection, capability, value } + setReasoning(value) + }, + [commitTarget], + ) const changeCursorOptions = useCallback((next: CursorOptionsValue) => { - setTarget({ kind: 'cursor', model: next.model, variant: next.variant }) + if (!targetChangeAllowed(streamingRef.current)) return + commitTarget({ kind: 'cursor', model: next.model, variant: next.variant }) setCursorSettings({ mode: next.mode, repositoryUrl: next.repositoryUrl, startingRef: next.startingRef, autoCreatePR: next.autoCreatePR, }) - }, []) + }, [commitTarget]) /** * Record the run a follow-up would continue, when the composer is already * pointed at exactly that selection. Reports whether it could. @@ -533,10 +592,13 @@ export default function ChatPage() { // The composer already holds this exact selection; no catalogue lookup // can tell us anything new. if (params && applyCursorBaseline(modelId, params, settings, reuseValid)) return + const generation = hydrationRef.current const current = targetRef.current void get<{ models?: CursorModel[]; needs_key?: boolean }>('/providers/cursor/models') .then((d) => { - // Never overwrite a target the user chose while this was in flight. + // Another session opened, or the user chose something, while this + // catalogue request was in flight. + if (!restoreIsCurrent(generation, hydrationRef.current)) return if (targetRef.current !== current) return if (d.needs_key) { setError(t('target.cursorNeedsKey')) @@ -558,14 +620,14 @@ export default function ChatPage() { setError(t('cursor.staleSelection')) return } - setTarget({ kind: 'cursor', model, variant }) + commitTarget({ kind: 'cursor', model, variant }) setLastCursorRun( params ? { options: { model, variant, ...settings }, reuseValid } : null, ) }) .catch(() => {}) }, - [applyCursorBaseline, t], + [applyCursorBaseline, commitTarget, t], ) /** * Apply a session's durable Cursor state. Opening a session restores the @@ -578,10 +640,6 @@ export default function ChatPage() { const worthShowing = hydration.active || Boolean(hydration.remoteStatus) || hydration.branches.length > 0 setCursorState(worthShowing ? hydration : null) - if (!hydration.active) { - setLastCursorRun(null) - return - } const settings: CursorTurnSettings = { mode: hydration.mode ?? 'agent', repositoryUrl: hydration.repositoryUrl ?? null, @@ -589,9 +647,26 @@ export default function ChatPage() { startingRef: hydration.startingRef ?? null, autoCreatePR: hydration.autoCreatePR === true, } - if (!hydration.modelId) { - // The server could not decode an exact selection; restore no target. - if (restoreComposer) setCursorSettings(settings) + if (restoreComposer) { + // Whatever this session is, the composer must stop pointing at another + // conversation's Cursor agent before the next message can be sent. + const decision = targetAfterCursorHydration({ + active: hydration.active, + modelId: hydration.modelId, + current: targetRef.current, + pendingDefault: pendingDefaultRef.current, + lastChat: composerReasoningRef.current?.selection ?? null, + }) + targetOwnerRef.current = decision.owner + if (decision.action === 'set') { + if (decision.target) selectTarget(decision.target, 'restore') + else commitTarget(null) + } + } + if (!hydration.active || !hydration.modelId) { + // Not a Cursor session, or one whose exact selection is unreadable: + // there is no run a follow-up could continue. + if (restoreComposer && hydration.active) setCursorSettings(settings) setLastCursorRun(null) return } @@ -608,7 +683,7 @@ export default function ChatPage() { setCursorSettings(settings) restoreCursorTarget(hydration.modelId, params, settings, reuseValid) }, - [applyCursorBaseline, restoreCursorTarget], + [applyCursorBaseline, commitTarget, restoreCursorTarget, selectTarget], ) const pickReasoning = useCallback((value: string) => { const current = composerReasoningRef.current @@ -890,7 +965,11 @@ export default function ChatPage() { // A Cursor run lives in Cursor's cloud: Stop may only close this browser's // stream. Interrupting the turn, or cancelling it remotely, is never // implied by leaving — cancellation is a separate, approved action. - const behavior = stopBehavior(isCursorTarget(targetRef.current) ? 'cursor' : 'chat') + // Which semantics apply is decided by the stream that is running, not by + // the composer, which an attached run can outlive. + const behavior = stopBehavior( + stopStreamKind(streamKindRef.current, cursorStateRef.current?.active === true), + ) abortRef.current?.() abortRef.current = null setStreaming(false) @@ -1206,6 +1285,17 @@ export default function ChatPage() { ) useEffect(() => { + // A brand-new chat navigates to its own url mid-stream. The live messages + // are already on screen; re-fetching now would find the turn not yet + // persisted and wipe them, and adopting an id is not a session switch — the + // running turn keeps its target, approvals, and Cursor state. + if (sessionId && sessionId === localSessionRef.current) { + setLoading(false) + return + } + // Every hydration gets its own token, so a slower answer for the session + // that was open a moment ago can never apply to the one open now. + const generation = ++hydrationRef.current // Approvals, Cursor recovery state, and the detach flag all belong to one // conversation; carrying them into another session would show a decision // that no longer blocks anything. @@ -1214,7 +1304,15 @@ export default function ChatPage() { setLastCursorRun(null) detachedRef.current = false setDetached(false) + // A Cursor target belongs to the conversation that chose it. Until this + // session's own state says otherwise, the composer holds no Cursor target, + // so a message sent meanwhile cannot reach another session's agent. + if (isCursorTarget(targetRef.current)) commitTarget(null) + targetOwnerRef.current = sessionId ? 'pending' : 'free' if (!sessionId) { + // A new chat is owned by nobody, so the picker's default may fill it. + const stashed = pendingDefaultRef.current + if (stashed && !targetRef.current) selectTarget(stashed, 'default') setMessages([]) setTitle('') setProjectDir('') @@ -1222,19 +1320,12 @@ export default function ChatPage() { setLoading(false) return } - // A brand-new chat navigates to its own url mid-stream. The live messages - // are already on screen; re-fetching now would find the turn not yet - // persisted and wipe them. Skip the hydrate for that one session. - if (sessionId === localSessionRef.current) { - setLoading(false) - return - } setLoading(true) let cancelled = false let closeAttach: (() => void) | undefined get(`/sessions/${sessionId}`) .then((d) => { - if (cancelled) return + if (cancelled || !restoreIsCurrent(generation, hydrationRef.current)) return const restored = hydrate(d) // Open a restored transcript at its newest message. Set before the list // mounts (it is still `loading`), so Virtuoso reads the final value once. @@ -1279,6 +1370,13 @@ export default function ChatPage() { return } setError(e instanceof Error ? e.message : String(e)) + // Nothing will claim the target now, so the composer must not stay + // waiting on a session state that never arrived. + if (restoreIsCurrent(generation, hydrationRef.current)) { + targetOwnerRef.current = 'free' + const stashed = pendingDefaultRef.current + if (stashed && !targetRef.current) selectTarget(stashed, 'default') + } }) get<{ role?: string }>(`/sessions/${sessionId}/role`) .then((r) => { @@ -1293,7 +1391,15 @@ export default function ChatPage() { cancelled = true closeAttach?.() } - }, [sessionId, t, attachLive, refreshApprovals, applyCursorHydration]) + }, [ + sessionId, + t, + attachLive, + refreshApprovals, + applyCursorHydration, + commitTarget, + selectTarget, + ]) /** Append a locally-produced message without touching the server. */ const pushSystem = useCallback((content: string) => { @@ -1443,10 +1549,13 @@ export default function ChatPage() { // session is being followed again. detachedRef.current = false setDetached(false) - // The turn being started is the run a follow-up would continue. Whether the - // server keeps it reusable is only known once it ends, which the durable - // state at end-of-turn corrects. - if (cursor) setLastCursorRun({ options: cursor, reuseValid: true }) + // Stop must know what it is stopping even if the composer moves on. + streamKindRef.current = cursor ? 'cursor' : 'chat' + // A refused request starts nothing, so the run a follow-up would continue + // only changes once the server has accepted this one. + const baselineBeforeSend = lastCursorRunRef.current + const generation = hydrationRef.current + let accepted = false abortRef.current = streamPost( cursor ? '/chat/cursor' : '/chat', cursor @@ -1483,6 +1592,20 @@ export default function ChatPage() { : {}), }, (event: StreamEvent) => { + // Events only flow after the server accepted the request, which is the + // first moment this turn is the one a follow-up would continue. + if (!accepted) { + accepted = true + if (cursor) { + setLastCursorRun( + baselineAfterSend({ + previous: baselineBeforeSend, + attempted: cursor, + accepted: true, + }), + ) + } + } // End-of-turn: stop streaming immediately rather than waiting for the // socket to close. A detached run keeps the connection open past the // final event, which otherwise left the indicator and the task bar @@ -1494,6 +1617,7 @@ export default function ChatPage() { setLive((s) => ({ ...s, waiting: false })) abortRef.current?.() abortRef.current = null + streamKindRef.current = null localSessionRef.current = null // The turn may have written project_info — refresh the sidebar. setSidebarRefresh((n) => n + 1) @@ -1505,6 +1629,7 @@ export default function ChatPage() { if (sid) { get(`/sessions/${sid}`) .then((d) => { + if (!restoreIsCurrent(generation, hydrationRef.current)) return setMessages(hydrate(d)) setTitle(d.session.title || t('chat.conversation')) // The turn that just ended decides whether a follow-up can @@ -1543,13 +1668,30 @@ export default function ChatPage() { ) setStreaming(false) abortRef.current = null + streamKindRef.current = null // The turn is persisted now, so a later revisit should hydrate fresh. localSessionRef.current = null + if (!cursor) return + // Nothing was started, so the previous follow-up baseline still stands. + // Anything that did happen is in the durable state, which decides. + setLastCursorRun( + baselineAfterSend({ previous: baselineBeforeSend, attempted: cursor, accepted: false }), + ) + const sid = sessionIdRef.current + if (sid) { + get(`/sessions/${sid}`) + .then((d) => { + if (!restoreIsCurrent(generation, hydrationRef.current)) return + applyCursorHydration(cursorHydrationFromDetail(d), false) + }) + .catch(() => {}) + } }, () => { drainPatches() setStreaming(false) abortRef.current = null + streamKindRef.current = null localSessionRef.current = null }, ) @@ -1861,7 +2003,9 @@ export default function ChatPage() { {/* A Cursor run has no Antares role and no generic reasoning override — its own variant controls take that place. */} {cursorMode ? null : } - + {/* The running stream owns the target: changing it mid-turn would + leave Stop and the next send disagreeing about where it went. */} + {cursorMode && cursorOptions ? ( @@ -297,7 +298,11 @@ function AgentModelRow({ model }: { model: CursorModel }) { {t('providers.variantCount', { n: variants.length })} {summary ? ` · ${t('providers.defaultVariant', { summary })}` : ''}

- ) : null} + ) : ( +

+ {t('target.cursorNoVariant')} +

+ )}
) } From b4eaff360ee10fac38af0b0d7d6ba9475a2d527c Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Thu, 13 Aug 2026 13:31:41 +0700 Subject: [PATCH 36/41] Filter Cursor variants instead of stepping one axis Co-authored-by: Cursor --- web/src/components/chat/CursorOptions.tsx | 98 +++++------ web/src/lib/composerRestore.test.mjs | 90 ++++++++++ web/src/lib/composerRestore.ts | 27 ++- web/src/lib/cursorModels.test.mjs | 190 +++++++++++++++------- web/src/lib/cursorModels.ts | 102 +++++++----- web/src/lib/i18n.tsx | 20 ++- web/src/pages/ChatPage.tsx | 60 +++++-- 7 files changed, 423 insertions(+), 164 deletions(-) diff --git a/web/src/components/chat/CursorOptions.tsx b/web/src/components/chat/CursorOptions.tsx index c505a4d..d2fe958 100644 --- a/web/src/components/chat/CursorOptions.tsx +++ b/web/src/components/chat/CursorOptions.tsx @@ -4,13 +4,15 @@ import { get } from '@/lib/api' import type { CursorMode, CursorOptionsValue, CursorRunBaseline } from '@/lib/composerTargets' import { startsNewCursorAgent } from '@/lib/composerTargets' import { - applyCursorDimension, - cursorDimensionAvailability, + cursorFilterCommit, + cursorFilterFromVariant, + cursorFilterMatches, cursorOtherDimensions, cursorReasoningDimension, cursorVariantSummary, - variantParamValue, + withCursorFilter, type CursorDimension, + type CursorFilterEntry, } from '@/lib/cursorModels' import { useI18n } from '@/lib/i18n' import { cn } from '@/lib/utils' @@ -48,11 +50,21 @@ export function CursorOptions({ const { t } = useI18n() const [open, setOpen] = useState(false) const [preflight, setPreflight] = useState() - // Set when a control could not commit: the catalogue has no single variant - // for that combination, and guessing one would change something else. - const [unavailable, setUnavailable] = useState(false) + // The controls narrow the catalogue rather than editing a selection: what + // runs only changes once the filter leaves exactly one upstream variant. + const [filter, setFilter] = useState(() => + cursorFilterFromVariant(value.model, value.variant), + ) const ref = useRef(null) + // A newly committed variant, a different model, and opening or closing the + // popover all restart the filter from what is actually selected. Staging an + // ambiguous filter changes none of those, so work in progress survives until + // it either commits or is abandoned. + useEffect(() => { + setFilter(cursorFilterFromVariant(value.model, value.variant)) + }, [open, value.model, value.variant]) + useEffect(() => { if (!open) return const onClick = (e: MouseEvent) => { @@ -89,21 +101,16 @@ export function CursorOptions({ const summary = cursorVariantSummary(value.model, value.variant) const newAgent = startsNewCursorAgent(lastStarted, value) + const selected = filterSelectionOf(filter) + const remaining = cursorFilterMatches(value.model, filter) + const pickDimension = (dimension: CursorDimension, option: string) => { - const variant = applyCursorDimension( - value.model, - value.variant, - dimension.id, - option, - ) - // Only a combination that identifies one upstream variant is committed; - // anything else leaves the current selection exactly as it was. - if (!variant) { - setUnavailable(true) - return - } - setUnavailable(false) - onChange({ ...value, variant }) + const next = withCursorFilter(value.model, filter, dimension.id, option) + setFilter(next) + // Only a filter that leaves one upstream variant changes what will run; + // while several remain, the current selection stands untouched. + const variant = cursorFilterCommit(value.model, next) + if (variant && variant !== value.variant) onChange({ ...value, variant }) } const discoveredRepo = preflight?.repository ? (preflight.url ?? '') : '' @@ -154,17 +161,19 @@ export function CursorOptions({ pickDimension(dimension, option)} disabled={disabled} /> ))} - {unavailable ? ( + {remaining.length > 1 ? ( +

+ {t('cursor.variantPending', { n: remaining.length })} +

+ ) : remaining.length === 0 ? (

{ + const selection: Record = {} + for (const entry of filter) selection[entry.id] = entry.value + return selection +} + function DimensionRow({ dimension, selected, - available, onPick, disabled, }: { dimension: CursorDimension selected?: string - /** Values that resolve to exactly one variant from the current selection. */ - available: string[] onPick: (value: string) => void disabled?: boolean }) { - const { t } = useI18n() return (

- {dimension.values.map((option) => { - const reachable = available.includes(option.value) - return ( - onPick(option.value)} - label={option.label} - /> - ) - })} + {dimension.values.map((option) => ( + onPick(option.value)} + label={option.label} + /> + ))}
) diff --git a/web/src/lib/composerRestore.test.mjs b/web/src/lib/composerRestore.test.mjs index 94fd00c..610150c 100644 --- a/web/src/lib/composerRestore.test.mjs +++ b/web/src/lib/composerRestore.test.mjs @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { baselineAfterSend, + composerCanSend, restoreIsCurrent, shouldAdoptDefaultTarget, stopStreamKind, @@ -51,6 +52,24 @@ describe('automatic chat defaults', () => { }) }) +describe('sending while a session is still hydrating', () => { + test('a session whose target is not yet known cannot submit at all', () => { + // Both the send button and Enter go through this gate, so neither route + // can post a Cursor conversation's turn to /chat. + expect(composerCanSend({ owner: 'pending', streaming: false })).toBe(false) + }) + + test('a resolved session may submit', () => { + expect(composerCanSend({ owner: 'free', streaming: false })).toBe(true) + expect(composerCanSend({ owner: 'restored', streaming: false })).toBe(true) + }) + + test('a streaming turn still blocks a second submit', () => { + expect(composerCanSend({ owner: 'free', streaming: true })).toBe(false) + expect(composerCanSend({ owner: 'restored', streaming: true })).toBe(false) + }) +}) + describe('the target a hydrated session should hold', () => { test('an active Cursor session keeps the composer while its exact variant loads', () => { expect( @@ -135,6 +154,77 @@ describe('the target a hydrated session should hold', () => { }), ).toEqual({ owner: 'free', action: 'keep' }) }) + + test('an ordinary session installs a chat target when the composer holds none', () => { + // The session switch already cleared the previous Cursor target, so there + // is nothing left to replace — the fallback still has to be installed. + expect( + targetAfterCursorHydration({ + active: false, + modelId: undefined, + current: null, + pendingDefault: chatTarget, + lastChat: otherChatTarget, + }), + ).toEqual({ owner: 'free', action: 'set', target: chatTarget }) + expect( + targetAfterCursorHydration({ + active: false, + modelId: undefined, + current: null, + pendingDefault: null, + lastChat: otherChatTarget, + }), + ).toEqual({ owner: 'free', action: 'set', target: otherChatTarget }) + }) + + test('an undecodable selection installs a chat target over an empty composer', () => { + expect( + targetAfterCursorHydration({ + active: true, + modelId: '', + current: null, + pendingDefault: chatTarget, + lastChat: null, + }), + ).toEqual({ owner: 'free', action: 'set', target: chatTarget }) + }) + + test('an empty composer with nothing to fall back on stays empty', () => { + expect( + targetAfterCursorHydration({ + active: false, + modelId: undefined, + current: null, + pendingDefault: null, + lastChat: null, + }), + ).toEqual({ owner: 'free', action: 'keep' }) + }) + + test('a choice made after hydration began is never overwritten', () => { + const chosen = cursorTarget('claude-opus-5') + expect( + targetAfterCursorHydration({ + active: false, + modelId: undefined, + current: chosen, + pendingDefault: chatTarget, + lastChat: chatTarget, + userChose: true, + }), + ).toEqual({ owner: 'free', action: 'keep' }) + expect( + targetAfterCursorHydration({ + active: true, + modelId: 'gpt-5.6-sol', + current: chatTarget, + pendingDefault: null, + lastChat: chatTarget, + userChose: true, + }), + ).toEqual({ owner: 'free', action: 'keep' }) + }) }) describe('stop semantics', () => { diff --git a/web/src/lib/composerRestore.ts b/web/src/lib/composerRestore.ts index eb472d1..ac6f512 100644 --- a/web/src/lib/composerRestore.ts +++ b/web/src/lib/composerRestore.ts @@ -34,6 +34,18 @@ export function shouldAdoptDefaultTarget(state: { return state.owner === 'free' && !state.hasTarget } +/** + * Whether the composer may submit. A session whose target is still being + * resolved has no answer to "where does this go?", and guessing would post a + * Cursor conversation's turn to the chat model instead. + */ +export function composerCanSend(state: { + owner: TargetOwner + streaming: boolean +}): boolean { + return state.owner !== 'pending' && !state.streaming +} + export interface HydrationTargetInput { /** Whether durable state still points this session at Cursor. */ active: boolean @@ -44,6 +56,8 @@ export interface HydrationTargetInput { pendingDefault: ChatTarget | null /** The last chat target this tab used, if any. */ lastChat: ChatTarget | null + /** Whether the user picked the current target after hydration began. */ + userChose?: boolean } export type HydrationTargetDecision = @@ -59,7 +73,10 @@ export type HydrationTargetDecision = export function targetAfterCursorHydration( input: HydrationTargetInput, ): HydrationTargetDecision { - const { active, modelId, current, pendingDefault, lastChat } = input + const { active, modelId, current, pendingDefault, lastChat, userChose } = input + // Someone chose deliberately while the session was loading; that outranks + // anything the session itself would have restored. + if (userChose) return { owner: 'free', action: 'keep' } if (active && modelId) { // A restore is on its way for this session's own model. if (current?.kind === 'cursor' && current.model.id !== modelId) { @@ -68,8 +85,14 @@ export function targetAfterCursorHydration( return { owner: 'restored', action: 'keep' } } // This session does not run on Cursor, or names nothing exact enough to run. + // Its Cursor target goes, and the composer needs a chat target to fall back + // to — including when the session switch already emptied it. + const fallback = pendingDefault ?? lastChat ?? null if (current?.kind === 'cursor') { - return { owner: 'free', action: 'set', target: pendingDefault ?? lastChat ?? null } + return { owner: 'free', action: 'set', target: fallback } + } + if (current === null && fallback) { + return { owner: 'free', action: 'set', target: fallback } } return { owner: 'free', action: 'keep' } } diff --git a/web/src/lib/cursorModels.test.mjs b/web/src/lib/cursorModels.test.mjs index 5ab9a1e..40a3d03 100644 --- a/web/src/lib/cursorModels.test.mjs +++ b/web/src/lib/cursorModels.test.mjs @@ -1,7 +1,8 @@ import { describe, expect, test } from 'bun:test' import { - applyCursorDimension, - cursorDimensionAvailability, + cursorFilterCommit, + cursorFilterFromVariant, + cursorFilterMatches, cursorModelMatches, cursorModelSelectable, cursorReasoningDimension, @@ -12,6 +13,7 @@ import { resolveCursorVariant, selectExactVariant, variantSelection, + withCursorFilter, } from './cursorModels.ts' const modelFixture = { @@ -171,79 +173,145 @@ describe('exact Cursor variants', () => { expect(selectExactVariant(multiVariantFixture, { context: '272k' })).toBeNull() }) - test('changing one dimension keeps the others and carries the hidden params', () => { - const from = multiVariantFixture.variants[0] - const next = applyCursorDimension(multiVariantFixture, from, 'reasoning', 'max') - expect(next).toBe(multiVariantFixture.variants[1]) - expect(variantSelection(next)).toEqual({ - context: '272k', +}) + +// Two variants that share no axis value: reaching one from the other means +// moving both dimensions, which one-axis-at-a-time filtering cannot express. +const diagonalFixture = { + id: 'diagonal', + name: 'Diagonal', + aliases: [], + parameters: [ + { id: 'context', values: [{ value: 'short' }, { value: 'long' }] }, + { id: 'reasoning', values: [{ value: 'low' }, { value: 'max' }] }, + ], + variants: [ + { + params: [ + { id: 'context', value: 'short' }, + { id: 'reasoning', value: 'low' }, + { id: 'internal', value: 'cheap' }, + ], + displayName: 'Short · low', + isDefault: true, + }, + { + params: [ + { id: 'context', value: 'long' }, + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'rich' }, + ], + displayName: 'Long · max', + }, + ], +} + +const tiedFixture = { + id: 'tied', + name: 'Tied', + aliases: [], + parameters: [{ id: 'fast', values: [{ value: 'off' }, { value: 'on' }] }], + variants: [ + { params: [{ id: 'fast', value: 'off' }], displayName: 'off', isDefault: true }, + { + params: [ + { id: 'fast', value: 'on' }, + { id: 'internal', value: 'a' }, + ], + displayName: 'on a', + }, + { + params: [ + { id: 'fast', value: 'on' }, + { id: 'internal', value: 'b' }, + ], + displayName: 'on b', + }, + ], +} + +describe('filtering variants with staged controls', () => { + test('a filter starts as the committed variant, hidden params excluded', () => { + expect( + cursorFilterFromVariant(multiVariantFixture, multiVariantFixture.variants[2]), + ).toEqual([ + { id: 'context', value: '1m' }, + { id: 'reasoning', value: 'max' }, + ]) + }) + + test('a diagonal variant is reachable, and keeps its hidden params exactly', () => { + const from = cursorFilterFromVariant(diagonalFixture, diagonalFixture.variants[0]) + // The older filter entry gives way to the choice just made, rather than + // making the only other configuration unreachable. + const next = withCursorFilter(diagonalFixture, from, 'context', 'long') + expect(next).toEqual([{ id: 'context', value: 'long' }]) + const committed = cursorFilterCommit(diagonalFixture, next) + expect(committed).toBe(diagonalFixture.variants[1]) + expect(variantSelection(committed)).toEqual({ + context: 'long', reasoning: 'max', - internal: 'off', + internal: 'rich', }) + }) + + test('the other axis is reachable from the same starting point', () => { + const from = cursorFilterFromVariant(diagonalFixture, diagonalFixture.variants[0]) + const next = withCursorFilter(diagonalFixture, from, 'reasoning', 'max') + expect(cursorFilterCommit(diagonalFixture, next)).toBe(diagonalFixture.variants[1]) + }) + + test('a filter that still matches several variants commits nothing yet', () => { + const partial = [{ id: 'context', value: '272k' }] + expect(cursorFilterMatches(multiVariantFixture, partial)).toHaveLength(2) + expect(cursorFilterCommit(multiVariantFixture, partial)).toBeNull() - const wider = applyCursorDimension( - multiVariantFixture, + const narrowed = withCursorFilter(multiVariantFixture, partial, 'reasoning', 'max') + expect(narrowed).toEqual([ + { id: 'context', value: '272k' }, + { id: 'reasoning', value: 'max' }, + ]) + expect(cursorFilterCommit(multiVariantFixture, narrowed)).toBe( multiVariantFixture.variants[1], - 'context', - '1m', ) - expect(wider).toBe(multiVariantFixture.variants[2]) }) - test('a value that would silently change another dimension commits nothing', () => { - // Only 1M + max exists upstream, so moving context while reasoning is low - // must not quietly raise reasoning too. - expect( - applyCursorDimension(multiVariantFixture, multiVariantFixture.variants[0], 'context', '1m'), - ).toBeNull() + test('variants that differ only in a hidden param are never broken by a guess', () => { + const filter = withCursorFilter(tiedFixture, [], 'fast', 'on') + expect(cursorFilterMatches(tiedFixture, filter)).toHaveLength(2) + expect(cursorFilterCommit(tiedFixture, filter)).toBeNull() }) - test('an unreachable dimension value commits nothing', () => { - expect( - applyCursorDimension(modelFixture, modelFixture.variants[0], 'context', '1m'), - ).toBeNull() + test('a filter no variant satisfies matches nothing and commits nothing', () => { + const impossible = [ + { id: 'context', value: '1m' }, + { id: 'reasoning', value: 'low' }, + ] + expect(cursorFilterMatches(multiVariantFixture, impossible)).toEqual([]) + expect(cursorFilterCommit(multiVariantFixture, impossible)).toBeNull() }) - test('a tie between variants commits nothing', () => { - // Two variants share every visible dimension and differ only in a hidden - // one, so "fast on" cannot identify a single upstream variant. - const tied = { - id: 'tied', - name: 'Tied', - aliases: [], - parameters: [{ id: 'fast', values: [{ value: 'off' }, { value: 'on' }] }], - variants: [ - { params: [{ id: 'fast', value: 'off' }], displayName: 'off', isDefault: true }, - { - params: [ - { id: 'fast', value: 'on' }, - { id: 'internal', value: 'a' }, - ], - displayName: 'on a', - }, - { - params: [ - { id: 'fast', value: 'on' }, - { id: 'internal', value: 'b' }, - ], - displayName: 'on b', - }, - ], + test('staging never produces a filter that matches nothing', () => { + const from = cursorFilterFromVariant(multiVariantFixture, multiVariantFixture.variants[0]) + for (const dimension of ['context', 'reasoning']) { + for (const value of ['272k', '1m', 'low', 'max']) { + const next = withCursorFilter(multiVariantFixture, from, dimension, value) + if (next.some((entry) => entry.id === dimension && entry.value === value)) { + expect(cursorFilterMatches(multiVariantFixture, next).length).toBeGreaterThan(0) + } + } } - expect(applyCursorDimension(tied, tied.variants[0], 'fast', 'on')).toBeNull() - expect(cursorDimensionAvailability(tied, tied.variants[0], 'fast')).toEqual(['off']) }) - test('availability marks exactly the values a control may commit', () => { - expect( - cursorDimensionAvailability(multiVariantFixture, multiVariantFixture.variants[0], 'context'), - ).toEqual(['272k']) - expect( - cursorDimensionAvailability(multiVariantFixture, multiVariantFixture.variants[1], 'context'), - ).toEqual(['272k', '1m']) - expect( - cursorDimensionAvailability(multiVariantFixture, multiVariantFixture.variants[0], 'reasoning'), - ).toEqual(['low', 'max']) + test('choosing the same dimension twice replaces rather than repeats it', () => { + const first = withCursorFilter(multiVariantFixture, [], 'reasoning', 'low') + const second = withCursorFilter(multiVariantFixture, first, 'reasoning', 'max') + expect(second).toEqual([{ id: 'reasoning', value: 'max' }]) + }) + + test('a value no variant offers is refused instead of emptying the filter', () => { + const from = cursorFilterFromVariant(modelFixture, modelFixture.variants[0]) + expect(withCursorFilter(modelFixture, from, 'context', '1m')).toEqual(from) }) }) diff --git a/web/src/lib/cursorModels.ts b/web/src/lib/cursorModels.ts index 6b98704..3c1a30d 100644 --- a/web/src/lib/cursorModels.ts +++ b/web/src/lib/cursorModels.ts @@ -146,60 +146,84 @@ export function resolveCursorVariant( return null } -/** The current variant's values for the dimensions a control can show. */ -function visibleSelection( - model: CursorModel, - variant: CursorVariant, -): Record { - const params = variantSelection(variant) +/** + * One dimension the controls have narrowed, in the order it was chosen. The + * filter is a view over the catalogue, never a selection in its own right: only + * a filter that leaves exactly one upstream variant changes what will run. + */ +export interface CursorFilterEntry { + id: string + value: string +} + +function filterSelection(filter: CursorFilterEntry[]): Record { const selection: Record = {} - for (const dimension of cursorVariantDimensions(model)) { - const value = params[dimension.id] - if (value !== undefined) selection[dimension.id] = value - } + for (const entry of filter ?? []) selection[entry.id] = entry.value return selection } +/** The upstream variants a filter still allows. */ +export function cursorFilterMatches( + model: CursorModel, + filter: CursorFilterEntry[], +): CursorVariant[] { + return matchingCursorVariants(model, filterSelection(filter)) +} + /** - * Move one dimension and keep every other visible one exactly as it was. The - * move commits only when that combination identifies a single upstream variant: - * picking the nearest candidate instead would silently change a dimension the - * user did not touch, or pick between variants that differ only in params the - * catalogue never shows. + * The one variant a filter identifies, or null while it still allows several + * (or none). Variants that differ only in params the catalogue does not show + * therefore never get chosen for the user. */ -export function applyCursorDimension( +export function cursorFilterCommit( model: CursorModel, - current: CursorVariant, - dimensionId: string, - value: string, + filter: CursorFilterEntry[], ): CursorVariant | null { - const matches = matchingCursorVariants(model, { - ...visibleSelection(model, current), - [dimensionId]: value, - }) + const matches = cursorFilterMatches(model, filter) return matches.length === 1 ? matches[0] : null } +/** The filter a committed variant corresponds to: its visible dimensions. */ +export function cursorFilterFromVariant( + model: CursorModel, + variant: CursorVariant, +): CursorFilterEntry[] { + const params = variantSelection(variant) + const filter: CursorFilterEntry[] = [] + for (const dimension of cursorVariantDimensions(model)) { + const value = params[dimension.id] + if (value !== undefined) filter.push({ id: dimension.id, value }) + } + return filter +} + /** - * The values of one dimension a control may commit from the current variant. - * Anything else would need another dimension to move first, so the UI shows it - * as unavailable rather than silently rewriting the rest of the selection. + * Narrow a filter with one more choice. The newest choice always survives; + * older ones give way to it when they cannot hold together, which is what makes + * a variant that shares no value with the current one reachable without ever + * inventing a combination the catalogue does not offer. A value no variant + * carries at all changes nothing. */ -export function cursorDimensionAvailability( +export function withCursorFilter( model: CursorModel, - current: CursorVariant, + filter: CursorFilterEntry[], dimensionId: string, -): string[] { - const dimension = cursorVariantDimensions(model).find( - (candidate) => candidate.id === dimensionId, - ) - if (!dimension) return [] - return dimension.values - .filter( - (option) => - applyCursorDimension(model, current, dimensionId, option.value) !== null, - ) - .map((option) => option.value) + value: string, +): CursorFilterEntry[] { + if (cursorFilterMatches(model, [{ id: dimensionId, value }]).length === 0) { + return filter + } + // Oldest first, with the new choice last and any older take on the same + // dimension removed. + let staged = [ + ...(filter ?? []).filter((entry) => entry.id !== dimensionId), + { id: dimensionId, value }, + ] + // Drop the least recent choices until the catalogue can satisfy the rest. + while (staged.length > 1 && cursorFilterMatches(model, staged).length === 0) { + staged = staged.slice(1) + } + return staged } /** diff --git a/web/src/lib/i18n.tsx b/web/src/lib/i18n.tsx index fb8dad0..0d5f42a 100644 --- a/web/src/lib/i18n.tsx +++ b/web/src/lib/i18n.tsx @@ -467,7 +467,9 @@ const en = { 'target.cursorConnect': 'Connect Cursor', 'target.cursorNoVariant': 'Cursor returned no runnable configuration for this model, so it cannot be selected.', 'target.lockedWhileStreaming': 'The target cannot change while a turn is running.', - 'cursor.variantUnavailable': 'Cursor has no single configuration for that combination. Change another option first.', + 'cursor.variantUnavailable': 'Cursor has no configuration matching those options.', + 'cursor.variantPending': '{n} Cursor configurations still match. Choose another option to settle on one.', + 'target.resolving': 'Restoring where this conversation runs…', 'cursor.options': 'Cursor options', 'cursor.mode': 'Conversation mode', 'cursor.modeAgent': 'Agent', @@ -1549,7 +1551,9 @@ const id: Dict = { 'target.cursorConnect': 'Hubungkan Cursor', 'target.cursorNoVariant': 'Cursor tidak mengembalikan konfigurasi yang bisa dijalankan untuk model ini, jadi model ini tidak bisa dipilih.', 'target.lockedWhileStreaming': 'Target tidak bisa diubah selama satu giliran masih berjalan.', - 'cursor.variantUnavailable': 'Cursor tidak punya satu konfigurasi pun untuk kombinasi itu. Ubah opsi lain dulu.', + 'cursor.variantUnavailable': 'Cursor tidak punya konfigurasi yang cocok dengan opsi itu.', + 'cursor.variantPending': 'Masih ada {n} konfigurasi Cursor yang cocok. Pilih opsi lain untuk menentukan satu.', + 'target.resolving': 'Memulihkan tempat percakapan ini berjalan…', 'cursor.options': 'Opsi Cursor', 'cursor.mode': 'Mode percakapan', 'cursor.modeAgent': 'Agent', @@ -2398,7 +2402,9 @@ const ja: Dict = { 'target.cursorConnect': 'Cursor を接続', 'target.cursorNoVariant': 'Cursor はこのモデルの実行可能な構成を返していないため、選択できません。', 'target.lockedWhileStreaming': 'ターンの実行中は実行先を変更できません。', - 'cursor.variantUnavailable': 'その組み合わせに対応する構成が Cursor にありません。先に別のオプションを変更してください。', + 'cursor.variantUnavailable': 'その組み合わせに一致する構成は Cursor にありません。', + 'cursor.variantPending': '一致する Cursor の構成がまだ {n} 件あります。別のオプションを選んで 1 つに絞ってください。', + 'target.resolving': 'この会話の実行先を復元しています…', 'cursor.options': 'Cursor のオプション', 'cursor.mode': '会話モード', 'cursor.modeAgent': 'Agent', @@ -3164,7 +3170,9 @@ const zh: Dict = { 'target.cursorConnect': '连接 Cursor', 'target.cursorNoVariant': 'Cursor 没有为该模型返回可运行的配置,因此无法选择。', 'target.lockedWhileStreaming': '回合运行期间无法更改执行目标。', - 'cursor.variantUnavailable': 'Cursor 没有与该组合对应的唯一配置,请先更改其他选项。', + 'cursor.variantUnavailable': 'Cursor 没有与这些选项匹配的配置。', + 'cursor.variantPending': '仍有 {n} 个 Cursor 配置符合条件,请再选择一个选项以确定唯一配置。', + 'target.resolving': '正在恢复该对话的执行位置…', 'cursor.options': 'Cursor 选项', 'cursor.mode': '对话模式', 'cursor.modeAgent': 'Agent', @@ -3928,7 +3936,9 @@ const ru: Dict = { 'target.cursorConnect': 'Подключить Cursor', 'target.cursorNoVariant': 'Cursor не вернул для этой модели работоспособной конфигурации, поэтому выбрать её нельзя.', 'target.lockedWhileStreaming': 'Пока идёт ход, цель выполнения изменить нельзя.', - 'cursor.variantUnavailable': 'У Cursor нет единственной конфигурации для такого сочетания. Сначала измените другой параметр.', + 'cursor.variantUnavailable': 'У Cursor нет конфигурации, подходящей под эти параметры.', + 'cursor.variantPending': 'Подходящих конфигураций Cursor всё ещё {n}. Выберите другой параметр, чтобы осталась одна.', + 'target.resolving': 'Восстанавливаем, где выполняется этот разговор…', 'cursor.options': 'Параметры Cursor', 'cursor.mode': 'Режим разговора', 'cursor.modeAgent': 'Agent', diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 3730586..afe99ec 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -58,6 +58,7 @@ import { } from '@/lib/composerTargets' import { baselineAfterSend, + composerCanSend, restoreIsCurrent, shouldAdoptDefaultTarget, stopStreamKind, @@ -443,9 +444,17 @@ export default function ChatPage() { const cursorMode = isCursorTarget(target) // Who may set the target right now. A session's own durable state outranks // the picker's automatic active-model default, which is stashed until the - // session turns out not to own the target. + // session turns out not to own the target. The state mirror drives the + // composer, which must not accept a message while the answer is unknown. + const [targetOwner, setTargetOwnerState] = useState('free') const targetOwnerRef = useRef('free') + const setTargetOwner = useCallback((owner: TargetOwner) => { + targetOwnerRef.current = owner + setTargetOwnerState(owner) + }, []) const pendingDefaultRef = useRef(null) + // Whether the target was chosen deliberately since this session opened. + const userChoseRef = useRef(false) // Bumped for every session hydration, so an answer for the session that was // open a moment ago can never apply to the one open now. const hydrationRef = useRef(0) @@ -523,7 +532,11 @@ export default function ChatPage() { return } } - if (origin === 'user' && !targetChangeAllowed(streamingRef.current)) return + if (origin === 'user') { + if (!targetChangeAllowed(streamingRef.current)) return + // A deliberate choice outranks whatever this session would restore. + userChoseRef.current = true + } commitTarget(selection) if (selection.kind !== 'chat') return const capability = selection.reasoningCapability @@ -540,6 +553,7 @@ export default function ChatPage() { ) const changeCursorOptions = useCallback((next: CursorOptionsValue) => { if (!targetChangeAllowed(streamingRef.current)) return + userChoseRef.current = true commitTarget({ kind: 'cursor', model: next.model, variant: next.variant }) setCursorSettings({ mode: next.mode, @@ -647,17 +661,20 @@ export default function ChatPage() { startingRef: hydration.startingRef ?? null, autoCreatePR: hydration.autoCreatePR === true, } + const userChose = userChoseRef.current if (restoreComposer) { // Whatever this session is, the composer must stop pointing at another - // conversation's Cursor agent before the next message can be sent. + // conversation's Cursor agent — and must end up with somewhere to send + // — before the next message can go out. const decision = targetAfterCursorHydration({ active: hydration.active, modelId: hydration.modelId, current: targetRef.current, pendingDefault: pendingDefaultRef.current, lastChat: composerReasoningRef.current?.selection ?? null, + userChose, }) - targetOwnerRef.current = decision.owner + setTargetOwner(decision.owner) if (decision.action === 'set') { if (decision.target) selectTarget(decision.target, 'restore') else commitTarget(null) @@ -674,7 +691,9 @@ export default function ChatPage() { // exact one must be matched exactly. const params = hydration.params ?? null const reuseValid = hydration.reuseValid === true - if (!restoreComposer) { + // A deliberate choice keeps the composer; only the follow-up baseline is + // still worth taking from durable state. + if (!restoreComposer || userChose) { if (!params || !applyCursorBaseline(hydration.modelId, params, settings, reuseValid)) { setLastCursorRun(null) } @@ -683,7 +702,7 @@ export default function ChatPage() { setCursorSettings(settings) restoreCursorTarget(hydration.modelId, params, settings, reuseValid) }, - [applyCursorBaseline, commitTarget, restoreCursorTarget, selectTarget], + [applyCursorBaseline, commitTarget, restoreCursorTarget, selectTarget, setTargetOwner], ) const pickReasoning = useCallback((value: string) => { const current = composerReasoningRef.current @@ -1308,7 +1327,9 @@ export default function ChatPage() { // session's own state says otherwise, the composer holds no Cursor target, // so a message sent meanwhile cannot reach another session's agent. if (isCursorTarget(targetRef.current)) commitTarget(null) - targetOwnerRef.current = sessionId ? 'pending' : 'free' + userChoseRef.current = false + // Until this session says where its messages go, the composer accepts none. + setTargetOwner(sessionId ? 'pending' : 'free') if (!sessionId) { // A new chat is owned by nobody, so the picker's default may fill it. const stashed = pendingDefaultRef.current @@ -1373,7 +1394,7 @@ export default function ChatPage() { // Nothing will claim the target now, so the composer must not stay // waiting on a session state that never arrived. if (restoreIsCurrent(generation, hydrationRef.current)) { - targetOwnerRef.current = 'free' + setTargetOwner('free') const stashed = pendingDefaultRef.current if (stashed && !targetRef.current) selectTarget(stashed, 'default') } @@ -1399,6 +1420,7 @@ export default function ChatPage() { applyCursorHydration, commitTarget, selectTarget, + setTargetOwner, ]) /** Append a locally-produced message without touching the server. */ @@ -1480,7 +1502,11 @@ export default function ChatPage() { const sendText = useCallback( (raw: string, attached: string[] = [], attachedDocs: { path: string; name: string }[] = []) => { const text = raw.trim() - if ((!text && attached.length === 0 && attachedDocs.length === 0) || streaming) return + if (!text && attached.length === 0 && attachedDocs.length === 0) return + // Nothing may be sent before this session's execution target is known: + // a Cursor conversation must not fall through to the chat model while + // its exact selection is still loading. + if (!composerCanSend({ owner: targetOwnerRef.current, streaming })) return if (text.startsWith('/') && text.length > 1) { // Still record slash commands so ↑ recalls them. if (text) { @@ -1710,9 +1736,13 @@ export default function ChatPage() { ], ) + // Both composer routes — the send button and Enter — go through here, so the + // hydration gate covers each of them. + const canSend = composerCanSend({ owner: targetOwner, streaming }) const send = useCallback(() => { const text = input.trim() - if ((!text && images.length === 0 && docs.length === 0) || streaming) return + if (!text && images.length === 0 && docs.length === 0) return + if (!composerCanSend({ owner: targetOwnerRef.current, streaming })) return sendText(text, images, docs) }, [input, images, docs, streaming, sendText]) @@ -1994,6 +2024,8 @@ export default function ChatPage() { onSend={send} onStop={stop} streaming={streaming} + canSend={canSend} + pendingLabel={t('target.resolving')} placeholder={t('chat.placeholder')} sendLabel={t('chat.send')} stopLabel={t('chat.stop')} @@ -2319,6 +2351,9 @@ interface ComposerProps { onSend: () => void onStop: () => void streaming: boolean + /** False while this session's execution target is still being resolved. */ + canSend: boolean + pendingLabel: string placeholder: string sendLabel: string stopLabel: string @@ -2348,6 +2383,8 @@ const Composer = ({ onSend, onStop, streaming, + canSend, + pendingLabel, placeholder, sendLabel, stopLabel, @@ -2469,8 +2506,9 @@ const Composer = ({