fix(orbit): stream raw response body in 'remote query'
What does this MR do and why?
Fixes a regression on the default code path of glab orbit remote query: every query that uses the default (or explicit) llm response_format fails with
Invalid character '@' looking for beginning of value.in glab v1.97.0, regardless of query correctness. This is a different defect from the one fixed by !3241 (merged) — that MR addressed @ on the input side; this one addresses @ on the response side.
Symptom
cat > /tmp/q.json <<'JSON'
{"query":{"query_type":"traversal","node":{"id":"p","entity":"Project","filters":{"id":{"op":"eq","value":278964}}},"limit":1}}
JSON
glab orbit remote query /tmp/q.json
# ERROR Invalid character '@' looking for beginning of value.The --format raw path happened to work — but only by coincidence: the server happens to return JSON in that case.
Root cause
For response_format=llm, the Orbit API returns GOON/TOON text (Content-Type: text/plain; charset=utf-8) whose first byte is @ (header marker):
$ glab api -X POST orbit/query --header "Content-Type: application/json" --input /tmp/llm.json
@header
query_type:traversal
goon_version:1.0.0
nodes:1
edges:0
@nodes
Project(1):
278964 name=GitLab
@edgesThe previous CLI path went through client.Lab().Orbit.Query(...) in the client-go SDK, which uses do[*OrbitQueryResult] → client.Do(req, &OrbitQueryResult{}) → json.NewDecoder(resp.Body).Decode(...). That decoder rejects the leading @ with invalid character '@' looking for beginning of value, which Fang's error renderer sentence-cases to Invalid character '@' ....
!3241 (merged) documented and tightened the input-side path so a stray @ outside a string literal in the user's request body produces a helpful hint pointing at jq and clarifying that @ inside string values is fine. None of that touches the response-side decode, which is why the failure persisted in v1.97.0.
Fix
Bypass the typed OrbitService.Query helper and stream the response body verbatim:
client.Lab().NewRequest(POST, "orbit/query", req, ...)builds the request (Content-Type, auth, and User-Agent are still set automatically).client.Lab().Do(httpReq, &bytes.Buffer{})instructs the SDK toio.Copythe body into the buffer instead of decoding it as JSON. Thego-gitlabClient.Doswitch-cases onv's type: whenvis anio.Writer, the body is copied verbatim (seegitlab.goline 1247-1249).- The buffer is written to stdout.
Both formats now round-trip the server's bytes unchanged. orbiterr.Translate continues to map non-2xx responses (401/403/404/429/4xx/5xx) to the existing exit codes — that path runs before the body decode in client.Do.
Why bypass the typed SDK call
The typed *OrbitQueryResult shape only makes sense for response_format=raw (JSON envelope). For llm, the body is not JSON at all, so any client-side decode is wrong by construction. The CLI's job is to forward the server's payload (raw JSON or GOON text) to the user — exactly what the new code does. The MR description on !3241 (merged) already declared the contract:
The body is not preprocessed before JSON parsing: bytes inside JSON string literals (including
@) are forwarded verbatim.
This MR extends the same "forward verbatim" guarantee to the response side.
Tests
The mock-based tests in this package previously asserted on the gitlabtesting.MockOrbit.Query interceptor; that mock cannot exercise the JSON-decoding behaviour because it short-circuits the wire-format path entirely. They are rewritten on top of httptest.NewServer, so the test handler observes the exact request the CLI sends and controls the exact response bytes the CLI consumes. This matches the existing convention in internal/commands/search/semantic/semantic_test.go.
New regressions:
TestQuery_LLMResponseStreamedVerbatim— sends a multi-line GOON body starting with@headerand asserts byte-equality with stdout. Locks in that no JSON decode of the response is attempted.TestQuery_RawResponseStreamedVerbatim— sends a compactrawJSON envelope and asserts byte-equality with stdout. Guards against any future client-side re-marshalling that would reorder keys or change whitespace.
All previously existing scenarios are preserved in the rewritten tests: flag-vs-body precedence (TestQuery_FlagOverridesBodyResponseFormat, TestQuery_BodyFormatHonoredWhenNoFlag), file vs stdin paths, @-in-string-literal preservation (TestQuery_FromFile_WithAtInStrings, TestQuery_Stdin_WithAtInStrings), stray-@ error hint, BOM stripping, and 401 → ExitUnauthenticated mapping.
How to set up and validate locally
Click to expand
# 1. Default `llm` format now works (was broken before this MR).
cat > /tmp/q.json <<'JSON'
{"query":{"query_type":"traversal","node":{"id":"p","entity":"Project","filters":{"id":{"op":"eq","value":278964}}},"limit":1}}
JSON
./bin/glab orbit remote query /tmp/q.json
# → @header
# query_type:traversal
# ...
# 2. --format raw still works unchanged.
./bin/glab orbit remote query --format raw /tmp/q.json
# → {"result":{"format_version":"2.0.0",...},"query_type":"traversal","row_count":1}
# 3. !3241's input-side error hint still fires for stray `@`.
cat > /tmp/bad.json <<'JSON'
{"query": @variable}
JSON
./bin/glab orbit remote query /tmp/bad.json
# → Query body is not valid JSON: stray '@' outside a string literal
# at byte 11 (1-indexed)...
# 4. Run the new tests
cd ~/workspace/cli
go test ./internal/commands/orbit/remote/query/...E2E test results (production gitlab.com, knowledge_graph FF enabled)
glab column is unpatched v1.97.0; patched is this branch built locally.
| # | Test | Command | glab v1.97.0 | patched | Notes |
|---|---|---|---|---|---|
| 1 | Project lookup, default (llm) | glab orbit remote query q.json |
Invalid character '@' |
The bug | |
| 2 | Project lookup, --format raw |
glab orbit remote query --format raw q.json |
Already worked | ||
| 3 | Multi-node traversal, default (llm) | 2-node MR-in-Project traversal | Invalid character '@' |
||
| 4 | Multi-node traversal, --format raw |
same as #3 (closed) with --format raw |
|||
| 5 | Aggregation, default (llm) | count(MergeRequest) where IN_PROJECT(GitLab) | Invalid character '@' |
||
| 6 | Aggregation, --format raw |
same as #5 (closed) with --format raw |
mr_count: 230095 |
||
| 7 | Input has @ in JSON string literal, default |
username: "@dgruzd", default format |
Invalid character '@' |
Result-side bug, not input | |
| 8 | Input has @ in JSON string literal, raw |
same as #7 (closed) with --format raw |
|||
| 9 | --format llm overrides body response_format=raw |
--format llm flag wins |
|||
| 10 | Body response_format: llm (no flag) |
flag absent, body picks llm | |||
| 11 | Body response_format: raw (no flag) |
flag absent, body picks raw | |||
| 12 | Stray @ outside string literal |
{"query": @variable} |
Input-side error path | ||
| 13 | HTTP 400 schema error | bad neighbors body | orbiterr.Translate |
¹ The !3241 (merged) input-side hint is only present in builds containing commit 1a815c8 (merged after v1.97.0 was tagged). v1.97.0 itself shows only the bare stdlib message for stray @; the patched build here is rebased on top of !3241 (merged) so the helpful hint is included for completeness.
Bytes-on-the-wire sanity: for the same 2-node multi-node query, llm returns 465 bytes of GOON vs 697 bytes of raw JSON — a 33% size reduction. Streaming the body verbatim preserves that token savings for agents.
Compatibility
No CLI surface changes: same command, same flags, same exit codes. Behaviour change is strictly additive — the previously-broken llm path now returns a response body instead of a stdlib decoder error.
The Long help has been updated to spell out the new contract ("server response is written to stdout verbatim regardless of format — no client-side decoding or re-encoding is performed") and is regenerated via make gen-docs.
/cc @phikai