Skip to content

Commit aa7f346

Browse files
authored
fix(tui): show bare tool results, extract parallel results arrays (#76)
1 parent 588b547 commit aa7f346

2 files changed

Lines changed: 206 additions & 27 deletions

File tree

‎internal/tui/events.go‎

Lines changed: 120 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
141141
if steps[j].name == nm && !steps[j].done {
142142
steps[j].done = true
143143
steps[j].result = resultPreview(ev.Data)
144-
steps[j].isErr = looksLikeError(steps[j].result)
144+
steps[j].isErr = looksLikeError(steps[j].result) || hasFailedExit(ev.Data)
145145
if steps[j].isErr && !steps[j].expanded {
146146
// A failing step is why anyone expands anything —
147147
// unfold it once so the diagnosis is on screen
@@ -552,20 +552,24 @@ func eventTail(ev client.Event) string {
552552
}
553553

554554
// normalizeToolResult prepares raw tool output for DISPLAY ONLY — the stored
555-
// and forwarded data is never touched. It handles two render-noise shapes:
555+
// and forwarded data is never touched. It handles three render-noise shapes:
556556
// 1. JSON envelopes (read_file, search_files, …): Go's encoding/json
557557
// HTML-escapes <, >, & in string values, so the odek prompt-injection
558558
// wrapper shows up as <untrusted_content_… — the envelope is
559559
// decoded and the real content rendered, with remaining scalar metadata
560560
// as a one-line footer.
561-
// 2. untrusted_content wrappers, folded into a "⚠ untrusted: <source>"
562-
// badge line. Both the literal tag form (live stream events) and the
561+
// 2. parallel-tool envelopes (parallel_shell, delegate_tasks): a top-level
562+
// "results" array is extracted item by item — each item renders only its
563+
// display body (stdout, stderr, non-zero exit codes); command echoes,
564+
// indexes, and durations stay hidden.
565+
// 3. untrusted_content wrappers, folded away entirely
566+
// body renders. Both the literal tag form (live stream events) and the
563567
// < escaped form (undecoded JSON envelopes) are recognized.
564568
//
565569
// Fail-safe: anything that does not match a known shape exactly is returned
566570
// unchanged — normalization is never lossy.
567571
func normalizeToolResult(data string) string {
568-
return foldUntrustedWrappers(decodeToolEnvelope(data))
572+
return foldUntrustedWrappers(decodeToolEnvelope(decodeResultsArray(data)))
569573
}
570574

571575
// decodeToolEnvelope unwraps a JSON object with a string "content" field,
@@ -616,25 +620,128 @@ func decodeToolEnvelope(data string) string {
616620
return content + "\n(" + strings.Join(parts, " · ") + ")"
617621
}
618622

623+
// resultsItemFields lists, in priority order, the per-item string fields a
624+
// parallel-tool result may carry; the first non-empty one is the item's
625+
// display body (shell stdout, delegate headline, …).
626+
var resultsItemFields = []string{
627+
"stdout", "content", "result", "output", "text", "headline", "summary",
628+
}
629+
630+
// decodeResultsArray unwraps a parallel-tool envelope: a JSON object whose
631+
// "results" field is an array of per-call objects (odek parallel_shell and
632+
// delegate_tasks shapes). Each item renders as its display body plus stderr
633+
// and non-zero exit-code lines; items are labelled [1], [2], … only when
634+
// there are several. Metadata beside "results" is ignored — the items are
635+
// the payload. Any foreign shape — empty arrays, non-object items, items
636+
// without a known display field — is returned unchanged: never lossy.
637+
func decodeResultsArray(data string) string {
638+
t := strings.TrimSpace(data)
639+
if len(t) < 2 || t[0] != '{' {
640+
return data
641+
}
642+
var env map[string]any
643+
if err := json.Unmarshal([]byte(t), &env); err != nil {
644+
return data
645+
}
646+
raw, ok := env["results"].([]any)
647+
if !ok || len(raw) == 0 {
648+
return data
649+
}
650+
items := make([]string, 0, len(raw))
651+
for _, r := range raw {
652+
obj, ok := r.(map[string]any)
653+
if !ok {
654+
return data
655+
}
656+
var body string
657+
known := false
658+
for _, k := range resultsItemFields {
659+
v, ok := obj[k]
660+
if !ok {
661+
continue
662+
}
663+
known = true
664+
if s, ok := v.(string); ok && s != "" {
665+
body = s
666+
break
667+
}
668+
}
669+
if !known {
670+
return data
671+
}
672+
if body != "" {
673+
body = decodeToolEnvelope(body) // item stdout may itself be an envelope
674+
}
675+
parts := make([]string, 0, 3)
676+
if f, ok := obj["exit_code"].(float64); ok && f != 0 {
677+
// Leading line, not a suffix: looksLikeError keys off the "exit
678+
// status" prefix, so failed items tint red and auto-expand.
679+
parts = append(parts, "exit status "+strconv.Itoa(int(f)))
680+
}
681+
if body != "" {
682+
parts = append(parts, body)
683+
}
684+
if s, ok := obj["stderr"].(string); ok && s != "" {
685+
parts = append(parts, "stderr: "+s)
686+
}
687+
if len(parts) == 0 {
688+
parts = append(parts, "(no output)")
689+
}
690+
items = append(items, strings.Join(parts, "\n"))
691+
}
692+
if len(items) == 1 {
693+
return items[0]
694+
}
695+
var b strings.Builder
696+
for i, it := range items {
697+
if i > 0 {
698+
b.WriteString("\n\n")
699+
}
700+
b.WriteString("[")
701+
b.WriteString(strconv.Itoa(i + 1))
702+
b.WriteString("] ")
703+
b.WriteString(it)
704+
}
705+
return b.String()
706+
}
707+
708+
// hasFailedExit reports whether a raw parallel-tool envelope carries any
709+
// non-zero exit_code — the extracted display text can otherwise read as
710+
// innocuous and slip past looksLikeError.
711+
func hasFailedExit(data string) bool {
712+
t := strings.TrimSpace(data)
713+
if len(t) < 2 || t[0] != '{' {
714+
return false
715+
}
716+
var env struct {
717+
Results []struct {
718+
ExitCode int `json:"exit_code"`
719+
} `json:"results"`
720+
}
721+
if err := json.Unmarshal([]byte(t), &env); err != nil {
722+
return false
723+
}
724+
for _, r := range env.Results {
725+
if r.ExitCode != 0 {
726+
return true
727+
}
728+
}
729+
return false
730+
}
731+
619732
// untrustedWrapperRe matches an odek prompt-injection wrapper in either its
620733
// literal form or the < escaped form produced by encoding/json.
621734
// Capture groups: 1 = source attribute, 2 = wrapped body.
622735
var untrustedWrapperRe = regexp.MustCompile(
623736
`(?s)(?:<|\\u003c)untrusted_content_[0-9a-f]+ source=\\?"([^"]*)\\?"(?:>|\\u003e)\n?(.*?)\n?(?:<|\\u003c)/untrusted_content_[0-9a-f]+(?:>|\\u003e)`)
624737

625-
// foldUntrustedWrappers replaces each untrusted_content wrapper with a badge
626-
// line naming the source, followed by the wrapped body.
738+
// foldUntrustedWrappers strips each untrusted_content wrapper with the wrapped body only — display stays content-only under the peek framing.
627739
func foldUntrustedWrappers(s string) string {
628740
if !strings.Contains(s, "untrusted_content_") {
629741
return s
630742
}
631743
return untrustedWrapperRe.ReplaceAllStringFunc(s, func(m string) string {
632-
sub := untrustedWrapperRe.FindStringSubmatch(m)
633-
badge := "⚠ untrusted: " + sub[1]
634-
if body := sub[2]; body != "" {
635-
return badge + "\n" + body
636-
}
637-
return badge
744+
return untrustedWrapperRe.FindStringSubmatch(m)[2]
638745
})
639746
}
640747

‎internal/tui/normalize_test.go‎

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,14 @@ import (
66
)
77

88
// read_file-style JSON envelope: Go's encoding/json HTML-escapes <, >, & in
9-
// the wrapped content, so the raw result exposes < untrusted_content_…
10-
// noise. resultPreview must decode the envelope and fold the wrapper.
9+
// the wrapped content, so the raw result exposes \u003c untrusted_content_…
10+
// noise. resultPreview must decode the envelope and fold the wrapper away
11+
// entirely — the body renders bare, with scalar metadata as a footer.
1112
func TestResultPreviewJSONEnvelope(t *testing.T) {
1213
raw := `{"content":"<untrusted_content_dd96e237d3be7447 source=\"/path/to/file.go\">\n217|\n218|\tta := textarea.New()\n</untrusted_content_dd96e237d3be7447>","total_lines":300}`
1314
got := resultPreview(raw)
14-
if strings.Contains(got, `<`) || strings.Contains(got, "untrusted_content_") {
15-
t.Errorf("escaped wrapper still visible:\n%s", got)
16-
}
17-
if !strings.Contains(got, "⚠ untrusted: /path/to/file.go") {
18-
t.Errorf("missing source badge:\n%s", got)
15+
if strings.Contains(got, `<`) || strings.Contains(got, "untrusted_content_") || strings.Contains(got, "⚠") {
16+
t.Errorf("wrapper or badge noise still visible:\n%s", got)
1917
}
2018
if !strings.Contains(got, "218|\tta := textarea.New()") { // tabs survive sanitize (copy fidelity)
2119
t.Errorf("body line numbers not intact:\n%s", got)
@@ -25,16 +23,13 @@ func TestResultPreviewJSONEnvelope(t *testing.T) {
2523
}
2624
}
2725

28-
// Live (non-JSON) tool results carry the wrapper literally; it folds into a
29-
// badge line plus the body.
26+
// Live (non-JSON) tool results carry the wrapper literally; it folds away,
27+
// leaving only the body.
3028
func TestResultPreviewLiteralWrapper(t *testing.T) {
3129
raw := "<untrusted_content_ab12cd34 source=\"/etc/hosts\">\n127.0.0.1 localhost\n</untrusted_content_ab12cd34>"
3230
got := resultPreview(raw)
33-
if strings.Contains(got, "untrusted_content_") {
34-
t.Errorf("wrapper tags still visible:\n%s", got)
35-
}
36-
if !strings.Contains(got, "⚠ untrusted: /etc/hosts") {
37-
t.Errorf("missing source badge:\n%s", got)
31+
if strings.Contains(got, "untrusted_content_") || strings.Contains(got, "⚠") {
32+
t.Errorf("wrapper tags or badge still visible:\n%s", got)
3833
}
3934
if !strings.Contains(got, "127.0.0.1 localhost") {
4035
t.Errorf("body lost:\n%s", got)
@@ -64,3 +59,80 @@ func TestResultPreviewMalformedEnvelope(t *testing.T) {
6459
t.Errorf("malformed envelope rewritten: got %q", got)
6560
}
6661
}
62+
63+
// Parallel tools (parallel_shell) return a top-level results array of per-
64+
// call objects. resultPreview extracts each item's display body — stdout,
65+
// stderr, non-zero exit codes — and drops the JSON noise (command echoes,
66+
// index, duration). Wrappers inside stdout fold away entirely.
67+
func TestResultPreviewParallelResults(t *testing.T) {
68+
raw := `{"results":[{"index":0,"command":"gofmt -l .","description":"check formatting","stdout":"\u003cuntrusted_content_abc123 source=\"parallel_shell:0:stdout\"\u003e\nREADME.md\n\u003c/untrusted_content_abc123\u003e","stderr":"","exit_code":0,"duration_ms":12},{"index":1,"command":"go vet ./...","description":"vet","stdout":"","stderr":"vets hate this","exit_code":1,"duration_ms":300}]}`
69+
got := resultPreview(raw)
70+
for _, want := range []string{"[1] README.md", "[2] exit status 1", "stderr: vets hate this"} {
71+
if !strings.Contains(got, want) {
72+
t.Errorf("parallel results missing %q in:\n%s", want, got)
73+
}
74+
}
75+
for _, banned := range []string{"{", "}", "\"command\"", "gofmt -l", "go vet ./...", "index", "duration_ms", "description", "untrusted", "⚠"} {
76+
if strings.Contains(got, banned) {
77+
t.Errorf("parallel results leak %q:\n%s", banned, got)
78+
}
79+
}
80+
}
81+
82+
// A single-item results array renders the body bare — no index label.
83+
func TestResultPreviewSingleResult(t *testing.T) {
84+
raw := `{"results":[{"index":0,"command":"ls","stdout":"main.go\nutil.go","stderr":"","exit_code":0,"duration_ms":5}]}`
85+
got := resultPreview(raw)
86+
if !strings.Contains(got, "main.go") || !strings.Contains(got, "util.go") {
87+
t.Errorf("stdout body lost:\n%s", got)
88+
}
89+
if strings.Contains(got, "[1]") || strings.Contains(got, "ls") || strings.Contains(got, "index") {
90+
t.Errorf("single result carries labels or arg echoes:\n%s", got)
91+
}
92+
}
93+
94+
// Non-stdout result shapes (delegate_tasks headlines) still extract.
95+
func TestResultPreviewResultsHeadline(t *testing.T) {
96+
raw := `{"results":[{"headline":"built 3 sub-agents","artifacts":[{"id":"a1","path":"x.go","bytes":10}],"cost_usd":0.5}]}`
97+
got := resultPreview(raw)
98+
if !strings.Contains(got, "built 3 sub-agents") {
99+
t.Errorf("headline body lost:\n%s", got)
100+
}
101+
if strings.Contains(got, "artifacts") || strings.Contains(got, "cost_usd") {
102+
t.Errorf("nested metadata leaked:\n%s", got)
103+
}
104+
}
105+
106+
// A non-zero exit_code anywhere in a parallel envelope flags the step as
107+
// failed — even when the extracted display text looks innocuous.
108+
func TestHasFailedExit(t *testing.T) {
109+
for _, tc := range []struct {
110+
raw string
111+
want bool
112+
}{
113+
{"{\"results\":[{\"exit_code\":0}]}", false},
114+
{"{\"results\":[{\"stdout\":\"all good\"}]}", false},
115+
{"{\"results\":[{\"exit_code\":1,\"stdout\":\"hmm\"}]}", true},
116+
{"{\"results\":[{\"exit_code\":0},{\"exit_code\":2}]}", true},
117+
{"plain output", false},
118+
{"{\"other\":1}", false},
119+
} {
120+
if got := hasFailedExit(tc.raw); got != tc.want {
121+
t.Errorf("hasFailedExit(%q) = %v, want %v", tc.raw, got, tc.want)
122+
}
123+
}
124+
}
125+
126+
// Unknown results shapes are never rewritten — the fail-safe holds.
127+
func TestResultPreviewResultsFailSafe(t *testing.T) {
128+
for _, s := range []string{
129+
`{"results":[]}`,
130+
`{"results":["plain string"]}`,
131+
`{"results":[{"weird":{"a":1}}]}`,
132+
`{"results":[{"other":"only unknown scalars"}]}`,
133+
} {
134+
if got := resultPreview(s); got != s {
135+
t.Errorf("resultPreview(%q) = %q, want unchanged", s, got)
136+
}
137+
}
138+
}

0 commit comments

Comments
 (0)