-
Notifications
You must be signed in to change notification settings - Fork 279
fix: handle Claude Code 2.x JSON array response in summarization #820
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 { | ||||||||||||||||||||||||||||||||||||||
| // 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, fmt.Errorf("failed to parse claude CLI response: %w", 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,41 @@ 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 { | ||||||||||||||||||||||||||||||||||||||
| hasEmptyResultElement := false | ||||||||||||||||||||||||||||||||||||||
| for _, r := range responses { | ||||||||||||||||||||||||||||||||||||||
| if r.Type == "result" { | ||||||||||||||||||||||||||||||||||||||
| if r.Result == "" { | ||||||||||||||||||||||||||||||||||||||
| hasEmptyResultElement = true | ||||||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| return r.Result, nil | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+168
to
+176
|
||||||||||||||||||||||||||||||||||||||
| for _, r := range responses { | |
| if r.Type == "result" && r.Result != "" { | |
| return r.Result, nil | |
| } | |
| } | |
| hasEmptyResultElement := false | |
| for _, r := range responses { | |
| if r.Type == "result" { | |
| if r.Result == "" { | |
| hasEmptyResultElement = true | |
| continue | |
| } | |
| return r.Result, nil | |
| } | |
| } | |
| if hasEmptyResultElement { | |
| return "", errors.New("claude CLI response array contained empty result") | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed — added hasEmptyResultElement tracking in the array path. Now returns 'contained empty result' (matching 1.x behavior) instead of the generic 'no result element' message.
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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,75 @@ 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", | ||||||||||||||||
| }, | ||||||||||||||||
|
||||||||||||||||
| }, | |
| }, | |
| { | |
| name: "array with empty result element", | |
| input: `[{"type":"system"},{"type":"result","result":""}]`, | |
| expectError: "empty result", | |
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added — new test case "array with empty result element" covers this path and expects the "empty result" error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The JSON example in this comment isn’t valid JSON (
{type:"system"}is missing quotes around keys). Since this is explaining a wire format, it’s worth using valid JSON in the example to avoid confusion.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed — doc comment now uses valid JSON with quoted keys.