diff --git a/internal/parse/claude.go b/internal/parse/claude.go index 7dfc2f3..0e5e079 100644 --- a/internal/parse/claude.go +++ b/internal/parse/claude.go @@ -225,8 +225,10 @@ func updateClaude(ent *Entry, line []byte) { if obj.Message == nil { return } - if obj.Message.Model != "" { - ent.Model = obj.Message.Model + // "" is the model id on system-generated messages; it + // must not displace the session's real model in the MODEL column + if m := obj.Message.Model; m != "" && m != "" { + ent.Model = m } if txt := firstUserText(obj.Message); txt != "" { ent.LastText = truncRunes(collapseWS(txt), lastTextW) diff --git a/internal/parse/entry.go b/internal/parse/entry.go index 768d126..647dab0 100644 --- a/internal/parse/entry.go +++ b/internal/parse/entry.go @@ -7,9 +7,13 @@ // weird line, just skip it. package parse -// ParserV is bumped when parsers extract new fields: forces a one-time -// rescan. It tracks the v1 PARSER_V so a v1 cache survives the upgrade. -const ParserV = 6 +// ParserV is bumped when parsers extract new fields OR derive an existing +// field differently: forces a one-time rescan. It tracks the v1 PARSER_V so +// a v1 cache survives the upgrade. v7: title derivation changed (prompts, +// not instruction files or path drops) and model ids are +// ignored — cached v6 titles and models persist otherwise, since an +// unchanged file is never rescanned. +const ParserV = 7 // Entry is one session file's accumulated state. The JSON tags match the // v1 Python cache exactly so ~/.cache/agentdash/usage.json round-trips diff --git a/internal/parse/parse_test.go b/internal/parse/parse_test.go index 48e4b0d..8a8561f 100644 --- a/internal/parse/parse_test.go +++ b/internal/parse/parse_test.go @@ -329,3 +329,46 @@ func TestStatusOf(t *testing.T) { eq(t, "busy", StatusOf(&Entry{Mtime: now - 70, LastType: "user"}, 0, now, th), "busy?") eq(t, "respawn", StatusOf(&Entry{Mtime: now - 10}, 4, now, th), "respawn ×4") } + +// A mixed-version cache: entries scanned by an older binary must be fully +// rescanned by this one, or a derivation change (titles, model filtering) +// never reaches files that stopped growing. This is the long-running-old- +// watcher case: a stale `agentdash -w` kept rewriting v6 entries that the +// upgraded binary then trusted forever. +func TestOldParserVersionEntryIsRescanned(t *testing.T) { + path := filepath.Join(t.TempDir(), "s.jsonl") + body := `{"type":"user","timestamp":"2026-01-01T00:00:00.000Z","message":{"content":"fix the exporter"}}` + "\n" + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + c := NewCache() + // simulate an old binary's entry: fully scanned, junk title, stale V + st, _ := os.Stat(path) + c.Entries[path] = &Entry{Kind: "claude", V: ParserV - 1, Offset: st.Size(), + TitleUser: "# AGENTS.md instructions for /x"} + ent := ScanSession(path, c, "claude", tNow) + eq(t, "V", ent.V, ParserV) + eq(t, "TitleUser after rescan", ent.TitleUser, "fix the exporter") +} + +// system-generated messages carry model "": it must not displace +// the session's real model (a board row read "","usage":{"input_tokens":5}}}` + "\n" + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + ent := ScanSession(path, NewCache(), "claude", tNow) + eq(t, "Model", ent.Model, "claude-fable-5") + + // a session with only synthetic messages reports no model at all + path = filepath.Join(t.TempDir(), "s2.jsonl") + body = `{"type":"assistant","message":{"id":"m1","model":"","usage":{"input_tokens":5}}}` + "\n" + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + ent = ScanSession(path, NewCache(), "claude", tNow) + eq(t, "Model (synthetic only)", ent.Model, "") +}