From c136dba2e07fb012d22392b930a39a2bd46045f4 Mon Sep 17 00:00:00 2001 From: Caleb Piekstra Date: Tue, 22 Sep 2026 14:25:45 -0400 Subject: [PATCH] fix(llmadapters): accept a Claude session listing with no background jobs claude agents --json lists interactive sessions too, and only background sessions carry an id, so an interactive-only listing failed discovery as an unrecognized shape. Recognize the session-listing shape and return no active jobs for it. Closes #615 --- internal/llmadapters/subprocess.go | 20 ++++++++++++- internal/llmadapters/subprocess_test.go | 28 +++++++++++++++++++ .../claude_agents_interactive_only.json | 20 +++++++++++++ .../testdata/claude_agents_mixed.json | 22 +++++++++++++++ 4 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 internal/llmadapters/testdata/claude_agents_interactive_only.json create mode 100644 internal/llmadapters/testdata/claude_agents_mixed.json diff --git a/internal/llmadapters/subprocess.go b/internal/llmadapters/subprocess.go index 05420f1..33a228b 100644 --- a/internal/llmadapters/subprocess.go +++ b/internal/llmadapters/subprocess.go @@ -1817,12 +1817,30 @@ func parseClaudeBGActiveJobs(data []byte) (map[string]bool, error) { } activeJobs := map[string]bool{} collectClaudeBGActiveJobs(jsonRoot, 0, activeJobs) - if len(activeJobs) == 0 && claudeBGJSONShapeNonEmpty(jsonRoot) { + if len(activeJobs) == 0 && claudeBGJSONShapeNonEmpty(jsonRoot) && !isClaudeSessionListing(jsonRoot) { return nil, errors.New("unrecognized non-empty JSON shape") } return activeJobs, nil } +// isClaudeSessionListing reports whether value is the session listing +// `claude agents --json` prints: an array of session objects, each carrying a +// sessionId. Only background sessions carry a job id, so a listing of +// interactive sessions alone is a recognized shape with no active jobs. +func isClaudeSessionListing(value any) bool { + sessions, ok := value.([]any) + if !ok || len(sessions) == 0 { + return false + } + for _, session := range sessions { + fields, ok := session.(map[string]any) + if !ok || claudeBGStateString(fields, "sessionId") == "" { + return false + } + } + return true +} + func collectClaudeBGActiveJobs(value any, depth int, activeJobs map[string]bool) { if depth > claudeBGInspectMaxDepth { return diff --git a/internal/llmadapters/subprocess_test.go b/internal/llmadapters/subprocess_test.go index b058a23..fd0d5e1 100644 --- a/internal/llmadapters/subprocess_test.go +++ b/internal/llmadapters/subprocess_test.go @@ -622,6 +622,34 @@ func TestParseClaudeBGActiveJobs(t *testing.T) { } }) + t.Run("accepts a session listing with only interactive sessions", func(t *testing.T) { + payload, err := os.ReadFile(filepath.Join("testdata", "claude_agents_interactive_only.json")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + activeJobs, err := parseClaudeBGActiveJobs(payload) + if err != nil { + t.Fatalf("parseClaudeBGActiveJobs: %v", err) + } + if len(activeJobs) != 0 { + t.Fatalf("activeJobs = %#v, want empty", activeJobs) + } + }) + + t.Run("collects background ids from a mixed session listing", func(t *testing.T) { + payload, err := os.ReadFile(filepath.Join("testdata", "claude_agents_mixed.json")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + activeJobs, err := parseClaudeBGActiveJobs(payload) + if err != nil { + t.Fatalf("parseClaudeBGActiveJobs: %v", err) + } + if len(activeJobs) != 1 || !activeJobs["de41e310"] { + t.Fatalf("activeJobs = %#v, want only de41e310", activeJobs) + } + }) + t.Run("allows empty arrays", func(t *testing.T) { activeJobs, err := parseClaudeBGActiveJobs([]byte(`[]`)) if err != nil { diff --git a/internal/llmadapters/testdata/claude_agents_interactive_only.json b/internal/llmadapters/testdata/claude_agents_interactive_only.json new file mode 100644 index 0000000..de0fd9b --- /dev/null +++ b/internal/llmadapters/testdata/claude_agents_interactive_only.json @@ -0,0 +1,20 @@ +[ + { + "pid": 10240, + "cwd": "/home/dev/projects/service-a", + "kind": "interactive", + "startedAt": 1789704757845, + "sessionId": "0a119af3-fa96-44b8-b975-e69936e7d0e2", + "name": "service-a-3b", + "status": "idle" + }, + { + "pid": 75433, + "cwd": "/home/dev/projects/service-b", + "kind": "interactive", + "startedAt": 1789732852316, + "sessionId": "ad2fba54-17c1-4fe0-af6b-1722fbf5a636", + "name": "service-b-9f", + "status": "busy" + } +] diff --git a/internal/llmadapters/testdata/claude_agents_mixed.json b/internal/llmadapters/testdata/claude_agents_mixed.json new file mode 100644 index 0000000..f7b4a56 --- /dev/null +++ b/internal/llmadapters/testdata/claude_agents_mixed.json @@ -0,0 +1,22 @@ +[ + { + "pid": 10240, + "cwd": "/home/dev/projects/service-a", + "kind": "interactive", + "startedAt": 1789704757845, + "sessionId": "0a119af3-fa96-44b8-b975-e69936e7d0e2", + "name": "service-a-3b", + "status": "idle" + }, + { + "pid": 7035, + "id": "de41e310", + "cwd": "/home/dev/.local/share/cr/runs/run-1/workbench/reviewers/reviewer-a/repo", + "kind": "background", + "startedAt": 1790101202780, + "sessionId": "de41e310-0409-418b-bc03-809db309cd71", + "name": "read cr prompt", + "status": "busy", + "state": "working" + } +]