From 9a9d1f7edbc111c592b4b21fa8f0e52c1440a0f9 Mon Sep 17 00:00:00 2001 From: Bradenream <51544548+Bradenream@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:33:13 +0000 Subject: [PATCH] fix: encode TOON from the JSON form, so agents see what the API returned (COR-14205) (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary TOON is the default output in agent mode (`CLAUDECODE`, `CURSOR_AGENT`, …), so it is what every coding agent reads from `vf`. `output.Result` handed the SDK's Go values straight to `gotoon.Encode`, which walks them by reflection and gets the SDK's types wrong in four ways: - **The key is the whole json tag.** `agent get` showed 42 distinct keys like `"instructions,omitzero"`. - **Optional-nullable fields render as `null` even when set.** They are a `map[bool]*T` underneath, and gotoon turns a map with non-string keys into null. `agent get --include-instructions` returned 11,934 characters of instructions as JSON and `null` as TOON, so agents never saw the instructions. - **Unions render as their Go wrapper** (`StableToolV2API: null`, `StableToolV2Function: {…}`) instead of the member the API returned. - **`omitempty` and `omitzero` are ignored.** Every unset union member is printed, so `document list` came to 4.9 MB of TOON against 1.27 MB of JSON. The `--include-headers` path already avoided all of this by encoding the JSON form. A shared `jsonValue` helper now does the same for plain TOON: marshal with the SDK's JSON rules, decode into plain maps and slices, then encode. TOON carries exactly what `--output-format json` does. JSON, YAML, table and pretty output are unchanged. ## Before and after Measured live and read-only on a real project. | | master | this PR | |---|---|---| | `agent get`: keys with tag options | 42 | 0 | | `agent get --include-instructions`: instructions | `null` | present (11,934 chars) | | `tool list --global`: shape | Go wrappers | the API's shape | | `document list`: TOON size (JSON is 1.27 MB) | 4.9 MB | 1.4 MB | | `document list`: run time | ~1.1 s | ~1.1 s | ## Test plan - [x] `gofmt`, `go vet ./...` and `go test ./...` pass; `go.mod` is unchanged. - [x] `internal/output/toon_test.go`: 4 Go tests on real SDK types. They cover plain keys, a set optional-nullable field, a flat union, and TOON equal to the JSON output re-encoded. All 4 fail on master and pass here. - [x] `test/toon-output.test.ts`: 3 hermetic cases on the built binary. They run with a mock server, isolated HOME and agent mode. All 3 fail with master's binary and pass here. - [x] Live before and after on a real project, as in the table. - [ ] CI The 4 vitest cases that already fail on master (`docs-command` ×2, `flag-errors`, `flag-raw-text`) still fail here and are unrelated. Fixes COR-14205 --- internal/output/output.go | 116 +++++++++++++++++++++--- internal/output/toon_test.go | 167 +++++++++++++++++++++++++++++++++++ test/toon-output.test.ts | 104 ++++++++++++++++++++++ 3 files changed, 376 insertions(+), 11 deletions(-) create mode 100644 internal/output/toon_test.go create mode 100644 test/toon-output.test.ts diff --git a/internal/output/output.go b/internal/output/output.go index 9e617b7..05bed52 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -5,6 +5,7 @@ package output import ( + "bytes" "encoding/json" "fmt" "io" @@ -258,7 +259,12 @@ func Result(cmd *cobra.Command, res interface{}) error { return err } case "toon": - toonStr, err := gotoon.Encode(content) + // Encode the JSON form, not the Go value; see toonValue. + value, err := toonValue(content) + if err != nil { + return err + } + toonStr, err := gotoon.Encode(value) if err != nil { return fmt.Errorf("failed to encode response as TOON: %w", err) } @@ -271,6 +277,95 @@ func Result(cmd *cobra.Command, res interface{}) error { return nil } +// jsonValue returns content in the form the json output format renders it, +// decoded into plain maps, slices and scalars. Numbers decode to float64, as +// encoding/json decodes them. +// +// Encoders that walk Go values by reflection get the SDK's types wrong. +// gotoon uses a json tag verbatim as the key ("instructions,omitzero"), +// ignores omitempty and omitzero, turns an optional-nullable field (a +// map[bool]*T) into null even when it is set, and prints a union's Go wrapper +// fields instead of the member the API returned. The SDK's own JSON marshaling +// gets all of these right, so encoders work from its output. +func jsonValue(content interface{}) (interface{}, error) { + if content == nil { + return nil, nil + } + data, err := marshalJSON(content) + if err != nil { + return nil, err + } + var value interface{} + if err := json.Unmarshal(data, &value); err != nil { + return nil, fmt.Errorf("failed to decode response JSON: %w", err) + } + return value, nil +} + +// toonValue is jsonValue for the TOON encoder, which holds every number as a +// float64. A float64 cannot hold every integer beyond ±(2^53−1): 2^53+1 +// silently becomes 2^53. The TOON spec requires encode then decode to return +// the same value, and for integers outside the encoder's numeric domain it +// prescribes a quoted decimal string (Appendix E), so those keep every digit +// as a string instead of being rounded. +func toonValue(content interface{}) (interface{}, error) { + if content == nil { + return nil, nil + } + data, err := marshalJSON(content) + if err != nil { + return nil, err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var value interface{} + if err := decoder.Decode(&value); err != nil { + return nil, fmt.Errorf("failed to decode response JSON: %w", err) + } + return toonNumbers(value), nil +} + +// maxSafeInteger is the largest integer n for which n and every integer below +// it have an exact float64: 2^53−1, JavaScript's Number.MAX_SAFE_INTEGER. +const maxSafeInteger = 1<<53 - 1 + +// toonNumbers replaces each json.Number in a decoded JSON tree with the value +// the TOON encoder can represent exactly. +func toonNumbers(value interface{}) interface{} { + switch v := value.(type) { + case map[string]interface{}: + for key, item := range v { + v[key] = toonNumbers(item) + } + return v + case []interface{}: + for i, item := range v { + v[i] = toonNumbers(item) + } + return v + case json.Number: + return toonNumber(v) + } + return value +} + +// toonNumber is a safe integer as a float64, an integer beyond that range as +// its exact decimal digits, and any other number as the float64 it was +// marshaled from. +func toonNumber(n json.Number) interface{} { + literal := n.String() + if !strings.ContainsAny(literal, ".eE") { + if i, err := strconv.ParseInt(literal, 10, 64); err == nil && i >= -maxSafeInteger && i <= maxSafeInteger { + return float64(i) + } + return literal + } + if f, err := n.Float64(); err == nil { + return f + } + return literal +} + // Error handles SDK errors, outputting structured JSON when --output-format=json, // --jq, or agent mode is active. Always returns the error for non-zero exit // code. For non-JSON output modes outside agent mode, returns the error as-is @@ -636,16 +731,15 @@ func injectHeaders(data interface{}, headers http.Header) interface{} { // and outputs in the specified format. This is the common path for --include-headers // in json, yaml, toon, and jq output modes. func outputWithHeaders(out io.Writer, content interface{}, headers http.Header, format, jqExpr string, colorize bool) error { - // Marshal content to JSON for a uniform representation - var parsed interface{} - if content != nil { - data, err := marshalJSON(content) - if err != nil { - return err - } - if err := json.Unmarshal(data, &parsed); err != nil { - return err - } + // Marshal content to JSON for a uniform representation. TOON decodes + // numbers its own way; see toonValue. + decode := jsonValue + if format == "toon" && jqExpr == "" { + decode = toonValue + } + parsed, err := decode(content) + if err != nil { + return err } merged := injectHeaders(parsed, headers) diff --git a/internal/output/toon_test.go b/internal/output/toon_test.go new file mode 100644 index 0000000..bc7906b --- /dev/null +++ b/internal/output/toon_test.go @@ -0,0 +1,167 @@ +package output + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/alpkeskin/gotoon" + "github.com/spf13/cobra" + + "github.com/voiceflow/cli/internal/sdk/models/components" + "github.com/voiceflow/cli/internal/sdk/models/operations" +) + +// render runs Result the way a command does, with the output flags the root +// command registers, and returns what it printed. +func render(t *testing.T, format string, res interface{}) string { + t.Helper() + return renderWith(t, format, false, res) +} + +func renderWith(t *testing.T, format string, includeHeaders bool, res interface{}) string { + t.Helper() + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("output-format", "pretty", "") + cmd.Flags().String("color", "never", "") + cmd.Flags().String("jq", "", "") + cmd.Flags().Bool("include-headers", false, "") + if err := cmd.Flags().Set("output-format", format); err != nil { + t.Fatal(err) + } + if includeHeaders { + if err := cmd.Flags().Set("include-headers", "true"); err != nil { + t.Fatal(err) + } + } + var out bytes.Buffer + cmd.SetOut(&out) + if err := Result(cmd, res); err != nil { + t.Fatalf("Result(%s): %v", format, err) + } + return out.String() +} + +// agentResponse is an agent get response whose instructions are set. The +// instructions are an optional-nullable field, a map[bool]*string underneath. +func agentResponse() operations.StableAgentControllerGetV2Response { + instructions := "Route refund questions to Refunds." + prompt := "You are Nova, the returns assistant." + agent := components.StableAgentReadV2{Prompt: &prompt, PromptLineCount: 1, InstructionsLineCount: 1} + agent.Instructions.Set(&instructions) + return operations.StableAgentControllerGetV2Response{ + StableAgentResponseV2: &components.StableAgentResponseV2{Agent: agent}, + } +} + +func toolListResponse(t *testing.T) operations.StableToolControllerListV2Response { + t.Helper() + var tool components.StableToolV2 + raw := `{"type":"function","id":"tool-1","functionID":"fn-1","description":"Look up an order",` + + `"createdAt":"2026-09-01T00:00:00Z","updatedAt":"2026-09-02T00:00:00Z","asyncExecution":false,` + + `"inputVariables":{},"captureResponse":{},"captureInputVariables":{},"messages":null}` + if err := json.Unmarshal([]byte(raw), &tool); err != nil { + t.Fatal(err) + } + return operations.StableToolControllerListV2Response{ + StableToolListResponseV2: &components.StableToolListResponseV2{Tools: []components.StableToolV2{tool}}, + } +} + +func TestTOONKeysCarryNoTagOptions(t *testing.T) { + out := render(t, "toon", agentResponse()) + if strings.Contains(out, ",omit") { + t.Fatalf("a key carries a json tag option:\n%s", out) + } + if !strings.Contains(out, "prompt:") { + t.Fatalf("want a plain prompt key:\n%s", out) + } +} + +func TestTOONShowsOptionalNullableFields(t *testing.T) { + out := render(t, "toon", agentResponse()) + if !strings.Contains(out, "Route refund questions to Refunds.") { + t.Fatalf("the instructions were set but did not render:\n%s", out) + } +} + +func TestTOONRendersUnionsAsTheAPIShapesThem(t *testing.T) { + out := render(t, "toon", toolListResponse(t)) + if strings.Contains(out, "StableToolV2") || strings.Contains(out, "UnknownRaw") { + t.Fatalf("the union's Go wrapper leaked into the output:\n%s", out) + } + for _, want := range []string{"functionID: fn-1", "type: function"} { + if !strings.Contains(out, want) { + t.Errorf("want %q in:\n%s", want, out) + } + } +} + +// TestTOONKeepsIntegersBeyondTheSafeRangeExact: gotoon holds numbers as +// float64, which rounds 2^53+1 to 2^53. Past ±(2^53−1) an integer is written +// as a quoted decimal string, as the TOON spec prescribes; within it, as a +// number. +func TestTOONKeepsIntegersBeyondTheSafeRangeExact(t *testing.T) { + res := agentResponse() + res.StableAgentResponseV2.Agent.PromptLineCount = 9007199254740993 // 2^53+1 + res.StableAgentResponseV2.Agent.InstructionsLineCount = 9007199254740991 // 2^53−1 + + for name, out := range map[string]string{ + "toon": render(t, "toon", res), + "toon --include-headers": renderWith(t, "toon", true, res), + } { + if !strings.Contains(out, `promptLineCount: "9007199254740993"`) { + t.Errorf("%s: an integer past the safe range must keep every digit:\n%s", name, out) + } + if strings.Contains(out, "9007199254740992") { + t.Errorf("%s: 2^53+1 was rounded to 2^53:\n%s", name, out) + } + if !strings.Contains(out, "instructionsLineCount: 9007199254740991") { + t.Errorf("%s: a safe integer must stay a number:\n%s", name, out) + } + } +} + +func TestToonNumber(t *testing.T) { + cases := []struct { + in string + want interface{} + }{ + {"65", float64(65)}, + {"-0", float64(0)}, + {"9007199254740991", float64(9007199254740991)}, + {"-9007199254740991", float64(-9007199254740991)}, + {"9007199254740992", "9007199254740992"}, + {"-9007199254740993", "-9007199254740993"}, + {"123456789012345678901234567890", "123456789012345678901234567890"}, + {"0.25", 0.25}, + {"1e+21", 1e21}, + } + for _, c := range cases { + if got := toonNumber(json.Number(c.in)); got != c.want { + t.Errorf("toonNumber(%s) = %#v, want %#v", c.in, got, c.want) + } + } +} + +// TestTOONIsTheJSONOutputReencoded holds the two formats together: TOON must +// carry exactly what the JSON output does, only encoded differently. +func TestTOONIsTheJSONOutputReencoded(t *testing.T) { + for name, res := range map[string]interface{}{ + "agent get": agentResponse(), + "tool list": toolListResponse(t), + } { + var fromJSON interface{} + if err := json.Unmarshal([]byte(render(t, "json", res)), &fromJSON); err != nil { + t.Fatalf("%s: json output does not parse: %v", name, err) + } + want, err := gotoon.Encode(fromJSON) + if err != nil { + t.Fatal(err) + } + if got := render(t, "toon", res); got != want { + t.Errorf("%s: TOON differs from the JSON output re-encoded\n got:\n%s\nwant:\n%s", name, got, want) + } + } +} diff --git a/test/toon-output.test.ts b/test/toon-output.test.ts new file mode 100644 index 0000000..350c049 --- /dev/null +++ b/test/toon-output.test.ts @@ -0,0 +1,104 @@ +// Tests for TOON output: the default format in agent mode, so what every AI +// coding agent reads. +// +// TOON used to be encoded from the SDK's Go values by reflection, which put +// json tag options into keys ("instructions,omitzero"), printed optional +// fields as null even when they were set — the agent's instructions among +// them — and showed union types as their Go wrappers. It is now encoded from +// the same JSON the json format prints. +// +// Every case runs the real binary with an isolated HOME against a mock server +// on loopback. Requires: go build -o vf ./cmd/vf + +import { execa } from 'execa'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const VF = path.resolve(__dirname, '..', 'vf'); + +// Every variable that puts the CLI into agent mode; see flag-errors.test.ts. +const AGENT_ENV_VARS = [ + 'CLAUDECODE', 'CLAUDE_CODE', 'CURSOR_AGENT', 'CODEX', 'AIDER', 'CLINE', + 'WINDSURF_AGENT', 'GITHUB_COPILOT', 'AMAZON_Q', 'GEMINI_CODE_ASSIST', + 'SRC_CODY', 'FORCE_AGENT_MODE', +]; + +const PROJECT = ['--project-id', '0123456789abcdef01234567', '--environment-alias', 'main']; +const INSTRUCTIONS = 'Route refund questions to the Refunds playbook.'; + +const FIXTURES: Record = { + '/v2/stable/agent': { + agent: { + llm: { defaults: { model: 'voiceflow-core-4.1' } }, + prompt: 'You are Nova, the returns assistant.', + instructions: INSTRUCTIONS, + includeGuidelines: false, + promptLineCount: 1, + instructionsLineCount: 1, + playbooks: [], + workflows: [], + pathToolOrder: [], + }, + }, + '/v2/stable/tool': { + tools: [ + { + type: 'function', id: 'tool-1', functionID: 'fn-1', description: 'Look up an order', + createdAt: '2026-09-01T00:00:00.000Z', updatedAt: '2026-09-02T00:00:00.000Z', + asyncExecution: false, inputVariables: {}, captureResponse: {}, captureInputVariables: {}, messages: null, + }, + ], + }, +}; + +let server: http.Server; +let serverURL: string; +let home: string; + +beforeAll(async () => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'vf-toon-home-')); + server = http.createServer((req, res) => { + const fixture = FIXTURES[new URL(req.url ?? '/', 'http://mock').pathname]; + res.writeHead(fixture ? 200 : 404, { 'content-type': 'application/json' }); + res.end(JSON.stringify(fixture ?? { statusCode: 404, message: 'Not found' })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + serverURL = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(home, { recursive: true, force: true }); +}); + +function run(args: string[]) { + const env: Record = Object.fromEntries(AGENT_ENV_VARS.map((name) => [name, undefined])); + Object.assign(env, { HOME: home, VF_TOKEN: 'vfp_test', CLAUDECODE: '1' }); + return execa({ reject: false, timeout: 20_000, stdin: 'ignore', env, extendEnv: true })(VF, [...args, '--server-url', serverURL]); +} + +describe('TOON output in agent mode', () => { + it('is the default, and its keys carry no json tag options', async () => { + const result = await run(['agent', 'get', ...PROJECT, '--include-instructions']); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain('prompt:'); // TOON, not JSON + expect(result.stdout).not.toContain(',omit'); + }); + + it('shows optional fields that are set — the agent instructions among them', async () => { + const result = await run(['agent', 'get', ...PROJECT, '--include-instructions', '--output-format', 'toon']); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain(INSTRUCTIONS); + }); + + it('renders a union as the API returns it, not as its Go wrapper', async () => { + const result = await run(['tool', 'list', ...PROJECT, '--global', '--output-format', 'toon']); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain('functionID: fn-1'); + expect(result.stdout).not.toMatch(/StableToolV2|UnknownRaw/); + }); +});