Pre-PR codex review flagged missing coverage for the 10 MB scanner buffer called out in design §3.5. Adds: - TestParseLastUsage_largeLine: 5 MB JSONL line with inert padding still extracts the usage object correctly (no silent truncation). - TestParseLastUsage_oversizedLineErrors: 11 MB line surfaces a scanner error cleanly (no panic, nil usage). Follow-up to parent 1.0, dev/plans/26174-planctl-context-tokens/tasks.md.
478 lines
15 KiB
Go
478 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// spec:26174-planctl-context-tokens/R4.4+D§3.8
|
|
func TestFormatTokenCount(t *testing.T) {
|
|
cases := []struct {
|
|
in int64
|
|
want string
|
|
}{
|
|
{0, "< 1 k"},
|
|
{500, "< 1 k"},
|
|
{999, "< 1 k"},
|
|
{1000, "1 k"},
|
|
{1499, "1 k"},
|
|
{1500, "2 k"},
|
|
{143000, "143 k"},
|
|
{1000000, "1000 k"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := formatTokenCount(c.in); got != c.want {
|
|
t.Errorf("formatTokenCount(%d) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R3.1+R3.2+R3.5+D§3.7
|
|
func TestClassify_bands(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
ctx *TokenCtx
|
|
wantSev string
|
|
wantMsg string
|
|
}{
|
|
{"nil ctx", nil, "", ""},
|
|
{"pct 0", &TokenCtx{Used: 0, Limit: 200000}, "", ""},
|
|
{"pct 69", &TokenCtx{Used: 138000, Limit: 200000}, "", ""},
|
|
{"pct 70 info lower", &TokenCtx{Used: 140000, Limit: 200000}, "info", "plan to wrap up this session soon."},
|
|
{"pct 84 info upper", &TokenCtx{Used: 168000, Limit: 200000}, "info", "plan to wrap up this session soon."},
|
|
{"pct 85 warn lower", &TokenCtx{Used: 170000, Limit: 200000}, "warn", "commit current work and start a new session after this task."},
|
|
{"pct 94 warn upper", &TokenCtx{Used: 188000, Limit: 200000}, "warn", "commit current work and start a new session after this task."},
|
|
{"pct 95 error lower", &TokenCtx{Used: 190000, Limit: 200000}, "error", "stop new work; commit and close out immediately."},
|
|
{"pct 100 error", &TokenCtx{Used: 200000, Limit: 200000}, "error", "stop new work; commit and close out immediately."},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
sev, msg := c.ctx.Classify()
|
|
if sev != c.wantSev || msg != c.wantMsg {
|
|
t.Errorf("Classify() = (%q, %q), want (%q, %q)", sev, msg, c.wantSev, c.wantMsg)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R3.3+D§3.7
|
|
func TestClassify_unknownLimit(t *testing.T) {
|
|
ctx := &TokenCtx{Used: 143000, Limit: 0}
|
|
sev, msg := ctx.Classify()
|
|
if sev != "" || msg != "" {
|
|
t.Errorf("Classify() with Limit=0 = (%q, %q), want (\"\", \"\")", sev, msg)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R2.4+D§3.6
|
|
func TestReadLimit_set(t *testing.T) {
|
|
t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "200000")
|
|
if got := readLimit(); got != 200000 {
|
|
t.Errorf("readLimit() = %d, want 200000", got)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R2.4+D§3.6
|
|
func TestReadLimit_unset(t *testing.T) {
|
|
t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "")
|
|
if got := readLimit(); got != 0 {
|
|
t.Errorf("readLimit() unset = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R2.4+D§3.6
|
|
func TestReadLimit_invalid(t *testing.T) {
|
|
t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "not-a-number")
|
|
if got := readLimit(); got != 0 {
|
|
t.Errorf("readLimit() invalid = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R1.5+D§3.4
|
|
func TestTranscriptPath_slash(t *testing.T) {
|
|
got := transcriptPath("abc", "/Users/x", "/Users/x/repos/foo")
|
|
want := "/Users/x/.claude/projects/-Users-x-repos-foo/abc.jsonl"
|
|
if got != want {
|
|
t.Errorf("transcriptPath = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R1.5+D§3.4
|
|
func TestTranscriptPath_nested(t *testing.T) {
|
|
got := transcriptPath("uuid", "/home/u", "/a/b/c/d")
|
|
want := "/home/u/.claude/projects/-a-b-c-d/uuid.jsonl"
|
|
if got != want {
|
|
t.Errorf("transcriptPath nested = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R1.6+D§3.4
|
|
func TestTranscriptPath_fallbackCwd(t *testing.T) {
|
|
got := transcriptPath("uuid", "/h", "")
|
|
// fallback uses os.Getwd; just assert shape contains home and the uuid.jsonl suffix
|
|
if !strings.HasPrefix(got, "/h/.claude/projects/") {
|
|
t.Errorf("fallback path prefix wrong: %q", got)
|
|
}
|
|
if !strings.HasSuffix(got, "/uuid.jsonl") {
|
|
t.Errorf("fallback path suffix wrong: %q", got)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R6.2+D§3.9
|
|
func TestParsePIDFile_valid(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "1234")
|
|
if err := os.WriteFile(p, []byte("817fec78-6ff1-4fb2-a17c-5708ea5df968\n1700000000\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
uuid, stamp, err := parsePIDFile(p)
|
|
if err != nil {
|
|
t.Fatalf("parsePIDFile: %v", err)
|
|
}
|
|
if uuid != "817fec78-6ff1-4fb2-a17c-5708ea5df968" {
|
|
t.Errorf("uuid = %q", uuid)
|
|
}
|
|
if stamp != 1700000000 {
|
|
t.Errorf("stamp = %d, want 1700000000", stamp)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R6.3+D§3.9
|
|
func TestParsePIDFile_missingLine2(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "1234")
|
|
if err := os.WriteFile(p, []byte("817fec78-6ff1-4fb2-a17c-5708ea5df968\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := parsePIDFile(p); err == nil {
|
|
t.Fatal("expected error for missing line 2")
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R6.3+D§3.9
|
|
func TestParsePIDFile_malformedInt(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "1234")
|
|
if err := os.WriteFile(p, []byte("817fec78-6ff1-4fb2-a17c-5708ea5df968\nnot-a-number\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := parsePIDFile(p); err == nil {
|
|
t.Fatal("expected error for malformed int")
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R6.3+D§3.9+D§4.1
|
|
func TestParsePIDFile_trailingContent(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "1234")
|
|
if err := os.WriteFile(p, []byte("uuid-abc\n1700000000\nunexpected\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := parsePIDFile(p); err == nil {
|
|
t.Fatal("expected error for trailing content")
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R6.3+D§3.9+D§4.1
|
|
// Blank third line also disallowed — design §4.1 specifies exactly two newline-terminated lines.
|
|
func TestParsePIDFile_blankThirdLine(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "1234")
|
|
if err := os.WriteFile(p, []byte("uuid-abc\n1700000000\n\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := parsePIDFile(p); err == nil {
|
|
t.Fatal("expected error for blank third line")
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R6.3+D§3.9+D§4.1
|
|
// Missing final newline indicates a partial/truncated write.
|
|
func TestParsePIDFile_missingFinalNewline(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "1234")
|
|
if err := os.WriteFile(p, []byte("uuid-abc\n1700000000"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := parsePIDFile(p); err == nil {
|
|
t.Fatal("expected error for missing final newline")
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R2.1+R2.2+D§3.5
|
|
func TestParseLastUsage_valid(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "t.jsonl")
|
|
content := `{"type":"user","message":{"content":"hi"}}
|
|
{"type":"assistant","message":{"usage":{"input_tokens":10,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}}}
|
|
{"type":"assistant","message":{"usage":{"input_tokens":20,"cache_creation_input_tokens":200,"cache_read_input_tokens":2000}}}
|
|
`
|
|
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := parseLastUsage(p)
|
|
if err != nil {
|
|
t.Fatalf("parseLastUsage: %v", err)
|
|
}
|
|
// last line wins: 20 + 200 + 2000 = 2220
|
|
if got != 2220 {
|
|
t.Errorf("got %d, want 2220", got)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R2.3+D§3.5
|
|
func TestParseLastUsage_malformedMid(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "t.jsonl")
|
|
content := `{"type":"assistant","message":{"usage":{"input_tokens":1}}}
|
|
{not valid json at all
|
|
{"type":"assistant","message":{"usage":{"input_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
|
|
`
|
|
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := parseLastUsage(p)
|
|
if err != nil {
|
|
t.Fatalf("parseLastUsage: %v", err)
|
|
}
|
|
if got != 5 {
|
|
t.Errorf("got %d, want 5 (last good line after malformed skip)", got)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R2.3+D§3.5
|
|
func TestParseLastUsage_noAssistant(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "t.jsonl")
|
|
content := `{"type":"user","message":{"content":"hi"}}
|
|
{"type":"system","message":{"content":"boot"}}
|
|
`
|
|
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := parseLastUsage(p); err == nil {
|
|
t.Fatal("expected errNoUsageLine")
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R2.3+D§3.5
|
|
func TestParseLastUsage_emptyFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "t.jsonl")
|
|
if err := os.WriteFile(p, []byte(""), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := parseLastUsage(p); err == nil {
|
|
t.Fatal("expected errNoUsageLine for empty file")
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R2.1+R2.6+D§3.5+D§7.1
|
|
// parseLastUsage must handle oversized-but-valid JSONL lines — design §3.5
|
|
// sizes the scanner buffer at 10 MB to accommodate large messages. Here we
|
|
// stuff the assistant line with a 5 MB inert string field (ignored by the
|
|
// transcriptUsage struct) to confirm the scanner doesn't truncate.
|
|
func TestParseLastUsage_largeLine(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "big.jsonl")
|
|
f, err := os.Create(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pad := strings.Repeat("x", 5*1024*1024)
|
|
if _, err := f.WriteString(`{"type":"user","message":{"content":"hi"}}` + "\n"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Single 5 MB assistant line with a harmless "noise" field plus the
|
|
// usage object we do care about.
|
|
line := `{"type":"assistant","noise":"` + pad + `","message":{"usage":{"input_tokens":11,"cache_creation_input_tokens":22,"cache_read_input_tokens":33}}}` + "\n"
|
|
if _, err := f.WriteString(line); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := parseLastUsage(p)
|
|
if err != nil {
|
|
t.Fatalf("parseLastUsage on 5 MB line: %v", err)
|
|
}
|
|
if got != 66 {
|
|
t.Errorf("got %d, want 66 (11+22+33)", got)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R2.3+R2.6+D§3.5
|
|
// A line exceeding the scanner's 10 MB cap must error out cleanly (no panic)
|
|
// — the design specifically sizes the buffer at 10 MB; lines larger than
|
|
// that should surface a scanner error via parseLastUsage's sc.Err() check.
|
|
func TestParseLastUsage_oversizedLineErrors(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "huge.jsonl")
|
|
f, err := os.Create(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pad := strings.Repeat("x", 11*1024*1024) // 11 MB > 10 MB buffer
|
|
line := `{"type":"assistant","noise":"` + pad + `","message":{"usage":{"input_tokens":1}}}` + "\n"
|
|
if _, err := f.WriteString(line); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := parseLastUsage(p); err == nil {
|
|
t.Fatal("expected error on oversized line; got none")
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R2.1+D§3.5
|
|
// Zero-usage lines still qualify per PRD R2.1 (presence, not non-zero).
|
|
func TestParseLastUsage_zeroUsageStillQualifies(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "t.jsonl")
|
|
content := `{"type":"assistant","message":{"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
|
|
`
|
|
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := parseLastUsage(p)
|
|
if err != nil {
|
|
t.Fatalf("parseLastUsage: %v", err)
|
|
}
|
|
if got != 0 {
|
|
t.Errorf("got %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R1.1+D§3.3
|
|
func TestResolveSessionUUID_envWins(t *testing.T) {
|
|
t.Setenv("CLAUDE_SESSION_ID", "env-uuid-12345")
|
|
got := resolveSessionUUID()
|
|
if got != "env-uuid-12345" {
|
|
t.Errorf("resolveSessionUUID = %q, want env-uuid-12345", got)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R1.2+R6.1+R6.3+D§3.3
|
|
func TestResolveViaPIDWalk_pidWalkFinds(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// Ancestor chain: 100 -> 200 (winner) -> 300 -> 1
|
|
parents := map[int]int{100: 200, 200: 300, 300: 1}
|
|
parentOf := func(pid int) int { return parents[pid] }
|
|
alive := func(pid int) bool { return pid == 200 }
|
|
stampOf := func(pid int) (int64, error) {
|
|
if pid == 200 {
|
|
return 1_700_000_000, nil
|
|
}
|
|
return 0, nil
|
|
}
|
|
p := filepath.Join(dir, "200")
|
|
if err := os.WriteFile(p, []byte("the-uuid\n1700000000\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
|
|
if got != "the-uuid" {
|
|
t.Errorf("got %q, want the-uuid", got)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R6.3+D§3.3
|
|
func TestResolveViaPIDWalk_staleSkipped(t *testing.T) {
|
|
dir := t.TempDir()
|
|
parents := map[int]int{100: 200, 200: 1}
|
|
parentOf := func(pid int) int { return parents[pid] }
|
|
// dead: returns false → skip
|
|
alive := func(_ int) bool { return false }
|
|
stampOf := func(_ int) (int64, error) { return 1_700_000_000, nil }
|
|
p := filepath.Join(dir, "200")
|
|
if err := os.WriteFile(p, []byte("the-uuid\n1700000000\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
|
|
if got != "" {
|
|
t.Errorf("stale PID should not resolve, got %q", got)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R6.3+D§3.3
|
|
func TestResolveViaPIDWalk_startTimeMismatch(t *testing.T) {
|
|
dir := t.TempDir()
|
|
parents := map[int]int{100: 200, 200: 1}
|
|
parentOf := func(pid int) int { return parents[pid] }
|
|
alive := func(_ int) bool { return true }
|
|
// file says 1700000000, actual says 9999999999 → mismatch → skip
|
|
stampOf := func(_ int) (int64, error) { return 9_999_999_999, nil }
|
|
p := filepath.Join(dir, "200")
|
|
if err := os.WriteFile(p, []byte("the-uuid\n1700000000\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
|
|
if got != "" {
|
|
t.Errorf("start-time mismatch should not resolve, got %q", got)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R1.4+D§3.3
|
|
func TestResolveViaPIDWalk_exhausted(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// Deep chain with no files anywhere.
|
|
parentOf := func(pid int) int {
|
|
if pid <= 1 {
|
|
return 0
|
|
}
|
|
return pid - 1
|
|
}
|
|
alive := func(_ int) bool { return true }
|
|
stampOf := func(_ int) (int64, error) { return 0, nil }
|
|
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
|
|
if got != "" {
|
|
t.Errorf("exhausted walk should return empty, got %q", got)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R1.1+R1.5+R2.1+R2.2+R2.4+D§3.2
|
|
func TestReadTokenCtx_happyPath(t *testing.T) {
|
|
home := t.TempDir()
|
|
projectDir := "/path/to/proj"
|
|
slug := "-path-to-proj"
|
|
uuid := "abc-123"
|
|
dir := filepath.Join(home, ".claude", "projects", slug)
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content := `{"type":"assistant","message":{"usage":{"input_tokens":10,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}}}
|
|
`
|
|
if err := os.WriteFile(filepath.Join(dir, uuid+".jsonl"), []byte(content), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("CLAUDE_PROJECT_DIR", projectDir)
|
|
t.Setenv("CLAUDE_SESSION_ID", uuid)
|
|
t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "2000")
|
|
|
|
ctx := ReadTokenCtx()
|
|
if ctx == nil {
|
|
t.Fatal("ReadTokenCtx returned nil, want non-nil")
|
|
}
|
|
if ctx.Used != 1110 {
|
|
t.Errorf("Used = %d, want 1110", ctx.Used)
|
|
}
|
|
if ctx.Limit != 2000 {
|
|
t.Errorf("Limit = %d, want 2000", ctx.Limit)
|
|
}
|
|
}
|
|
|
|
// spec:26174-planctl-context-tokens/R1.4+D§3.2
|
|
func TestReadTokenCtx_noSession(t *testing.T) {
|
|
t.Setenv("CLAUDE_SESSION_ID", "")
|
|
// Re-route PID walk to an empty dir so ancestor probing finds nothing.
|
|
empty := t.TempDir()
|
|
origDir := pidSessionDir
|
|
pidSessionDir = empty
|
|
t.Cleanup(func() { pidSessionDir = origDir })
|
|
if ctx := ReadTokenCtx(); ctx != nil {
|
|
t.Errorf("expected nil for no session, got %+v", ctx)
|
|
}
|
|
}
|