From 8410c9d2846a0accc04e7c85ae83ba17ac8a6c4e Mon Sep 17 00:00:00 2001 From: anh nguyen <29374105+areporeporepo@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:09:44 -0700 Subject: [PATCH 1/2] fix: handle Claude Code 2.x JSON array response in summarization Claude Code 2.x changed `--output-format json` to return a JSON array of messages instead of a single object. The summarize package now tries the array format first (extracting the element with `type: "result"`), falling back to the single-object format for Claude Code 1.x compat. Fixes #750 Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/entire/cli/summarize/claude.go | 43 ++++++-- cmd/entire/cli/summarize/claude_test.go | 125 ++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 7 deletions(-) diff --git a/cmd/entire/cli/summarize/claude.go b/cmd/entire/cli/summarize/claude.go index 7b2e6dd3e..5a575ffb7 100644 --- a/cmd/entire/cli/summarize/claude.go +++ b/cmd/entire/cli/summarize/claude.go @@ -67,7 +67,10 @@ type ClaudeGenerator struct { } // claudeCLIResponse represents the JSON response from the Claude CLI. +// Claude Code 1.x returns a single object: {"result": "..."} +// Claude Code 2.x returns an array: [{type:"system",...}, {type:"result", result:"..."}] type claudeCLIResponse struct { + Type string `json:"type"` Result string `json:"result"` } @@ -133,15 +136,14 @@ func (g *ClaudeGenerator) Generate(ctx context.Context, input Input) (*checkpoin return nil, fmt.Errorf("failed to run claude CLI: %w", err) } - // Parse the CLI response - var cliResponse claudeCLIResponse - if err := json.Unmarshal(stdout.Bytes(), &cliResponse); err != nil { - return nil, fmt.Errorf("failed to parse claude CLI response: %w", err) + // Parse the CLI response. + // Claude Code 2.x returns a JSON array of messages; extract the "result" element. + // Claude Code 1.x returns a single JSON object; try that as a fallback. + resultJSON, err := parseClaudeCLIResult(stdout.Bytes()) + if err != nil { + return nil, err } - // The result field contains the actual JSON summary - resultJSON := cliResponse.Result - // Try to extract JSON if it's wrapped in markdown code blocks resultJSON = extractJSONFromMarkdown(resultJSON) @@ -154,6 +156,33 @@ func (g *ClaudeGenerator) Generate(ctx context.Context, input Input) (*checkpoin return &summary, nil } +// parseClaudeCLIResult extracts the result string from the Claude CLI JSON output. +// It handles both formats: +// - Claude Code 2.x: JSON array with a "type":"result" element +// - Claude Code 1.x: single JSON object with a "result" field +func parseClaudeCLIResult(data []byte) (string, error) { + // Try array format first (Claude Code 2.x) + var responses []claudeCLIResponse + if err := json.Unmarshal(data, &responses); err == nil { + for _, r := range responses { + if r.Type == "result" && r.Result != "" { + return r.Result, nil + } + } + return "", errors.New("claude CLI response array contained no result element") + } + + // Fall back to single object (Claude Code 1.x) + var single claudeCLIResponse + if err := json.Unmarshal(data, &single); err != nil { + return "", fmt.Errorf("failed to parse claude CLI response: %w", err) + } + if single.Result == "" { + return "", errors.New("claude CLI response contained empty result") + } + return single.Result, nil +} + // buildSummarizationPrompt creates the prompt for the Claude CLI. func buildSummarizationPrompt(transcriptText string) string { return fmt.Sprintf(summarizationPromptTemplate, transcriptText) diff --git a/cmd/entire/cli/summarize/claude_test.go b/cmd/entire/cli/summarize/claude_test.go index aa4518540..e42e4cf6f 100644 --- a/cmd/entire/cli/summarize/claude_test.go +++ b/cmd/entire/cli/summarize/claude_test.go @@ -133,6 +133,67 @@ func TestClaudeGenerator_NonZeroExit(t *testing.T) { } } +func TestClaudeGenerator_ArrayResponse(t *testing.T) { + t.Parallel() + + summaryJSON := `{\"intent\":\"Fix a bug\",\"outcome\":\"Bug fixed\",\"learnings\":{\"repo\":[],\"code\":[],\"workflow\":[]},\"friction\":[],\"open_items\":[]}` + // Claude Code 2.x array format with system, assistant, and result elements + response := `[{"type":"system","system":"..."},{"type":"assistant","message":"..."},{"type":"result","result":"` + summaryJSON + `"}]` + + gen := &ClaudeGenerator{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "printf '%s' '"+response+"'") + }, + } + + input := Input{ + Transcript: []Entry{ + {Type: EntryTypeUser, Content: "Fix the bug"}, + }, + } + + summary, err := gen.Generate(context.Background(), input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if summary.Intent != "Fix a bug" { + t.Errorf("unexpected intent: %s", summary.Intent) + } + + if summary.Outcome != "Bug fixed" { + t.Errorf("unexpected outcome: %s", summary.Outcome) + } +} + +func TestClaudeGenerator_ArrayResponseNoResult(t *testing.T) { + t.Parallel() + + // Array with no "result" type element + response := `[{"type":"system","system":"..."},{"type":"assistant","message":"..."}]` + + gen := &ClaudeGenerator{ + CommandRunner: func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "printf '%s' '"+response+"'") + }, + } + + input := Input{ + Transcript: []Entry{ + {Type: EntryTypeUser, Content: "Hello"}, + }, + } + + _, err := gen.Generate(context.Background(), input) + if err == nil { + t.Fatal("expected error when array has no result element") + } + + if !strings.Contains(err.Error(), "no result element") { + t.Errorf("expected 'no result element' error, got: %v", err) + } +} + func TestClaudeGenerator_ErrorCases(t *testing.T) { tests := []struct { name string @@ -247,6 +308,70 @@ func TestClaudeGenerator_MarkdownCodeBlock(t *testing.T) { } } +func TestParseClaudeCLIResult(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + expectError string + }{ + { + name: "single object (1.x format)", + input: `{"result": "the summary"}`, + expected: "the summary", + }, + { + name: "array with result (2.x format)", + input: `[{"type":"system","system":"..."},{"type":"result","result":"the summary"}]`, + expected: "the summary", + }, + { + name: "array picks result type over others", + input: `[{"type":"assistant","result":"wrong"},{"type":"result","result":"correct"}]`, + expected: "correct", + }, + { + name: "array with no result element", + input: `[{"type":"system"},{"type":"assistant"}]`, + expectError: "no result element", + }, + { + name: "invalid JSON", + input: `not json at all`, + expectError: "parse claude CLI response", + }, + { + name: "single object with empty result", + input: `{"result": ""}`, + expectError: "empty result", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result, err := parseClaudeCLIResult([]byte(tt.input)) + if tt.expectError != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.expectError) + } + if !strings.Contains(err.Error(), tt.expectError) { + t.Errorf("expected error containing %q, got: %v", tt.expectError, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + func TestBuildSummarizationPrompt(t *testing.T) { transcriptText := "[User] Hello\n\n[Assistant] Hi" From 4598d0b2e67123163778bf80faaf8744624266e2 Mon Sep 17 00:00:00 2001 From: anh nguyen <29374105+areporeporepo@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:48:47 -0700 Subject: [PATCH 2/2] fix: address Copilot review findings - Wrap parseClaudeCLIResult errors with consistent prefix in Generate() - Distinguish empty result element from missing result in array path - Fix invalid JSON in doc comment (unquoted keys) - Add test case for array with empty result element Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/entire/cli/summarize/claude.go | 14 +++++++++++--- cmd/entire/cli/summarize/claude_test.go | 5 +++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/summarize/claude.go b/cmd/entire/cli/summarize/claude.go index 5a575ffb7..9a7331be6 100644 --- a/cmd/entire/cli/summarize/claude.go +++ b/cmd/entire/cli/summarize/claude.go @@ -68,7 +68,7 @@ type ClaudeGenerator struct { // claudeCLIResponse represents the JSON response from the Claude CLI. // Claude Code 1.x returns a single object: {"result": "..."} -// Claude Code 2.x returns an array: [{type:"system",...}, {type:"result", result:"..."}] +// Claude Code 2.x returns an array: [{"type":"system",...}, {"type":"result", "result":"..."}] type claudeCLIResponse struct { Type string `json:"type"` Result string `json:"result"` @@ -141,7 +141,7 @@ func (g *ClaudeGenerator) Generate(ctx context.Context, input Input) (*checkpoin // Claude Code 1.x returns a single JSON object; try that as a fallback. resultJSON, err := parseClaudeCLIResult(stdout.Bytes()) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to parse claude CLI response: %w", err) } // Try to extract JSON if it's wrapped in markdown code blocks @@ -164,11 +164,19 @@ func parseClaudeCLIResult(data []byte) (string, error) { // Try array format first (Claude Code 2.x) var responses []claudeCLIResponse if err := json.Unmarshal(data, &responses); err == nil { + hasEmptyResultElement := false for _, r := range responses { - if r.Type == "result" && r.Result != "" { + if r.Type == "result" { + if r.Result == "" { + hasEmptyResultElement = true + continue + } return r.Result, nil } } + if hasEmptyResultElement { + return "", errors.New("claude CLI response contained empty result") + } return "", errors.New("claude CLI response array contained no result element") } diff --git a/cmd/entire/cli/summarize/claude_test.go b/cmd/entire/cli/summarize/claude_test.go index e42e4cf6f..ede5e5335 100644 --- a/cmd/entire/cli/summarize/claude_test.go +++ b/cmd/entire/cli/summarize/claude_test.go @@ -347,6 +347,11 @@ func TestParseClaudeCLIResult(t *testing.T) { input: `{"result": ""}`, expectError: "empty result", }, + { + name: "array with empty result element", + input: `[{"type":"system"},{"type":"result","result":""}]`, + expectError: "empty result", + }, } for _, tt := range tests {