Skip to content

Commit 4e42558

Browse files
authored
fix(session): unify session output control onto global --output-format (#31)
`session list` and `session export` defined a bespoke local --format flag (jsonl|json|toon) while every other command uses the inherited global --output-format (+--json) flag. Because --output-format is persistent it showed up on both session commands too, where it was silently ignored — an agent that reached for the conventional `--output-format json` got jsonl back and thrashed. Remove the bespoke --format flag. Session commands now resolve --output-format themselves via resolveSessionFormat (default jsonl, accepts jsonl|json|toon). jsonl is not a value the global table|json|toon resolver accepts, so the two session commands carry an ownsOutputFormat annotation that exempts them from the root PersistentPreRunE strict check; they validate their own set in RunE. The global resolver is unchanged for every other command. --output-format's shell-completion is overridden per session command to advertise the session set. session export now honors json/toon (buffer the NDJSON stream into a JSON array / TOON document); jsonl and no-flag keep streaming line-by-line so a huge transcript never lands in memory.
1 parent 8cad69d commit 4e42558

4 files changed

Lines changed: 310 additions & 37 deletions

File tree

internal/cli/command_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,23 @@ func saveAndResetGlobals(t *testing.T) {
2323
origFlagNoTrunc := flagNoTrunc
2424
origFlagAppKey := flagAppKey
2525
origFlagBaseURL := flagBaseURL
26+
origFlagOutputFormat := flagOutputFormat
2627
origStdinReader := stdinReader
2728

2829
// Reset to defaults so tests start clean.
2930
flagJSON = false
3031
flagNoTrunc = false
3132
flagAppKey = ""
3233
flagBaseURL = ""
34+
flagOutputFormat = ""
3335

3436
t.Cleanup(func() {
3537
newClientFn = origNewClientFn
3638
flagJSON = origFlagJSON
3739
flagNoTrunc = origFlagNoTrunc
3840
flagAppKey = origFlagAppKey
3941
flagBaseURL = origFlagBaseURL
42+
flagOutputFormat = origFlagOutputFormat
4043
stdinReader = origStdinReader
4144
})
4245
}

internal/cli/root.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,15 @@ var rootCmd = &cobra.Command{
4040
SilenceUsage: true,
4141
SilenceErrors: true,
4242
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
43-
if _, err := resolveOutputFormat(); err != nil {
44-
return err
43+
// Commands carrying ownsOutputFormat resolve --output-format against
44+
// their own value set (e.g. session commands accept jsonl, which the
45+
// global table|json|toon enum rejects). Skip the strict global check
46+
// for them; they validate in their own RunE. Every other command still
47+
// fails fast here on a bad --output-format.
48+
if cmd.Annotations[ownsOutputFormat] != "true" {
49+
if _, err := resolveOutputFormat(); err != nil {
50+
return err
51+
}
4552
}
4653
if cmd.CommandPath() == "flashduty update" {
4754
return nil
@@ -185,6 +192,13 @@ func loadResolvedConfig() (*config.Config, error) {
185192
return cfg, nil
186193
}
187194

195+
// ownsOutputFormat is a command annotation key. A command sets it to "true" to
196+
// declare that it resolves --output-format itself (against a command-specific
197+
// value set) instead of through the global table|json|toon resolver. The root
198+
// PersistentPreRunE skips its strict --output-format validation for such
199+
// commands so a session-only value like jsonl is not rejected before RunE runs.
200+
const ownsOutputFormat = "owns-output-format"
201+
188202
// resolveOutputFormat maps the global flags to an output.Format. --output-format
189203
// wins when set; otherwise --json selects JSON; otherwise the human table view.
190204
// An unrecognized --output-format value is an error so a typo fails fast rather

internal/cli/session.go

Lines changed: 113 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,40 @@ func newSessionCmd() *cobra.Command {
2727
return cmd
2828
}
2929

30-
// sessionListFormats are the output shapes 'session list' supports. jsonl (one
31-
// SessionItem JSON object per line) is the default because the rows feed
32-
// line-oriented downstream tooling (the /insight skill streams them through jq);
33-
// json emits the whole SessionListResponse envelope; toon is the compact,
34-
// fewer-tokens encoding.
30+
// session commands accept these output shapes via the global --output-format
31+
// flag (and its --json alias). jsonl (one SessionItem JSON object per line) is
32+
// the default because the rows feed line-oriented downstream tooling (the
33+
// /insight skill streams them through jq); json emits the whole
34+
// SessionListResponse envelope; toon is the compact, fewer-tokens encoding.
35+
//
36+
// jsonl is NOT a value the global table|json|toon resolver accepts, so session
37+
// commands carry the ownsOutputFormat annotation and resolve the flag here via
38+
// resolveSessionFormat instead of through resolveOutputFormat.
3539
const (
3640
sessionFormatJSONL = "jsonl"
3741
sessionFormatJSON = "json"
3842
sessionFormatTOON = "toon"
3943
)
4044

45+
// resolveSessionFormat maps the global --output-format / --json flags to a
46+
// session output shape, defaulting to jsonl. Unlike the account-wide resolver
47+
// it accepts jsonl (and rejects table, which is meaningless for the bulk
48+
// streaming rows these commands emit). An unrecognized value errors so a typo
49+
// fails fast rather than silently falling back.
50+
func resolveSessionFormat() (string, error) {
51+
switch f := strings.ToLower(strings.TrimSpace(flagOutputFormat)); f {
52+
case sessionFormatJSONL, sessionFormatJSON, sessionFormatTOON:
53+
return f, nil
54+
case "":
55+
if flagJSON {
56+
return sessionFormatJSON, nil
57+
}
58+
return sessionFormatJSONL, nil
59+
default:
60+
return "", fmt.Errorf("invalid --output-format %q (want jsonl, json, or toon)", flagOutputFormat)
61+
}
62+
}
63+
4164
// sessionPageLimit is the largest per-page Limit the /safari/session/list
4265
// handler accepts. The server validates limit with binding "lte=100": a
4366
// limit > 100 is a hard 400 bind failure, NOT a clamp, so every page request
@@ -51,7 +74,6 @@ func newSessionListCmd() *cobra.Command {
5174
scope string
5275
status string
5376
since string
54-
format string
5577
teamID int64
5678
limit int
5779
page int
@@ -60,6 +82,9 @@ func newSessionListCmd() *cobra.Command {
6082
cmd := &cobra.Command{
6183
Use: "list",
6284
Short: "List agent sessions",
85+
// Resolve --output-format ourselves: jsonl is the default and is not a
86+
// value the global table|json|toon resolver accepts.
87+
Annotations: map[string]string{ownsOutputFormat: "true"},
6388
Long: curatedLong(
6489
"List agent sessions visible to the caller, newest first. Reads are scoped to the "+
6590
"person the app_key resolves to within its account.\n\n"+
@@ -68,15 +93,14 @@ func newSessionListCmd() *cobra.Command {
6893
"updated_at after fetching. --team-id restricts to one team (sets team_ids); --scope "+
6994
"chooses the visibility bucket (all = own + member-teams, the default). Output is "+
7095
"newline-delimited JSON (jsonl) by default so rows pipe straight into jq; use "+
71-
"--format json for the full envelope or --format toon for the compact encoding.",
96+
"--output-format json for the full envelope or --output-format toon for the compact "+
97+
"encoding.",
7298
"Sessions", "List"),
7399
RunE: func(cmd *cobra.Command, args []string) error {
74100
return runCommand(cmd, args, func(ctx *RunContext) error {
75-
format = strings.ToLower(strings.TrimSpace(format))
76-
switch format {
77-
case sessionFormatJSONL, sessionFormatJSON, sessionFormatTOON:
78-
default:
79-
return fmt.Errorf("invalid --format %q (want jsonl, json, or toon)", format)
101+
format, err := resolveSessionFormat()
102+
if err != nil {
103+
return err
80104
}
81105

82106
var sinceUnix int64
@@ -121,8 +145,10 @@ func newSessionListCmd() *cobra.Command {
121145
cmd.Flags().Int64Var(&teamID, "team-id", 0, "Restrict to one team ID")
122146
cmd.Flags().IntVar(&limit, "limit", 200, "Max sessions to fetch; fetched across multiple 100-row server pages as needed")
123147
cmd.Flags().IntVar(&page, "page", 1, "1-based page to start paginating from")
124-
cmd.Flags().StringVar(&format, "format", sessionFormatJSONL, "Output format: jsonl (default), json, or toon")
125-
registerEnumFlag(cmd, "format", sessionFormatJSONL, sessionFormatJSON, sessionFormatTOON)
148+
// --output-format is the inherited global flag; session commands accept
149+
// jsonl (default), json, or toon. Override its completion so it advertises
150+
// the session set, not the global table|json|toon.
151+
registerEnumFlag(cmd, "output-format", sessionFormatJSONL, sessionFormatJSON, sessionFormatTOON)
126152

127153
return cmd
128154
}
@@ -259,16 +285,27 @@ func buildSessionExportCmd(use string) *cobra.Command {
259285
cmd := &cobra.Command{
260286
Use: use,
261287
Short: "Stream a session's full event transcript as NDJSON",
288+
// Resolve --output-format ourselves: jsonl is the default and is not a
289+
// value the global table|json|toon resolver accepts.
290+
Annotations: map[string]string{ownsOutputFormat: "true"},
262291
Long: "Stream one session's full event transcript as newline-delimited JSON (NDJSON) to stdout.\n\n" +
263292
"The first line is always a session_meta envelope; each subsequent line is one event\n" +
264293
"(user_message, llm_call, tool_call, subagent_dispatch, final_answer, agent_text, error).\n" +
265294
"With --include-subagents, each subagent_dispatch line is followed by the child session's\n" +
266-
"own stream. The transcript can be large, so redirect it to a file rather than reading it\n" +
267-
"into a terminal:\n\n" +
295+
"own stream.\n\n" +
296+
"The default (jsonl) streams line-by-line so a huge transcript never lands in memory;\n" +
297+
"redirect it to a file rather than reading it into a terminal. --output-format json\n" +
298+
"buffers the whole transcript into a single JSON array and --output-format toon into the\n" +
299+
"compact encoding (both materialize the full transcript, so prefer jsonl for large ones):\n\n" +
268300
" flashduty session export <id> > session.ndjson\n",
269301
Args: requireArgs("session_id"),
270302
RunE: func(cmd *cobra.Command, args []string) error {
271303
return runCommand(cmd, args, func(ctx *RunContext) error {
304+
format, err := resolveSessionFormat()
305+
if err != nil {
306+
return err
307+
}
308+
272309
rc, _, err := ctx.Client.Sessions.Export(cmdContext(ctx.Cmd), &flashduty.SessionExportRequest{
273310
SessionID: ctx.Args[0],
274311
IncludeSubagents: includeSubagents,
@@ -278,25 +315,75 @@ func buildSessionExportCmd(use string) *cobra.Command {
278315
}
279316
defer func() { _ = rc.Close() }()
280317

281-
// Stream the NDJSON straight through to the writer without
282-
// buffering the whole transcript: copy line-by-line so a huge
283-
// export never lands in memory or the agent's context.
284-
sc := flashduty.NewExportScanner(rc)
285-
for sc.Scan() {
286-
if _, err := fmt.Fprintln(ctx.Writer, sc.Text()); err != nil {
287-
return err
288-
}
289-
}
290-
return sc.Err()
318+
return writeSessionExport(ctx.Writer, format, rc)
291319
})
292320
},
293321
}
294322

295323
cmd.Flags().BoolVar(&includeSubagents, "include-subagents", false, "Inline each dispatched subagent's own event stream")
324+
// --output-format is the inherited global flag; session export accepts
325+
// jsonl (default, streamed), json, or toon. Override its completion so it
326+
// advertises the session set, not the global table|json|toon.
327+
registerEnumFlag(cmd, "output-format", sessionFormatJSONL, sessionFormatJSON, sessionFormatTOON)
296328

297329
return cmd
298330
}
299331

332+
// writeSessionExport renders the export NDJSON stream in the requested format.
333+
// jsonl streams each line straight through without buffering, so a huge
334+
// transcript never lands in memory; json and toon necessarily materialize the
335+
// whole transcript (those encodings need every line) — json emits one indented
336+
// JSON array of the event objects, toon emits the compact encoding.
337+
func writeSessionExport(w io.Writer, format string, rc io.Reader) error {
338+
sc := flashduty.NewExportScanner(rc)
339+
340+
if format == sessionFormatJSONL {
341+
for sc.Scan() {
342+
if _, err := fmt.Fprintln(w, sc.Text()); err != nil {
343+
return err
344+
}
345+
}
346+
return sc.Err()
347+
}
348+
349+
// json/toon: collect every event line, then encode the whole array.
350+
events := make([]json.RawMessage, 0, 256)
351+
for sc.Scan() {
352+
line := strings.TrimSpace(sc.Text())
353+
if line == "" {
354+
continue
355+
}
356+
events = append(events, json.RawMessage(line))
357+
}
358+
if err := sc.Err(); err != nil {
359+
return err
360+
}
361+
362+
var (
363+
out []byte
364+
err error
365+
)
366+
if format == sessionFormatTOON {
367+
// TOON marshals Go values, not raw JSON, so decode the events first.
368+
decoded := make([]any, 0, len(events))
369+
for _, raw := range events {
370+
var v any
371+
if err := json.Unmarshal(raw, &v); err != nil {
372+
return fmt.Errorf("failed to decode export event: %w", err)
373+
}
374+
decoded = append(decoded, v)
375+
}
376+
out, err = toon.Marshal(decoded)
377+
} else {
378+
out, err = json.MarshalIndent(events, "", " ")
379+
}
380+
if err != nil {
381+
return fmt.Errorf("failed to marshal export: %w", err)
382+
}
383+
_, _ = fmt.Fprintln(w, string(out))
384+
return nil
385+
}
386+
300387
// attachSafariSessionExport adds the path-is-king `safari session-export` leaf to
301388
// the generated `safari` group. It must run AFTER registerGenerated so the group
302389
// exists; genGroup find-or-creates it and genAddLeaf is a no-op if a same-named

0 commit comments

Comments
 (0)