From e7ced9c3a355fada639216dbbaee53efd7f5dc2c Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Mon, 23 Mar 2026 13:29:19 +0100 Subject: [PATCH] test(01-02): add failing tests then implement session + state - TailReadJSONL: reverse-read JSONL without loading entire file - FindSessionForProcess: match PID to most recent JSONL via EncodePath - DetectState: heuristic based on last message stop_reason + tool name - ExtractPreview: extract first 3 lines of last assistant text - All 17 tests pass (session_test.go + state_test.go) Co-Authored-By: Claude Opus 4.6 (1M context) --- main.go | 5 ++ session.go | 197 ++++++++++++++++++++++++++++++++++++++++++++ session_test.go | 213 ++++++++++++++++++++++++++++++++++++++++++++++++ state.go | 83 +++++++++++++++++++ state_test.go | 182 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 680 insertions(+) create mode 100644 main.go create mode 100644 session.go create mode 100644 session_test.go create mode 100644 state.go create mode 100644 state_test.go diff --git a/main.go b/main.go new file mode 100644 index 0000000..6a0f815 --- /dev/null +++ b/main.go @@ -0,0 +1,5 @@ +package main + +func main() { + // Placeholder - will be implemented in Task 2 +} diff --git a/session.go b/session.go new file mode 100644 index 0000000..4dd81cc --- /dev/null +++ b/session.go @@ -0,0 +1,197 @@ +package main + +import ( + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "sort" +) + +// JSONLMessage represents a single entry in a Claude Code JSONL file. +type JSONLMessage struct { + Type string `json:"type"` + Timestamp string `json:"timestamp"` + SessionID string `json:"sessionId"` + Cwd string `json:"cwd"` + GitBranch string `json:"gitBranch"` + Message *MessagePayload `json:"message,omitempty"` + Data *ProgressData `json:"data,omitempty"` +} + +type MessagePayload struct { + Role string `json:"role"` + Content []ContentBlock `json:"content"` + StopReason string `json:"stop_reason"` +} + +type ContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Name string `json:"name,omitempty"` +} + +type ProgressData struct { + Type string `json:"type"` +} + +const tailBlockSize = 8192 + +// TailReadJSONL reads the last n complete JSONL lines from path without loading +// the entire file. Incomplete trailing lines (race condition with writer) are ignored. +func TailReadJSONL(path string, n int) ([]JSONLMessage, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + size, err := f.Seek(0, io.SeekEnd) + if err != nil { + return nil, err + } + if size == 0 { + return nil, nil + } + + // Read backwards in blocks, collecting complete lines + var rawLines [][]byte + remaining := size + var buf []byte + + for remaining > 0 && len(rawLines) < n+1 { + blockSize := int64(tailBlockSize) + if blockSize > remaining { + blockSize = remaining + } + remaining -= blockSize + + if _, err := f.Seek(remaining, io.SeekStart); err != nil { + return nil, err + } + block := make([]byte, blockSize) + if _, err := io.ReadFull(f, block); err != nil { + return nil, err + } + + // Prepend to accumulated buffer + buf = append(block, buf...) + + // Extract complete lines from buf + rawLines = extractCompleteLines(buf, n+1) + } + + if rawLines == nil { + rawLines = extractCompleteLines(buf, n+1) + } + + // Drop the first "line" if file didn't start at position 0 in our buffer + // Actually, re-extract properly: buf now contains from 'remaining' to end of file. + // If remaining == 0, buf is the entire file. Lines are all complete. + // If remaining > 0, the first partial content before the first \n is not a complete line. + + // Re-parse: split buf on \n, take last n complete lines + lines := splitLines(buf) + + // If buf doesn't end with \n, the last element is a truncated line: discard it + if len(buf) > 0 && buf[len(buf)-1] != '\n' { + if len(lines) > 0 { + lines = lines[:len(lines)-1] + } + } + + // Take the last n lines + if len(lines) > n { + lines = lines[len(lines)-n:] + } + + // Parse each line + var messages []JSONLMessage + for _, line := range lines { + if len(line) == 0 { + continue + } + var msg JSONLMessage + if err := json.Unmarshal(line, &msg); err != nil { + continue // skip corrupt lines + } + messages = append(messages, msg) + } + + return messages, nil +} + +// splitLines splits data by \n, returning non-empty line contents. +func splitLines(data []byte) [][]byte { + var lines [][]byte + start := 0 + for i, b := range data { + if b == '\n' { + line := data[start:i] + if len(line) > 0 { + lines = append(lines, line) + } + start = i + 1 + } + } + // Remaining after last \n (possibly truncated) + if start < len(data) { + lines = append(lines, data[start:]) + } + return lines +} + +// extractCompleteLines is a helper used during block reading (unused in final approach). +func extractCompleteLines(data []byte, max int) [][]byte { + lines := splitLines(data) + if len(lines) > max { + lines = lines[len(lines)-max:] + } + return lines +} + +// FindSessionForProcess finds the most recently modified JSONL file matching +// the process's cwd in claudeDir (typically ~/.claude/projects/). +// Only top-level *.jsonl files are considered (subagent directories are excluded). +func FindSessionForProcess(claudeDir string, proc Process) (string, []JSONLMessage, error) { + encoded := EncodePath(proc.Cwd) + pattern := filepath.Join(claudeDir, encoded, "*.jsonl") + + matches, err := filepath.Glob(pattern) + if err != nil { + return "", nil, err + } + + // Filter out files in subdirectories (Glob with *.jsonl at one level shouldn't + // match deeper, but be defensive) + var topLevel []string + expectedDir := filepath.Join(claudeDir, encoded) + for _, m := range matches { + if filepath.Dir(m) == expectedDir { + topLevel = append(topLevel, m) + } + } + + if len(topLevel) == 0 { + return "", nil, errors.New("no JSONL files found for " + proc.Cwd) + } + + // Sort by mtime, most recent first + sort.Slice(topLevel, func(i, j int) bool { + si, _ := os.Stat(topLevel[i]) + sj, _ := os.Stat(topLevel[j]) + if si == nil || sj == nil { + return false + } + return si.ModTime().After(sj.ModTime()) + }) + + newest := topLevel[0] + messages, err := TailReadJSONL(newest, 5) + if err != nil { + return "", nil, err + } + + return newest, messages, nil +} diff --git a/session_test.go b/session_test.go new file mode 100644 index 0000000..da294b9 --- /dev/null +++ b/session_test.go @@ -0,0 +1,213 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +func writeJSONL(t *testing.T, path string, messages []JSONLMessage) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + enc := json.NewEncoder(f) + for _, msg := range messages { + if err := enc.Encode(msg); err != nil { + t.Fatal(err) + } + } +} + +func makeMessages(n int) []JSONLMessage { + msgs := make([]JSONLMessage, n) + for i := range msgs { + msgs[i] = JSONLMessage{ + Type: "assistant", + Timestamp: time.Now().UTC().Format(time.RFC3339), + SessionID: "test-session", + Cwd: "/tmp", + GitBranch: "main", + Message: &MessagePayload{ + Role: "assistant", + Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("line %d", i+1)}}, + StopReason: "end_turn", + }, + } + } + return msgs +} + +func TestTailReadJSONL(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.jsonl") + writeJSONL(t, path, makeMessages(50)) + + msgs, err := TailReadJSONL(path, 3) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(msgs) != 3 { + t.Fatalf("expected 3 messages, got %d", len(msgs)) + } + // Should be the last 3 (chronological order: 48, 49, 50) + if msgs[0].Message.Content[0].Text != "line 48" { + t.Errorf("first message text = %q, want %q", msgs[0].Message.Content[0].Text, "line 48") + } + if msgs[2].Message.Content[0].Text != "line 50" { + t.Errorf("last message text = %q, want %q", msgs[2].Message.Content[0].Text, "line 50") + } +} + +func TestTailReadJSONL_TruncatedLastLine(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.jsonl") + + // Write 5 complete lines then a truncated line (no trailing \n) + writeJSONL(t, path, makeMessages(5)) + + // Append truncated JSON (no newline) + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + t.Fatal(err) + } + f.WriteString(`{"type":"assistant","timestamp":"2026-01-01T00:00:00Z"`) + f.Close() + + msgs, err := TailReadJSONL(path, 3) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(msgs) != 3 { + t.Fatalf("expected 3 messages (truncated ignored), got %d", len(msgs)) + } + // Last complete line should be line 5 + if msgs[2].Message.Content[0].Text != "line 5" { + t.Errorf("last message text = %q, want %q", msgs[2].Message.Content[0].Text, "line 5") + } +} + +func TestTailReadJSONL_EmptyFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "empty.jsonl") + os.WriteFile(path, []byte{}, 0o644) + + msgs, err := TailReadJSONL(path, 3) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(msgs) != 0 { + t.Errorf("expected 0 messages for empty file, got %d", len(msgs)) + } +} + +func TestTailReadJSONL_SmallFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "small.jsonl") + writeJSONL(t, path, makeMessages(2)) + + msgs, err := TailReadJSONL(path, 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(msgs) != 2 { + t.Fatalf("expected 2 messages, got %d", len(msgs)) + } +} + +func TestFindSessionForProcess(t *testing.T) { + dir := t.TempDir() + cwd := "/home/user/my-project" + encoded := EncodePath(cwd) + projectDir := filepath.Join(dir, encoded) + os.MkdirAll(projectDir, 0o755) + + // Create two JSONL files with different mtimes + older := filepath.Join(projectDir, "old-session.jsonl") + newer := filepath.Join(projectDir, "new-session.jsonl") + + olderMsgs := []JSONLMessage{{ + Type: "assistant", Timestamp: "2026-03-22T10:00:00Z", + SessionID: "old-id", Cwd: cwd, GitBranch: "old-branch", + Message: &MessagePayload{Role: "assistant", Content: []ContentBlock{{Type: "text", Text: "old"}}, StopReason: "end_turn"}, + }} + newerMsgs := []JSONLMessage{{ + Type: "assistant", Timestamp: "2026-03-23T12:00:00Z", + SessionID: "new-id", Cwd: cwd, GitBranch: "feat-x", + Message: &MessagePayload{Role: "assistant", Content: []ContentBlock{{Type: "text", Text: "new"}}, StopReason: "end_turn"}, + }} + + writeJSONL(t, older, olderMsgs) + // Ensure newer has a later mtime + time.Sleep(10 * time.Millisecond) + writeJSONL(t, newer, newerMsgs) + + proc := Process{PID: 42, Cwd: cwd} + jsonlPath, msgs, err := FindSessionForProcess(dir, proc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if jsonlPath != newer { + t.Errorf("expected newest JSONL %q, got %q", newer, jsonlPath) + } + if len(msgs) == 0 { + t.Fatal("expected at least 1 message") + } + if msgs[0].SessionID != "new-id" { + t.Errorf("sessionID = %q, want %q", msgs[0].SessionID, "new-id") + } +} + +func TestFindSessionForProcess_NoMatch(t *testing.T) { + dir := t.TempDir() + proc := Process{PID: 42, Cwd: "/nonexistent/path"} + + _, _, err := FindSessionForProcess(dir, proc) + if err == nil { + t.Fatal("expected error for no matching JSONL, got nil") + } +} + +func TestFindSessionForProcess_ExcludesSubagents(t *testing.T) { + dir := t.TempDir() + cwd := "/home/user/project" + encoded := EncodePath(cwd) + projectDir := filepath.Join(dir, encoded) + os.MkdirAll(projectDir, 0o755) + + // Create a main session JSONL + mainJSONL := filepath.Join(projectDir, "main-session.jsonl") + writeJSONL(t, mainJSONL, []JSONLMessage{{ + Type: "assistant", Timestamp: "2026-03-23T12:00:00Z", + SessionID: "main-id", Cwd: cwd, GitBranch: "main", + Message: &MessagePayload{Role: "assistant", Content: []ContentBlock{{Type: "text", Text: "main"}}, StopReason: "end_turn"}, + }}) + + // Create a subagent JSONL in a subdirectory (should be excluded) + subDir := filepath.Join(projectDir, "some-uuid", "subagents") + os.MkdirAll(subDir, 0o755) + subJSONL := filepath.Join(subDir, "agent-1.jsonl") + writeJSONL(t, subJSONL, []JSONLMessage{{ + Type: "assistant", Timestamp: "2026-03-23T13:00:00Z", + SessionID: "sub-id", Cwd: cwd, GitBranch: "main", + Message: &MessagePayload{Role: "assistant", Content: []ContentBlock{{Type: "text", Text: "sub"}}, StopReason: "end_turn"}, + }}) + + proc := Process{PID: 42, Cwd: cwd} + jsonlPath, msgs, err := FindSessionForProcess(dir, proc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if jsonlPath != mainJSONL { + t.Errorf("expected main JSONL %q, got %q", mainJSONL, jsonlPath) + } + if msgs[0].SessionID != "main-id" { + t.Errorf("should use main session, got sessionID=%q", msgs[0].SessionID) + } +} diff --git a/state.go b/state.go new file mode 100644 index 0000000..2da6fd7 --- /dev/null +++ b/state.go @@ -0,0 +1,83 @@ +package main + +import ( + "strings" + "time" +) + +const IdleThreshold = 60 * time.Second + +// DetectState determines the session state from the last JSONL messages. +// The now parameter enables deterministic testing of the idle threshold. +func DetectState(messages []JSONLMessage, now time.Time) SessionState { + if len(messages) == 0 { + return Unknown + } + + last := messages[len(messages)-1] + + // Check idle threshold first + if ts, err := time.Parse(time.RFC3339, last.Timestamp); err == nil { + if now.Sub(ts) > IdleThreshold { + return Idle + } + } + + if last.Type == "assistant" && last.Message != nil { + switch last.Message.StopReason { + case "end_turn": + return NeedsInput + case "tool_use": + for _, block := range last.Message.Content { + if block.Type == "tool_use" && block.Name == "AskUserQuestion" { + return NeedsInput + } + } + return Working + } + } + + if last.Type == "progress" { + return Working + } + + if last.Type == "user" && last.Message != nil { + for _, block := range last.Message.Content { + if block.Type == "tool_result" { + return Working + } + } + } + + return Unknown +} + +// ExtractPreview finds the last assistant text content and returns the first +// 3 lines, truncated to 200 characters. +func ExtractPreview(messages []JSONLMessage) string { + for i := len(messages) - 1; i >= 0; i-- { + msg := messages[i] + if msg.Type != "assistant" || msg.Message == nil { + continue + } + for _, block := range msg.Message.Content { + if block.Type == "text" && block.Text != "" { + return truncatePreview(block.Text) + } + } + } + return "" +} + +func truncatePreview(text string) string { + lines := strings.SplitN(text, "\n", 4) + if len(lines) > 3 { + lines = lines[:3] + } + result := strings.Join(lines, "\n") + + if len(result) > 200 { + result = result[:200] + "..." + } + return result +} diff --git a/state_test.go b/state_test.go new file mode 100644 index 0000000..34d96e3 --- /dev/null +++ b/state_test.go @@ -0,0 +1,182 @@ +package main + +import ( + "testing" + "time" +) + +var testNow = time.Date(2026, 3, 23, 12, 0, 30, 0, time.UTC) + +func TestDetectState_EndTurnText(t *testing.T) { + msgs := []JSONLMessage{{ + Type: "assistant", + Timestamp: "2026-03-23T12:00:00Z", + Message: &MessagePayload{ + Role: "assistant", + Content: []ContentBlock{{Type: "text", Text: "Done."}}, + StopReason: "end_turn", + }, + }} + + state := DetectState(msgs, testNow) + if state != NeedsInput { + t.Errorf("expected NeedsInput, got %v", state) + } +} + +func TestDetectState_ToolUseAskUser(t *testing.T) { + msgs := []JSONLMessage{{ + Type: "assistant", + Timestamp: "2026-03-23T12:00:00Z", + Message: &MessagePayload{ + Role: "assistant", + Content: []ContentBlock{ + {Type: "text", Text: "Let me ask..."}, + {Type: "tool_use", Name: "AskUserQuestion"}, + }, + StopReason: "tool_use", + }, + }} + + state := DetectState(msgs, testNow) + if state != NeedsInput { + t.Errorf("expected NeedsInput, got %v", state) + } +} + +func TestDetectState_ToolUseOther(t *testing.T) { + msgs := []JSONLMessage{{ + Type: "assistant", + Timestamp: "2026-03-23T12:00:00Z", + Message: &MessagePayload{ + Role: "assistant", + Content: []ContentBlock{ + {Type: "tool_use", Name: "Read"}, + }, + StopReason: "tool_use", + }, + }} + + state := DetectState(msgs, testNow) + if state != Working { + t.Errorf("expected Working, got %v", state) + } +} + +func TestDetectState_Progress(t *testing.T) { + msgs := []JSONLMessage{{ + Type: "progress", + Timestamp: "2026-03-23T12:00:00Z", + Data: &ProgressData{Type: "agent_progress"}, + }} + + state := DetectState(msgs, testNow) + if state != Working { + t.Errorf("expected Working, got %v", state) + } +} + +func TestDetectState_ToolResult(t *testing.T) { + msgs := []JSONLMessage{{ + Type: "user", + Timestamp: "2026-03-23T12:00:00Z", + Message: &MessagePayload{ + Role: "user", + Content: []ContentBlock{{Type: "tool_result"}}, + }, + }} + + state := DetectState(msgs, testNow) + if state != Working { + t.Errorf("expected Working, got %v", state) + } +} + +func TestDetectState_IdleThreshold(t *testing.T) { + // Message timestamp > 60s in the past + msgs := []JSONLMessage{{ + Type: "assistant", + Timestamp: "2026-03-23T11:58:00Z", // 2min30s before testNow + Message: &MessagePayload{ + Role: "assistant", + Content: []ContentBlock{{Type: "text", Text: "Done."}}, + StopReason: "end_turn", + }, + }} + + state := DetectState(msgs, testNow) + if state != Idle { + t.Errorf("expected Idle, got %v", state) + } +} + +func TestDetectState_EmptyMessages(t *testing.T) { + state := DetectState(nil, testNow) + if state != Unknown { + t.Errorf("expected Unknown, got %v", state) + } +} + +func TestExtractPreview(t *testing.T) { + msgs := []JSONLMessage{ + { + Type: "user", + Message: &MessagePayload{ + Role: "user", + Content: []ContentBlock{{Type: "text", Text: "do something"}}, + }, + }, + { + Type: "assistant", + Message: &MessagePayload{ + Role: "assistant", + Content: []ContentBlock{{Type: "text", Text: "Voici le resultat\nLigne 2\nLigne 3\nLigne 4\nLigne 5"}}, + }, + }, + } + + preview := ExtractPreview(msgs) + want := "Voici le resultat\nLigne 2\nLigne 3" + if preview != want { + t.Errorf("ExtractPreview = %q, want %q", preview, want) + } +} + +func TestExtractPreview_NoAssistant(t *testing.T) { + msgs := []JSONLMessage{{ + Type: "user", + Message: &MessagePayload{ + Role: "user", + Content: []ContentBlock{{Type: "text", Text: "hello"}}, + }, + }} + + preview := ExtractPreview(msgs) + if preview != "" { + t.Errorf("expected empty preview, got %q", preview) + } +} + +func TestExtractPreview_LongText(t *testing.T) { + // Text longer than 200 chars should be truncated + longText := "" + for i := 0; i < 100; i++ { + longText += "abcde " + } + + msgs := []JSONLMessage{{ + Type: "assistant", + Message: &MessagePayload{ + Role: "assistant", + Content: []ContentBlock{{Type: "text", Text: longText}}, + }, + }} + + preview := ExtractPreview(msgs) + if len(preview) > 203 { // 200 + "..." + t.Errorf("preview too long: %d chars", len(preview)) + } + if preview[len(preview)-3:] != "..." { + t.Errorf("expected truncation marker '...', got %q", preview[len(preview)-3:]) + } +}