Skip to content

Commit fbae2b4

Browse files
committed
feat(claude): support stream-json output format
Add parsing for Claude Code v2.x --output-format stream-json JSONL. Detect format by checking for "type" + "session_id" keys, extract assistant messages with tool_use blocks into HistoryEntry objects.
1 parent 90253a7 commit fbae2b4

6 files changed

Lines changed: 144 additions & 4 deletions

File tree

pkg/claude/reader.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,53 @@ func ReadHistory(r io.Reader) ([]HistoryEntry, error) {
3939
return entries, scanner.Err()
4040
}
4141

42+
// streamJSONLine represents a line in Claude Code's stream-json format
43+
type streamJSONLine struct {
44+
Type string `json:"type"`
45+
Subtype string `json:"subtype,omitempty"`
46+
SessionID string `json:"session_id,omitempty"`
47+
UUID string `json:"uuid,omitempty"`
48+
Message json.RawMessage `json:"message,omitempty"`
49+
Timestamp string `json:"timestamp,omitempty"`
50+
}
51+
52+
// ReadStreamJSON reads Claude Code stream-json JSONL, extracting assistant messages into HistoryEntry objects
53+
func ReadStreamJSON(r io.Reader) ([]HistoryEntry, error) {
54+
var entries []HistoryEntry
55+
scanner := bufio.NewScanner(r)
56+
scanner.Buffer(make([]byte, 1024*1024), 10*1024*1024)
57+
58+
for scanner.Scan() {
59+
line := scanner.Bytes()
60+
if len(line) == 0 {
61+
continue
62+
}
63+
64+
var sj streamJSONLine
65+
if err := json.Unmarshal(line, &sj); err != nil {
66+
continue // skip unparseable lines
67+
}
68+
69+
if sj.Type != "assistant" || len(sj.Message) == 0 {
70+
continue
71+
}
72+
73+
var msg Message
74+
if err := json.Unmarshal(sj.Message, &msg); err != nil {
75+
continue
76+
}
77+
78+
entries = append(entries, HistoryEntry{
79+
SessionID: sj.SessionID,
80+
UUID: sj.UUID,
81+
Timestamp: sj.Timestamp,
82+
Message: msg,
83+
})
84+
}
85+
86+
return entries, scanner.Err()
87+
}
88+
4289
// HistoryIterator provides streaming access to JSONL history
4390
type HistoryIterator struct {
4491
scanner *bufio.Scanner

pkg/claude/reader_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,53 @@ not valid json
5959
}
6060
}
6161

62+
func TestReadStreamJSON(t *testing.T) {
63+
input := `{"type":"system","subtype":"init","cwd":"/tmp","session_id":"sess-1","model":"claude-sonnet-4-20250514","tools":["Bash","Read"]}
64+
{"type":"user","message":{"role":"user","content":[{"type":"text","text":"list files"}]},"session_id":"sess-1","uuid":"msg-1"}
65+
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I'll list the files."},{"type":"tool_use","id":"tu-1","name":"Bash","input":{"command":"ls -la"}}]},"session_id":"sess-1","uuid":"msg-2","timestamp":"2024-01-15T10:00:00Z"}
66+
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu-2","name":"Read","input":{"file_path":"/tmp/foo.go"}}]},"session_id":"sess-1","uuid":"msg-3","timestamp":"2024-01-15T10:01:00Z"}
67+
{"type":"result","subtype":"success","session_id":"sess-1","cost_usd":0.01,"duration_ms":500}`
68+
69+
entries, err := ReadStreamJSON(strings.NewReader(input))
70+
if err != nil {
71+
t.Fatalf("ReadStreamJSON failed: %v", err)
72+
}
73+
74+
if len(entries) != 2 {
75+
t.Fatalf("expected 2 assistant entries, got %d", len(entries))
76+
}
77+
78+
if entries[0].UUID != "msg-2" {
79+
t.Errorf("expected UUID msg-2, got %s", entries[0].UUID)
80+
}
81+
if entries[0].SessionID != "sess-1" {
82+
t.Errorf("expected session sess-1, got %s", entries[0].SessionID)
83+
}
84+
if entries[0].Timestamp != "2024-01-15T10:00:00Z" {
85+
t.Errorf("expected timestamp 2024-01-15T10:00:00Z, got %s", entries[0].Timestamp)
86+
}
87+
88+
toolUses := entries[0].Message.GetToolUses()
89+
if len(toolUses) != 1 || toolUses[0].Name != "Bash" {
90+
t.Errorf("expected Bash tool use in first entry, got %+v", toolUses)
91+
}
92+
93+
toolUses = entries[1].Message.GetToolUses()
94+
if len(toolUses) != 1 || toolUses[0].Name != "Read" {
95+
t.Errorf("expected Read tool use in second entry, got %+v", toolUses)
96+
}
97+
}
98+
99+
func TestReadStreamJSON_EmptyInput(t *testing.T) {
100+
entries, err := ReadStreamJSON(strings.NewReader(""))
101+
if err != nil {
102+
t.Fatalf("unexpected error: %v", err)
103+
}
104+
if len(entries) != 0 {
105+
t.Errorf("expected 0 entries, got %d", len(entries))
106+
}
107+
}
108+
62109
func TestHistoryIterator(t *testing.T) {
63110
jsonl := `{"uuid":"1","sessionId":"s1","timestamp":"2024-01-15T10:00:00Z","message":{"role":"user","content":[{"type":"text","text":"First"}]}}
64111
{"uuid":"2","sessionId":"s1","timestamp":"2024-01-15T10:01:00Z","message":{"role":"assistant","content":[{"type":"text","text":"Second"}]}}`

pkg/claude/stdin.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ import (
88
type StreamFormat string
99

1010
const (
11-
FormatClaudeJSONL StreamFormat = "claude-jsonl"
12-
FormatCodexJSONL StreamFormat = "codex-jsonl"
13-
FormatClaudeCLI StreamFormat = "claude-cli"
14-
FormatUnknown StreamFormat = "unknown"
11+
FormatClaudeJSONL StreamFormat = "claude-jsonl"
12+
FormatClaudeStreamJSON StreamFormat = "claude-stream-json"
13+
FormatCodexJSONL StreamFormat = "codex-jsonl"
14+
FormatClaudeCLI StreamFormat = "claude-cli"
15+
FormatUnknown StreamFormat = "unknown"
1516
)
1617

1718
type ClaudeCLIOutput struct {
@@ -58,6 +59,13 @@ func DetectFormat(firstLine []byte) StreamFormat {
5859
}
5960
}
6061

62+
// Claude Code stream-json: has "type" and "session_id" (not a Codex type)
63+
if _, hasType := m["type"]; hasType {
64+
if _, hasSessionID := m["session_id"]; hasSessionID {
65+
return FormatClaudeStreamJSON
66+
}
67+
}
68+
6169
// Claude CLI JSON: has "result" and "session_id" (underscore)
6270
if _, hasResult := m["result"]; hasResult {
6371
if _, hasSessionID := m["session_id"]; hasSessionID {

pkg/claude/stdin_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ func TestDetectFormat(t *testing.T) {
3232
input: `{"timestamp":"2024-01-01T00:00:00Z","type":"event_msg","payload":{"type":"agent_message","message":"done"}}`,
3333
expected: FormatCodexJSONL,
3434
},
35+
{
36+
name: "claude stream-json init",
37+
input: `{"type":"system","subtype":"init","cwd":"/tmp/project","session_id":"sess-abc","model":"claude-sonnet-4-20250514","tools":["Bash","Read"]}`,
38+
expected: FormatClaudeStreamJSON,
39+
},
40+
{
41+
name: "claude stream-json assistant",
42+
input: `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello"}]},"session_id":"sess-abc","uuid":"msg-1"}`,
43+
expected: FormatClaudeStreamJSON,
44+
},
3545
{
3646
name: "claude cli json",
3747
input: `{"result":"Hello world","session_id":"sess-abc","cost_usd":0.01,"duration_ms":1234,"num_turns":1,"usage":{"input_tokens":100,"output_tokens":50}}`,

pkg/cli/stdin.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ func parseFromReader(data []byte) (*stdinParseResult, error) {
4747
ToolUses: claude.ExtractToolUses(entries),
4848
}, nil
4949

50+
case claude.FormatClaudeStreamJSON:
51+
entries, err := claude.ReadStreamJSON(bytes.NewReader(data))
52+
if err != nil {
53+
return nil, fmt.Errorf("parsing claude stream-json: %w", err)
54+
}
55+
return &stdinParseResult{
56+
Format: format,
57+
ToolUses: claude.ExtractToolUses(entries),
58+
}, nil
59+
5060
case claude.FormatCodexJSONL:
5161
codexUses, err := history.ExtractCodexToolUsesFromReader(bytes.NewReader(data))
5262
if err != nil {

pkg/cli/stdin_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,24 @@ func TestParseFromReader_CodexJSONL(t *testing.T) {
3939
assert.Equal(t, "echo hello", result.ToolUses[0].Input["command"])
4040
}
4141

42+
func TestParseFromReader_ClaudeStreamJSON(t *testing.T) {
43+
data := []byte(`{"type":"system","subtype":"init","cwd":"/tmp","session_id":"sess-1","model":"claude-sonnet-4-20250514","tools":["Bash","Read"]}
44+
{"type":"user","message":{"role":"user","content":[{"type":"text","text":"list files"}]},"session_id":"sess-1","uuid":"msg-1"}
45+
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu-1","name":"Bash","input":{"command":"ls -la"}},{"type":"tool_use","id":"tu-2","name":"Read","input":{"file_path":"/tmp/foo.go"}}]},"session_id":"sess-1","uuid":"msg-2","timestamp":"2024-01-15T10:00:00Z"}
46+
{"type":"result","subtype":"success","session_id":"sess-1","cost_usd":0.01}
47+
`)
48+
49+
result, err := parseFromReader(data)
50+
require.NoError(t, err)
51+
assert.Equal(t, claude.FormatClaudeStreamJSON, result.Format)
52+
assert.Nil(t, result.CLIOut)
53+
require.Len(t, result.ToolUses, 2)
54+
assert.Equal(t, "Bash", result.ToolUses[0].Tool)
55+
assert.Equal(t, "ls -la", result.ToolUses[0].Input["command"])
56+
assert.Equal(t, "Read", result.ToolUses[1].Tool)
57+
assert.Equal(t, "/tmp/foo.go", result.ToolUses[1].Input["file_path"])
58+
}
59+
4260
func TestParseFromReader_ClaudeCLI(t *testing.T) {
4361
data := []byte(`{"result":"Hello world","session_id":"sess-abc","cost_usd":0.01,"duration_ms":1234,"num_turns":1,"usage":{"input_tokens":100,"output_tokens":50}}`)
4462

0 commit comments

Comments
 (0)