package engine // elicitation_adapter_test.go - locks the wiring between mcp.ElicitationHandler // (called from inside an MCP server's elicitation/create dispatch) and // engine.ElicitationHandler (consumer-layer SDK interface). // // The two godoc promises being locked: // 1. engine.Config.ElicitationHandler "处理 MCP 服务器发出的用户输入请求" // -- exercised by TestAdaptElicitationHandler_*: prove the consumer handler // actually receives the request, with all schema fields including Default. // 2. NoopElicitationHandler / nil-handler "返回 cancel,不阻塞,不 panic" // -- exercised by TestAdaptElicitationHandler_NilDelegatesToNoop. // // elicitation_adapter_test.go - 锁住 mcp.ElicitationHandler (被 MCP server 的 // elicitation/create 派发调用) 和 engine.ElicitationHandler (消费层 SDK 接口) // 的接线. // // 锁住两个 godoc 承诺: // 1. engine.Config.ElicitationHandler "处理 MCP 服务器发出的用户输入请求" // -- 由 TestAdaptElicitationHandler_* 验证: 证明消费层 handler 真收到请求, // 含 Default 在内的所有 schema 字段都到达. // 2. NoopElicitationHandler / nil-handler "返回 cancel,不阻塞,不 panic" // -- 由 TestAdaptElicitationHandler_NilDelegatesToNoop 验证. import ( "errors" "testing" "git.flytoex.net/yuanwei/flyto-agent/internal/mcp" ) // recordingElicitHandler captures the engine-side request and returns canned // (action / values / err). Used to assert the wire request is faithfully // converted to the SDK shape including Default-from-schema. // // recordingElicitHandler 捕获 engine 侧请求, 返回预设 (action/values/err). // 用于断言 wire 请求被忠实转换成 SDK 形状, 包括 Default-from-schema. type recordingElicitHandler struct { called bool req ElicitationRequest resp ElicitationResponse err error } func (h *recordingElicitHandler) HandleElicitation(req ElicitationRequest) (ElicitationResponse, error) { h.called = true h.req = req return h.resp, h.err } func TestAdaptElicitationHandler_DefaultReachesEngineHandler(t *testing.T) { rec := &recordingElicitHandler{ resp: ElicitationResponse{Action: "accept", Values: map[string]string{"name": "alice"}}, } adapter := adaptElicitationHandler(rec, nil) schema := &mcp.ElicitationSchema{ Type: "object", Properties: map[string]mcp.ElicitationProperty{ "name": {Type: "string", Title: "Name", Description: "Your name"}, "timeout": {Type: "number", Default: 30.0}, "verbose": {Type: "boolean", Default: true}, "language": {Type: "string", Default: "en"}, }, Required: []string{"name"}, } _ = adapter.HandleElicitation("test-server", "Please provide your details", schema) if !rec.called { t.Fatal("consumer handler not invoked -- adapter wiring broken") } if rec.req.ServerName != "test-server" { t.Errorf("ServerName: got %q, want test-server", rec.req.ServerName) } if rec.req.Message != "Please provide your details" { t.Errorf("Message mismatch: got %q", rec.req.Message) } if len(rec.req.Fields) != 4 { t.Fatalf("Fields count: got %d, want 4", len(rec.req.Fields)) } byName := make(map[string]ElicitationField, len(rec.req.Fields)) for _, f := range rec.req.Fields { byName[f.Name] = f } if got := byName["name"]; got.Title != "Name" || got.Description != "Your name" || !got.Required || got.Default != "" { t.Errorf("name field: got %+v", got) } if got := byName["timeout"]; got.Default != "30" { t.Errorf("timeout Default: got %q, want \"30\" (number reduced to fmt.Sprint)", got.Default) } if got := byName["verbose"]; got.Default != "true" { t.Errorf("verbose Default: got %q, want \"true\"", got.Default) } if got := byName["language"]; got.Default != "en" { t.Errorf("language Default: got %q, want \"en\" (string passes through)", got.Default) } } func TestAdaptElicitationHandler_AcceptFillsMissingDefaults(t *testing.T) { // Consumer handler accepts but only supplies "name" -- the adapter should // fill timeout/verbose/language from schema defaults so the MCP server sees // a complete Content map. rec := &recordingElicitHandler{ resp: ElicitationResponse{ Action: "accept", Values: map[string]string{"name": "alice"}, }, } adapter := adaptElicitationHandler(rec, nil) schema := &mcp.ElicitationSchema{ Type: "object", Properties: map[string]mcp.ElicitationProperty{ "name": {Type: "string"}, "timeout": {Type: "number", Default: 30.0}, "verbose": {Type: "boolean", Default: true}, "language": {Type: "string", Default: "en"}, }, } result := adapter.HandleElicitation("test-server", "msg", schema) if result.Action != "accept" { t.Fatalf("Action: got %q, want accept", result.Action) } if got, _ := result.Content["name"].(string); got != "alice" { t.Errorf("Content[name]: got %v, want alice", result.Content["name"]) } if got, _ := result.Content["timeout"].(string); got != "30" { t.Errorf("Content[timeout] should auto-fill to schema default 30, got %v", result.Content["timeout"]) } if got, _ := result.Content["verbose"].(string); got != "true" { t.Errorf("Content[verbose] should auto-fill to true, got %v", result.Content["verbose"]) } if got, _ := result.Content["language"].(string); got != "en" { t.Errorf("Content[language] should auto-fill to en, got %v", result.Content["language"]) } } func TestAdaptElicitationHandler_AcceptUserValueOverridesDefault(t *testing.T) { // When the consumer handler explicitly supplied a value, the adapter must // not clobber it with the schema default (override > default). rec := &recordingElicitHandler{ resp: ElicitationResponse{ Action: "accept", Values: map[string]string{"language": "zh"}, }, } adapter := adaptElicitationHandler(rec, nil) schema := &mcp.ElicitationSchema{ Type: "object", Properties: map[string]mcp.ElicitationProperty{ "language": {Type: "string", Default: "en"}, }, } result := adapter.HandleElicitation("s", "m", schema) if got, _ := result.Content["language"].(string); got != "zh" { t.Errorf("user-supplied value zh should override default en, got %v", result.Content["language"]) } } func TestAdaptElicitationHandler_DeclineCancelDontFill(t *testing.T) { // decline / cancel responses must not carry Content (per MCP spec, Content // is only meaningful for accept). Even if the schema has defaults, they // must not leak into a declined response. for _, action := range []string{"decline", "cancel"} { t.Run(action, func(t *testing.T) { rec := &recordingElicitHandler{ resp: ElicitationResponse{Action: action}, } adapter := adaptElicitationHandler(rec, nil) schema := &mcp.ElicitationSchema{ Type: "object", Properties: map[string]mcp.ElicitationProperty{ "x": {Type: "string", Default: "leaked-if-bug"}, }, } result := adapter.HandleElicitation("s", "m", schema) if result.Action != action { t.Errorf("Action: got %q, want %q", result.Action, action) } if len(result.Content) != 0 { t.Errorf("%s must have empty Content, got %+v", action, result.Content) } }) } } func TestAdaptElicitationHandler_NilDelegatesToNoop(t *testing.T) { // nil engine handler must not panic and must auto-cancel (matches the // godoc: "nil 时自动使用 NoopElicitationHandler 返回 cancel"). adapter := adaptElicitationHandler(nil, nil) schema := &mcp.ElicitationSchema{ Type: "object", Properties: map[string]mcp.ElicitationProperty{ "x": {Type: "string"}, }, } result := adapter.HandleElicitation("s", "m", schema) if result.Action != "cancel" { t.Errorf("nil handler should auto-cancel, got Action=%q", result.Action) } } func TestAdaptElicitationHandler_HandlerErrorYieldsCancel(t *testing.T) { // Handler errors must be coerced to cancel rather than propagated as RPC // errors -- the MCP wire protocol expects an ElicitationCreateResult, and // we don't want to leak Go error strings to remote servers. rec := &recordingElicitHandler{err: errors.New("ui crashed")} adapter := adaptElicitationHandler(rec, nil) result := adapter.HandleElicitation("s", "m", &mcp.ElicitationSchema{Type: "object"}) if result.Action != "cancel" { t.Errorf("handler error should yield cancel, got %q", result.Action) } } // recordingObserver captures the most recent Event(name, data) call so tests // can assert observability hooks fire. Errors path goes through Error(...) // which we don't exercise here (NoopObserver covers it). // // recordingObserver 捕获最后一次 Event(name, data) 调用, test 可断言可观测 // hook 触发. Error(...) 路径这里不练 (NoopObserver 覆盖). type recordingObserver struct { events []struct { name string data map[string]any } } func (o *recordingObserver) Event(name string, data map[string]any) { o.events = append(o.events, struct { name string data map[string]any }{name, data}) } func (o *recordingObserver) Error(_ error, _ map[string]any) {} func TestAdaptElicitationHandler_HandlerErrorEmitsObserverEvent(t *testing.T) { // The handler-error -> cancel coercion silences the Go error to avoid // leaking it over the wire. Without an observer breadcrumb, production // triage of "elicitation always cancels" has no signal. Lock the event. // // handler 错误强转 cancel 屏蔽了 Go 错误以防泄漏到 wire. 没有 observer 面包屑, // 生产环境排查 "elicitation 总是 cancel" 没信号. 锁住事件. rec := &recordingElicitHandler{err: errors.New("ui crashed")} obs := &recordingObserver{} adapter := adaptElicitationHandler(rec, obs) _ = adapter.HandleElicitation("test-server", "msg", &mcp.ElicitationSchema{ Type: "object", Properties: map[string]mcp.ElicitationProperty{"x": {Type: "string"}}, }) var found bool for _, e := range obs.events { if e.name == "elicitation_handler_error" { found = true if e.data["server_name"] != "test-server" { t.Errorf("server_name in event: got %v, want test-server", e.data["server_name"]) } if e.data["error"] != "ui crashed" { t.Errorf("error in event: got %v, want \"ui crashed\"", e.data["error"]) } if got, _ := e.data["field_count"].(int); got != 1 { t.Errorf("field_count: got %v, want 1", e.data["field_count"]) } } } if !found { t.Errorf("expected elicitation_handler_error event, got events: %+v", obs.events) } }