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
2 changes: 2 additions & 0 deletions internal/cli/alert_event.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ func newAlertEventListCmd() *cobra.Command {
fieldNames := []string{"event_id", "alert_id", "event_severity", "event_status", "event_time", "title"}
if fields != "" {
fieldNames = parseStringSlice(fields)
} else {
noteDefaultProjection(cmd.ErrOrStderr(), fieldNames)
}
proj, err := projectFields(result.Items, fieldNames)
if err != nil {
Expand Down
22 changes: 22 additions & 0 deletions internal/cli/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,28 @@ func execCommand(args ...string) (string, error) {
return buf.String(), err
}

// execCommandSplit is execCommand with stdout and stderr captured separately,
// for tests that assert machine-readable stdout stays pure while advisory
// notices (e.g. the default-projection note) land on stderr.
func execCommandSplit(args ...string) (stdout, stderr string, err error) {
resetCommandFlags(rootCmd)

outBuf := new(bytes.Buffer)
errBuf := new(bytes.Buffer)
rootCmd.SetOut(outBuf)
rootCmd.SetErr(errBuf)
rootCmd.SetArgs(args)

err = rootCmd.Execute()

rootCmd.SetArgs(nil)
rootCmd.SetOut(nil)
rootCmd.SetErr(nil)
resetCommandFlags(rootCmd)

return outBuf.String(), errBuf.String(), err
}

func resetCommandFlags(cmd *cobra.Command) {
if cmd == nil {
return
Expand Down
11 changes: 11 additions & 0 deletions internal/cli/fieldproject.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"fmt"
"io"
"reflect"
"sort"
"strings"
Expand Down Expand Up @@ -69,6 +70,16 @@ func projectFields(items any, fields []string) ([]map[string]any, error) {
return out, nil
}

// noteDefaultProjection announces on stderr that structured rows were reduced
// to the command's compact default projection. Without it, a reader piping
// stdout to jq sees an unselected key (labels, description, …) as null on
// every row and can conclude the server never returns it, when it is one
// --fields away. stderr keeps stdout byte-identical for jq/toon pipelines.
func noteDefaultProjection(w io.Writer, fields []string) {
_, _ = fmt.Fprintf(w, "note: rows projected to default compact fields (%s); other response fields are available via --fields\n",
strings.Join(fields, ","))
}

// boundProjectedOutput keeps the new agent-oriented projections below their
// command budget without changing the selected keys. Short values remain byte
// identical; when retained strings alone would overflow the actual JSON/TOON
Expand Down
30 changes: 22 additions & 8 deletions internal/cli/fieldproject_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,29 +118,37 @@ func TestIncidentListStructuredDefaultUsesCompactProjection(t *testing.T) {
stub := newGFStub(t)
stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1}

out, err := execCommand("incident", "list", "--output-format", "json")
out, stderrText, err := execCommandSplit("incident", "list", "--output-format", "json")
if err != nil {
t.Fatalf("execCommand: %v", err)
t.Fatalf("execCommandSplit: %v", err)
}

assertProjectedJSONFields(t, out, []string{"incident_id", "title", "incident_severity", "progress", "start_time", "channel_id"})
if !strings.Contains(stderrText, "note: rows projected to default compact fields") {
t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText)
}
})

t.Run("toon default", func(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1}

out, err := execCommand("incident", "list", "--output-format", "toon")
out, stderrText, err := execCommandSplit("incident", "list", "--output-format", "toon")
if err != nil {
t.Fatalf("execCommand: %v", err)
t.Fatalf("execCommandSplit: %v", err)
}

// Positive keys must come from stdout alone: the stderr note embeds the
// same field names, so a merged capture would satisfy this vacuously.
for _, key := range []string{"incident_id", "title", "incident_severity", "progress", "start_time", "channel_id"} {
if !strings.Contains(out, key) {
t.Errorf("default toon output missing compact key %q, got:\n%s", key, out)
}
}
if !strings.Contains(stderrText, "note: rows projected to default compact fields") {
t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText)
}
for _, key := range []string{"responders", "labels", "description"} {
if strings.Contains(out, key) {
t.Errorf("default toon output should not contain full-record key %q, got:\n%s", key, out)
Expand Down Expand Up @@ -394,13 +402,16 @@ func TestIncidentSimilarStructuredProjection(t *testing.T) {
}
stub.data = map[string]any{"items": items, "total": len(items)}

out, err := execCommand("incident", "similar", "inc-1", "--limit", "20", "--output-format", "json")
out, stderrText, err := execCommandSplit("incident", "similar", "inc-1", "--limit", "20", "--output-format", "json")
if err != nil {
t.Fatalf("execCommand: %v", err)
t.Fatalf("execCommandSplit: %v", err)
}
if len(out) >= 16*1024 {
t.Fatalf("compact similar output is %d bytes, want <16 KiB", len(out))
}
if !strings.Contains(stderrText, "note: rows projected to default compact fields") {
t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText)
}

var rows []map[string]json.RawMessage
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil {
Expand Down Expand Up @@ -482,13 +493,16 @@ func TestAlertEventListStructuredProjection(t *testing.T) {
}
stub.data = map[string]any{"items": items, "total": len(items)}

out, err := execCommand("alert-event", "list", "--limit", "30", "--output-format", "json")
out, stderrText, err := execCommandSplit("alert-event", "list", "--limit", "30", "--output-format", "json")
if err != nil {
t.Fatalf("execCommand: %v", err)
t.Fatalf("execCommandSplit: %v", err)
}
if len(out) >= 16*1024 {
t.Fatalf("compact alert-event output is %d bytes, want <16 KiB", len(out))
}
if !strings.Contains(stderrText, "note: rows projected to default compact fields") {
t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText)
}
var rows []map[string]json.RawMessage
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil {
t.Fatalf("parse compact alert-event json: %v\n%s", err, out)
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/incident.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ func newIncidentListCmd() *cobra.Command {
if len(selectedFields) == 0 {
return fmt.Errorf("--fields must name at least one field")
}
} else {
noteDefaultProjection(cmd.ErrOrStderr(), selectedFields)
}
proj, err := projectFields(result.Items, selectedFields)
if err != nil {
Expand Down Expand Up @@ -604,6 +606,8 @@ func newIncidentSimilarCmd() *cobra.Command {
fieldNames := []string{"incident_id", "title", "incident_severity", "progress", "start_time", "close_time", "ack_time", "alert_cnt", "root_cause", "score"}
if fields != "" {
fieldNames = parseStringSlice(fields)
} else {
noteDefaultProjection(cmd.ErrOrStderr(), fieldNames)
}
proj, err := projectFields(result.Items, fieldNames)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion skills/flashduty/reference/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ fduty alert feed <alert-id> --output-format toon
fduty alert-event list --channel <channel-id> --since 1h --limit 30 --output-format toon
```

Structured `alert-event list` output stays below 16 KiB. A trailing `...` means a long retained string was shortened.
Structured `alert-event list` output stays below 16 KiB. A trailing `...` means a long retained string was shortened. In json/toon mode rows default to the compact projection `event_id,alert_id,event_severity,event_status,event_time,title` (a stderr note says so when it applies); any other response field is one `--fields` away — a key missing from the output means it wasn't selected, not that the server omits it.

## Hot flow — merge noisy alerts into an existing incident

Expand Down
2 changes: 1 addition & 1 deletion skills/flashduty/reference/incident.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Projected `similar` lists stay below 16 KiB, and projected `detail --fields` out

`comment` never accepts the text as a command-line argument — only `--comment-file <path>` (or `--comment-file -` to read stdin), so backticks/`$()`/quotes inside the comment are inert. The command also reads back every target's timeline after writing and exits non-zero unless it finds an entry matching what it sent, so `Commented on ...` is proof of content fidelity, not just acceptance — no separate manual read-back is needed. Leading and trailing whitespace is stripped before sending (the server strips it too, so this is what gets stored); everything else, including interior blank lines, is preserved exactly.

> `incident list --output-format json|toon` defaults to the compact row projection `incident_id,title,incident_severity,progress,start_time,channel_id`. Pass `--fields incident_id,title,channel_id,start_time` when you need different list columns; use `incident detail <id>` / `incident get <id>` for full incident records.
> `incident list --output-format json|toon` defaults to the compact row projection `incident_id,title,incident_severity,progress,start_time,channel_id`. Pass `--fields incident_id,title,channel_id,start_time` when you need different list columns; use `incident detail <id>` / `incident get <id>` for full incident records. Any list-response field — including `labels` — is selectable this way (a key missing from the output means it wasn't selected, NOT that the server omits it; the command prints a stderr note when the default projection applies). The one exception is `alerts`: neither list nor detail responses ever fill it — use `incident alerts <id>` for an incident's alerts. Wide fields over many rows can exceed the 16 KiB structured-output bound and the command errors with "request fewer rows or fields" — lower `--limit`/page through, or use `insight` aggregates for distributions instead of dumping labels row by row.

## Hot flow — full fault analysis (read-only summary)

Expand Down