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) <noreply@anthropic.com>
This commit is contained in:
Pierre Martin
2026-03-23 13:29:19 +01:00
parent d1c1239e1e
commit e7ced9c3a3
5 changed files with 680 additions and 0 deletions

5
main.go Normal file
View File

@@ -0,0 +1,5 @@
package main
func main() {
// Placeholder - will be implemented in Task 2
}

197
session.go Normal file
View File

@@ -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
}

213
session_test.go Normal file
View File

@@ -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)
}
}

83
state.go Normal file
View File

@@ -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
}

182
state_test.go Normal file
View File

@@ -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:])
}
}