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:
5
main.go
Normal file
5
main.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Placeholder - will be implemented in Task 2
|
||||||
|
}
|
||||||
197
session.go
Normal file
197
session.go
Normal 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
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
83
state.go
Normal file
83
state.go
Normal 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
182
state_test.go
Normal 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:])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user