Skip to content

Commit 0e6c27e

Browse files
sinnet3000claude
andauthored
amp: fix missing tool output in UI (toolUseID/run.result) (#122)
AMP has tool results that don't use the usual `tool_use_id` + `content` fields. Instead, you'll see `toolUseID` and the payload under `run.result`. The shared extractor doesn't recognize that, so those results were being skipped and tool calls showed up with empty output in the UI. Example: AMP built-in `librarian` ```json {"type":"tool_use","id":"toolu_vrtx_01Mxhky7udxH85j2LkLiW33f","name":"librarian"} ``` Previously dropped result: ```json { "type": "tool_result", "toolUseID": "toolu_vrtx_01Mxhky7udxH85j2LkLiW33f", "run": { "status": "done", "result": "Here is a complete, code-level breakdown..." } } ``` ## Changes - `internal/parser/amp.go`: parse AMP-style tool results (`toolUseID` + `run.result`) and normalize the output into displayable text; keep the shared extractor untouched and append AMP results. - `internal/parser/amp_test.go`: add unit + integration coverage for the AMP result shapes (string/dict/list/null + error/cancelled). - `internal/sync/engine_test.go`: add a regression that checks the parse -> pair -> decode path so `ResultContent` is actually populated. Empty successful output is still treated as "nothing to show". Errors/cancelled runs get a small placeholder so you can tell what happened. ## Validation ```bash go test ./internal/parser -run 'TestSerializeAmpResult|TestExtractAmpToolResults|TestParseAmpSession_AmpToolResultSchema|TestParseAmpSession_AmpToolResultDict|TestParseAmpSession_ToolUseAndThinking' go test ./internal/sync -run 'TestPairToolResultsContent' ``` --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bf58d85 commit 0e6c27e

File tree

3 files changed

+422
-0
lines changed

3 files changed

+422
-0
lines changed

internal/parser/amp.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package parser
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"os"
67
"path/filepath"
@@ -98,6 +99,7 @@ func ParseAmpSession(
9899

99100
content, hasThinking, hasToolUse, tcs, trs :=
100101
ExtractTextContent(msg.Get("content"))
102+
trs = append(trs, extractAmpToolResults(msg.Get("content"))...)
101103
if strings.TrimSpace(content) == "" && len(trs) == 0 {
102104
return true
103105
}
@@ -180,3 +182,139 @@ func ampThreadIDFromPath(path string) string {
180182
}
181183
return stem
182184
}
185+
186+
func serializeAmpResult(result gjson.Result) string {
187+
if !result.Exists() || result.Type == gjson.Null {
188+
return ""
189+
}
190+
191+
if result.Type == gjson.String {
192+
return result.Str
193+
}
194+
195+
if result.IsObject() {
196+
// Priority order is intentional: Bash commonly uses "output",
197+
// Read uses "content", and Edit uses "diff". If shapes overlap,
198+
// prefer the most common display fields.
199+
knownFieldSeen := false
200+
for _, key := range []string{"output", "content", "diff"} {
201+
if field := result.Get(key); field.Exists() {
202+
knownFieldSeen = true
203+
if s := serializeAmpResult(field); s != "" {
204+
return s
205+
}
206+
}
207+
}
208+
209+
success := result.Get("success")
210+
if success.Exists() {
211+
if success.Bool() {
212+
return "success"
213+
}
214+
return "failed"
215+
}
216+
217+
// If a known display field was present but empty, return empty
218+
// rather than falling back to noisy raw JSON metadata.
219+
if knownFieldSeen {
220+
return ""
221+
}
222+
223+
return result.Raw
224+
}
225+
226+
if result.IsArray() {
227+
items := result.Array()
228+
if len(items) == 0 {
229+
return ""
230+
}
231+
232+
if items[0].Type == gjson.String {
233+
// Only apply "string list" formatting when all items are strings.
234+
// Mixed-type arrays should round-trip via Raw rather than silently
235+
// dropping or mangling non-string elements.
236+
lines := make([]string, 0, len(items))
237+
for _, item := range items {
238+
if item.Type != gjson.String {
239+
return result.Raw
240+
}
241+
lines = append(lines, item.Str)
242+
}
243+
return strings.Join(lines, "\n")
244+
}
245+
246+
if items[0].IsObject() {
247+
// Only treat as binary/image if the first element looks like an
248+
// image block. Generic arrays of objects (search results, file
249+
// listings, etc.) should round-trip as raw JSON instead.
250+
if items[0].Get("type").Str == "image" {
251+
return "[binary content]"
252+
}
253+
return result.Raw
254+
}
255+
256+
return result.Raw
257+
}
258+
259+
return result.Raw
260+
}
261+
262+
func extractAmpToolResults(content gjson.Result) []ParsedToolResult {
263+
if !content.IsArray() {
264+
return nil
265+
}
266+
267+
var results []ParsedToolResult
268+
for _, block := range content.Array() {
269+
if block.Get("type").Str != "tool_result" {
270+
continue
271+
}
272+
273+
if block.Get("tool_use_id").Str != "" {
274+
// Canonical schema is handled by shared extractor.
275+
continue
276+
}
277+
278+
toolUseID := block.Get("toolUseID").Str
279+
if toolUseID == "" {
280+
continue
281+
}
282+
283+
var text string
284+
hasResult := false
285+
result := block.Get("run.result")
286+
if result.Exists() && result.Type != gjson.Null {
287+
text = serializeAmpResult(result)
288+
hasResult = true
289+
} else {
290+
switch block.Get("run.status").Str {
291+
case "error":
292+
text = block.Get("run.error.message").Str
293+
if text == "" {
294+
text = "[unknown error]"
295+
}
296+
case "cancelled":
297+
text = "[cancelled]"
298+
}
299+
}
300+
// Skip blocks with no result and no error/cancelled status.
301+
// Preserve blocks where run.result existed but serialized to empty
302+
// (e.g. empty string, empty array) so the tool call is not left pending.
303+
if text == "" && !hasResult {
304+
continue
305+
}
306+
307+
quoted, err := json.Marshal(text)
308+
if err != nil {
309+
continue
310+
}
311+
312+
results = append(results, ParsedToolResult{
313+
ToolUseID: toolUseID,
314+
ContentRaw: string(quoted),
315+
ContentLength: len(text),
316+
})
317+
}
318+
319+
return results
320+
}

0 commit comments

Comments
 (0)