diff --git a/shortcuts/contact/contact_search_user_fanout.go b/shortcuts/contact/contact_search_user_fanout.go index ec1d5e84..e3dd8d40 100644 --- a/shortcuts/contact/contact_search_user_fanout.go +++ b/shortcuts/contact/contact_search_user_fanout.go @@ -176,7 +176,11 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo if firstErrCode != 0 { return nil, output.ErrAPI(firstErrCode, msg, "") } - return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg, "") + // No structured API code — the failure was transport, parse, panic, or + // cancellation. Suggest the actionable next step rather than shipping + // an empty hint that would leave the calling agent with nothing to do. + return nil, output.ErrWithHint(output.ExitInternal, "fanout", msg, + "retry the command; if it persists, narrow --queries to a single term to isolate the failing input") } return out, nil } diff --git a/shortcuts/contact/contact_search_user_test.go b/shortcuts/contact/contact_search_user_test.go index a3fd4e1a..5e14f197 100644 --- a/shortcuts/contact/contact_search_user_test.go +++ b/shortcuts/contact/contact_search_user_test.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -18,6 +19,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -1133,6 +1135,33 @@ func TestFanoutAssemble_AllFailed_ReturnsError(t *testing.T) { } } +// When all queries fail with no structured Lark API code (transport, parse, +// panic, ctx-canceled), the returned ExitError must carry an actionable +// hint so the calling agent has a next step to try instead of giving up. +func TestFanoutAssemble_AllFailed_NoCode_HasActionableHint(t *testing.T) { + results := []fanoutResult{ + {Index: 0, Query: "alice", ErrMsg: "transport: connection refused"}, + {Index: 1, Query: "bob", ErrMsg: "transport: timeout"}, + } + _, err := buildFanoutResponse([]string{"alice", "bob"}, results) + if err == nil { + t.Fatalf("expected error when all queries failed") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if exitErr.Detail == nil { + t.Fatalf("expected Detail, got nil") + } + if exitErr.Detail.Hint == "" { + t.Errorf("expected non-empty Hint so agents have a next step; got empty") + } + if !strings.Contains(exitErr.Detail.Hint, "retry") { + t.Errorf("hint should suggest retry as the first action; got %q", exitErr.Detail.Hint) + } +} + // Codes from the first failure must propagate through output.ErrAPI so the // CLI's exit-code classifier sees the real signal (e.g., 99991663 rate limit) // instead of 0, which would mean "success" in the Lark protocol.