Files

250 lines
8.8 KiB
Go

package utility
import (
"context"
"reflect"
"testing"
"time"
acp "github.com/coder/acp-go-sdk"
)
type fakeModelConn struct {
calls []string
}
func (f *fakeModelConn) SetSessionConfigOption(_ context.Context, req acp.SetSessionConfigOptionRequest) (acp.SetSessionConfigOptionResponse, error) {
f.calls = append(f.calls, "config:"+string(req.ValueId.ConfigId)+":"+string(req.ValueId.Value))
return acp.SetSessionConfigOptionResponse{}, nil
}
func (f *fakeModelConn) UnstableSetSessionModel(_ context.Context, req acp.UnstableSetSessionModelRequest) (acp.UnstableSetSessionModelResponse, error) {
f.calls = append(f.calls, "legacy:"+string(req.SessionId)+":"+req.ModelId)
return acp.UnstableSetSessionModelResponse{}, nil
}
func TestApplySessionModel_UsesConfigOptionWhenSessionAdvertisesModelOption(t *testing.T) {
t.Parallel()
modelCat := acp.SessionConfigOptionCategoryModel
conn := &fakeModelConn{}
method, err := applySessionModel(context.Background(), conn, "sess-1", "gpt-5.4-mini", []acp.SessionConfigOption{
{Select: &acp.SessionConfigOptionSelect{
Id: "model",
Category: &modelCat,
Type: "select",
}},
})
if err != nil {
t.Fatalf("applySessionModel() error = %v", err)
}
if method != "session/set_config_option" {
t.Fatalf("method = %q, want session/set_config_option", method)
}
wantCalls := []string{"config:model:gpt-5.4-mini"}
if !reflect.DeepEqual(conn.calls, wantCalls) {
t.Fatalf("calls = %#v, want %#v", conn.calls, wantCalls)
}
}
func TestApplySessionModelWithConfigOptions_ReturnsProviderSnapshot(t *testing.T) {
t.Parallel()
modelCat := acp.SessionConfigOptionCategoryModel
effortCat := acp.SessionConfigOptionCategory("reasoning_effort")
configOptions := []acp.SessionConfigOption{
{Select: &acp.SessionConfigOptionSelect{
Id: "model",
Category: &modelCat,
Type: "select",
}},
{Select: &acp.SessionConfigOptionSelect{
Id: "reasoning_effort",
Category: &effortCat,
Type: "select",
}},
}
conn := &fakeModelConnWithConfig{configOptions: configOptions}
method, got, err := applySessionModelWithConfigOptions(
context.Background(), conn, "sess-1", "model-with-effort", []acp.SessionConfigOption{
{Select: &acp.SessionConfigOptionSelect{
Id: "model",
Category: &modelCat,
Type: "select",
}},
},
)
if err != nil {
t.Fatalf("applySessionModelWithConfigOptions() error = %v", err)
}
if method != "session/set_config_option" {
t.Fatalf("method = %q, want session/set_config_option", method)
}
if !reflect.DeepEqual(got, configOptions) {
t.Fatalf("config options = %#v, want %#v", got, configOptions)
}
}
type fakeModelConnWithConfig struct {
configOptions []acp.SessionConfigOption
}
func (f *fakeModelConnWithConfig) SetSessionConfigOption(_ context.Context, _ acp.SetSessionConfigOptionRequest) (acp.SetSessionConfigOptionResponse, error) {
return acp.SetSessionConfigOptionResponse{ConfigOptions: f.configOptions}, nil
}
func (f *fakeModelConnWithConfig) UnstableSetSessionModel(_ context.Context, _ acp.UnstableSetSessionModelRequest) (acp.UnstableSetSessionModelResponse, error) {
return acp.UnstableSetSessionModelResponse{}, nil
}
func TestApplySessionConfigOptions_SortsAndAppliesValues(t *testing.T) {
t.Parallel()
conn := &fakeModelConn{}
_, err := applySessionConfigOptions(context.Background(), conn, "sess-1", map[string]string{
"fast": "true",
"effort": "high",
})
if err != nil {
t.Fatalf("applySessionConfigOptions() error = %v", err)
}
wantCalls := []string{
"config:effort:high",
"config:fast:true",
}
if !reflect.DeepEqual(conn.calls, wantCalls) {
t.Fatalf("calls = %#v, want %#v", conn.calls, wantCalls)
}
}
// TestApplySessionModel_FallsBackToLegacySetModel pins that when the session
// advertises no model-shaped config option, the dispatcher falls through to
// the pre-v0.13.5 session/set_model RPC restored by the kdlbs acp-go-sdk
// fork. This keeps model switching working for unmigrated agents like
// auggie 0.29.x, which surface their models via the legacy top-level
// `models` field on session/new instead of a typed config option.
func TestApplySessionModel_FallsBackToLegacySetModel(t *testing.T) {
t.Parallel()
conn := &fakeModelConn{}
method, err := applySessionModel(context.Background(), conn, "sess-1", "legacy-model", nil)
if err != nil {
t.Fatalf("applySessionModel() error = %v", err)
}
if method != "session/set_model" {
t.Fatalf("method = %q, want session/set_model", method)
}
wantCalls := []string{"legacy:sess-1:legacy-model"}
if !reflect.DeepEqual(conn.calls, wantCalls) {
t.Fatalf("calls = %#v, want %#v", conn.calls, wantCalls)
}
}
// TestApplySessionModel_NoOpWhenAgentSupportsNeitherSurface pins that when
// the session has no model-shaped config option AND the legacy
// session/set_model RPC returns JSON-RPC -32601 (method not found), the
// dispatcher treats the request as a clean no-op (empty method, nil error).
// This is how agents that support no model-selection surface at all signal
// "I don't support model selection".
func TestApplySessionModel_NoOpWhenAgentSupportsNeitherSurface(t *testing.T) {
t.Parallel()
conn := &fakeModelConnLegacyErr{
err: acp.NewMethodNotFound(acp.LegacyAgentMethodSessionSetModel),
}
method, err := applySessionModel(context.Background(), conn, "sess-1", "legacy-model", nil)
if err != nil {
t.Fatalf("applySessionModel() error = %v, want nil", err)
}
if method != "" {
t.Fatalf("method = %q, want empty (MethodNone)", method)
}
wantCalls := []string{"legacy:sess-1:legacy-model"}
if !reflect.DeepEqual(conn.calls, wantCalls) {
t.Fatalf("calls = %#v, want %#v", conn.calls, wantCalls)
}
}
// TestApplyProbeModel_LegacyNoConfigOptionsReturnsEmpty pins that when the
// legacy session/set_model RPC succeeds but the agent surfaces no per-model
// config options (and pushes no follow-up config-update notification), the
// probe treats it as a valid empty resolution (nil options, nil error) rather
// than a hard failure. This is the auggie shape: a flat model list with no
// reasoning-effort style selectors to resolve.
func TestApplyProbeModel_LegacyNoConfigOptionsReturnsEmpty(t *testing.T) {
t.Parallel()
conn := &fakeModelConn{}
state := newACPProbeNotificationState("auggie")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
options, err := applyProbeModel(ctx, conn, acp.SessionId("sess-1"), "legacy-model", nil, state)
if err != nil {
t.Fatalf("applyProbeModel() error = %v, want nil", err)
}
if options != nil {
t.Fatalf("options = %#v, want nil (empty resolution)", options)
}
wantCalls := []string{"legacy:sess-1:legacy-model"}
if !reflect.DeepEqual(conn.calls, wantCalls) {
t.Fatalf("calls = %#v, want %#v", conn.calls, wantCalls)
}
}
// TestApplyProbeModel_ConfigOptionNoSnapshotReturnsError pins the other half of
// the narrowing: when the agent advertises a typed model config option (so the
// model is applied via session/set_config_option) but returns neither inline
// options nor a config-update notification, applyProbeModel must fail rather than
// keep the stale pre-switch snapshot. The empty-resolution relaxation is scoped
// to the legacy session/set_model path only.
func TestApplyProbeModel_ConfigOptionNoSnapshotReturnsError(t *testing.T) {
t.Parallel()
modelCat := acp.SessionConfigOptionCategoryModel
conn := &fakeModelConn{}
state := newACPProbeNotificationState("modern-agent")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
options, err := applyProbeModel(ctx, conn, acp.SessionId("sess-1"), "model-with-effort", []acp.SessionConfigOption{
{Select: &acp.SessionConfigOptionSelect{
Id: "model",
Category: &modelCat,
Type: "select",
}},
}, state)
if err == nil {
t.Fatalf("applyProbeModel() error = nil, want no-configuration-options error")
}
if options != nil {
t.Fatalf("options = %#v, want nil on error", options)
}
wantCalls := []string{"config:model:model-with-effort"}
if !reflect.DeepEqual(conn.calls, wantCalls) {
t.Fatalf("calls = %#v, want %#v", conn.calls, wantCalls)
}
}
// fakeModelConnLegacyErr returns a configurable error from the legacy
// session/set_model RPC. Used to exercise the -32601 no-op path.
type fakeModelConnLegacyErr struct {
err error
calls []string
}
func (f *fakeModelConnLegacyErr) SetSessionConfigOption(_ context.Context, req acp.SetSessionConfigOptionRequest) (acp.SetSessionConfigOptionResponse, error) {
f.calls = append(f.calls, "config:"+string(req.ValueId.ConfigId)+":"+string(req.ValueId.Value))
return acp.SetSessionConfigOptionResponse{}, nil
}
func (f *fakeModelConnLegacyErr) UnstableSetSessionModel(_ context.Context, req acp.UnstableSetSessionModelRequest) (acp.UnstableSetSessionModelResponse, error) {
f.calls = append(f.calls, "legacy:"+string(req.SessionId)+":"+req.ModelId)
return acp.UnstableSetSessionModelResponse{}, f.err
}