package engine import ( "errors" "testing" "git.flytoex.net/yuanwei/flyto-agent/core/pkg/flyto" ) func TestEngineError_Error_WithMessage(t *testing.T) { e := &EngineError{ Code: ErrAPIAuth, Message: "invalid API key", } if got := e.Error(); got != "invalid API key" { t.Errorf("Error() = %q, want %q", got, "invalid API key") } } func TestEngineError_Error_FallbackToCode(t *testing.T) { e := &EngineError{Code: ErrAPIRateLimit} if got := e.Error(); got != string(ErrAPIRateLimit) { t.Errorf("Error() = %q, want %q", got, string(ErrAPIRateLimit)) } } // TestEngineError_Error_MessageAndDetail covers the fail-loud // behavior introduced for Bug U / ADR-0006: when Detail is populated // (typically the wire layer's specific failure message), Error() // returns "Message: Detail" so any errors.Error() / fmt.Errorf("%v") // consumer sees the actual cause without type-asserting. // // 覆盖 Bug U / ADR-0006 引入的 fail-loud 行为: Detail 非空 (通常是 // wire 层具体失败消息) 时 Error() 返 "Message: Detail", 让任何走 // errors.Error() / fmt.Errorf("%v") 的消费方不必 type-assert 就看到 // 真因. func TestEngineError_Error_MessageAndDetail(t *testing.T) { e := &EngineError{ Code: ErrInternal, Message: "API 调用失败", Detail: "openai_compat: provider error (via openrouter→SiliconFlow): [20015] Invalid 'tools[0].function.name'", } got := e.Error() want := "API 调用失败: openai_compat: provider error (via openrouter→SiliconFlow): [20015] Invalid 'tools[0].function.name'" if got != want { t.Errorf("Error() = %q, want %q", got, want) } } // TestEngineError_Error_DetailOnly covers the unusual but possible // case where Message is empty but Detail is set -- prefer Detail over // the bare Code string. // // Message 空 Detail 非空时优先 Detail 而非裸 Code. func TestEngineError_Error_DetailOnly(t *testing.T) { e := &EngineError{ Code: ErrInternal, Detail: "wire-only diagnostic", } if got := e.Error(); got != "wire-only diagnostic" { t.Errorf("Error() = %q, want %q", got, "wire-only diagnostic") } } // TestClassifyAPIErrorTyped_FlytoEngineError covers the ADR-0006 fail-loud // path: wire layer constructs *flyto.EngineError with a typed code, and // engine layer recognizes it via errors.As without falling back to // string heuristics. // // 覆盖 ADR-0006 fail-loud 路径: wire 层构造 *flyto.EngineError 带 typed // code, engine 层 errors.As 识别不必字符串模式 fallback. func TestClassifyAPIErrorTyped_FlytoEngineError(t *testing.T) { tests := []struct { name string code flyto.ErrorCode want ErrorCode }{ {"http_status", flyto.ErrProviderHTTPStatus, ErrProviderHTTPStatus}, {"non_sse", flyto.ErrProviderNonSSE, ErrProviderNonSSE}, {"mid_stream", flyto.ErrProviderMidStreamErr, ErrProviderMidStreamErr}, {"unmarshal", flyto.ErrWireUnmarshal, ErrWireUnmarshal}, {"tool_unsup", flyto.ErrModelToolUnsupported, ErrModelToolUnsupported}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := &flyto.EngineError{ Code: tt.code, Message: "wire layer specific failure", Detail: "provider error code 20015", } if got := ClassifyAPIErrorTyped(err); got != tt.want { t.Errorf("ClassifyAPIErrorTyped(%q) = %q, want %q", tt.code, got, tt.want) } }) } } // TestClassifyAPIErrorTyped_StringFallback verifies plain errors still // fall through to string heuristics so old wire paths (transport // classifier returning *api.APIError, plain fmt.Errorf) keep working. // // 普通 error 走字符串 heuristic 兼容旧 wire 路径. func TestClassifyAPIErrorTyped_StringFallback(t *testing.T) { tests := []struct { errStr string want ErrorCode }{ {"HTTP 401: invalid_api_key", ErrAPIAuth}, {"HTTP 429: rate limited", ErrAPIRateLimit}, {"HTTP 529: overloaded", ErrAPIOverloaded}, {"HTTP 400: invalid request", ErrAPIBadRequest}, {"unknown weird error", ErrInternal}, } for _, tt := range tests { t.Run(tt.errStr, func(t *testing.T) { err := errors.New(tt.errStr) if got := ClassifyAPIErrorTyped(err); got != tt.want { t.Errorf("ClassifyAPIErrorTyped(%q) = %q, want %q", tt.errStr, got, tt.want) } }) } } // TestWrapError_FlytoEngineError verifies WrapError preserves Detail // from the wire-layer's *flyto.EngineError instead of stringifying it // (which would produce nested "Message: Detail: ..." redundancy). // // WrapError 直接复用 *flyto.EngineError.Detail, 不字符串化叠加. func TestWrapError_FlytoEngineError(t *testing.T) { cause := &flyto.EngineError{ Code: flyto.ErrProviderHTTPStatus, Message: "openai_compat: http 400", Detail: "openai_compat: provider error (via openrouter→SiliconFlow): [20015] Invalid 'tools[0].function.name'", } wrapped := WrapError(cause, ErrProviderHTTPStatus, "API 调用在 1 次尝试后失败") if wrapped.Detail != cause.Detail { t.Errorf("WrapError.Detail = %q, want cause.Detail %q (no string叠加)", wrapped.Detail, cause.Detail) } if wrapped.Code != ErrProviderHTTPStatus { t.Errorf("WrapError.Code = %q, want %q", wrapped.Code, ErrProviderHTTPStatus) } } // TestNewErrorEvent_FlytoDetailPropagated covers the ErrorEvent.Detail // propagation: wire layer's flyto.EngineError flows through engine and // surfaces in the event consumed by orchestrator/CLI/SDK. // // 覆盖 ErrorEvent.Detail 透传: wire 层 flyto.EngineError 经 engine 流入 // orchestrator/CLI/SDK 消费的事件. func TestNewErrorEvent_FlytoDetailPropagated(t *testing.T) { wireErr := &flyto.EngineError{ Code: flyto.ErrProviderMidStreamErr, Message: "openai_compat: provider mid-stream error", Detail: "code=429 message=rate_limit_exceeded", } wrapped := WrapError(wireErr, ErrProviderMidStreamErr, "API 流式响应出错") ev := newErrorEvent(wrapped) if ev.Code != string(ErrProviderMidStreamErr) { t.Errorf("ErrorEvent.Code = %q, want %q", ev.Code, ErrProviderMidStreamErr) } if ev.Detail != wireErr.Detail { t.Errorf("ErrorEvent.Detail = %q, want %q", ev.Detail, wireErr.Detail) } } func TestEngineError_Unwrap(t *testing.T) { cause := errors.New("root cause") e := &EngineError{ Code: ErrInternal, Cause: cause, } if got := e.Unwrap(); got != cause { t.Errorf("Unwrap() = %v, want %v", got, cause) } // nil cause e2 := &EngineError{Code: ErrInternal} if got := e2.Unwrap(); got != nil { t.Errorf("Unwrap() = %v, want nil", got) } } func TestEngineError_ErrorsIs(t *testing.T) { cause := errors.New("root") e := &EngineError{Code: ErrToolExecution, Cause: cause} if !errors.Is(e, cause) { t.Error("errors.Is should find the wrapped cause") } } func TestWrapError_NilCause(t *testing.T) { e := WrapError(nil, ErrAPIAuth, "no key") if e.Code != ErrAPIAuth { t.Errorf("Code = %q, want %q", e.Code, ErrAPIAuth) } if e.Cause != nil { t.Error("Cause should be nil when wrapping nil") } if e.Retryable { t.Error("ErrAPIAuth should not be retryable") } } func TestWrapError_PlainError(t *testing.T) { cause := errors.New("connection timeout") e := WrapError(cause, ErrAPIOverloaded, "service busy") if e.Code != ErrAPIOverloaded { t.Errorf("Code = %q", e.Code) } if e.Message != "service busy" { t.Errorf("Message = %q", e.Message) } if e.Detail != "connection timeout" { t.Errorf("Detail = %q, want cause's Error()", e.Detail) } if !e.Retryable { t.Error("ErrAPIOverloaded should be retryable") } if e.Suggestion == "" { t.Error("Suggestion should be populated from defaultSuggestions") } } func TestWrapError_EngineError(t *testing.T) { inner := &EngineError{ Code: ErrToolExecution, Detail: "grep returned exit code 1", } outer := WrapError(inner, ErrInternal, "tool pipeline failed") if outer.Code != ErrInternal { t.Errorf("Code = %q, want ErrInternal", outer.Code) } if outer.Detail != "grep returned exit code 1" { t.Errorf("Detail = %q, should preserve inner Detail", outer.Detail) } if !outer.Retryable { t.Error("ErrInternal should be retryable") } // errors.As should find the inner EngineError var found *EngineError if !errors.As(outer.Cause, &found) { t.Error("Cause should contain the inner EngineError") } } func TestFormatErrorForDisplay_PlainError(t *testing.T) { err := errors.New("something broke") got := FormatErrorForDisplay(err, false) if got != "错误: something broke" { t.Errorf("FormatErrorForDisplay = %q", got) } } func TestFormatErrorForDisplay_EngineError(t *testing.T) { e := NewEngineError(ErrAPIAuth, "API key expired", nil) got := FormatErrorForDisplay(e, false) if got == "" { t.Fatal("FormatErrorForDisplay returned empty string") } // should contain the message and suggestion assertContains(t, got, "API key expired") assertContains(t, got, "建议:") // ErrAPIAuth is not retryable, so no retry hint assertNotContains(t, got, "可自动重试") } func TestFormatErrorForDisplay_Verbose(t *testing.T) { e := &EngineError{ Code: ErrToolExecution, Message: "grep failed", Detail: "exit code 2: no such file", Suggestion: "check the file path", Retryable: false, } // non-verbose: no detail brief := FormatErrorForDisplay(e, false) assertNotContains(t, brief, "exit code 2") // verbose: includes detail verbose := FormatErrorForDisplay(e, true) assertContains(t, verbose, "exit code 2") assertContains(t, verbose, "详情:") } func TestFormatErrorForDisplay_Retryable(t *testing.T) { e := NewEngineError(ErrAPIOverloaded, "service overloaded", nil) got := FormatErrorForDisplay(e, false) assertContains(t, got, "可自动重试") } func TestNewEngineError_DefaultSuggestions(t *testing.T) { // spot-check a few error codes get their default suggestions and retryable flags tests := []struct { code ErrorCode retryable bool }{ {ErrAPIAuth, false}, {ErrAPIRateLimit, true}, {ErrAPIOverloaded, true}, {ErrToolNotFound, false}, {ErrBudgetExceeded, false}, {ErrMCPConnection, true}, {ErrStreamTruncated, true}, } for _, tt := range tests { e := NewEngineError(tt.code, "test", nil) if e.Retryable != tt.retryable { t.Errorf("NewEngineError(%s).Retryable = %v, want %v", tt.code, e.Retryable, tt.retryable) } if e.Suggestion == "" { t.Errorf("NewEngineError(%s).Suggestion is empty", tt.code) } } } // --- helpers --- func assertContains(t *testing.T, s, substr string) { t.Helper() if len(s) == 0 || len(substr) == 0 { return } for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return } } t.Errorf("expected %q to contain %q", s, substr) } func assertNotContains(t *testing.T, s, substr string) { t.Helper() for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { t.Errorf("expected %q to NOT contain %q", s, substr) return } } }