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:
213
session_test.go
Normal file
213
session_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user