From 462f2beeb68ce14ded6e34e573bb6ba6e47ccac3 Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Fri, 6 Nov 2026 11:05:21 -0800 Subject: [PATCH] fix: surface backend error messages in Java and Go Interactions SDK errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since `openapi-generation` v2.960.7, Speakeasy fills the error message from `error.message` only for operations with a typed error schema (the Interactions `*ClientError` / `*ServerError` carriers in Java and Go). The untyped fallback (`GaosApiException` in Java, `APIError` in Go), used by operations without a typed error schema and by bodies that do not match it (e.g. the `[{error: {...}}]` arrays streaming endpoints return), still passes the placeholder `"API error occurred"` and leaves the backend's `{error: {message}}` only in the raw response body. So a 400 validation error from such an operation surfaces in Java as `"400 . API error occurred"` and in Go as `APIError.Message == "API error occurred"`, and no Java gaos error populates `ApiException.status()` or carries `error.details`, so even typed errors read `"400 . "`. - **Java**: Emits package-private `GaosErrorBody` (`scripts/templates/error_body.java.tmpl`). `GaosBaseException`, `GaosClientException`, and `GaosServerException` forward `GaosErrorBody.status(body)` (OnePlatform `error.status`, else the Interactions API string `error.code`, else `""`) and `GaosErrorBody.message(message, body)` to `com.google.genai.errors.ApiException`. The message is `error.message` plus any `error.details`, appended one per line after `\nDetails: `; for `{error: {...}}` bodies this matches the native `ApiException.getErrorMessageFromBody`, so gaos and native errors read the same. - **Go**: Emits `messageFromBody` (`scripts/templates/error_body.go.tmpl`) in `interactions/models/apierrors/errorbody.go` and calls it from `NewAPIError`, which fills `APIError.Message` from `error.message`. - Both read the `{error: {...}}` object and the single-element `[{error: {...}}]` array that streaming endpoints return, replace only the placeholder (a more specific message is kept), and keep the placeholder when the body is not a recognized JSON error (including multi-element arrays). - **Behavior change (Java)**: `getMessage()` now spans several lines for errors that carry `error.details` (e.g. `400 INVALID_ARGUMENT. Bad thing\nDetails: {"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"API_KEY_INVALID"}`), so callers that log or compare the text see the extra line. `message()` gains the same lines: on the per-operation `*ClientError` / `*ServerError` carriers it is the message text alone (`Bad thing\nDetails: …`), as on the native `ApiException`, while `GaosApiException` keeps Speakeasy's existing override that returns the full `getMessage()` string. Go's `APIError.Message` stays `error.message` only; details remain in the body that `Error()` appends, following each language's native convention. - **Documented differences from the native Java `ApiException`**: `status()` falls back to the Interactions `error.code` where native uses the HTTP reason phrase (empty on HTTP/2); a single-element array also yields its details and an empty missing status where native `throwFromErrorNode` drops details and reports `UNKNOWN`; a multi-element array keeps the placeholder where native reads the first element. These are stated in the `GaosErrorBody` Javadoc and pinned by tests. - **Sync hardening & tests**: `reparent_java_error` replacements in `sync_speakeasy_outputs.py` now use `_replace_once`, so generator template changes fail loudly at sync time; adds `patch_go_apierrors`; adds Java (including parity and divergence tests against the native `ApiException`), Go, and sync unit tests. PiperOrigin-RevId: 1015698193 --- .github/workflows/ci.yaml | 2 +- internal/output/agentmode.go | 2 +- internal/output/artifact.go | 21 +++--- internal/output/artifact_test.go | 73 +++++++++++++++++++ internal/output/async.go | 2 +- internal/output/classify.go | 5 ++ internal/output/exitcodes.go | 2 +- internal/output/outcome.go | 34 +++++++-- internal/output/output.go | 12 ++- internal/output/output_test.go | 72 ++++++++++++++++++ internal/output/streaming.go | 2 +- .../models/sdkerrors/cancelinteractionbyid.go | 12 +++ .../sdk/models/sdkerrors/createinteraction.go | 12 +++ .../sdk/models/sdkerrors/deleteinteraction.go | 12 +++ .../models/sdkerrors/getinteractionbyid.go | 12 +++ internal/sdk/sdkinternal/utils/pathparams.go | 10 ++- tests/contract/cli_contract_test.go | 2 +- tests/contract/streaming_defaults_test.go | 2 +- 18 files changed, 260 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 68aa6b8..3f321a0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,7 +10,7 @@ permissions: contents: read env: - SPEAKEASY_VERSION: v2.959.1 + SPEAKEASY_VERSION: v2.960.7 jobs: non-live-validation: diff --git a/internal/output/agentmode.go b/internal/output/agentmode.go index f86c450..77cfc70 100644 --- a/internal/output/agentmode.go +++ b/internal/output/agentmode.go @@ -145,7 +145,7 @@ func CLIError(cmd *cobra.Command, err error) error { if !changed || jqExpr == "" { jqExpr = preparsedRendering.jq } - format := resolveOutputFormat(cmd) + format := resolveErrorOutputFormat(cmd) classification := Classify(cmd, withErrorOrigin(err, ErrorOriginCLI)) if shouldClassifyError(format, jqExpr) { return renderClassifiedError(cmd, err, classification, shouldRenderStructuredError(format, jqExpr)) diff --git a/internal/output/artifact.go b/internal/output/artifact.go index 2c7ca2e..3f1dc4f 100644 --- a/internal/output/artifact.go +++ b/internal/output/artifact.go @@ -181,7 +181,7 @@ func artifactResult(cmd *cobra.Command, res interface{}, cfg *artifactConfig) er return err } if extensionMismatch && !IsMachineMode(cmd) { - fmt.Fprintf(cmd.ErrOrStderr(), "Note: %s contains %s content; its extension does not match\n", filepath.Base(finalPath), mimeType) + fmt.Fprintf(cmd.ErrOrStderr(), "Note: %s contains %q content; its extension does not match\n", filepath.Base(finalPath), mimeType) } absPath, err := filepath.Abs(finalPath) if err != nil { @@ -195,14 +195,6 @@ func artifactResult(cmd *cobra.Command, res interface{}, cfg *artifactConfig) er return fmt.Errorf("the written %s artifact is empty: %s", cfg.Kind, absPath) } - if !IsMachineMode(cmd) { - mimeNote := "" - if mimeType != "" { - mimeNote = ", " + mimeType - } - fmt.Fprintf(cmd.ErrOrStderr(), "Wrote %s to %s (%d bytes%s)\n", cfg.Kind, absPath, written.Size(), mimeNote) - } - envelope := map[string]interface{}{ "path": absPath, "kind": cfg.Kind, @@ -222,6 +214,17 @@ func artifactResult(cmd *cobra.Command, res interface{}, cfg *artifactConfig) er envelope["status"] = status } } + if !IsMachineMode(cmd) { + mimeNote := "" + if mimeType != "" { + mimeNote = fmt.Sprintf(", %q", mimeType) + } + idNote := "" + if id, ok := envelope["response_id"].(string); ok { + idNote = fmt.Sprintf(", response ID %q", id) + } + fmt.Fprintf(cmd.ErrOrStderr(), "Wrote %s to %s (%d bytes%s%s)\n", cfg.Kind, absPath, written.Size(), mimeNote, idNote) + } return renderArtifactEnvelope(cmd, absPath, envelope) } diff --git a/internal/output/artifact_test.go b/internal/output/artifact_test.go index aecbf08..667f447 100644 --- a/internal/output/artifact_test.go +++ b/internal/output/artifact_test.go @@ -17,9 +17,12 @@ package output import ( + "bytes" "encoding/json" + "fmt" "net/http" "net/http/httptest" + "path/filepath" "strings" "sync/atomic" "testing" @@ -27,6 +30,76 @@ import ( "github.com/spf13/cobra" ) +func TestArtifactResultResponseIDNote(t *testing.T) { + for _, tc := range []struct { + id, mimeType, filename string + }{ + {id: "job-123", mimeType: "image/png", filename: "asset.png"}, + {id: "", mimeType: "image/png", filename: "asset.png"}, + {id: "job\n\x1b[31m", mimeType: "image/png", filename: "asset.png"}, + {id: "job-123", mimeType: "image/png;\n\x1b[31m", filename: "asset.png"}, + {id: "job-123", mimeType: "image/png;\n\x1b[31m", filename: "asset.jpg"}, + } { + t.Run(fmt.Sprintf("%q/%q/%s", tc.id, tc.mimeType, tc.filename), func(t *testing.T) { + ResetAgentMode() + ResetPreparsedRendering() + var stdout, stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.Flags().String("out", filepath.Join(t.TempDir(), tc.filename), "") + cmd.Flags().String("output-format", "pretty", "") + if err := cmd.Flags().Set("output-format", "pretty"); err != nil { + t.Fatal(err) + } + cfg := &artifactConfig{ + Pointer: []artifactSegment{ + {Field: "items"}, + {Wild: true}, + }, + Kind: "image", + DefaultPath: "asset.png", + TypeField: "type", + DataField: "data", + MimeTypeField: "mime_type", + IDField: "job_id", + } + body := map[string]interface{}{ + "job_id": tc.id, + "items": []interface{}{ + map[string]interface{}{"type": "image", "data": "eA==", "mime_type": tc.mimeType}, + }, + } + if err := artifactResult(cmd, body, cfg); err != nil { + t.Fatal(err) + } + if tc.id == "" { + if strings.Contains(stderr.String(), "response ID") { + t.Fatalf("missing ID must not add a note: %q", stderr.String()) + } + } else if want := fmt.Sprintf("response ID %q", tc.id); !strings.Contains(stderr.String(), want) { + t.Fatalf("expected %q in %q", want, stderr.String()) + } + if want := fmt.Sprintf(", %q", tc.mimeType); !strings.Contains(stderr.String(), want) { + t.Fatalf("expected quoted MIME %q in %q", want, stderr.String()) + } + lines := 1 + if strings.HasSuffix(tc.filename, ".jpg") { + lines++ + if want := fmt.Sprintf("contains %q content", tc.mimeType); !strings.Contains(stderr.String(), want) { + t.Fatalf("expected quoted mismatch MIME %q in %q", want, stderr.String()) + } + } + if strings.Count(stderr.String(), "\n") != lines || strings.Contains(stderr.String(), "\x1b") { + t.Fatalf("artifact notes must not contain unescaped control characters: %q", stderr.String()) + } + if !filepath.IsAbs(strings.TrimSpace(stdout.String())) || strings.Count(stdout.String(), "\n") != 1 { + t.Fatalf("stdout must contain only the absolute path: %q", stdout.String()) + } + }) + } +} + // apiResponseFrom stands in for the API response that delivered the URI. func apiResponseFrom(apiURL string) *http.Response { return &http.Response{Request: httptest.NewRequest(http.MethodPost, apiURL, nil)} diff --git a/internal/output/async.go b/internal/output/async.go index 117397e..e8efe49 100644 --- a/internal/output/async.go +++ b/internal/output/async.go @@ -539,7 +539,7 @@ func protocolAsyncError(cfg *asyncConfig, id, resumePrefix, message string, payl func renderAsyncError(cmd *cobra.Command, runtimeErr *asyncRuntimeError) error { jqExpr, _ := flagutil.GetStringFlag(cmd, "jq") - if !shouldRenderStructuredError(resolveOutputFormat(cmd), jqExpr) { + if !shouldRenderStructuredError(resolveErrorOutputFormat(cmd), jqExpr) { writeScrubbed(cmd.ErrOrStderr(), fmt.Sprintf("Error: %s (handle %s; resume: %s)%s", runtimeErr.message, runtimeErr.id, runtimeErr.resume, string(rune(10)))) classification := Classify(cmd, withErrorOrigin(runtimeErr, ErrorOriginCLI)) return markRendered(runtimeErr, classification) diff --git a/internal/output/classify.go b/internal/output/classify.go index 0938f13..bad0f7c 100644 --- a/internal/output/classify.go +++ b/internal/output/classify.go @@ -29,6 +29,7 @@ import ( "github.com/google-gemini/gemini-api-cli/internal/clierrors" "github.com/google-gemini/gemini-api-cli/internal/flagutil" + "github.com/google-gemini/gemini-api-cli/internal/sdk/sdkinternal/utils" "github.com/spf13/cobra" ) @@ -181,6 +182,10 @@ func Classify(cmd *cobra.Command, err error) Classification { if classification, ok := ClassificationFrom(err); ok { return classification } + var invalidPath *utils.InvalidPathParameterError + if errors.As(err, &invalidPath) { + err = flagutil.WithCLIValidation(err) + } c := Classification{ StatusCode: extractErrorStatusCode(err), diff --git a/internal/output/exitcodes.go b/internal/output/exitcodes.go index 763f407..63f4c49 100644 --- a/internal/output/exitcodes.go +++ b/internal/output/exitcodes.go @@ -220,7 +220,7 @@ func renderingMode(cmd *cobra.Command) (format, jqExpr string) { if !changed || jqExpr == "" { jqExpr = preparsedRendering.jq } - return resolveOutputFormat(cmd), jqExpr + return resolveErrorOutputFormat(cmd), jqExpr } func structuredErrorsRequested(cmd *cobra.Command) bool { diff --git a/internal/output/outcome.go b/internal/output/outcome.go index 4db7e6b..6735ce1 100644 --- a/internal/output/outcome.go +++ b/internal/output/outcome.go @@ -29,12 +29,13 @@ import ( const outcomeAnnotation = "speakeasy_outcome" type outcomeConfig struct { - StatePointer string `json:"statePointer"` - StreamStatePointer string `json:"streamStatePointer"` - States map[string]string `json:"states"` - ErrorPointer string `json:"errorPointer"` - StreamErrorPointer string `json:"streamErrorPointer"` - ErrorMessageField string `json:"errorMessageField"` + StatePointer string `json:"statePointer"` + StreamStatePointer string `json:"streamStatePointer"` + StreamStatePointers []string `json:"streamStatePointers"` + States map[string]string `json:"states"` + ErrorPointer string `json:"errorPointer"` + StreamErrorPointer string `json:"streamErrorPointer"` + ErrorMessageField string `json:"errorMessageField"` } func activeOutcome(cmd *cobra.Command) *outcomeConfig { @@ -182,7 +183,7 @@ func outcomePointer(root interface{}, pointer string) (interface{}, bool) { } func outcomeStreamFailure(cmd *cobra.Command, cfg *outcomeConfig, item interface{}, frame map[string]interface{}) *outcomeError { - if cfg == nil || cfg.StreamStatePointer == "" { + if cfg == nil { return nil } var payload interface{} = frame @@ -197,5 +198,22 @@ func outcomeStreamFailure(cmd *cobra.Command, cfg *outcomeConfig, item interface } payload = decoded } - return outcomeFailure(cmd, cfg, payload, cfg.StreamStatePointer, cfg.StreamErrorPointer) + for _, pointer := range cfg.streamStatePointers() { + if value, ok := outcomePointer(payload, pointer); ok { + if state, ok := value.(string); ok && state != "" { + return outcomeFailure(cmd, cfg, payload, pointer, cfg.StreamErrorPointer) + } + } + } + return nil +} + +func (cfg *outcomeConfig) streamStatePointers() []string { + if len(cfg.StreamStatePointers) > 0 { + return cfg.StreamStatePointers + } + if cfg.StreamStatePointer != "" { + return []string{cfg.StreamStatePointer} + } + return nil } diff --git a/internal/output/output.go b/internal/output/output.go index ac63527..999cbc5 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -121,12 +121,16 @@ func formatErrorBody(body string) string { // 2. Agent mode default (TOON) // 3. Default "pretty" func resolveOutputFormat(cmd *cobra.Command) string { + if preparsedRendering.outputFormat == "" && WantsRawResponse(cmd) { + return flagutil.ResolveOutputFormat(cmd, "json", IsAgentMode()) + } + return resolveErrorOutputFormat(cmd) +} + +func resolveErrorOutputFormat(cmd *cobra.Command) string { if format := preparsedRendering.outputFormat; format != "" { return flagutil.ResolveOutputFormat(cmd, format, IsAgentMode()) } - if WantsRawResponse(cmd) { - return flagutil.ResolveOutputFormat(cmd, "json", IsAgentMode()) - } return flagutil.ResolveOutputFormat(cmd, config.GetString("output-format"), IsAgentMode()) } @@ -589,7 +593,7 @@ func Error(cmd *cobra.Command, err error) error { if err == nil { return nil } - format := resolveOutputFormat(cmd) + format := resolveErrorOutputFormat(cmd) jqExpr, _ := flagutil.GetStringFlag(cmd, "jq") classification := Classify(cmd, withErrorOrigin(err, ErrorOriginAPI)) structured := shouldRenderStructuredError(format, jqExpr) diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 1e9626d..b14bb9a 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -21,6 +21,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "reflect" @@ -28,10 +29,54 @@ import ( "testing" "time" + "github.com/google-gemini/gemini-api-cli/internal/clierrors" "github.com/google-gemini/gemini-api-cli/internal/config" + "github.com/google-gemini/gemini-api-cli/internal/sdk/sdkinternal/utils" "github.com/spf13/cobra" ) +func TestPathParameterErrorsAreUsageErrors(t *testing.T) { + for _, format := range []string{"pretty", "json"} { + for _, wrapped := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/wrapped=%t", format, wrapped), func(t *testing.T) { + ResetAgentMode() + ResetPreparsedRendering() + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetErr(&stderr) + cmd.Flags().String("output-format", "", "") + if err := cmd.Flags().Set("output-format", format); err != nil { + t.Fatal(err) + } + var err error = &utils.InvalidPathParameterError{Name: "resource"} + if wrapped { + err = fmt.Errorf("construct request: %w", err) + } + classification := Classify(cmd, err) + if classification.Type != ErrorTypeValidation || classification.Reason != ReasonCLIValidation || classification.Origin != ErrorOriginCLI || classification.StatusCode != 0 { + t.Fatalf("unexpected classification: %+v", classification) + } + rendered := Error(cmd, err) + if clierrors.ExitCode(rendered) != clierrors.ExitUsage { + t.Fatalf("expected exit 2, got %v", rendered) + } + var invalidPath *utils.InvalidPathParameterError + if !errors.As(rendered, &invalidPath) || invalidPath.Name != "resource" { + t.Fatalf("typed path error lost during rendering: %v", rendered) + } + if got := json.Valid(stderr.Bytes()); got != (format == "json") { + t.Fatalf("wrong error format: %s", stderr.String()) + } + stderr.Reset() + untyped := Classify(cmd, errors.New(err.Error())) + if untyped.Reason == ReasonCLIValidation { + t.Fatal("an untyped error must not be classified by matching its text") + } + }) + } + } +} + type tableTestRow struct { ID int64 `json:"id"` Name string `json:"name"` @@ -1025,6 +1070,33 @@ func TestUnknownSubcommandKeepsHelpAndCompletionWorking(t *testing.T) { } } +func TestOutcomeStreamStatePointerOrder(t *testing.T) { + cmd := &cobra.Command{Use: "compose"} + cfg := &outcomeConfig{ + StreamStatePointers: []string{"/data/status", "/data/widget/status"}, + States: map[string]string{"done": "success", "failed": "failure"}, + } + for _, tc := range []struct { + name string + status interface{} + wantFailure bool + }{ + {"first wins", "done", false}, + {"empty falls through", "", true}, + {"non-string falls through", false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + frame := map[string]interface{}{"data": map[string]interface{}{ + "status": tc.status, + "widget": map[string]interface{}{"status": "failed"}, + }} + if got := outcomeStreamFailure(cmd, cfg, nil, frame); (got != nil) != tc.wantFailure { + t.Fatalf("failure = %v, want failure %v", got, tc.wantFailure) + } + }) + } +} + func TestUnknownSubcommandHonoursDisableSuggestions(t *testing.T) { root, _ := unknownCommandTestTree() group, _, _ := root.Find([]string{"errors"}) diff --git a/internal/output/streaming.go b/internal/output/streaming.go index a4d88fd..668a7c1 100644 --- a/internal/output/streaming.go +++ b/internal/output/streaming.go @@ -75,7 +75,7 @@ func streamResult(cmd *cobra.Command, res interface{}, streamFieldName string, h projector := newStreamProjector(cmd) outcomeCfg := activeOutcome(cmd) - if outcomeCfg != nil && outcomeCfg.StreamStatePointer == "" { + if outcomeCfg != nil && len(outcomeCfg.streamStatePointers()) == 0 { outcomeCfg = nil } var streamFailure *outcomeError diff --git a/internal/sdk/models/sdkerrors/cancelinteractionbyid.go b/internal/sdk/models/sdkerrors/cancelinteractionbyid.go index 6cf554c..50abbd2 100644 --- a/internal/sdk/models/sdkerrors/cancelinteractionbyid.go +++ b/internal/sdk/models/sdkerrors/cancelinteractionbyid.go @@ -33,6 +33,12 @@ type CancelInteractionByIDServerError struct { var _ error = &CancelInteractionByIDServerError{} func (e *CancelInteractionByIDServerError) Error() string { + if e != nil { + if e.ErrorInfo.Message != nil { + return *e.ErrorInfo.Message + } + } + data, _ := json.Marshal(e) return string(data) } @@ -47,6 +53,12 @@ type CancelInteractionByIDClientError struct { var _ error = &CancelInteractionByIDClientError{} func (e *CancelInteractionByIDClientError) Error() string { + if e != nil { + if e.ErrorInfo.Message != nil { + return *e.ErrorInfo.Message + } + } + data, _ := json.Marshal(e) return string(data) } diff --git a/internal/sdk/models/sdkerrors/createinteraction.go b/internal/sdk/models/sdkerrors/createinteraction.go index b96f035..abe9298 100644 --- a/internal/sdk/models/sdkerrors/createinteraction.go +++ b/internal/sdk/models/sdkerrors/createinteraction.go @@ -33,6 +33,12 @@ type CreateInteractionServerError struct { var _ error = &CreateInteractionServerError{} func (e *CreateInteractionServerError) Error() string { + if e != nil { + if e.ErrorInfo.Message != nil { + return *e.ErrorInfo.Message + } + } + data, _ := json.Marshal(e) return string(data) } @@ -47,6 +53,12 @@ type CreateInteractionClientError struct { var _ error = &CreateInteractionClientError{} func (e *CreateInteractionClientError) Error() string { + if e != nil { + if e.ErrorInfo.Message != nil { + return *e.ErrorInfo.Message + } + } + data, _ := json.Marshal(e) return string(data) } diff --git a/internal/sdk/models/sdkerrors/deleteinteraction.go b/internal/sdk/models/sdkerrors/deleteinteraction.go index e143749..7af6912 100644 --- a/internal/sdk/models/sdkerrors/deleteinteraction.go +++ b/internal/sdk/models/sdkerrors/deleteinteraction.go @@ -33,6 +33,12 @@ type DeleteInteractionServerError struct { var _ error = &DeleteInteractionServerError{} func (e *DeleteInteractionServerError) Error() string { + if e != nil { + if e.ErrorInfo.Message != nil { + return *e.ErrorInfo.Message + } + } + data, _ := json.Marshal(e) return string(data) } @@ -47,6 +53,12 @@ type DeleteInteractionClientError struct { var _ error = &DeleteInteractionClientError{} func (e *DeleteInteractionClientError) Error() string { + if e != nil { + if e.ErrorInfo.Message != nil { + return *e.ErrorInfo.Message + } + } + data, _ := json.Marshal(e) return string(data) } diff --git a/internal/sdk/models/sdkerrors/getinteractionbyid.go b/internal/sdk/models/sdkerrors/getinteractionbyid.go index 42be572..c0c61b3 100644 --- a/internal/sdk/models/sdkerrors/getinteractionbyid.go +++ b/internal/sdk/models/sdkerrors/getinteractionbyid.go @@ -33,6 +33,12 @@ type GetInteractionByIDServerError struct { var _ error = &GetInteractionByIDServerError{} func (e *GetInteractionByIDServerError) Error() string { + if e != nil { + if e.ErrorInfo.Message != nil { + return *e.ErrorInfo.Message + } + } + data, _ := json.Marshal(e) return string(data) } @@ -47,6 +53,12 @@ type GetInteractionByIDClientError struct { var _ error = &GetInteractionByIDClientError{} func (e *GetInteractionByIDClientError) Error() string { + if e != nil { + if e.ErrorInfo.Message != nil { + return *e.ErrorInfo.Message + } + } + data, _ := json.Marshal(e) return string(data) } diff --git a/internal/sdk/sdkinternal/utils/pathparams.go b/internal/sdk/sdkinternal/utils/pathparams.go index 315d87d..9b3bab2 100644 --- a/internal/sdk/sdkinternal/utils/pathparams.go +++ b/internal/sdk/sdkinternal/utils/pathparams.go @@ -28,6 +28,14 @@ import ( "github.com/google-gemini/gemini-api-cli/internal/sdk/types" ) +type InvalidPathParameterError struct { + Name string +} + +func (e *InvalidPathParameterError) Error() string { + return fmt.Sprintf("path parameter %q contains a dot segment", e.Name) +} + func GenerateURL(_ context.Context, serverURL, path string, pathParams interface{}, globals interface{}) (string, error) { uri := strings.TrimSuffix(serverURL, "/") + path @@ -47,7 +55,7 @@ func GenerateURL(_ context.Context, serverURL, path string, pathParams interface for name, value := range parsedParameters { for _, segment := range strings.Split(value, "/") { if segment == "." || segment == ".." { - return "", fmt.Errorf("path parameter %q contains a dot segment", name) + return "", &InvalidPathParameterError{Name: name} } } } diff --git a/tests/contract/cli_contract_test.go b/tests/contract/cli_contract_test.go index 79dad3a..7f8b121 100644 --- a/tests/contract/cli_contract_test.go +++ b/tests/contract/cli_contract_test.go @@ -176,7 +176,7 @@ func TestImageArtifactOutput(t *testing.T) { if strings.TrimSpace(result.stdout) != target { t.Errorf("--out path = %q, want %q (explicit path kept as given)", strings.TrimSpace(result.stdout), target) } - if !strings.Contains(result.stderr, "Note: hero.jpg contains image/png content; its extension does not match") { + if !strings.Contains(result.stderr, `Note: hero.jpg contains "image/png" content; its extension does not match`) { t.Errorf("mismatch note missing from stderr:\n%s", result.stderr) } <-requests diff --git a/tests/contract/streaming_defaults_test.go b/tests/contract/streaming_defaults_test.go index e5d132f..e7cab7c 100644 --- a/tests/contract/streaming_defaults_test.go +++ b/tests/contract/streaming_defaults_test.go @@ -447,7 +447,7 @@ func TestImageInteractivePromptsForOutputFile(t *testing.T) { if data, err := os.ReadFile(target); err != nil || len(data) != 69 { t.Errorf("written file %q: err=%v len=%d, want the 69-byte PNG", target, err, len(data)) } - if !strings.Contains(stderr, "Note: hero.jpg contains image/png content; its extension does not match") { + if !strings.Contains(stderr, `Note: hero.jpg contains "image/png" content; its extension does not match`) { t.Errorf("stderr lacks the extension-mismatch note:\n%s", stderr) }