Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 105 additions & 11 deletions internal/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package output

import (
"bytes"
"encoding/json"
"fmt"
"io"
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
167 changes: 167 additions & 0 deletions internal/output/toon_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading