Skip to content
Merged
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
7 changes: 4 additions & 3 deletions internal/cli/root.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -258,13 +258,14 @@ func currentOutputFormat() output.Format {
}

// marshalStructured serializes v for machine-readable output: indented JSON for
// FormatJSON (byte-compatible with the legacy --json path) and TOON via the
// toon-format encoder for FormatTOON.
// FormatJSON (byte-compatible with the legacy --json path, except that unset
// SDK timestamps now render as null instead of the bare integer 0 — see
// output.NullUnsetInstants) and TOON via the toon-format encoder for FormatTOON.
func marshalStructured(v any) ([]byte, error) {
if currentOutputFormat() == output.FormatTOON {
return toon.Marshal(v)
}
return json.MarshalIndent(v, "", " ")
return json.MarshalIndent(output.NullUnsetInstants(v), "", " ")
}

// newPrinter creates a Printer based on global flags.
Expand Down
10 changes: 7 additions & 3 deletions internal/cli/session.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"
toon "github.com/toon-format/toon-go"

"github.com/flashcatcloud/flashduty-cli/internal/output"
"github.com/flashcatcloud/flashduty-cli/internal/timeutil"
)

Expand DownExpand Up@@ -229,13 +230,16 @@ func filterSessionsSince(sessions []flashduty.SessionItem, sinceUnix int64) []fl

// writeSessionList renders the session rows in the requested format. jsonl emits
// one SessionItem per line; json emits the whole SessionListResponse envelope;
// toon emits the compact encoding of that envelope.
// toon emits the compact encoding of that envelope. The json/jsonl paths route
// through output.NullUnsetInstants so unset SDK timestamps (e.g. archived_at
// on a live session) render as null instead of the bare integer 0, matching
// every other --json surface.
func writeSessionList(w io.Writer, format string, sessions []flashduty.SessionItem, total int64) error {
switch format {
case sessionFormatJSONL:
enc := json.NewEncoder(w)
for i := range sessions {
if err := enc.Encode(sessions[i]); err != nil {
if err := enc.Encode(output.NullUnsetInstants(sessions[i])); err != nil {
return fmt.Errorf("failed to encode session: %w", err)
}
}
Expand All@@ -249,7 +253,7 @@ func writeSessionList(w io.Writer, format string, sessions []flashduty.SessionIt
if format == sessionFormatTOON {
out, err = toon.Marshal(envelope)
} else {
out, err = json.MarshalIndent(envelope, "", " ")
out, err = json.MarshalIndent(output.NullUnsetInstants(envelope), "", " ")
}
if err != nil {
return fmt.Errorf("failed to marshal sessions: %w", err)
Expand Down
56 changes: 56 additions & 0 deletions internal/cli/session_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ package cli

import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"net/http"
Expand DownExpand Up@@ -511,6 +512,61 @@ func TestCommandSessionExportMapsErrorEnvelope(t *testing.T) {
}
}

// TestWriteSessionListNullsUnsetInstants is the regression guard for the
// session-list bypass: writeSessionList marshals SDK structs directly, so
// without output.NullUnsetInstants an unset archived_at (0 = not archived)
// would leak as the bare integer 0 while a set one renders as an RFC3339
// string — the mixed-type defect, on both the json envelope and jsonl paths.
func TestWriteSessionListNullsUnsetInstants(t *testing.T) {
const archivedMs = 1779432894000
sessions := []flashduty.SessionItem{
{SessionID: "sess-live"}, // archived_at unset
{SessionID: "sess-arch", ArchivedAt: flashduty.TimestampMilli(archivedMs)}, // archived
}

t.Run("json envelope", func(t *testing.T) {
var buf bytes.Buffer
if err := writeSessionList(&buf, sessionFormatJSON, sessions, 2); err != nil {
t.Fatalf("writeSessionList(json): %v", err)
}
var envelope struct {
Sessions []map[string]any `json:"sessions"`
}
if err := json.Unmarshal(buf.Bytes(), &envelope); err != nil {
t.Fatalf("json output is not valid JSON: %v\n%s", err, buf.String())
}
if v := envelope.Sessions[0]["archived_at"]; v != nil {
t.Errorf("live session archived_at = %#v, want nil (JSON null)", v)
}
if v, ok := envelope.Sessions[1]["archived_at"].(string); !ok {
t.Errorf("archived session archived_at = %#v, want RFC3339 string", envelope.Sessions[1]["archived_at"])
} else if _, err := time.Parse(time.RFC3339, v); err != nil {
t.Errorf("archived_at = %q, not RFC3339: %v", v, err)
}
})

t.Run("jsonl", func(t *testing.T) {
var buf bytes.Buffer
if err := writeSessionList(&buf, sessionFormatJSONL, sessions, 2); err != nil {
t.Fatalf("writeSessionList(jsonl): %v", err)
}
lines := nonEmptyLines(buf.String())
if len(lines) != 2 {
t.Fatalf("expected 2 jsonl lines, got %d:\n%s", len(lines), buf.String())
}
var live map[string]any
if err := json.Unmarshal([]byte(lines[0]), &live); err != nil {
t.Fatalf("line 0 is not valid JSON: %v", err)
}
if v := live["archived_at"]; v != nil {
t.Errorf("live session archived_at = %#v, want nil (JSON null)", v)
}
if strings.Contains(lines[0], `"archived_at":0`) {
t.Errorf("jsonl line leaked the bare integer 0: %s", lines[0])
}
})
}

func nonEmptyLines(s string) []string {
var out []string
for _, l := range strings.Split(s, "\n") {
Expand Down
8 changes: 4 additions & 4 deletions internal/cli/zz_generated_a2a_agents.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion internal/cli/zz_generated_account.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading