From 05e73f17477f830b93af2033046ee19902dd3fcb Mon Sep 17 00:00:00 2001 From: TZZheng Date: Tue, 14 Jul 2026 01:34:38 -0500 Subject: [PATCH 1/7] feat(tui): establish direct mail persistence contracts --- tui/internal/fs/ANATOMY.md | 34 ++- tui/internal/fs/direct_mail.go | 67 +++++ tui/internal/fs/direct_mail_test.go | 55 ++++ tui/internal/fs/network.go | 18 +- tui/internal/fs/rail_unread.go | 268 ++++++++++++++++++ tui/internal/fs/rail_unread_test.go | 238 ++++++++++++++++ tui/internal/fs/session.go | 48 ++-- .../fs/session_persistence_role_test.go | 115 ++++++++ tui/internal/fs/session_race_test.go | 2 +- .../fs/session_rebuild_offsets_test.go | 10 +- tui/internal/fs/session_rebuild_test.go | 18 +- tui/internal/fs/session_window_test.go | 46 +-- tui/internal/tui/ANATOMY.md | 4 +- tui/internal/tui/mail.go | 6 +- 14 files changed, 842 insertions(+), 87 deletions(-) create mode 100644 tui/internal/fs/direct_mail.go create mode 100644 tui/internal/fs/direct_mail_test.go create mode 100644 tui/internal/fs/rail_unread.go create mode 100644 tui/internal/fs/rail_unread_test.go create mode 100644 tui/internal/fs/session_persistence_role_test.go diff --git a/tui/internal/fs/ANATOMY.md b/tui/internal/fs/ANATOMY.md index 03e513c8..4c027add 100644 --- a/tui/internal/fs/ANATOMY.md +++ b/tui/internal/fs/ANATOMY.md @@ -17,6 +17,10 @@ related_files: - tui/internal/fs/heartbeat_test.go - tui/internal/fs/mail.go - tui/internal/fs/mail_test.go + - tui/internal/fs/direct_mail.go + - tui/internal/fs/direct_mail_test.go + - tui/internal/fs/rail_unread.go + - tui/internal/fs/rail_unread_test.go - tui/internal/fs/network.go - tui/internal/fs/network_test.go - tui/internal/fs/session.go @@ -24,6 +28,7 @@ related_files: - tui/internal/fs/session_rebuild_offsets_test.go - tui/internal/fs/session_tail_test.go - tui/internal/fs/session_window_test.go + - tui/internal/fs/session_persistence_role_test.go - tui/internal/sqlitelog/event.go - tui/internal/sqlitelog/query_test.go - tui/internal/fs/signal.go @@ -48,7 +53,7 @@ maintenance: | ## What this is -The TUI's filesystem window into an agent working directory (`/.lingtai//`). Agent state — manifest, heartbeat, mail, token ledger, location, network topology, chat history — is read through this package. The kernel owns agent state; the TUI's narrow writes are signal files, human outbox/location, and its derived human `logs/session.jsonl` replay cache. +The TUI's filesystem window into an agent working directory (`/.lingtai//`). Agent state — manifest, heartbeat, mail, token ledger, location, network topology, chat history — is read through this package. The kernel owns agent state; the TUI's narrow writes are signal files, human outbox/location, its derived human `logs/session.jsonl` replay cache, and the TUI-owned direct-mail unread boundary file. ## Components @@ -91,7 +96,12 @@ The TUI's filesystem window into an agent working directory (`/.lingtai | `ReadSent(dir)` | `tui/internal/fs/mail.go:92` | reads `mailbox/sent/` → `[]MailMessage` | | `MailCache` | `tui/internal/fs/mail.go:99` | incremental refresh cache: outbox + inbox + sent merged | | `NewMailCache(humanDir)` | `tui/internal/fs/mail.go:109` | creates cache; `Refresh()` returns updated copy (receiver not mutated) | -| `WriteMail` | `tui/internal/fs/mail.go:237` | writes to recipient inbox + sender sent (or human outbox for pseudo-agent); allocates id via `prepareMailDirs` | +| `WriteMail` | `tui/internal/fs/mail.go:249` | writes to recipient inbox + sender sent (or human outbox for pseudo-agent); allocates id via `prepareMailDirs` | +| **direct_mail.go** | | | +| `NormalizeMailEndpoints` / `IsDirectMail` | `tui/internal/fs/direct_mail.go:5-57` | normalizes the string-or-list `To` schema once and applies strict human-target direct membership without using CC | +| **rail_unread.go** | | | +| `OpenRailUnreadStore` / `SyncTargets` | `tui/internal/fs/rail_unread.go:53-115` | loads versioned per-project boundaries; missing/corrupt state and new/reused/address-changed target identities baseline to the supplied accepted snapshot | +| `UnreadCount` / `MarkSeen` | `tui/internal/fs/rail_unread.go:117-169` | counts incoming direct mail beyond `(maximum timestamp, IDs at timestamp)` and atomically advances an exact accepted boundary | | **ledger.go** | | | | `ReadLedger(dir)` | `tui/internal/fs/ledger.go:17` | reads `delegates/ledger.jsonl` → `[]AvatarEdge` + child dirs | | **location.go** | | | @@ -115,12 +125,12 @@ The TUI's filesystem window into an agent working directory (`/.lingtai | `CleanSignals(dir)` | `tui/internal/fs/signal.go:32` | remove all signal + refresh handshake files | | `SuspendAndWait` | `tui/internal/fs/signal.go:43` | touch `.suspend`, poll heartbeat until dead or timeout | | **session.go** | | | -| `SessionCache` | `tui/internal/fs/session.go:123-162` | mutex-protected derived replay cache backed by human `logs/session.jsonl`; tracks parser-proven offsets plus full-history count metadata | -| `NewSessionCache` | `tui/internal/fs/session.go:166-178` | pure in-memory construction; creates no file or directory | -| `RebuildFromSources` / `RebuildFromSourcesInMemory` | `tui/internal/fs/session.go:184-201` | authoritative full ingest; write-through for accepted callers or detached/no-persist for generation-gated Mail work | -| `RebuildFromSourcesWindowedInMemory` / `Complete` / `ExactHistoryStats` | `tui/internal/fs/session.go:208-220`, `tui/internal/fs/session.go:399-410` | bounded newest-content ingest plus a separately invoked exact metadata count for the captured canonical JSONL source/horizon; partial caches remain memory-only while complete caches may persist | -| `Persist` / `append` | `tui/internal/fs/session.go:300-361` | writes only proven-complete accepted state; both snapshot persistence and incremental disk append decline while a bounded cache is partial | -| `Refresh` | `tui/internal/fs/session.go:2228-2235` | incremental poll from each source's last complete consumed record; partial caches accept additions in memory without writing a misleading derived file | +| `SessionPersistenceRole` / `SessionCache` | `tui/internal/fs/session.go:123-164` | separates the sole `MainAggregateWriter` from `NoPersist`, independently of mutex-protected replay-window completeness and offsets | +| `NewSessionCache` | `tui/internal/fs/session.go:174-190` | pure in-memory construction with an explicit persistence role; creates no file or directory | +| `RebuildFromSources` / `RebuildFromSourcesInMemory` | `tui/internal/fs/session.go:192-204` | authoritative full ingest; write-through requests still pass through the cache's persistence role | +| `RebuildFromSourcesWindowedInMemory` / `Complete` / `ExactHistoryStats` | `tui/internal/fs/session.go:206-230`, `tui/internal/fs/session.go:417-428` | bounded newest-content ingest plus a separately invoked exact metadata count for the captured canonical JSONL source/horizon; completeness prevents partial-file truncation but does not grant write authority | +| `Persist` / `rewriteFile` / `append` | `tui/internal/fs/session.go:311-378` | both write primitives enforce `MainAggregateWriter`; bounded caches independently remain memory-only until complete | +| `Refresh` | `tui/internal/fs/session.go:2244-2252` | incremental poll from each source's last complete consumed record; `NoPersist` caches update memory without appending the shared aggregate | | **project_hash.go** | | | | `ProjectHash(projectPath)` | `tui/internal/fs/project_hash.go:9` | SHA-256 first 12 hex chars — used as the registry key for each project | | **contacts.go** | | | @@ -138,7 +148,7 @@ The TUI's filesystem window into an agent working directory (`/.lingtai - **Called by `tui/internal/inventory/`** — running-agent inventory enriches process rows with `.agent.json`, heartbeat, status PID, lock, admin, IM identity, and orchestrator-role metadata. - **Reads from agent working directories** — `.agent.json`, `.agent.heartbeat`, `.status.json`, `mailbox/*/`, `logs/log.sqlite` (molt/session-boundary and diagnostic indexes, never canonical session replay authority), `logs/token_ledger.jsonl` (main rows only for agent totals/detail), `logs/events.jsonl`, `logs/soul_inquiry.jsonl`, `logs/soul_flow.jsonl`, `delegates/ledger.jsonl`, `mailbox/contacts.json`, `daemons/*/daemon.json`, `daemons/*/logs/token_ledger.jsonl`. - **Writes signal files** (the only agent-owned files the TUI writes): `.sleep`, `.suspend`, `.interrupt`, `.prompt`, `.inquiry`, `.refresh`/`.refresh.taken`. -- **Writes human-owned/derived state** — `WriteMail` writes `human/mailbox/outbox//`; only complete accepted `SessionCache` persistence/appends write `human/logs/session.jsonl` (`tui/internal/fs/session.go:300-361`). +- **Writes human-owned/derived state** — `WriteMail` writes `human/mailbox/outbox//`; only a complete `MainAggregateWriter` changes `human/logs/session.jsonl` (`tui/internal/fs/session.go:311-378`); `RailUnreadStore` atomically writes `.tui-asset/rail-last-seen.json` (`tui/internal/fs/rail_unread.go:192-205`). - **Calls `ipinfo.io`** — `ResolveLocation` makes an HTTP call; `UpdateHumanLocation` caches result in human's `.agent.json`. ## Composition @@ -150,7 +160,7 @@ The TUI's filesystem window into an agent working directory (`/.lingtai ## State - **Reads**: `.agent.json`, `.agent.heartbeat`, `.status.json`, `mailbox/inbox/*`, `mailbox/sent/*`, `logs/log.sqlite` (additive index), `logs/token_ledger.jsonl` (main rows only for agent totals/detail), `logs/events.jsonl`, `logs/soul_inquiry.jsonl`, `logs/soul_flow.jsonl`, `delegates/ledger.jsonl`, `mailbox/contacts.json`, `daemons/*/daemon.json`, `daemons/*/logs/token_ledger.jsonl`. -- **Writes**: signal files (`.sleep`, `.suspend`, `.interrupt`, `.prompt`, `.inquiry`), human `mailbox/outbox/*`, human `.agent.json` location field, and the TUI-derived human `logs/session.jsonl` replay cache only on accepted persist/append paths. +- **Writes**: signal files (`.sleep`, `.suspend`, `.interrupt`, `.prompt`, `.inquiry`), human `mailbox/outbox/*`, human `.agent.json` location field, `.tui-asset/rail-last-seen.json`, and the TUI-derived human `logs/session.jsonl` replay cache only from `MainAggregateWriter` persist/append paths. ## Notes @@ -158,7 +168,9 @@ The TUI's filesystem window into an agent working directory (`/.lingtai - **Mailbox id shape.** `WriteMail` allocates short, human-scannable ids of the form `YYYYMMDDTHHMMSS-xxxx` (20 chars, UTC, 4 hex chars of UUID4 entropy) via `newMailboxID`. This matches the kernel's `_new_mailbox_id` in `lingtai-kernel/src/lingtai/kernel/intrinsics/email/primitives.py` and the portal's mirror in `portal/internal/fs/mail.go`, so directory names, `id`, and `_mailbox_id` look identical regardless of which side wrote the message. The directory name IS the id — `prepareMailDirs` uses `os.Mkdir` (not `MkdirAll`) on each leaf so collisions in any target folder surface as `fs.ErrExist` and trigger up to 8 regenerations without overwriting existing mail. - **`Delivered` is transient.** `MailMessage.Delivered` is `json:"-"` — set by `MailCache.Refresh()` based on which folder the message was found in. Outbox → false; inbox/sent → true. - **`MailCache` is copy-on-refresh.** `Refresh()` returns a new `MailCache`; the receiver is not mutated. Safe for goroutine use. -- **Session cache reconstruction.** `RebuildFromSources` is idempotent — it re-ingests all mail + events + inquiries from offset 0, sorts by timestamp, and rewrites `session.jsonl`; `RebuildFromSourcesInMemory` performs the same read/merge without filesystem writes for detached generation-gated work. Canonical `logs/events.jsonl` owns session content and completeness: the additive SQLite log's source identity and endpoint offsets do not prove interior continuity, so they are not used to declare a replay complete. Every path retains the last complete-record boundary it actually consumed, so trailing partial records and concurrent appends are retried by `Refresh` rather than leaked, duplicated, or skipped. +- **Direct projection and unread boundaries.** `NormalizeMailEndpoints` is the shared list-first parser for direct membership and topology counting; legacy mailbox label formatting remains separate. `RailUnreadStore` never scans mail or advances to wall clock: callers provide an accepted mailbox snapshot, and only incoming target→human direct mail contributes to each boundary/count. Canonical target directory plus address fingerprint makes nickname changes stable while address changes, disappearance, and directory reuse re-baseline. +- **Session persistence role.** `MainAggregateWriter` is the only role authorized to mutate the compatibility aggregate `human/logs/session.jsonl`; `NoPersist` is enforced inside both rewrite and append primitives. `complete` describes whether the in-memory history window can safely replace/extend a complete derived file—it is not write authorization. +- **Session cache reconstruction.** `RebuildFromSources` is idempotent — it re-ingests all mail + events + inquiries from offset 0, sorts by timestamp, and requests a role-gated `session.jsonl` rewrite (only `MainAggregateWriter` writes the file); `RebuildFromSourcesInMemory` performs the same read/merge without filesystem writes for detached generation-gated work. Canonical `logs/events.jsonl` owns session content and completeness: the additive SQLite log's source identity and endpoint offsets do not prove interior continuity, so they are not used to declare a replay complete. Every path retains the last complete-record boundary it actually consumed, so trailing partial records and concurrent appends are retried by `Refresh` rather than leaked, duplicated, or skipped. - **Windowed reconstruction and count metadata.** `RebuildFromSourcesWindowedInMemory` retains only the newest requested parser-produced session-event content window while loading mail/inquiries in full. `mail_page_size` directly owns that initial window and every later Ctrl+U increment. Empty/missing/wrong-type text rows do not spend content slots, while hidden `llm_call` and zero-token `llm_response` grouping carriers still do. The content path captures the canonical JSONL source/horizon but never runs a full-history aggregate. `ExactHistoryStats` is one async metadata task per activation/source/horizon: same-horizon Ctrl+U caches reuse it, while a genuinely newer horizon supersedes the old task. Accepted stats are cache/identity/generation/current-horizon-gated, reused by older-page caches, and incremented for parser-proven EOF refresh rows. JSONL content is read backward from EOF; top-level count/window metadata uses a structural fixed-buffer fast path across arbitrarily long string/nested payloads, enforces the same 10,000-container limit as `encoding/json`, and falls back to canonical one-record decoding whenever a bounded key/type/number lexeme or parser edge is declined. A cut legacy group retains only its nearest hidden `llm_response` marker. Increasing windows rescan the same canonical horizon, include every session row regardless of SQLite sparsity, and become complete only after reaching byte offset zero; parser-proven offsets, stable sort, and the shared completeness gate on both persistence and incremental disk append keep that convergence honest. - **`parseEvent` event-type allow-list.** Only certain `events.jsonl` / `log.sqlite` types become `SessionEntry`s: `thinking`, `diary`, `text_input`, `text_output`, `tool_call`, `tool_result`, `insight`, `soul_flow`, `notification`, `aed`, and `apriori_summary`. Four kernel-side rename/promotion rules at ingest: `consultation_fire → soul_flow` (carries `fire_id` for voice-index inflation against `logs/soul_flow.jsonl`); `notification_pair_injected → notification` (carries `sources []string` and prefers the kernel-logged `summary` string for body, **plus an optional `meta *NotificationMeta`** with `current_time`, `context.{system_tokens,history_tokens,usage}`, and `injection_seq` — the kernel's `build_meta` snapshot at injection time, rendered as a faint footer line by `mail.go`; nil for events written before issue #40); `aed_attempt`/`aed_exhausted`/`aed_timeout → aed` (subtype written to `Source`, body recovered from raw `type` plus per-subtype fields — `attempt`/`error`, `attempts`/`error`, `seconds`); and `apriori_summary_generated`/`apriori_summary_cap_refused`/`apriori_summary_failed`/`apriori_summary_empty`/`apriori_summary_no_summarizer → apriori_summary` (summary metadata and generated text preserved for Ctrl+O rendering). To surface a new event type in the chat replay: extend the rename map (if needed), the allow-list in `parseEventMap` (the `switch eventType` in `tui/internal/fs/session.go`) and the `sqlitelog` session-event filter (`sessionEventFilterSQL` in `tui/internal/sqlitelog/event.go`), `extractSessionEventText`, and the renderer in `tui/internal/tui/mail.go`. - **Provider derivation.** `DeriveLedgerProvider` uses endpoint host substring matching first, then model prefix fallback. Unknown endpoints surface the hostname so the UI still shows a breakdown. diff --git a/tui/internal/fs/direct_mail.go b/tui/internal/fs/direct_mail.go new file mode 100644 index 00000000..1034fa2b --- /dev/null +++ b/tui/internal/fs/direct_mail.go @@ -0,0 +1,67 @@ +package fs + +import "strings" + +// NormalizeMailEndpoints converts the mailbox schema's string-or-list endpoint +// value into one deduplicated list. CC is deliberately not part of this input. +func NormalizeMailEndpoints(value interface{}) []string { + var raw []string + switch endpoints := value.(type) { + case string: + raw = []string{endpoints} + case []string: + raw = endpoints + case []interface{}: + raw = make([]string, 0, len(endpoints)) + for _, endpoint := range endpoints { + if text, ok := endpoint.(string); ok { + raw = append(raw, text) + } + } + default: + return nil + } + + seen := make(map[string]struct{}, len(raw)) + result := make([]string, 0, len(raw)) + for _, endpoint := range raw { + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" { + continue + } + if _, exists := seen[endpoint]; exists { + continue + } + seen[endpoint] = struct{}{} + result = append(result, endpoint) + } + if len(result) == 0 { + return nil + } + return result +} + +// IsDirectMail reports whether msg belongs to the strict human-target thread. +// Only From and To participate; CC never creates membership. +func IsDirectMail(msg MailMessage, humanAddress, targetAddress string) bool { + humanAddress = strings.TrimSpace(humanAddress) + targetAddress = strings.TrimSpace(targetAddress) + from := strings.TrimSpace(msg.From) + if humanAddress == "" || targetAddress == "" { + return false + } + to := NormalizeMailEndpoints(msg.To) + if from == humanAddress { + return endpointListContains(to, targetAddress) + } + return from == targetAddress && endpointListContains(to, humanAddress) +} + +func endpointListContains(endpoints []string, address string) bool { + for _, endpoint := range endpoints { + if endpoint == address { + return true + } + } + return false +} diff --git a/tui/internal/fs/direct_mail_test.go b/tui/internal/fs/direct_mail_test.go new file mode 100644 index 00000000..f4d72470 --- /dev/null +++ b/tui/internal/fs/direct_mail_test.go @@ -0,0 +1,55 @@ +package fs + +import ( + "reflect" + "testing" +) + +func TestNormalizeMailEndpoints(t *testing.T) { + tests := []struct { + name string + to interface{} + want []string + }{ + {name: "string", to: "agent-a", want: []string{"agent-a"}}, + {name: "typed list", to: []string{"agent-a", "agent-b"}, want: []string{"agent-a", "agent-b"}}, + {name: "decoded list", to: []interface{}{"agent-a", 7, "agent-b"}, want: []string{"agent-a", "agent-b"}}, + {name: "trim empty and duplicates", to: []interface{}{" agent-a ", "", "agent-a"}, want: []string{"agent-a"}}, + {name: "unsupported", to: map[string]string{"to": "agent-a"}, want: nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NormalizeMailEndpoints(tt.to); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("NormalizeMailEndpoints(%#v) = %#v, want %#v", tt.to, got, tt.want) + } + }) + } +} + +func TestIsDirectMail(t *testing.T) { + const human = "project/human" + const main = "project/main" + const agentB = "project/agent-b" + tests := []struct { + name string + msg MailMessage + target string + want bool + }{ + {name: "human to scalar target", msg: MailMessage{From: human, To: main}, target: main, want: true}, + {name: "human multi-to belongs to main", msg: MailMessage{From: human, To: []interface{}{main, agentB}}, target: main, want: true}, + {name: "human multi-to belongs to b", msg: MailMessage{From: human, To: []string{main, agentB}}, target: agentB, want: true}, + {name: "b multi-to belongs to b", msg: MailMessage{From: agentB, To: []interface{}{human, main}}, target: agentB, want: true}, + {name: "b multi-to does not belong to main", msg: MailMessage{From: agentB, To: []interface{}{human, main}}, target: main, want: false}, + {name: "cc does not create main membership", msg: MailMessage{From: agentB, To: human, CC: []string{main}}, target: main, want: false}, + {name: "cc leaves b membership direct", msg: MailMessage{From: agentB, To: human, CC: []string{main}}, target: agentB, want: true}, + {name: "human cc only is not direct", msg: MailMessage{From: human, To: agentB, CC: []string{main}}, target: main, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsDirectMail(tt.msg, human, tt.target); got != tt.want { + t.Fatalf("IsDirectMail(%#v, %q, %q) = %v, want %v", tt.msg, human, tt.target, got, tt.want) + } + }) + } +} diff --git a/tui/internal/fs/network.go b/tui/internal/fs/network.go index 830f9ad5..0770ba87 100644 --- a/tui/internal/fs/network.go +++ b/tui/internal/fs/network.go @@ -76,7 +76,7 @@ func buildMailEdges(nodes []AgentNode, baseDir string) []MailEdge { inbox, _ := ReadInbox(n.WorkingDir) for _, msg := range inbox { from := RelativizeAddress(ResolveAddress(msg.From, baseDir), baseDir) - recipients := resolveRecipients(msg.To) + recipients := NormalizeMailEndpoints(msg.To) for _, r := range recipients { counts[edgeKey{from, RelativizeAddress(ResolveAddress(r, baseDir), baseDir)}]++ } @@ -94,22 +94,6 @@ func buildMailEdges(nodes []AgentNode, baseDir string) []MailEdge { return edges } -func resolveRecipients(to interface{}) []string { - switch v := to.(type) { - case string: - return []string{v} - case []interface{}: - var result []string - for _, item := range v { - if s, ok := item.(string); ok { - result = append(result, s) - } - } - return result - } - return nil -} - func computeStats(nodes []AgentNode, mailEdges []MailEdge) NetworkStats { var s NetworkStats for _, n := range nodes { diff --git a/tui/internal/fs/rail_unread.go b/tui/internal/fs/rail_unread.go new file mode 100644 index 00000000..13c4d3a4 --- /dev/null +++ b/tui/internal/fs/rail_unread.go @@ -0,0 +1,268 @@ +package fs + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +const RailUnreadStateVersion = 1 + +type DirectTarget struct { + Directory string + Address string +} + +type unreadBoundary struct { + Timestamp string `json:"timestamp,omitempty"` + IDs []string `json:"ids,omitempty"` +} + +type unreadTargetState struct { + AddressFingerprint string `json:"address_fingerprint"` + LastSeen unreadBoundary `json:"last_seen"` +} + +type railUnreadState struct { + Version int `json:"version"` + Targets map[string]unreadTargetState `json:"targets"` +} + +// RailUnreadStore owns the TUI's durable per-project direct-mail boundaries. +// It is intentionally not a mailbox scanner; callers supply accepted snapshots. +type RailUnreadStore struct { + path string + state railUnreadState +} + +func RailUnreadStatePath(projectDir string) string { + return filepath.Join(projectDir, ".tui-asset", "rail-last-seen.json") +} + +func AddressFingerprint(address string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(address))) + return hex.EncodeToString(sum[:]) +} + +// OpenRailUnreadStore loads the versioned state. Missing, malformed, or +// unsupported state is replaced with an all-read baseline at snapshot. +func OpenRailUnreadStore(projectDir string, targets []DirectTarget, snapshot []MailMessage, humanAddress string) (*RailUnreadStore, error) { + store := &RailUnreadStore{ + path: RailUnreadStatePath(projectDir), + state: railUnreadState{ + Version: RailUnreadStateVersion, + Targets: make(map[string]unreadTargetState), + }, + } + data, err := os.ReadFile(store.path) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("read rail unread state: %w", err) + } + valid := err == nil && json.Unmarshal(data, &store.state) == nil && + store.state.Version == RailUnreadStateVersion && store.state.Targets != nil + if !valid { + store.state = railUnreadState{Version: RailUnreadStateVersion, Targets: make(map[string]unreadTargetState)} + store.baselineTargets(targets, snapshot, humanAddress) + if err := store.write(); err != nil { + return nil, err + } + return store, nil + } + if err := store.SyncTargets(targets, snapshot, humanAddress); err != nil { + return nil, err + } + return store, nil +} + +// SyncTargets drops disappeared directories and baselines new or address-changed +// identities. Existing matching identities retain their last-seen boundary. +func (s *RailUnreadStore) SyncTargets(targets []DirectTarget, snapshot []MailMessage, humanAddress string) error { + current := make(map[string]DirectTarget, len(targets)) + for _, target := range targets { + key := canonicalTargetDirectory(target.Directory) + if key != "" { + current[key] = target + } + } + changed := false + for key := range s.state.Targets { + if _, exists := current[key]; !exists { + delete(s.state.Targets, key) + changed = true + } + } + for key, target := range current { + fingerprint := AddressFingerprint(target.Address) + state, exists := s.state.Targets[key] + if !exists || state.AddressFingerprint != fingerprint { + s.state.Targets[key] = unreadTargetState{ + AddressFingerprint: fingerprint, + LastSeen: incomingBoundary(snapshot, humanAddress, target.Address), + } + changed = true + } + } + if changed { + return s.write() + } + return nil +} + +func (s *RailUnreadStore) UnreadCount(target DirectTarget, snapshot []MailMessage, humanAddress string) int { + state, ok := s.targetState(target) + if !ok { + return 0 + } + boundaryTime := parseMailTime(state.LastSeen.Timestamp) + seenAtBoundary := make(map[string]struct{}, len(state.LastSeen.IDs)) + for _, id := range state.LastSeen.IDs { + seenAtBoundary[id] = struct{}{} + } + count := 0 + for _, msg := range snapshot { + if !isIncomingDirectMail(msg, humanAddress, target.Address) { + continue + } + messageTime := parseMailTime(msg.ReceivedAt) + if messageTime.IsZero() { + continue + } + if boundaryTime.IsZero() || messageTime.After(boundaryTime) { + count++ + continue + } + if messageTime.Equal(boundaryTime) { + if _, seen := seenAtBoundary[mailBoundaryID(msg)]; !seen { + count++ + } + } + } + return count +} + +// MarkSeen advances exactly to the supplied accepted snapshot boundary. The +// target must already match the identity accepted by the latest SyncTargets. +func (s *RailUnreadStore) MarkSeen(target DirectTarget, snapshot []MailMessage, humanAddress string) error { + key := canonicalTargetDirectory(target.Directory) + if key == "" { + return fmt.Errorf("direct target directory is empty") + } + fingerprint := AddressFingerprint(target.Address) + state, exists := s.state.Targets[key] + if !exists { + return fmt.Errorf("direct target is not synchronized") + } + if state.AddressFingerprint != fingerprint { + return fmt.Errorf("direct target identity changed; synchronize targets before marking seen") + } + s.state.Targets[key] = unreadTargetState{ + AddressFingerprint: fingerprint, + LastSeen: incomingBoundary(snapshot, humanAddress, target.Address), + } + return s.write() +} + +func (s *RailUnreadStore) targetState(target DirectTarget) (unreadTargetState, bool) { + state, ok := s.state.Targets[canonicalTargetDirectory(target.Directory)] + if !ok || state.AddressFingerprint != AddressFingerprint(target.Address) { + return unreadTargetState{}, false + } + return state, true +} + +func (s *RailUnreadStore) baselineTargets(targets []DirectTarget, snapshot []MailMessage, humanAddress string) { + for _, target := range targets { + key := canonicalTargetDirectory(target.Directory) + if key == "" { + continue + } + s.state.Targets[key] = unreadTargetState{ + AddressFingerprint: AddressFingerprint(target.Address), + LastSeen: incomingBoundary(snapshot, humanAddress, target.Address), + } + } +} + +func (s *RailUnreadStore) write() error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return fmt.Errorf("create rail unread directory: %w", err) + } + data, err := json.MarshalIndent(s.state, "", " ") + if err != nil { + return fmt.Errorf("marshal rail unread state: %w", err) + } + data = append(data, '\n') + if err := writeJSONAtomic(s.path, data); err != nil { + return fmt.Errorf("write rail unread state: %w", err) + } + return nil +} + +func incomingBoundary(snapshot []MailMessage, humanAddress, targetAddress string) unreadBoundary { + var maximum time.Time + ids := make(map[string]struct{}) + timestamp := "" + for _, msg := range snapshot { + if !isIncomingDirectMail(msg, humanAddress, targetAddress) { + continue + } + messageTime := parseMailTime(msg.ReceivedAt) + if messageTime.IsZero() { + continue + } + switch { + case maximum.IsZero() || messageTime.After(maximum): + maximum = messageTime + timestamp = msg.ReceivedAt + ids = map[string]struct{}{mailBoundaryID(msg): {}} + case messageTime.Equal(maximum): + ids[mailBoundaryID(msg)] = struct{}{} + } + } + result := unreadBoundary{Timestamp: timestamp} + for id := range ids { + if id != "" { + result.IDs = append(result.IDs, id) + } + } + sort.Strings(result.IDs) + return result +} + +func isIncomingDirectMail(msg MailMessage, humanAddress, targetAddress string) bool { + return strings.TrimSpace(msg.From) == strings.TrimSpace(targetAddress) && + IsDirectMail(msg, humanAddress, targetAddress) +} + +func mailBoundaryID(msg MailMessage) string { + if msg.MailboxID != "" { + return msg.MailboxID + } + return msg.ID +} + +func parseMailTime(value string) time.Time { + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return time.Time{} + } + return parsed +} + +func canonicalTargetDirectory(directory string) string { + directory = strings.TrimSpace(directory) + if directory == "" { + return "" + } + absolute, err := filepath.Abs(directory) + if err != nil { + return filepath.Clean(directory) + } + return filepath.Clean(absolute) +} diff --git a/tui/internal/fs/rail_unread_test.go b/tui/internal/fs/rail_unread_test.go new file mode 100644 index 00000000..88670266 --- /dev/null +++ b/tui/internal/fs/rail_unread_test.go @@ -0,0 +1,238 @@ +package fs + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func incomingMail(id, from, human, ts string) MailMessage { + return MailMessage{ID: id, MailboxID: id, From: from, To: human, ReceivedAt: ts} +} + +func TestRailUnreadSameTimestampLateIDAndRestart(t *testing.T) { + projectDir := t.TempDir() + human := "project/human" + target := DirectTarget{Directory: filepath.Join(projectDir, ".lingtai", "agent-b"), Address: "project/agent-b"} + first := incomingMail("id-1", target.Address, human, "2026-07-13T10:00:00Z") + store, err := OpenRailUnreadStore(projectDir, []DirectTarget{target}, []MailMessage{first}, human) + if err != nil { + t.Fatal(err) + } + if got := store.UnreadCount(target, []MailMessage{first}, human); got != 0 { + t.Fatalf("startup unread = %d, want 0", got) + } + + late := incomingMail("id-2", target.Address, human, first.ReceivedAt) + snapshot := []MailMessage{first, late} + if got := store.UnreadCount(target, snapshot, human); got != 1 { + t.Fatalf("same-timestamp late-ID unread = %d, want 1", got) + } + if err := store.MarkSeen(target, snapshot, human); err != nil { + t.Fatal(err) + } + restarted, err := OpenRailUnreadStore(projectDir, []DirectTarget{target}, snapshot, human) + if err != nil { + t.Fatal(err) + } + if got := restarted.UnreadCount(target, snapshot, human); got != 0 { + t.Fatalf("restart unread = %d, want 0", got) + } +} + +func TestRailUnreadMissingAndCorruptStateBaselineCurrentSnapshot(t *testing.T) { + projectDir := t.TempDir() + human := "project/human" + target := DirectTarget{Directory: filepath.Join(projectDir, ".lingtai", "agent-b"), Address: "project/agent-b"} + history := []MailMessage{incomingMail("old", target.Address, human, "2026-07-13T09:00:00Z")} + + store, err := OpenRailUnreadStore(projectDir, []DirectTarget{target}, history, human) + if err != nil { + t.Fatal(err) + } + if got := store.UnreadCount(target, history, human); got != 0 { + t.Fatalf("missing-state baseline unread = %d, want 0", got) + } + path := RailUnreadStatePath(projectDir) + if err := os.WriteFile(path, []byte("not-json"), 0o644); err != nil { + t.Fatal(err) + } + store, err = OpenRailUnreadStore(projectDir, []DirectTarget{target}, history, human) + if err != nil { + t.Fatal(err) + } + if got := store.UnreadCount(target, history, human); got != 0 { + t.Fatalf("corrupt-state baseline unread = %d, want 0", got) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var persisted struct { + Version int `json:"version"` + } + if err := json.Unmarshal(data, &persisted); err != nil || persisted.Version != RailUnreadStateVersion { + t.Fatalf("recovered state = version %d, err %v", persisted.Version, err) + } + if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { + t.Fatalf("atomic write left temp path; stat err = %v", err) + } +} + +func TestRailUnreadIdentityChangesRebaselineAndOutgoingDoesNotCount(t *testing.T) { + projectDir := t.TempDir() + human := "project/human" + dir := filepath.Join(projectDir, ".lingtai", "agent-b") + target := DirectTarget{Directory: dir, Address: "project/agent-b"} + store, err := OpenRailUnreadStore(projectDir, []DirectTarget{target}, nil, human) + if err != nil { + t.Fatal(err) + } + outgoing := MailMessage{ID: "out", MailboxID: "out", From: human, To: target.Address, ReceivedAt: "2026-07-13T10:00:00Z"} + if got := store.UnreadCount(target, []MailMessage{outgoing}, human); got != 0 { + t.Fatalf("outgoing unread = %d, want 0", got) + } + incoming := incomingMail("in", target.Address, human, "2026-07-13T10:01:00Z") + if got := store.UnreadCount(target, []MailMessage{outgoing, incoming}, human); got != 1 { + t.Fatalf("incoming unread = %d, want 1", got) + } + + changed := DirectTarget{Directory: dir, Address: "project/agent-b-v2"} + changedHistory := []MailMessage{incomingMail("new-address-history", changed.Address, human, "2026-07-13T10:02:00Z")} + if err := store.SyncTargets([]DirectTarget{changed}, changedHistory, human); err != nil { + t.Fatal(err) + } + if got := store.UnreadCount(changed, changedHistory, human); got != 0 { + t.Fatalf("address-change unread = %d, want re-baselined 0", got) + } + + if err := store.SyncTargets(nil, changedHistory, human); err != nil { + t.Fatal(err) + } + reusedHistory := append(changedHistory, incomingMail("reused", changed.Address, human, "2026-07-13T10:03:00Z")) + if err := store.SyncTargets([]DirectTarget{changed}, reusedHistory, human); err != nil { + t.Fatal(err) + } + if got := store.UnreadCount(changed, reusedHistory, human); got != 0 { + t.Fatalf("directory-reuse unread = %d, want re-baselined 0", got) + } +} + +func TestRailUnreadUnsupportedVersionBaselinesCurrentSnapshot(t *testing.T) { + projectDir := t.TempDir() + human := "project/human" + target := DirectTarget{Directory: filepath.Join(projectDir, ".lingtai", "agent-b"), Address: "project/agent-b"} + history := []MailMessage{incomingMail("old", target.Address, human, "2026-07-13T09:00:00Z")} + path := RailUnreadStatePath(projectDir) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(`{"version":999,"targets":{}}`), 0o644); err != nil { + t.Fatal(err) + } + + store, err := OpenRailUnreadStore(projectDir, []DirectTarget{target}, history, human) + if err != nil { + t.Fatal(err) + } + if got := store.UnreadCount(target, history, human); got != 0 { + t.Fatalf("unsupported-version baseline unread = %d, want 0", got) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var persisted railUnreadState + if err := json.Unmarshal(data, &persisted); err != nil { + t.Fatal(err) + } + if persisted.Version != RailUnreadStateVersion { + t.Fatalf("recovered version = %d, want %d", persisted.Version, RailUnreadStateVersion) + } +} + +func TestRailUnreadReadErrorIsNotTreatedAsMissingOrCorrupt(t *testing.T) { + projectDir := t.TempDir() + path := RailUnreadStatePath(projectDir) + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } + + _, err := OpenRailUnreadStore(projectDir, nil, nil, "project/human") + if err == nil { + t.Fatal("OpenRailUnreadStore succeeded when the state path was unreadable") + } + if !strings.Contains(err.Error(), "read rail unread state") { + t.Fatalf("error = %q, want read failure context", err) + } +} + +func TestRailUnreadNicknameRenameRetainsBoundary(t *testing.T) { + projectDir := t.TempDir() + human := "project/human" + before := AgentNode{ + WorkingDir: filepath.Join(projectDir, ".lingtai", "agent-b"), + Address: "project/agent-b", + Nickname: "Before", + } + directTarget := func(node AgentNode) DirectTarget { + return DirectTarget{Directory: node.WorkingDir, Address: node.Address} + } + target := directTarget(before) + store, err := OpenRailUnreadStore(projectDir, []DirectTarget{target}, nil, human) + if err != nil { + t.Fatal(err) + } + incoming := incomingMail("in", target.Address, human, "2026-07-13T10:00:00Z") + snapshot := []MailMessage{incoming} + if got := store.UnreadCount(target, snapshot, human); got != 1 { + t.Fatalf("pre-rename unread = %d, want 1", got) + } + + after := before + after.Nickname = "After" + renamedTarget := directTarget(after) + if err := store.SyncTargets([]DirectTarget{renamedTarget}, snapshot, human); err != nil { + t.Fatal(err) + } + if got := store.UnreadCount(renamedTarget, snapshot, human); got != 1 { + t.Fatalf("nickname-only rename unread = %d, want retained 1", got) + } +} + +func TestRailUnreadMarkSeenRequiresSyncedIdentity(t *testing.T) { + projectDir := t.TempDir() + human := "project/human" + target := DirectTarget{Directory: filepath.Join(projectDir, ".lingtai", "agent-b"), Address: "project/agent-b"} + store, err := OpenRailUnreadStore(projectDir, []DirectTarget{target}, nil, human) + if err != nil { + t.Fatal(err) + } + + changed := DirectTarget{Directory: target.Directory, Address: "project/agent-b-v2"} + if err := store.MarkSeen(changed, nil, human); err == nil { + t.Fatal("MarkSeen accepted an address identity that had not been synchronized") + } + if err := store.SyncTargets([]DirectTarget{changed}, nil, human); err != nil { + t.Fatal(err) + } + if err := store.MarkSeen(changed, nil, human); err != nil { + t.Fatalf("MarkSeen rejected synchronized identity: %v", err) + } + + unsynced := DirectTarget{Directory: filepath.Join(projectDir, ".lingtai", "agent-c"), Address: "project/agent-c"} + if err := store.MarkSeen(unsynced, nil, human); err == nil { + t.Fatal("MarkSeen accepted a target that had not been synchronized") + } +} + +func TestRailUnreadNicknameIndependentAddressFingerprint(t *testing.T) { + if AddressFingerprint(" project/agent-b ") != AddressFingerprint("project/agent-b") { + t.Fatal("address fingerprint should normalize surrounding whitespace") + } + if AddressFingerprint("project/agent-b") == AddressFingerprint("project/agent-c") { + t.Fatal("different addresses must have different fingerprints") + } +} diff --git a/tui/internal/fs/session.go b/tui/internal/fs/session.go index a400a84a..9d48598f 100644 --- a/tui/internal/fs/session.go +++ b/tui/internal/fs/session.go @@ -120,6 +120,15 @@ type sessionHistoryCountPlan struct { upper int64 } +// SessionPersistenceRole separates history completeness from permission to +// write the shared human/logs/session.jsonl aggregate. +type SessionPersistenceRole uint8 + +const ( + NoPersist SessionPersistenceRole = iota + MainAggregateWriter +) + // SessionCache is an append-only cache backed by session.jsonl. // It incrementally tails three data sources and appends new entries. // @@ -145,6 +154,7 @@ type SessionCache struct { rebuilding bool // true during RebuildFromSources — suppress file writes complete bool // true when the cache holds the full history; false after a windowed rebuild truncated older events afterRebuildIngest func() // optional deterministic test hook after authoritative reads + persistenceRole SessionPersistenceRole // only MainAggregateWriter may change session.jsonl // soulVoices indexes voices by fire_id, populated by tailing // soul_flow.jsonl. Used to inflate soul_flow SessionEntry bodies that @@ -163,24 +173,25 @@ type soulVoiceRecord struct { // NewSessionCache constructs an in-memory cache without touching the filesystem. // Parent directories are created only by accepted persistence/append writes. -func NewSessionCache(humanDir string, projectPath string) *SessionCache { +func NewSessionCache(humanDir string, projectPath string, persistenceRole SessionPersistenceRole) *SessionCache { return &SessionCache{ - path: filepath.Join(humanDir, "logs", "session.jsonl"), - projectPath: projectPath, - soulVoices: make(map[string][]soulVoiceRecord), + path: filepath.Join(humanDir, "logs", "session.jsonl"), + projectPath: projectPath, + soulVoices: make(map[string][]soulVoiceRecord), + persistenceRole: persistenceRole, // Complete-from-zero: a fresh cache holds nothing, which trivially IS the - // full (empty) history. A plain NewSessionCache + full Refresh + Persist - // (no windowed rebuild) therefore still writes session.jsonl, matching the - // pre-windowing Persist contract. Only a windowed rebuild that PROVES it - // truncated older events flips this false; the JSONL fallback and every - // full rebuild keep it true. + // full (empty) history. A MainAggregateWriter cache followed by full Refresh + // + Persist (no windowed rebuild) may therefore still write session.jsonl, + // matching the pre-windowing completeness contract. NoPersist remains + // memory-only regardless. Only a windowed rebuild that PROVES it truncated + // older events flips this false; full rebuilds keep it true. complete: true, } } // RebuildFromSources reads all data sources from scratch, merges and sorts them -// chronologically, writes session.jsonl, and retains each source's last complete -// consumed-record boundary for subsequent Refresh calls. +// chronologically, requests a rewrite through the role-enforcing primitive, and +// retains each source's last complete consumed-record boundary for Refresh. func (sc *SessionCache) RebuildFromSources(cache MailCache, humanAddr, orchDir, orchName string) { sc.rebuildFromSources(cache, humanAddr, orchDir, orchName, true, 0) } @@ -297,11 +308,10 @@ func (sc *SessionCache) rebuildFromSources(cache MailCache, humanAddr, orchDir, } } -// Persist writes the accepted in-memory snapshot to session.jsonl — but ONLY -// when the cache is proven complete. A partial (windowed) cache holds just the -// newest slice of history; rewriting session.jsonl from it would truncate the -// operator's complete derived replay file. In that case Persist is a no-op and -// the existing complete session.jsonl (if any) is left untouched. +// Persist requests that the accepted in-memory snapshot replace session.jsonl. +// Completeness is a truncation-safety guard, not authorization: rewriteFile +// separately permits only MainAggregateWriter. A partial cache is a no-op so it +// cannot replace the operator's complete derived replay file. func (sc *SessionCache) Persist() { sc.mu.Lock() defer sc.mu.Unlock() @@ -314,6 +324,9 @@ func (sc *SessionCache) Persist() { // rewriteFile overwrites session.jsonl with the current in-memory entries. // The caller must hold sc.mu. func (sc *SessionCache) rewriteFile() { + if sc.persistenceRole != MainAggregateWriter { + return + } if err := os.MkdirAll(filepath.Dir(sc.path), 0o755); err != nil { return } @@ -342,6 +355,9 @@ func (sc *SessionCache) append(entries ...SessionEntry) { if sc.rebuilding || !sc.complete { return } + if sc.persistenceRole != MainAggregateWriter { + return + } // Append to file. Construction is pure, so the first accepted append owns // creation of the derived cache's parent directory. diff --git a/tui/internal/fs/session_persistence_role_test.go b/tui/internal/fs/session_persistence_role_test.go new file mode 100644 index 00000000..c2979884 --- /dev/null +++ b/tui/internal/fs/session_persistence_role_test.go @@ -0,0 +1,115 @@ +package fs + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDetachedSessionCacheSteadyRefreshRemainsMemoryOnly(t *testing.T) { + root := t.TempDir() + humanDir := filepath.Join(root, ".lingtai", "human") + sessionPath := filepath.Join(humanDir, "logs", "session.jsonl") + cache := MailCache{Messages: []MailMessage{{ + ID: "first", + MailboxID: "first", + From: "human", + To: "agent-b", + Message: "first", + ReceivedAt: "2026-07-13T10:00:00Z", + }}} + + sc := NewSessionCache(humanDir, root, NoPersist) + sc.RebuildFromSourcesInMemory(cache, "human", "", "agent-b") + cache.Messages = append(cache.Messages, MailMessage{ + ID: "second", + MailboxID: "second", + From: "agent-b", + To: "human", + Message: "second", + ReceivedAt: "2026-07-13T10:01:00Z", + }) + sc.Refresh(cache, "human", "", "agent-b") + + if _, err := os.Stat(sessionPath); !os.IsNotExist(err) { + t.Fatalf("memory-only cache steady refresh wrote %s; stat err = %v", sessionPath, err) + } + if got := sc.Len(); got != 2 { + t.Fatalf("memory-only cache length = %d, want 2", got) + } +} + +func TestNoPersistSessionCacheNeverChangesAggregateFile(t *testing.T) { + root := t.TempDir() + humanDir := filepath.Join(root, ".lingtai", "human") + sessionPath := filepath.Join(humanDir, "logs", "session.jsonl") + if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil { + t.Fatal(err) + } + const original = "existing-main-aggregate\n" + if err := os.WriteFile(sessionPath, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + cache := MailCache{Messages: []MailMessage{{ + ID: "only", MailboxID: "only", From: "agent-b", To: "human", + Message: "short complete history", ReceivedAt: "2026-07-13T10:00:00Z", + }}} + + sc := NewSessionCache(humanDir, root, NoPersist) + sc.RebuildFromSources(cache, "human", "", "agent-b") + if !sc.Complete() { + t.Fatal("short full rebuild should be complete") + } + sc.Persist() + cache.Messages = append(cache.Messages, MailMessage{ + ID: "next", MailboxID: "next", From: "agent-b", To: "human", + Message: "steady append", ReceivedAt: "2026-07-13T10:01:00Z", + }) + sc.Refresh(cache, "human", "", "agent-b") + + data, err := os.ReadFile(sessionPath) + if err != nil { + t.Fatal(err) + } + if string(data) != original { + t.Fatalf("noPersist changed aggregate file: %q", data) + } +} + +func TestMainAggregateWriterRebuildAndRefreshPersist(t *testing.T) { + root := t.TempDir() + humanDir := filepath.Join(root, ".lingtai", "human") + sessionPath := filepath.Join(humanDir, "logs", "session.jsonl") + cache := MailCache{Messages: []MailMessage{{ + ID: "first", MailboxID: "first", From: "human", To: "main", + Message: "first", ReceivedAt: "2026-07-13T10:00:00Z", + }}} + sc := NewSessionCache(humanDir, root, MainAggregateWriter) + sc.RebuildFromSources(cache, "human", "", "main") + cache.Messages = append(cache.Messages, MailMessage{ + ID: "second", MailboxID: "second", From: "main", To: "human", + Message: "second", ReceivedAt: "2026-07-13T10:01:00Z", + }) + sc.Refresh(cache, "human", "", "main") + + data, err := os.ReadFile(sessionPath) + if err != nil { + t.Fatal(err) + } + if got := len(sc.Entries()); got != 2 { + t.Fatalf("writer entries = %d, want 2", got) + } + if string(data) == "" || !containsAll(string(data), "first", "second") { + t.Fatalf("writer aggregate file missing rebuild/append entries: %q", data) + } +} + +func containsAll(s string, values ...string) bool { + for _, value := range values { + if !strings.Contains(s, value) { + return false + } + } + return true +} diff --git a/tui/internal/fs/session_race_test.go b/tui/internal/fs/session_race_test.go index a6678d09..6ed94a4b 100644 --- a/tui/internal/fs/session_race_test.go +++ b/tui/internal/fs/session_race_test.go @@ -42,7 +42,7 @@ func TestSessionCacheConcurrentRebuildAndRefresh(t *testing.T) { t.Fatal(err) } - sc := NewSessionCache(humanDir, tmp) + sc := NewSessionCache(humanDir, tmp, MainAggregateWriter) cache := NewMailCache(humanDir).Refresh() var wg sync.WaitGroup diff --git a/tui/internal/fs/session_rebuild_offsets_test.go b/tui/internal/fs/session_rebuild_offsets_test.go index 691cba2f..e7f08991 100644 --- a/tui/internal/fs/session_rebuild_offsets_test.go +++ b/tui/internal/fs/session_rebuild_offsets_test.go @@ -13,7 +13,7 @@ func TestSessionCacheConstructionAndDetachedRebuildAreFilesystemPure(t *testing. orchDir := filepath.Join(root, "orch") writeSessionTestFile(t, filepath.Join(orchDir, "logs", "events.jsonl"), `{"ts":1781300001,"type":"text_output","text":"detached"}`+"\n") - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) if _, err := os.Stat(humanDir); !os.IsNotExist(err) { t.Fatalf("constructor touched human directory: stat error = %v", err) } @@ -36,7 +36,7 @@ func TestRebuildPreservesTrailingPartialJSONLRecords(t *testing.T) { `{"ts":1781300001,"type":"text_output","text":"first"}`+"\n"+ `{"ts":1781300002,"type":"text_output","text":"sec`) - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) cache := NewMailCache(humanDir).Refresh() sc.RebuildFromSourcesInMemory(cache, "human", orchDir, "orch") appendSessionTestFile(t, path, `ond"}`+"\n") @@ -51,7 +51,7 @@ func TestRebuildPreservesTrailingPartialJSONLRecords(t *testing.T) { `{"ts":"2026-06-12T21:33:21Z","source":"human","voice":"first"}`+"\n"+ `{"ts":"2026-06-12T21:33:22Z","source":"insight","voice":"sec`) - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) cache := NewMailCache(humanDir).Refresh() sc.RebuildFromSourcesInMemory(cache, "human", orchDir, "orch") appendSessionTestFile(t, path, `ond"}`+"\n") @@ -66,7 +66,7 @@ func TestRebuildPreservesTrailingPartialJSONLRecords(t *testing.T) { path := filepath.Join(orchDir, "logs", "soul_flow.jsonl") writeSessionTestFile(t, path, `{"kind":"voice","fire_id":"fire-partial","source":"insights","voice":"partial`) - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) cache := NewMailCache(humanDir).Refresh() sc.RebuildFromSourcesInMemory(cache, "human", orchDir, "orch") appendSessionTestFile(t, path, ` voice"}`+"\n") @@ -86,7 +86,7 @@ func TestRebuildPreservesAppendsThatLandAfterAuthoritativeReads(t *testing.T) { writeSessionTestFile(t, inquiryPath, `{"ts":"2026-06-12T21:33:21Z","source":"human","voice":"before inquiry"}`+"\n") writeSessionTestFile(t, soulPath, "") - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) sc.afterRebuildIngest = func() { appendSessionTestFile(t, eventsPath, `{"ts":1781300003,"type":"text_output","text":"during event"}`+"\n") appendSessionTestFile(t, inquiryPath, `{"ts":"2026-06-12T21:33:23Z","source":"insight","voice":"during inquiry"}`+"\n") diff --git a/tui/internal/fs/session_rebuild_test.go b/tui/internal/fs/session_rebuild_test.go index 9c6b8eaa..b08abf3c 100644 --- a/tui/internal/fs/session_rebuild_test.go +++ b/tui/internal/fs/session_rebuild_test.go @@ -45,7 +45,7 @@ func TestRebuildDeduplicatesMailAcrossRestart(t *testing.T) { } // First rebuild (simulates fresh launch) - sc1 := NewSessionCache(humanDir, tmp) + sc1 := NewSessionCache(humanDir, tmp, MainAggregateWriter) cache1 := NewMailCache(humanDir).Refresh() sc1.RebuildFromSources(cache1, "human", orchDir, "xiake") firstLen := sc1.Len() @@ -54,7 +54,7 @@ func TestRebuildDeduplicatesMailAcrossRestart(t *testing.T) { } // Second rebuild (simulates relaunch — the bug scenario) - sc2 := NewSessionCache(humanDir, tmp) + sc2 := NewSessionCache(humanDir, tmp, MainAggregateWriter) cache2 := NewMailCache(humanDir).Refresh() sc2.RebuildFromSources(cache2, "human", orchDir, "xiake") secondLen := sc2.Len() @@ -88,7 +88,7 @@ func TestIngestMailWatermarkSkipsOldMail(t *testing.T) { t.Fatal(err) } - sc := NewSessionCache(humanDir, tmp) + sc := NewSessionCache(humanDir, tmp, MainAggregateWriter) sc.lastMailTs = "2026-04-10T00:00:00Z" // simulate post-rebuild watermark cache := MailCache{ @@ -126,7 +126,7 @@ func TestRefreshDoesNotReingestSQLiteHistory(t *testing.T) { sessionSQLiteInsert(1.0, "text_input", "hello from sqlite", rootSource, 0, "agent_events", "agent"), ) - sc := NewSessionCache(humanDir, tmp) + sc := NewSessionCache(humanDir, tmp, MainAggregateWriter) cache := NewMailCache(humanDir).Refresh() sc.afterRebuildIngest = func() { appendSessionTestFile(t, eventsPath, @@ -157,7 +157,7 @@ func TestCanonicalJSONLRebuildIgnoresForeignSQLiteRows(t *testing.T) { sessionSQLiteInsert(1.5, "text_output", "daemon leaked", daemonSource, int64(len(firstLine+tailLine)+100), "daemon_events", "daemon"), ) - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) cache := NewMailCache(humanDir).Refresh() sc.RebuildFromSourcesInMemory(cache, "human", orchDir, "orch") assertSessionBodiesExactly(t, sc.Entries(), "root indexed", "root tail") @@ -180,7 +180,7 @@ func TestCanonicalJSONLRebuildIgnoresForeignSQLiteRows(t *testing.T) { sessionSQLiteInsert(1.5, "text_output", "foreign leaked", foreignSource, foreignOffset, "agent_events", "agent"), ) - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) cache := NewMailCache(humanDir).Refresh() sc.RebuildFromSourcesInMemory(cache, "human", orchDir, "orch") assertSessionBodiesExactly(t, sc.Entries(), "root indexed", "root must not be skipped") @@ -202,7 +202,7 @@ func TestCanonicalJSONLRebuildIncludesRowsMissingFromSQLite(t *testing.T) { sessionSQLiteInsert(1.0, "text_input", "covered before query", rootSource, 0, "agent_events", "agent"), ) - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) cache := NewMailCache(humanDir).Refresh() sc.RebuildFromSourcesInMemory(cache, "human", orchDir, "orch") assertSessionBodiesExactly(t, sc.Entries(), "covered before query", "missing from sqlite") @@ -223,7 +223,7 @@ func TestSQLiteReplayRejectsInvalidNoNewlineBoundary(t *testing.T) { sessionSQLiteInsert(1.0, "text_input", "complete only after newline", rootSource, 0, "agent_events", "agent"), ) - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) cache := NewMailCache(humanDir).Refresh() sc.RebuildFromSourcesInMemory(cache, "human", orchDir, "orch") assertSessionBodiesExactly(t, sc.Entries()) @@ -255,7 +255,7 @@ func TestSQLiteReplayFallsBackWhenRootIdentityCannotBeProven(t *testing.T) { ` runSessionSQLiteSQL(t, sqliteBin, orchDir, oldSchema) - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) cache := NewMailCache(humanDir).Refresh() sc.RebuildFromSourcesInMemory(cache, "human", orchDir, "orch") sc.Refresh(cache, "human", orchDir, "orch") diff --git a/tui/internal/fs/session_window_test.go b/tui/internal/fs/session_window_test.go index e4d96f21..fea0d202 100644 --- a/tui/internal/fs/session_window_test.go +++ b/tui/internal/fs/session_window_test.go @@ -156,7 +156,7 @@ func TestWindowedRebuildTailIndexReachesJSONLPrefixOnOlderLoad(t *testing.T) { cache := NewMailCache(humanDir).Refresh() // (1) Initial newest window of 3 → newest 3 events (e9,e10,e11), partial. - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) sc.RebuildFromSourcesWindowedInMemory(cache, "human", orchDir, "orch", 3) assertSessionMarkersExactly(t, sc.Entries(), "e9", "e10", "e11") if sc.Complete() { @@ -166,14 +166,14 @@ func TestWindowedRebuildTailIndexReachesJSONLPrefixOnOlderLoad(t *testing.T) { // (2) A larger explicit older request (window 8) exhausts the 4 indexed tail // rows and must reach the un-indexed JSONL prefix — no duplicates, no order // break. Newest 8 events are e4..e11. - sc2 := NewSessionCache(humanDir, root) + sc2 := NewSessionCache(humanDir, root, MainAggregateWriter) sc2.RebuildFromSourcesWindowedInMemory(cache, "human", orchDir, "orch", 8) assertSessionMarkersExactly(t, sc2.Entries(), "e4", "e5", "e6", "e7", "e8", "e9", "e10", "e11") // (3) A window >= whole history (12) reaches the whole prefix → complete and // persistable. - sc3 := NewSessionCache(humanDir, root) + sc3 := NewSessionCache(humanDir, root, MainAggregateWriter) sc3.RebuildFromSourcesWindowedInMemory(cache, "human", orchDir, "orch", 12) assertSessionMarkersExactly(t, sc3.Entries(), "e0", "e1", "e2", "e3", "e4", "e5", "e6", "e7", "e8", "e9", "e10", "e11") @@ -197,7 +197,7 @@ func TestWindowedExactEqualWindowIsComplete(t *testing.T) { cache := NewMailCache(humanDir).Refresh() // Window exactly equal to history reaches byte offset zero and is complete. - scEqual := NewSessionCache(humanDir, root) + scEqual := NewSessionCache(humanDir, root, MainAggregateWriter) scEqual.RebuildFromSourcesWindowedInMemory(cache, "human", orchDir, "orch", 4) assertSessionBodiesExactly(t, scEqual.Entries(), "e0", "e1", "e2", "e3") if !scEqual.Complete() { @@ -205,7 +205,7 @@ func TestWindowedExactEqualWindowIsComplete(t *testing.T) { } // A larger request remains complete and stable. - scNext := NewSessionCache(humanDir, root) + scNext := NewSessionCache(humanDir, root, MainAggregateWriter) scNext.RebuildFromSourcesWindowedInMemory(cache, "human", orchDir, "orch", 8) assertSessionBodiesExactly(t, scNext.Entries(), "e0", "e1", "e2", "e3") if !scNext.Complete() { @@ -222,7 +222,7 @@ func TestWindowedRebuildKeepsOnlyNewestEventsButFullEOFOffset(t *testing.T) { root, humanDir, orchDir := newSessionTestDirs(t) buildWindowSQLiteEvents(t, sqliteBin, orchDir, 10) - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) cache := NewMailCache(humanDir).Refresh() sc.RebuildFromSourcesWindowedInMemory(cache, "human", orchDir, "orch", 3) @@ -251,7 +251,7 @@ func TestWindowedRebuildWithoutSQLitePreservesLegacyGroupBoundary(t *testing.T) `{"type":"tool_result","ts":5,"tool_name":"grep","status":"ok"}` + "\n" writeSessionTestFile(t, eventsPath, content) - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) sc.RebuildFromSourcesWindowedInMemory(NewMailCache(humanDir).Refresh(), "human", orchDir, "orch", 3) entries := sc.Entries() if len(entries) != 4 || entries[0].Type != "llm_response" { @@ -275,7 +275,7 @@ func TestWindowedRebuildWithoutSQLiteRetainsOnlyNewestContentAndCountsAll(t *tes writeSessionTestFile(t, eventsPath, content) cache := NewMailCache(humanDir).Refresh() - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) sc.RebuildFromSourcesWindowedInMemory(cache, "human", orchDir, "orch", 3) assertSessionBodiesExactly(t, sc.Entries(), "e7", "e8", "e9") if sc.Complete() { @@ -299,7 +299,7 @@ func TestWindowedRebuildWithoutSQLiteRetainsOnlyNewestContentAndCountsAll(t *tes t.Fatalf("stats after tail refresh = %+v, want Detailed=11", stats) } - complete := NewSessionCache(humanDir, root) + complete := NewSessionCache(humanDir, root, MainAggregateWriter) complete.RebuildFromSourcesWindowedInMemory(cache, "human", orchDir, "orch", 20) if !complete.Complete() || complete.Len() != 11 { t.Fatalf("larger no-index window = complete %v len %d, want true/11", complete.Complete(), complete.Len()) @@ -316,7 +316,7 @@ func TestJSONLWindowSkipsHugeOlderBodyAndCountsItFromBoundedMetadata(t *testing. sessionEventJSONL(4, "text_output", "e3") writeSessionTestFile(t, eventsPath, content) - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) sc.RebuildFromSourcesWindowedInMemory(NewMailCache(humanDir).Refresh(), "human", orchDir, "orch", 2) assertSessionBodiesExactly(t, sc.Entries(), "e2", "e3") stats, _, err := sc.ExactHistoryStats() @@ -351,7 +351,7 @@ func TestWindowedRebuildSparseInteriorIndexUsesCanonicalJSONL(t *testing.T) { createSessionSQLite(t, sqliteBin, orchDir, inserts) cache := NewMailCache(humanDir).Refresh() - newest := NewSessionCache(humanDir, root) + newest := NewSessionCache(humanDir, root, MainAggregateWriter) newest.RebuildFromSourcesWindowedInMemory(cache, "human", orchDir, "orch", 3) assertSessionBodiesExactly(t, newest.Entries(), "e7", "e8", "e9") if newest.Complete() { @@ -362,14 +362,14 @@ func TestWindowedRebuildSparseInteriorIndexUsesCanonicalJSONL(t *testing.T) { t.Fatalf("canonical sparse-index stats = %+v err=%v, want 10 detailed", stats, err) } - older := NewSessionCache(humanDir, root) + older := NewSessionCache(humanDir, root, MainAggregateWriter) older.RebuildFromSourcesWindowedInMemory(cache, "human", orchDir, "orch", 6) assertSessionBodiesExactly(t, older.Entries(), "e4", "e5", "e6", "e7", "e8", "e9") if older.Complete() { t.Fatal("interior holes are incorporated, but older canonical history still remains") } - all := NewSessionCache(humanDir, root) + all := NewSessionCache(humanDir, root, MainAggregateWriter) all.RebuildFromSourcesWindowedInMemory(cache, "human", orchDir, "orch", 10) assertSessionBodiesExactly(t, all.Entries(), "e0", "e1", "e2", "e3", "e4", "e5", "e6", "e7", "e8", "e9") if !all.Complete() { @@ -386,7 +386,7 @@ func TestSessionMetadataStructuralLateKeysAndNestedDecoys(t *testing.T) { `{"nested":{"type":"text_output","text":"nested only","input_tokens":5},"padding":"` + huge + `","type":"not_session","ts":3}` + "\n" writeSessionTestFile(t, eventsPath, content) - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) sc.RebuildFromSourcesWindowedInMemory(NewMailCache(humanDir).Refresh(), "human", orchDir, "orch", 10) entries := sc.Entries() if len(entries) != 2 || entries[0].Type != "text_output" || entries[0].Body != "late text" || entries[1].Type != "llm_response" { @@ -418,7 +418,7 @@ func TestWindowedRebuildSkipsNonRenderableTextMetadata(t *testing.T) { }, "\n") + "\n" writeSessionTestFile(t, eventsPath, content) - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) sc.RebuildFromSourcesWindowedInMemory(NewMailCache(humanDir).Refresh(), "human", orchDir, "orch", 5) entries := sc.Entries() wantTypes := []string{"llm_call", "text_output", "llm_response", "insight", "text_output"} @@ -476,7 +476,7 @@ func TestSessionMetadataCanonicalNumericFallback(t *testing.T) { }) } - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) sc.RebuildFromSourcesWindowedInMemory(NewMailCache(humanDir).Refresh(), "human", orchDir, "orch", 10) entries := sc.Entries() if len(entries) != 2 || entries[0].Body != "finite ignored number" || entries[1].Type != "llm_response" { @@ -521,7 +521,7 @@ func TestSessionMetadataCanonicalDepthLimit(t *testing.T) { t.Fatalf("over-depth metadata unexpectedly accepted: %+v", got) } - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) sc.RebuildFromSourcesWindowedInMemory(NewMailCache(humanDir).Refresh(), "human", orchDir, "orch", 10) entries := sc.Entries() if len(entries) != 1 || entries[0].Body != "root plus 9999 arrays" { @@ -541,7 +541,7 @@ func TestWindowedRebuildLargerThanHistoryIsComplete(t *testing.T) { root, humanDir, orchDir := newSessionTestDirs(t) buildWindowSQLiteEvents(t, sqliteBin, orchDir, 4) - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) cache := NewMailCache(humanDir).Refresh() sc.RebuildFromSourcesWindowedInMemory(cache, "human", orchDir, "orch", 2000) @@ -560,7 +560,7 @@ func TestPersistRefusesPartialWindowedCache(t *testing.T) { sessionPath := filepath.Join(humanDir, "logs", "session.jsonl") - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) cache := NewMailCache(humanDir).Refresh() sc.RebuildFromSourcesWindowedInMemory(cache, "human", orchDir, "orch", 3) sc.Persist() @@ -581,7 +581,7 @@ func TestPartialWindowRefreshDoesNotAppendSessionFile(t *testing.T) { cache := NewMailCache(humanDir).Refresh() sessionPath := filepath.Join(humanDir, "logs", "session.jsonl") - partial := NewSessionCache(humanDir, root) + partial := NewSessionCache(humanDir, root, MainAggregateWriter) partial.RebuildFromSourcesWindowedInMemory(cache, "human", orchDir, "orch", 2) if partial.Complete() { t.Fatal("two-entry window over four events must be partial") @@ -603,7 +603,7 @@ func TestPartialWindowRefreshDoesNotAppendSessionFile(t *testing.T) { t.Fatalf("partial EOF refresh must leave session.jsonl absent; stat err = %v", err) } - complete := NewSessionCache(humanDir, root) + complete := NewSessionCache(humanDir, root, MainAggregateWriter) complete.Refresh(cache, "human", orchDir, "orch") complete.Persist() if data, err := os.ReadFile(sessionPath); err != nil || len(data) == 0 { @@ -623,7 +623,7 @@ func TestFreshCacheRefreshPersistsAsComplete(t *testing.T) { writeSessionTestFile(t, eventsPath, `{"ts":1781300001,"type":"text_output","text":"only"}`+"\n") - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) cache := NewMailCache(humanDir).Refresh() // A plain Refresh reads the whole file from offset 0 — a full, complete load. sc.Refresh(cache, "human", orchDir, "orch") @@ -649,7 +649,7 @@ func TestWindowedRebuildUnboundedMatchesLegacy(t *testing.T) { root, humanDir, orchDir := newSessionTestDirs(t) buildWindowSQLiteEvents(t, sqliteBin, orchDir, 5) - sc := NewSessionCache(humanDir, root) + sc := NewSessionCache(humanDir, root, MainAggregateWriter) cache := NewMailCache(humanDir).Refresh() sc.RebuildFromSourcesWindowedInMemory(cache, "human", orchDir, "orch", 0) diff --git a/tui/internal/tui/ANATOMY.md b/tui/internal/tui/ANATOMY.md index f1f63d9c..1a9cc81b 100644 --- a/tui/internal/tui/ANATOMY.md +++ b/tui/internal/tui/ANATOMY.md @@ -86,7 +86,7 @@ Screen routing is centralized in the `App` struct (`app.go`), which holds every ### Screens - **`tui/internal/tui/firstrun.go:136-4483`** — `FirstRunModel`. Multi-step wizard with constructors for three flows: `NewFirstRunModel` (new project), `NewSetupModeModel` (reconfiguring an existing agent), and `NewRehydrateModel` (cloned network). Its background bootstrap calls `config.EnsureRuntimeQuiet`, so first-run venv creation is followed by the same non-blocking Python `lingtai` upgrade check as returning-user startup. Steps (`firstRunStep` enum, `tui/internal/tui/firstrun.go:62-77`): Welcome → API Key → Pick Preset → Edit Preset → Preset Key → Capabilities → Agent Presets → Agent Name/Dir → Recipe → (optional) Rehydrate Propagate → Launching. The Codex credential row behaviour is mode-split: in first-run (non-setupMode) it opens the inline two-method login chooser (`tui/internal/tui/firstrun.go:1059-1074`, rendered at `tui/internal/tui/firstrun.go:2355-2369`) — browser OAuth/localhost or device code — both routed through `startCodexLogin` (`tui/internal/tui/firstrun.go:603-628`, which always passes `forceLogin=false` to `startOAuthFlow` because first-run only manages the primary/legacy account) with epoch-gated stale-completion drops; in setupMode (`/setup`) it emits `ViewChangeMsg{View:"login"}` (`tui/internal/tui/firstrun.go:1155-1157`) to hand off to the dedicated Setup → Credentials subpage instead. Pressing Esc while a Codex login is mid-flight or the method chooser is open cancels that login and stays on the picker (clearing the spinner/URL/code) rather than exiting to home. Codex preset selectability is judged per-preset by `codexPresetAuthValid` — it resolves the preset's own `manifest.llm.codex_auth_path` (empty → legacy fallback) and validates that specific account file, so one missing Codex account never blocks a differently-bound codex preset. First-run manages the primary (legacy) account; additional accounts are added from Setup → Credentials. The Claude Code auth row that previously rendered below Codex is hidden for now (that auth path is unsupported); detection (`refreshClaudeCodeAuth` / `claudeCodeAuthConfigured`) still runs and feeds the `claude-agent-sdk` credential guard, only the user-visible row is suppressed. Emits `FirstRunDoneMsg` when complete. -- **`MailModel` / windowed history** (`tui/internal/tui/mail.go:155-390`, `tui/internal/tui/mail.go:792-1054`) — The network home screen owns async session reconstruction, chronological message rendering, compose input, and finite history reveal. Displayed mail rows are projected exactly once from live `fs.MailCache` in both the default and `ctrl+o` layers; derived `SessionCache` mail copies are skipped while non-mail event replay remains unchanged. The same mail row renderer is used in every layer: no verbose-only metadata header is added, and each live mail row shows its full local date/time (`tui/internal/tui/mail.go:585-673`, `tui/internal/tui/mail.go:1708-1736`). `mail_page_size` is the single owner of both the newest content-cache window and the UI render/Ctrl+U batch (100/200/500/1000/2000; default 200). Initial content paints from exactly one page; every real Ctrl+U requests exactly one further page from the same canonical JSONL horizon, and completeness is accepted only when that scan reaches the beginning. One canonical JSONL metadata task runs asynchronously per activation/source/horizon, so the first content frame never waits for a full-history scan and the UI keeps a neutral loading label until exact metadata arrives. Same-horizon older rebuilds reuse the accepted/in-flight task; a genuinely newer horizon supersedes it once. Accepted counts are generation/cache/current-horizon-gated and advanced by EOF refresh; partial caches keep EOF additions memory-only and never write the derived disk cache, while complete caches use the existing generation/cache-identity persistence gate. +- **`MailModel` / windowed history** (`tui/internal/tui/mail.go:155-390`, `tui/internal/tui/mail.go:792-1054`) — The network home screen owns async session reconstruction, chronological message rendering, compose input, and finite history reveal. Displayed mail rows are projected exactly once from live `fs.MailCache` in both the default and `ctrl+o` layers; derived `SessionCache` mail copies are skipped while non-mail event replay remains unchanged. The same mail row renderer is used in every layer: no verbose-only metadata header is added, and each live mail row shows its full local date/time (`tui/internal/tui/mail.go:585-673`, `tui/internal/tui/mail.go:1708-1736`). `mail_page_size` is the single owner of both the newest content-cache window and the UI render/Ctrl+U batch (100/200/500/1000/2000; default 200). Initial content paints from exactly one page; every real Ctrl+U requests exactly one further page from the same canonical JSONL horizon, and completeness is accepted only when that scan reaches the beginning. One canonical JSONL metadata task runs asynchronously per activation/source/horizon, so the first content frame never waits for a full-history scan and the UI keeps a neutral loading label until exact metadata arrives. Same-horizon older rebuilds reuse the accepted/in-flight task; a genuinely newer horizon supersedes it once. Accepted counts are generation/cache/current-horizon-gated and advanced by EOF refresh; partial caches keep EOF additions memory-only and never write the derived disk cache. Main constructs every current cache explicitly as `fs.MainAggregateWriter` (`tui/internal/tui/mail.go:264`, `tui/internal/tui/mail.go:302-303`, `tui/internal/tui/mail.go:344-345`), while the fs write primitives enforce that role independently of completeness. - **`tui/internal/tui/props.go:22-1593`** — `PropsModel` (KANBAN). Agent dashboard: status, heartbeat, token ledger visualization, Ctrl+D detail with current and last molt-session API/cache stats (including Codex transfer-mode `full` / `incremental` counts when present) plus lifecycle `tool_call` counts and `tool_calls/api_call` averages rendered by the same `appendSessionAPIStats` (`tui/internal/tui/props.go:1546`) — the tool counts are sourced independently from the events store via `fs.SumMoltSessionToolCalls`, overlaid in `loadDetail` so their freshness never rides the token-ledger cache, recent-call lanes split between selected-agent calls and stacked daemon calls (each row showing cached hits, cache `miss` = input − cached via `cacheMiss` at `tui/internal/tui/props.go:1297`, and cache-hit rate), retained context statistics, selected-agent daemon run counts, network activity, session history, signal controls, and preset health using both Codex OAuth and Claude Code CLI auth state. Constructor: `NewPropsModel` (`tui/internal/tui/props.go:84`). **Soul flow is shown as an on/off status, not a raw delay** — `renderSoulFlowValue` (`tui/internal/tui/props.go:698`) reads the opt-in from the agent's `env_file` (`config.SoulFlowEnabledInEnvFile`, resolved by `soulFlowEnvPath`, `tui/internal/tui/props.go:723`): disabled → localized "disabled · opt in via `LINGTAI_SOUL_FLOW_ENABLED` · see soul-manual"; enabled → cadence from `soul.delay` (or a "default cadence" label when omitted). The huge delay sentinel is never surfaced as an off switch. - **`tui/internal/tui/library.go:615-989`** — `LibraryModel`. Skill catalog browser, agent-scoped — scans `/.library/` plus all effective `skills.paths`: `readLibraryPaths` (`tui/internal/tui/library.go:270`) prefers the kernel-published resolved-manifest artifact `/system/manifest.resolved.json` (kernel issue #259) and falls back to raw `init.json` when the artifact is absent or malformed (stopped / never-booted agents). Constructor: `NewLibraryModel` (`tui/internal/tui/library.go:650`). Renders skill metadata in a sidebar list with markdown content pane. - **`tui/internal/tui/doctor.go:66-1485`** — `DoctorModel`. Health check screen. First runs `config.RunDoctorUpdate` (forced TUI + Python upgrade) and refreshes `preset.Bootstrap`, utility skills, and `ExportCommandsJSON`; then continues with the traditional diagnostics — version drift, heartbeat stats, capability validation, Python venv path, kernel CLI probe, LLM reachability. Constructor: `NewDoctorModel` (`tui/internal/tui/doctor.go:88`). Once complete it keeps an in-memory `doctorreport.Draft` and offers `[r] save report` (`canSaveReport`/`saveReportCmd`, `tui/internal/tui/doctor.go:114`) to write a redacted bundle under `/reports/` via `internal/doctorreport` without rerunning the diagnostic; it captures no event logs. The same forced-update routine is reachable from the shell as `lingtai-tui doctor`, useful when the TUI cannot start. @@ -144,7 +144,7 @@ Screen routing is centralized in the `App` struct (`app.go`), which holds every ## State -- **Writes:** per-project `settings.json` (orchestrator selection, mail page size, theme, language), signal files, and `init.json` during setup/preset edits. Mail's accepted initial completion is the narrow derived-cache exception: after its generation gate it persists human `logs/session.jsonl`; detached/stale rebuilds do not write (`tui/internal/tui/mail.go:698-713`). +- **Writes:** per-project `settings.json` (orchestrator selection, mail page size, theme, language), signal files, and `init.json` during setup/preset edits. Main Mail is the narrow derived-cache exception: after its generation/cache gate it asks its explicitly-role-bound `MainAggregateWriter` to persist human `logs/session.jsonl` (`tui/internal/tui/mail.go:1001-1008`); detached/stale rebuilds never reach that accepted call. - **Reads:** agent working directories (`.agent.json`, `.agent.heartbeat`, `init.json`, `knowledge//KNOWLEDGE.md` (legacy `codex/codex.json` / `knowledge/knowledge.json` only via one-time migration), `mailbox/`, `logs/token_ledger.jsonl`, `history/chat_history.jsonl`, `system/*.md`, `.library/`). Global config (`~/.lingtai-tui/config.json`, `presets/`, `runtime/`). - **Ephemeral:** `App.currentView`, `App.startupBanner`, `App.recoveryMode`, visit state (`visiting`, original context, target context, double-Esc timing), and `mailGeneration`. All screens maintain local cursor positions, scroll offsets, and input buffers — lost on process exit (Bubble Tea is stateless across launches). diff --git a/tui/internal/tui/mail.go b/tui/internal/tui/mail.go index af775557..3d8caa8c 100644 --- a/tui/internal/tui/mail.go +++ b/tui/internal/tui/mail.go @@ -261,7 +261,7 @@ func NewMailModel(humanDir, humanAddr, baseDir, orchDir, orchName string, pageSi insightsEnabled: insights, toolCallTruncate: toolCallTruncate, dismissedInsights: make(map[string]bool), - sessionCache: fs.NewSessionCache(humanDir, filepath.Dir(baseDir)), + sessionCache: fs.NewSessionCache(humanDir, filepath.Dir(baseDir), fs.MainAggregateWriter), // The authoritative session rebuild is deferred to initialRebuild() (see // below), so the first frames render before history is loaded. Show a // loading banner at the top of the stream until that rebuild's refresh @@ -299,7 +299,7 @@ func (m MailModel) initialRebuild() tea.Msg { // mail_page_size directly owns both the initial newest content window and the // visible/reveal batch. Exact full-history metadata is launched separately // after this bounded content result is accepted. - sessionCache := fs.NewSessionCache(m.humanDir, filepath.Dir(m.baseDir)) + sessionCache := fs.NewSessionCache(m.humanDir, filepath.Dir(m.baseDir), fs.MainAggregateWriter) sessionCache.RebuildFromSourcesWindowedInMemory(cache, m.humanAddr, m.orchestrator, m.orchDisplayName(), m.pageSize) m.cache = cache // Tag the resulting refresh as the initial one so the handler can clear the @@ -341,7 +341,7 @@ func (m MailModel) requestOlderPage() (MailModel, tea.Cmd) { // accepts this generation. func (m MailModel) olderPageCmd(window int, generation uint64) tea.Msg { cache := m.cache.Refresh() - sessionCache := fs.NewSessionCache(m.humanDir, filepath.Dir(m.baseDir)) + sessionCache := fs.NewSessionCache(m.humanDir, filepath.Dir(m.baseDir), fs.MainAggregateWriter) sessionCache.RebuildFromSourcesWindowedInMemory(cache, m.humanAddr, m.orchestrator, m.orchDisplayName(), window) return mailOlderPageMsg{ generation: generation, From 76ab358434ada1f15ee43dfb2acd4816a3026c5a Mon Sep 17 00:00:00 2001 From: TZZheng Date: Tue, 14 Jul 2026 05:26:32 -0500 Subject: [PATCH 2/7] refactor(tui): extract project mail store lifecycle --- tui/ANATOMY.md | 26 +- tui/internal/config/ANATOMY.md | 4 +- tui/internal/tui/ANATOMY.md | 43 +- tui/internal/tui/app.go | 257 +++++++-- tui/internal/tui/app_visit_test.go | 4 +- tui/internal/tui/auto_refresh.go | 16 +- tui/internal/tui/home_telemetry.go | 4 +- .../tui/home_telemetry_context_source_test.go | 4 +- .../home_telemetry_kanban_consistency_test.go | 2 +- .../tui/home_telemetry_ui_no_io_test.go | 2 +- tui/internal/tui/home_telemetry_view_test.go | 4 +- tui/internal/tui/mail.go | 135 ++--- tui/internal/tui/mail_generation_test.go | 70 ++- tui/internal/tui/mail_launch_defer_test.go | 23 +- tui/internal/tui/mail_loading_state_test.go | 8 +- tui/internal/tui/mail_mailbox_source_test.go | 4 +- tui/internal/tui/mail_window_test.go | 6 +- tui/internal/tui/project_mail_store.go | 338 +++++++++++ .../tui/project_mail_store_contract_test.go | 534 ++++++++++++++++++ .../project_mail_store_test_helpers_test.go | 46 ++ tui/internal/tui/recipe_save.go | 7 +- tui/internal/tui/settings_page_size_test.go | 12 +- tui/internal/tui/status_hint_copy_test.go | 2 +- tui/main.go | 3 - 24 files changed, 1276 insertions(+), 278 deletions(-) create mode 100644 tui/internal/tui/project_mail_store.go create mode 100644 tui/internal/tui/project_mail_store_contract_test.go create mode 100644 tui/internal/tui/project_mail_store_test_helpers_test.go diff --git a/tui/ANATOMY.md b/tui/ANATOMY.md index b0e05aee..13d6a68e 100644 --- a/tui/ANATOMY.md +++ b/tui/ANATOMY.md @@ -48,11 +48,11 @@ This folder is the self-contained Go module for the `lingtai-tui` terminal UI bi ## Components -- **`tui/main.go:33-1138`** — single-file `package main`. The version stamp (`tui/main.go:31`, set via `-ldflags`), welcome/help text, Rust toolchain startup guidance (`tui/main.go:688-756`), and interactive entry (`tui/main.go:33-96`). After parsing subcommands, it runs global migrations, checks invariants (init.json all-or-nothing, exactly-one-orchestrator), handles upgrade prompts and first-run wizard routing, then launches Bubble Tea. -- **`tui/main.go:35-96`** — subcommand dispatch. Each subcommand returns early; the fallthrough path starts the interactive TUI. +- **`tui/main.go:33-1192`** — single-file `package main`. The version stamp (`tui/main.go:31`, set via `-ldflags`), welcome/help text, Rust toolchain startup guidance (`tui/main.go:668-740`), and interactive entry (`tui/main.go:33-342`). After parsing subcommands, it runs global migrations, checks invariants (init.json all-or-nothing, exactly-one-orchestrator), handles upgrade prompts and first-run wizard routing, then launches Bubble Tea. +- **`tui/main.go:35-89`** — subcommand dispatch. Each subcommand returns early; the fallthrough path starts the interactive TUI. - **`list_common.go` / `list_unix.go` / `list_windows.go`** — `lingtai-tui list` process discovery and decentralized running-agent inventory rendering. Platform files call shared `processscan` discovery and fail loud with a nonzero exit when the scan command itself fails (`tui/list_unix.go:19-24`, `tui/list_windows.go:19-24`); `internal/inventory` converts process rows into typed, enriched, grouped records (`tui/internal/inventory/inventory.go:105-167`), and `list_common.go` keeps CLI parsing plus table/JSON rendering (`tui/list_common.go:47-181`). `--admin` is a detail mode that adds admin, IM, and state columns; it is not an admin-only filter. -- **`upgrade.go`** — startup TUI binary upgrade flow: after install-method routing (`tui/main.go:113-119`), Homebrew installs keep the existing prompt that detects other running TUI windows, puts affected agents to sleep, stops old TUI processes, and runs `brew upgrade` before asking the user to relaunch; source/user-local installs get a separate explicit `[y/N]` prompt that routes through the source updater backend (`install.sh --update`). Unknown installs are not mutated at startup (version-only). -- **`tui/main.go:688-756`** — `maybePromptRustToolchain` / `markRustPromptSeen`: one-time optional Rust/Cargo startup prompt. Only prompts on an interactive TTY when the managed runtime is on the Python file-search fallback and no `cargo` is on PATH. Writes the `~/.lingtai-tui/runtime/rust-toolchain-prompted` marker on decline/install/skip — including when the probe errors or reports an unsupported runtime — so the Python probe never re-spawns on every launch. +- **`upgrade.go`** — startup TUI binary upgrade flow: after install-method routing (`tui/main.go:103-112`), Homebrew installs keep the existing prompt that detects other running TUI windows, puts affected agents to sleep, stops old TUI processes, and runs `brew upgrade` before asking the user to relaunch; source/user-local installs get a separate explicit `[y/N]` prompt that routes through the source updater backend (`install.sh --update`). Unknown installs are not mutated at startup (version-only). +- **`tui/main.go:668-740`** — `maybePromptRustToolchain` / `markRustPromptSeen`: one-time optional Rust/Cargo startup prompt. Only prompts on an interactive TTY when the managed runtime is on the Python file-search fallback and no `cargo` is on PATH. Writes the `~/.lingtai-tui/runtime/rust-toolchain-prompted` marker on decline/install/skip — including when the probe errors or reports an unsupported runtime — so the Python probe never re-spawns on every launch. - **`tui/main.go:824-972`** — `cleanMain`/`cleanProject`: suspend agents in `.lingtai/` (10s timeout), then `os.RemoveAll`. Refuses to delete while any agent heartbeat is still fresh after the timeout, when agents cannot be listed, or when an agent appeared during the wait (re-discovered before deleting) — unless `--force` is given (issue #488). - **`tui/main.go:974-1022`** — `postmanMain`: parse `--port`, collect watch directories, call `postman.Run`. - **`tui/purge_common.go:9-39` / `tui/purge_unix.go:17-80`** — `purgeMain` (unix): shared processscan-to-purge-target filtering, then SIGTERM → SIGKILL survivors. Build tag `!windows`. @@ -67,19 +67,19 @@ This folder is the self-contained Go module for the `lingtai-tui` terminal UI bi - **`Makefile:1-23`** — build, dev (fast local), cross-compile (darwin/linux × arm64/amd64), clean. Version stamp via `-ldflags "-X main.version=$(VERSION)"` where `VERSION` is `git describe --tags --always`. - **`tui/i18n/i18n.go:10`** — `//go:embed en.json zh.json wen.json`. The only embed target in the root `tui/` package; all other embeds are in `internal/preset/`. - **`tui/internal/`** — all substantive packages (tui screens, preset engine, migration system, filesystem readers, process launcher, headless JSON CLI surface, postman, lock shims). -- **`tui/internal/headless/`** — JSON-emitting non-interactive surface. `RunPresets` (lists templates/saved presets as JSON), `RunSpawn` (creates a project + launches an agent), and `ExitError` (structured error codes). Wired from `main.go` via `bootstrapMain` (`tui/main.go:970`), `presetsMain` (`tui/main.go:1058`), and `spawnMain` (`tui/main.go:1082`). For agents and scripts that drive `lingtai-tui` without the Bubble Tea UI. +- **`tui/internal/headless/`** — JSON-emitting non-interactive surface. `RunPresets` (lists templates/saved presets as JSON), `RunSpawn` (creates a project + launches an agent), and `ExitError` (structured error codes). Wired from `main.go` via `bootstrapMain` (`tui/main.go:1024`), `presetsMain` (`tui/main.go:1112`), and `spawnMain` (`tui/main.go:1136`). For agents and scripts that drive `lingtai-tui` without the Bubble Tea UI. ## Connections - **Called from:** the shell (`lingtai-tui`), Homebrew tap (`lingtai-ai/lingtai/lingtai-tui`), `install.sh`. - **Calls out:** `tui/internal/tui` (Bubble Tea app), `tui/internal/sqlitelog` (sqlite3-backed `logs/log.sqlite` query helpers for notification events, session boundaries, diagnostics, doctor errors, and clear completion checks), `tui/internal/migrate` (per-project migrations), `tui/internal/globalmigrate` (per-machine migrations), `tui/internal/preset` (bootstrap + utility skill population), `tui/internal/process` (agent launch), `tui/internal/processscan` (shared ps-based agent-process detection), `tui/internal/inventory` (typed running-agent inventory shared by CLI list and `/projects`), `tui/internal/config` (global config, venv, upgrade checks, install-method-routed TUI updater), `tui/internal/postman` (mail relay daemon). -- **Locale resolution** (`tui/main.go:132-134`): immediately after `globalDir` is known, the TUI runs `config.MigrateLegacyLanguage` → `config.LoadTUIConfig` → `i18n.SetLang(tuiCfg.Language)` so every user-visible startup string (codex banner, welcome, agent-count reminder) renders in the configured locale rather than the i18n default. `tuiCfg` is reused for the rest of bootstrap. -- **Bootstrap sequence** (`tui/main.go:218-288`): on every launch, the TUI initializes project-local `.lingtai/` state with `process.InitProject`, registers the project, and explicitly refreshes user-level utility skills via `preset.PopulateBundledLibrary(globalDir)` before returning-user runtime/bootstrap work. Returning users then run `config.NeedsVenv` (for setup banner) → `config.EnsureRuntime` (create/repair venv if needed, then always run the non-blocking `CheckUpgrade`) → `preset.Bootstrap` → `tui.ExportCommandsJSON` → `maybePromptRustToolchain` (one-time optional Rust/Cargo prompt only when file search is on Python fallback and no cargo is on PATH). `CheckUpgrade` auto-upgrades the `lingtai` meta-package from PyPI, which bundles `lingtai-kernel` + all addon MCPs. See `tui/internal/config/ANATOMY.md`. -- **`lingtai-tui doctor` subcommand** (`tui/main.go:980-1020`): runs `config.RunDoctorUpdate` (`tui/main.go:992`) with both `ForceTUI=true` and `ForcePython=true`, then refreshes presets, utility skills, and `commands.json`. The report includes native file-search sidecar / Rust toolchain diagnostics (`config.checkFileSearchNative`). Designed to be usable when the TUI cannot start (broken venv, missing migrations) — it never touches `.lingtai/`. Exit nonzero only on unrecoverable failures. -- **`lingtai-tui self-update` subcommand** (`tui/main.go:1022-1043`): runs `config.RunManualTUIUpdate` (`tui/main.go:1029`) through the detected install-method backend. Homebrew installs run the brew updater; source/user-local installs run the source updater backend (`install.sh --update`); unknown installs produce unsupported guidance without trying brew. -- **Version flow:** `Makefile:4` injects `git describe` into `tui/main.go:31`. On startup, `tui/main.go:125` calls `tui.SetTUIVersion(version)`, which stores it for `/doctor` drift detection. -- **TUI binary upgrade:** `tui/main.go:111-120` checks for a newer release, detects the current install method (`config.DetectCurrentTUIInstall`), then routes on `install.Method` (`tui/main.go:113-119`) and delegates newer-release handling to `upgrade.go` (`handleTUIUpgrade`) for Homebrew and source/user-local installs. Homebrew keeps the existing confirmation flow, can put agents in their projects to sleep, stops other TUI processes, runs the Homebrew backend, and asks the user to relaunch. Source/user-local installs require an explicit `[y/N]` yes before running the source updater backend through `install.sh --update`; unknown installs keep the version-only startup path and do not mutate. -- **i18n loading:** `i18n/i18n.go` embeds the three locale JSONs; `tui/main.go:142` sets the active locale from `tuiCfg.Language` early in startup (see Locale resolution above) so startup banners localize correctly. Each catalog holds only its own language — there are no bilingual entries (a `mail.initial_loading` that mixed `loading...` and `加载中...` was the documented anti-pattern, since split per-locale). +- **Locale resolution** (`tui/main.go:132-137`): immediately after `globalDir` is known, the TUI runs `config.MigrateLegacyLanguage` → `config.LoadTUIConfig` → `i18n.SetLang(tuiCfg.Language)` so every user-visible startup string (codex banner, welcome, agent-count reminder) renders in the configured locale rather than the i18n default. `tuiCfg` is reused for the rest of bootstrap. +- **Bootstrap sequence** (`tui/main.go:213-283`): on every launch, the TUI initializes project-local `.lingtai/` state with `process.InitProject`, registers the project, and explicitly refreshes user-level utility skills via `preset.PopulateBundledLibrary(globalDir)` before returning-user runtime/bootstrap work. Returning users then run `config.NeedsVenv` (for setup banner) → `config.EnsureRuntime` (create/repair venv if needed, then always run the non-blocking `CheckUpgrade`) → `preset.Bootstrap` → `tui.ExportCommandsJSON` → `maybePromptRustToolchain` (one-time optional Rust/Cargo prompt only when file search is on Python fallback and no cargo is on PATH). `CheckUpgrade` auto-upgrades the `lingtai` meta-package from PyPI, which bundles `lingtai-kernel` + all addon MCPs. See `tui/internal/config/ANATOMY.md`. +- **`lingtai-tui doctor` subcommand** (`tui/main.go:1034-1074`): runs `config.RunDoctorUpdate` (`tui/main.go:1046`) with both `ForceTUI=true` and `ForcePython=true`, then refreshes presets, utility skills, and `commands.json`. The report includes native file-search sidecar / Rust toolchain diagnostics (`config.checkFileSearchNative`). Designed to be usable when the TUI cannot start (broken venv, missing migrations) — it never touches `.lingtai/`. Exit nonzero only on unrecoverable failures. +- **`lingtai-tui self-update` subcommand** (`tui/main.go:1076-1097`): runs `config.RunManualTUIUpdate` (`tui/main.go:1083`) through the detected install-method backend. Homebrew installs run the brew updater; source/user-local installs run the source updater backend (`install.sh --update`); unknown installs produce unsupported guidance without trying brew. +- **Version flow:** `Makefile:4` injects `git describe` into `tui/main.go:31`. On startup, `tui/main.go:119` calls `tui.SetTUIVersion(version)`, which stores it for `/doctor` drift detection. +- **TUI binary upgrade:** `tui/main.go:91-115` checks for a newer release, detects the current install method (`config.DetectCurrentTUIInstall`), then routes on `install.Method` (`tui/main.go:103-112`) and delegates newer-release handling to `upgrade.go` (`handleTUIUpgrade`) for Homebrew and source/user-local installs. Homebrew keeps the existing confirmation flow, can put agents in their projects to sleep, stops other TUI processes, runs the Homebrew backend, and asks the user to relaunch. Source/user-local installs require an explicit `[y/N]` yes before running the source updater backend through `install.sh --update`; unknown installs keep the version-only startup path and do not mutate. +- **i18n loading:** `i18n/i18n.go` embeds the three locale JSONs; `tui/main.go:134` sets the active locale from `tuiCfg.Language` early in startup (see Locale resolution above) so startup banners localize correctly. Each catalog holds only its own language — there are no bilingual entries (a `mail.initial_loading` that mixed `loading...` and `加载中...` was the documented anti-pattern, since split per-locale). ## Composition @@ -117,5 +117,5 @@ This folder is the self-contained Go module for the `lingtai-tui` terminal UI bi - **`main.go` is intentionally flat** — every subcommand's `*Main()` function is defined inline in `main.go` or platform-specific `*_unix.go`/`*_windows.go` files. Don't refactor subcommands into `internal/` packages; the flat `main.go` is the contract. - **Platform shims follow the `//go:build !windows` pattern.** Unix is the primary target; Windows files mirror the same function signatures. Every subcommand (`purge`, `list`, `suspend`) plus `countRunningAgents` and TUI-process upgrade helpers have paired platform files. - **The platform split** covers: `purge`, `list`, `suspend`, `agent_count`, and `tui_process`. The `postman` subcommand lives in `internal/` and shares no platform-specific `main.go` surface. -- **Version stamping:** `Makefile:4` uses `git describe --tags --always`. Dev builds get `-X main.version=dev`. The upgrade check in `tui/main.go:106-110` skips dev builds (those containing `-` in the version string). +- **Version stamping:** `Makefile:4` uses `git describe --tags --always`. Dev builds get `-X main.version=dev`. The upgrade check in `tui/main.go:98-102` skips dev builds (those containing `-` in the version string). - **MCP packages are dependencies of `lingtai`.** The `lingtai` PyPI package bundles `lingtai-kernel` + all addon MCPs. `config.CheckUpgrade` on every launch upgrades everything. Users never install MCP packages individually. diff --git a/tui/internal/config/ANATOMY.md b/tui/internal/config/ANATOMY.md index f5b805c0..ec8ad748 100644 --- a/tui/internal/config/ANATOMY.md +++ b/tui/internal/config/ANATOMY.md @@ -53,10 +53,10 @@ This package manages the TUI's bootstrap sequence — the steps that run before ## Connections -- **Called from:** `tui/main.go:132-288`, the manual `lingtai-tui self-update` path, `tui/internal/tui/firstrun.go:672-675`, and `tui/internal/headless/spawn.go:97-111` — startup, manual self-update, first-run, and headless bootstrap paths. (NOTE: line citations to recompute against current main via the anatomy citation checker.) +- **Called from:** `tui/main.go:93-304`, the manual `lingtai-tui self-update` path (`tui/main.go:1076-1097`), `tui/internal/tui/firstrun.go:672-675`, and `tui/internal/headless/spawn.go:97-111` — startup, manual self-update, first-run, and headless bootstrap paths. - **Calls out:** PyPI API (`pypi.org/pypi/lingtai/json`), GitHub API (`api.github.com/repos/Lingtai-AI/lingtai/releases/latest`), `uv` / `pip` CLI, and Homebrew for Homebrew-managed TUI binary updates. - **Bootstrap sequence:** - 1. `config.MigrateLegacyLanguage(globalDir)` then `config.LoadTUIConfig` + `i18n.SetLang` — one-shot language migration and locale resolution, run early (`tui/main.go:132-134`) so startup banners localize; project init and utility skill refresh happen explicitly at `tui/main.go:218-230`, and the returning-user runtime sequence follows at `tui/main.go:269-288` + 1. `config.MigrateLegacyLanguage(globalDir)` then `config.LoadTUIConfig` + `i18n.SetLang` — one-shot language migration and locale resolution, run early (`tui/main.go:132-137`) so startup banners localize; project init and utility skill refresh happen explicitly at `tui/main.go:213-225`, and the returning-user runtime sequence follows at `tui/main.go:269-283` 2. `config.NeedsVenv(globalDir)` — check if a setup banner should be printed 3. `config.EnsureRuntime(globalDir)` — create/repair venv if needed, then always auto-check/upgrade or convert the `lingtai` Python runtime 4. `preset.Bootstrap(globalDir)` — copy preset resources diff --git a/tui/internal/tui/ANATOMY.md b/tui/internal/tui/ANATOMY.md index f1f63d9c..16a58aed 100644 --- a/tui/internal/tui/ANATOMY.md +++ b/tui/internal/tui/ANATOMY.md @@ -14,6 +14,8 @@ related_files: - tui/internal/tui/firstrun.go - tui/internal/tui/firstrun_test.go - tui/internal/tui/mail.go + - tui/internal/tui/project_mail_store.go + - tui/internal/tui/project_mail_store_contract_test.go - tui/internal/tui/props.go - tui/internal/tui/library.go - tui/internal/tui/doctor.go @@ -67,17 +69,17 @@ Screen routing is centralized in the `App` struct (`app.go`), which holds every ### Root model and dispatcher - **`tui/internal/tui/app.go:24-42`** — `appView` enum: 18 view constants (`appViewFirstRun` through `appViewHelp`), including `appViewNotification` for `/notification`. -- **`tui/internal/tui/app.go:50-113`** — `App` struct: holds every root-routed screen model plus routing state (`currentView`, `orchDir`, `orchName`, `recoveryMode`), the active mail generation token, and the root-owned `/projects` visit state (first/original context, preserved origin view/model, current target, and double-Esc return timing). -- **`tui/internal/tui/app.go:95-190`** — `NewApp`: constructor deciding initial view — mail view (returning user), first-run wizard (new user or rehydration), or recovery mode (global config lost, agents intact). -- **`tui/internal/tui/app.go:193-211`** — `App.Init()`: delegates to the initial view's `Init()` and, when enabled, arms the app-level auto-refresh ticker (see `auto_refresh.go`). +- **`tui/internal/tui/app.go:50-124`** — `App` struct: holds every root-routed screen model plus routing state (`currentView`, `orchDir`, `orchName`, `recoveryMode`). Mail lifetime is root-owned: the active `ProjectMailStore`, an optional suspended home store during a cross-project visit, and the generation token sit beside `MailModel`, which is only the snapshot/UI consumer. `visitReturnState` is the one temporary App-owned back-navigation/UI adapter retained until PR7; it owns no mailbox cache, refresh, or tick execution, although its retained `MailModel` may still reference accepted snapshot/session-cache presentation state. +- **`tui/internal/tui/app.go:177-272`** — `NewApp`: constructor deciding initial view — mail view (returning user), first-run wizard (new user or rehydration), or recovery mode (global config lost, agents intact). Returning-user startup installs a `MailModel` and its matching root `ProjectMailStore` without synchronous mailbox scanning. +- **`tui/internal/tui/app.go:274-293`** — `App.Init()`: delegates to the initial view's `Init()`, asks the root store for the deferred authoritative mail refresh, and, when enabled, arms the separate app-level auto-refresh ticker (see `auto_refresh.go`). - **`auto_refresh.go`** — app-level auto-refresh: a single 1s `autoRefreshTick` (`autoRefreshTickMsg`) currently routed only to the kanban (`PropsModel`) because keyboard/markdown/picker-heavy views such as `/mailbox`, `/system`, and `/daemons` must not be reloaded every second while the human is selecting or scrolling. The kanban Ctrl+D detail layer is live too: the tick refreshes its detail caches in place before reloading the outer dashboard. Enabled by default; toggled off via `/settings` (`config.TUIConfig.AutoRefreshOff`). `App.startAutoRefresh()` arms exactly one ticker (guarded by `autoRefreshArmed`). -- **`tui/internal/tui/app.go:284-753`** — `App.Update()`: the central dispatcher. It records full terminal size only for raw `tea.WindowSizeMsg`, then forwards the reduced child budget (`tui/internal/tui/app.go:284-295`). Root cross-view routing sends `mailRefreshMsg`, its side-effect-free `mailPersistMsg` continuation, and the possible `homeTelemetryMsg` follow-up to the preserved Mail model regardless of whether Projects/Help is active, returning immediately so the active child is neither changed nor double-dispatched (`tui/internal/tui/app.go:301-309`). `ProjectsAgentSelectedMsg` remains root-owned (`tui/internal/tui/app.go:420-424`); root keys, including `ctrl+c`, stay available before child dispatch (`tui/internal/tui/app.go:637-670`). -- **`tui/internal/tui/app.go:714-1408`** — `openSetupCredentials` plus `handlePaletteCommand`: maps slash-command strings to view transitions (`/doctor` → `appViewDoctor`, `/daemons` → `appViewDaemons`, `/notification` → `appViewNotification`, `/knowledge` → `appViewKnowledge` (canonical; hidden `/library` and `/codex` aliases), `/skills` → `appViewLibrary`, etc.) and direct actions (`/suspend`, `/cpr`, `/refresh`, `/clear`, `/molt`, `/btw`, `/goal`, `/export`). `/login` is a compatibility shortcut into Setup → Credentials; `/setup credentials` jumps there directly while plain `/setup` opens `FirstRunModel` setup mode. -- **`tui/internal/tui/app.go:1500-1594`** — visit context helpers: `enterVisitedAgent` preserves exactly one original context plus the origin view/model when entering from `/projects`, switches `projectDir`/focused agent/human mailbox to the selected running agent's project, opens mail, and sets the target mail title's inline `Esc Esc` hint; `returnFromVisit` restores the original mail model/context with that hint cleared and returns either to the preserved `/projects` model or to mail; `maybeHandleVisitEsc` implements the 600ms double-Esc return window. -- **`tui/internal/tui/app.go:1614-1723`** — `switchToView(viewName string)`: the canonical route-to-view dispatcher used by `ViewChangeMsg` and palette commands returning to a view. Reconstructs models fresh on entry; `setup` routes to `FirstRunModel` setup mode and `login` reconstructs the Setup → Credentials model for the legacy `/login` shortcut. Entering `/projects` passes `ProjectsContext` from root state so the child can mark current/original/visiting rows without owning the transition (`tui/internal/tui/app.go:1703-1704`). -- **`tui/internal/tui/app.go:1727-1788`** — `App.View()`: delegates to current view's `View()` for the child content, then wraps it with root-owned chrome via `composeWithChrome` BEFORE building the `tea.NewView` (alt-screen + mouse mode). The startup banner and non-mail select-mode indicator are root chrome, so they render in rows the child yielded rather than being appended past full terminal height. The visit indication is not root chrome; target mail renders it inline in the title row (`tui/internal/tui/mail.go:1635-1640`). The `tea.NewView` defaults to `MouseModeCellMotion`, but copy/select mode switches it to `MouseModeNone` (`tui/internal/tui/app.go:1774-1781`). -- **`tui/internal/tui/app.go:1490-1498`** — `App.sendSize()`: returns a `tea.Cmd` carrying the *child* window size (terminal size minus root chrome rows) in a root-internal `childWindowSizeMsg`, so a freshly-routed view and a resized view agree on height without making the root treat a synthetic child size as a raw terminal resize. -- **`tui/internal/tui/app.go:1609-1906`** — portal launch helper; `SetTUIVersion` lives with doctor version diagnostics (`tui/internal/tui/doctor.go:30-35`). +- **`tui/internal/tui/app.go:329-836`** — `App.Update()`: the central dispatcher. It records full terminal size only for raw `tea.WindowSizeMsg`, then forwards the reduced child budget (`tui/internal/tui/app.go:329-340`). Project-mail request/result/tick messages are root-owned (`tui/internal/tui/app.go:347-379`): requests are generation/activation/current-Mail gated, and a refresh must pass store/project/activation/source-version plus current-`MailModel` generation checks before root cache/version/snapshot publication. An exact old-generation physical completion releases its slot without publication so a queued current authoritative initial can proceed; only an accepted tick can re-arm the one chain. Mail-local async continuations (`mailRefreshMsg`, `mailPersistMsg`, history pages/counts, telemetry) remain root-routed to the preserved Mail model even while another view is active (`tui/internal/tui/app.go:381-387`), so the active child is neither changed nor double-dispatched. +- **`tui/internal/tui/app.go:837-1200`** — `openSetupCredentials` plus `handlePaletteCommand`: maps slash-command strings to view transitions (`/doctor` → `appViewDoctor`, `/daemons` → `appViewDaemons`, `/notification` → `appViewNotification`, `/knowledge` → `appViewKnowledge` (canonical; hidden `/library` and `/codex` aliases), `/skills` → `appViewLibrary`, etc.) and direct actions (`/suspend`, `/cpr`, `/refresh`, `/clear`, `/molt`, `/btw`, `/goal`, `/export`). `/login` is a compatibility shortcut into Setup → Credentials; `/setup credentials` jumps there directly while plain `/setup` opens `FirstRunModel` setup mode. +- **`tui/internal/tui/app.go:1606-1724`** — visit context helpers: `enterVisitedAgent` preserves exactly one original UI/back-navigation context, suspends the root-owned home `ProjectMailStore`, installs a fresh visited-project store, and opens Mail. `returnFromVisit` suspends the visited owner, activates the retained home owner, but deliberately withholds its suspended snapshot until a fresh authoritative initial result is accepted; return-to-Projects keeps that owner active-but-paused (`tui/internal/tui/app.go:1644-1688`). `maybeHandleVisitEsc` implements the 600ms double-Esc return window. +- **`tui/internal/tui/app.go:1746-1877`** — `switchToView(viewName string)`: the canonical route-to-view dispatcher used by `ViewChangeMsg` and palette commands. A successful departure from Mail invalidates its store tick chain (unknown or unavailable routes are exact no-ops); re-entering Mail reloads presentation settings and resumes the one root refresh/tick pipeline (`tui/internal/tui/app.go:1746-1797`). Other screens reconstruct fresh on entry; `/projects` receives App-owned context rather than owning project transitions. +- **`tui/internal/tui/app.go:1879-1943`** — `App.View()`: delegates to the current child, then wraps it with root-owned chrome via `composeWithChrome` before building `tea.NewView`. Startup warnings and the non-mail select-mode indicator consume rows yielded by the child. The visit indication stays inline in the target Mail title. Mouse defaults to `MouseModeCellMotion`, while mail copy mode or global select mode switches it to `MouseModeNone` (`tui/internal/tui/app.go:1927-1934`). +- **`tui/internal/tui/app.go:1596-1604`** — `App.sendSize()`: returns a `tea.Cmd` carrying the *child* window size (terminal size minus root chrome rows) in a root-internal `childWindowSizeMsg`, so a freshly-routed view and a resized view agree on height without making the root treat a synthetic child size as a raw terminal resize. +- **`tui/internal/tui/app.go:1951-2074`** — portal launch helper; `SetTUIVersion` lives with doctor version diagnostics (`tui/internal/tui/doctor.go:30-35`). ### Root layout budget (`layout.go`) @@ -86,13 +88,14 @@ Screen routing is centralized in the `App` struct (`app.go`), which holds every ### Screens - **`tui/internal/tui/firstrun.go:136-4483`** — `FirstRunModel`. Multi-step wizard with constructors for three flows: `NewFirstRunModel` (new project), `NewSetupModeModel` (reconfiguring an existing agent), and `NewRehydrateModel` (cloned network). Its background bootstrap calls `config.EnsureRuntimeQuiet`, so first-run venv creation is followed by the same non-blocking Python `lingtai` upgrade check as returning-user startup. Steps (`firstRunStep` enum, `tui/internal/tui/firstrun.go:62-77`): Welcome → API Key → Pick Preset → Edit Preset → Preset Key → Capabilities → Agent Presets → Agent Name/Dir → Recipe → (optional) Rehydrate Propagate → Launching. The Codex credential row behaviour is mode-split: in first-run (non-setupMode) it opens the inline two-method login chooser (`tui/internal/tui/firstrun.go:1059-1074`, rendered at `tui/internal/tui/firstrun.go:2355-2369`) — browser OAuth/localhost or device code — both routed through `startCodexLogin` (`tui/internal/tui/firstrun.go:603-628`, which always passes `forceLogin=false` to `startOAuthFlow` because first-run only manages the primary/legacy account) with epoch-gated stale-completion drops; in setupMode (`/setup`) it emits `ViewChangeMsg{View:"login"}` (`tui/internal/tui/firstrun.go:1155-1157`) to hand off to the dedicated Setup → Credentials subpage instead. Pressing Esc while a Codex login is mid-flight or the method chooser is open cancels that login and stays on the picker (clearing the spinner/URL/code) rather than exiting to home. Codex preset selectability is judged per-preset by `codexPresetAuthValid` — it resolves the preset's own `manifest.llm.codex_auth_path` (empty → legacy fallback) and validates that specific account file, so one missing Codex account never blocks a differently-bound codex preset. First-run manages the primary (legacy) account; additional accounts are added from Setup → Credentials. The Claude Code auth row that previously rendered below Codex is hidden for now (that auth path is unsupported); detection (`refreshClaudeCodeAuth` / `claudeCodeAuthConfigured`) still runs and feeds the `claude-agent-sdk` credential guard, only the user-visible row is suppressed. Emits `FirstRunDoneMsg` when complete. -- **`MailModel` / windowed history** (`tui/internal/tui/mail.go:155-390`, `tui/internal/tui/mail.go:792-1054`) — The network home screen owns async session reconstruction, chronological message rendering, compose input, and finite history reveal. Displayed mail rows are projected exactly once from live `fs.MailCache` in both the default and `ctrl+o` layers; derived `SessionCache` mail copies are skipped while non-mail event replay remains unchanged. The same mail row renderer is used in every layer: no verbose-only metadata header is added, and each live mail row shows its full local date/time (`tui/internal/tui/mail.go:585-673`, `tui/internal/tui/mail.go:1708-1736`). `mail_page_size` is the single owner of both the newest content-cache window and the UI render/Ctrl+U batch (100/200/500/1000/2000; default 200). Initial content paints from exactly one page; every real Ctrl+U requests exactly one further page from the same canonical JSONL horizon, and completeness is accepted only when that scan reaches the beginning. One canonical JSONL metadata task runs asynchronously per activation/source/horizon, so the first content frame never waits for a full-history scan and the UI keeps a neutral loading label until exact metadata arrives. Same-horizon older rebuilds reuse the accepted/in-flight task; a genuinely newer horizon supersedes it once. Accepted counts are generation/cache/current-horizon-gated and advanced by EOF refresh; partial caches keep EOF additions memory-only and never write the derived disk cache, while complete caches use the existing generation/cache-identity persistence gate. +- **`ProjectMailStore`** (`tui/internal/tui/project_mail_store.go:63-105`, `tui/internal/tui/project_mail_store.go:118-229`, `tui/internal/tui/project_mail_store.go:230-338`) — Root-owned project-lifetime mailbox state: exactly one private `fs.MailCache`, immutable accepted-snapshot sequence, in-flight refresh pipeline, invalidatable polling chain, and human-location updater. Background commands receive detached values; the data-less process-wide `projectMailScanSingleflight` serializes their uncancellable physical refresh/rebuild bodies across home and visited store identities but owns no project data, accepted state, or tick lifecycle. Each in-flight refresh records whether it is initial and which `MailModel` generation launched it, so a current authoritative initial queues behind either a steady scan or an older-generation initial instead of treating that old rebuild as sufficient. A result installs only after store/project/activation/source-version and current-`MailModel` generation acceptance. An exact old-generation completion releases its physical slot without publishing root state, allowing the queued current authoritative initial rebuild to start; unrelated stale results cannot release the slot. Suspend/activate and accepted versions update a shared atomic runtime token, which delayed location commands re-check immediately before the sole updater side effect. App view/visit routing decides whether the one tick chain may run (`tui/internal/tui/app.go:308-379`, `tui/internal/tui/app.go:1606-1688`, `tui/internal/tui/app.go:1746-1797`). +- **`MailModel` / windowed history** (`tui/internal/tui/mail.go:155-283`, `tui/internal/tui/mail.go:544-644`, `tui/internal/tui/mail.go:786-974`) — The network home screen consumes only an immutable `ProjectMailSnapshot`; it owns bounded session reconstruction, chronological message rendering, compose input, and finite history reveal, but no mailbox refresh or polling chain. Displayed mail rows are projected exactly once from the accepted snapshot's private cache in both the default and `ctrl+o` layers; derived `SessionCache` mail copies are skipped while non-mail event replay remains unchanged. The same mail row renderer is used in every layer: no verbose-only metadata header is added, and each live mail row shows its full local date/time (`tui/internal/tui/mail.go:585-673`, `tui/internal/tui/mail.go:1708-1736`). `mail_page_size` is the single owner of both the newest content-cache window and the UI render/Ctrl+U batch (100/200/500/1000/2000; default 200). Initial content paints from exactly one page; every real Ctrl+U requests exactly one further page from the same canonical JSONL horizon, and completeness is accepted only when that scan reaches the beginning. One canonical JSONL metadata task runs asynchronously per activation/source/horizon, so the first content frame never waits for a full-history scan and the UI keeps a neutral loading label until exact metadata arrives. Same-horizon older rebuilds reuse the accepted/in-flight task; a genuinely newer horizon supersedes it once. Accepted counts are generation/cache/current-horizon-gated and advanced by EOF refresh; partial caches keep EOF additions memory-only and never write the derived disk cache, while complete caches use the existing generation/cache-identity persistence gate. - **`tui/internal/tui/props.go:22-1593`** — `PropsModel` (KANBAN). Agent dashboard: status, heartbeat, token ledger visualization, Ctrl+D detail with current and last molt-session API/cache stats (including Codex transfer-mode `full` / `incremental` counts when present) plus lifecycle `tool_call` counts and `tool_calls/api_call` averages rendered by the same `appendSessionAPIStats` (`tui/internal/tui/props.go:1546`) — the tool counts are sourced independently from the events store via `fs.SumMoltSessionToolCalls`, overlaid in `loadDetail` so their freshness never rides the token-ledger cache, recent-call lanes split between selected-agent calls and stacked daemon calls (each row showing cached hits, cache `miss` = input − cached via `cacheMiss` at `tui/internal/tui/props.go:1297`, and cache-hit rate), retained context statistics, selected-agent daemon run counts, network activity, session history, signal controls, and preset health using both Codex OAuth and Claude Code CLI auth state. Constructor: `NewPropsModel` (`tui/internal/tui/props.go:84`). **Soul flow is shown as an on/off status, not a raw delay** — `renderSoulFlowValue` (`tui/internal/tui/props.go:698`) reads the opt-in from the agent's `env_file` (`config.SoulFlowEnabledInEnvFile`, resolved by `soulFlowEnvPath`, `tui/internal/tui/props.go:723`): disabled → localized "disabled · opt in via `LINGTAI_SOUL_FLOW_ENABLED` · see soul-manual"; enabled → cadence from `soul.delay` (or a "default cadence" label when omitted). The huge delay sentinel is never surfaced as an off switch. - **`tui/internal/tui/library.go:615-989`** — `LibraryModel`. Skill catalog browser, agent-scoped — scans `/.library/` plus all effective `skills.paths`: `readLibraryPaths` (`tui/internal/tui/library.go:270`) prefers the kernel-published resolved-manifest artifact `/system/manifest.resolved.json` (kernel issue #259) and falls back to raw `init.json` when the artifact is absent or malformed (stopped / never-booted agents). Constructor: `NewLibraryModel` (`tui/internal/tui/library.go:650`). Renders skill metadata in a sidebar list with markdown content pane. - **`tui/internal/tui/doctor.go:66-1485`** — `DoctorModel`. Health check screen. First runs `config.RunDoctorUpdate` (forced TUI + Python upgrade) and refreshes `preset.Bootstrap`, utility skills, and `ExportCommandsJSON`; then continues with the traditional diagnostics — version drift, heartbeat stats, capability validation, Python venv path, kernel CLI probe, LLM reachability. Constructor: `NewDoctorModel` (`tui/internal/tui/doctor.go:88`). Once complete it keeps an in-memory `doctorreport.Draft` and offers `[r] save report` (`canSaveReport`/`saveReportCmd`, `tui/internal/tui/doctor.go:114`) to write a redacted bundle under `/reports/` via `internal/doctorreport` without rerunning the diagnostic; it captures no event logs. The same forced-update routine is reachable from the shell as `lingtai-tui doctor`, useful when the TUI cannot start. - **`tui/internal/tui/preset_editor.go:272-2272`** — `PresetEditorModel`. Full preset editing form (LLM provider/model, API key, capabilities on/off, model parameters). Constructor: `NewPresetEditorModel` (`tui/internal/tui/preset_editor.go:348`). Used by both the standalone `/presets` flow and the first-run wizard's stepEditPreset. The OpenAI wire-format selector is deliberately narrower than the generic compatibility field: `isCustomOpenAI` (`tui/internal/tui/preset_editor.go:889`) owns visibility/cyclability only for `provider=custom` plus `api_compat=openai`; `normalizeWireAPI` (`tui/internal/tui/preset_editor.go:1033`) removes stale/out-of-scope values and omits canonical `auto` without touching legacy delegation flags; the field cycles through `auto | chat_completions | responses` (`tui/internal/tui/preset_editor.go:1151`) and renders as a dedicated three-choice radio strip (`tui/internal/tui/preset_editor.go:1824`). - **`tui/internal/tui/preset_library.go:101-593`** — `PresetLibraryModel`. Preset browser: list templates/saved, create new, import. Constructor: `NewPresetLibraryModel` (`tui/internal/tui/preset_library.go:120`). Wired to `/presets`. -- **`tui/internal/tui/settings.go:66-541`** — `SettingsModel`. TUI preferences: theme, language, mail page size, agent default language, insights toggle, auto-refresh toggle (`auto_refresh`; "on" = default, persisted as the inverse `auto_refresh_off` flag), tool-call display limit (`tool_truncate`; "off" = full content, the default). Constructor: `NewSettingsModel` (`tui/internal/tui/settings.go:81`). Wired to `/settings`. +- **`tui/internal/tui/settings.go:66-537`** — `SettingsModel`. TUI preferences: theme, language, mail page size, agent default language, insights toggle, auto-refresh toggle (`auto_refresh`; "on" = default, persisted as the inverse `auto_refresh_off` flag), tool-call display limit (`tool_truncate`; "off" = full content, the default). Constructor: `NewSettingsModel` (`tui/internal/tui/settings.go:81`). Wired to `/settings`. - **`tui/internal/tui/addon.go:21-164`** — `AddonModel`. The `/mcp` control panel — a read-only view of each MCP bridge's config and status (IMAP, Telegram, Feishu, WeChat). Configs live at `/.addons//config.json`. Constructor: `NewAddonModel` (`tui/internal/tui/addon.go:34`). Wired to `/mcp` (the Go type retains the historical `Addon*` naming; the `/addon` slash-command was retired by PR #204 and `TestDefaultCommandsDoesNotKeepAddonAlias` enforces it stays gone). - **`tui/internal/tui/login.go`** — `LoginModel`. Setup → Credentials credential health/re-entry screen; `/login` is a compatibility shortcut into this setup subpage (`NewSetupCredentialsModel`). MULTI-ACCOUNT Codex OAuth: `NewLoginModel` enumerates every stored Codex account via `listCodexAccounts` (codex_auth_store.go) — the legacy `~/.lingtai-tui/codex-auth.json` plus per-account files under `~/.lingtai-tui/codex-auth/.json` — one `loginEntry` each carrying its `CodexPath`. A virtual "Add Codex OAuth" / "Add another Codex account" row (`virtualAddCodexRow`) is always shown; Enter there adds a NEW account (empty `codexLoginTargetPath` → completion derives a fresh per-account file, or seeds the legacy file when none exists yet) via the two-method chooser, then `startCodexLogin` runs browser OAuth or device-code login and `CodexOAuthDoneMsg` writes the bundle (0600) to the resolved target. ACTIVE-ACCOUNT SELECTION (the common action): Enter on an existing *valid* Codex account entry SETS THAT ACCOUNT ACTIVE and applies it to every saved Codex preset — `setActiveCodexAccount` → `applyActiveCodexAccount` (codex_auth_store.go) rewrites each saved-dir codex preset's `manifest.llm.codex_auth_path` to the account's `codexAuthRefForPath` (legacy → "" so presets fall back), reports how many presets it updated (or that there were none), and refuses an invalid account with a hint. This is NOT load balancing (that is the kernel `codex-pool` provider's job, driven by `codex-auth-pool.json` — see codex_pool_store.go) — the TUI binds all saved Codex presets to one chosen ChatGPT account. The account all saved Codex presets agree on is derived by `activeCodexAuthPath` and marked `(active)` in its row (`activeCodexPath`; blank when presets are mixed or absent). `l` edits the selected account's non-secret label in its token JSON; an empty saved value clears the label and display falls back to email/slug/default. `r` RE-AUTHENTICATES the selected account (the action Enter used to perform: target = its own `CodexPath`, opens the chooser); `d` / Del / Backspace removes only that account's file (two-press confirmation; `r` is no longer wired to delete). Templates are never rewritten — only user-owned `saved/` presets. Per-account health checks are disambiguated by `loginHealthMsg.CodexPath`. Pressing Esc while a Codex login is mid-flight cancels it and stays on the credentials screen (clearing spinner/URL/code) instead of emitting a `ViewChangeMsg` to home; an idle Esc still returns to mail. The Claude Code auth row is not rendered here (unsupported, hidden for now). Constructors: `NewSetupCredentialsModel`, `NewLoginModel`. Wired to `/setup credentials` and legacy `/login`. **Unification contract (do not fork):** first-run setup and `/setup credentials` must NOT diverge in credential/OAuth logic. `/setup credentials`, the legacy `/login` shortcut, AND the first-run wizard's setup-mode Codex row (which emits `ViewChangeMsg{View:"login"}`, `tui/internal/tui/firstrun.go:1161-1163`) all land on `NewSetupCredentialsModel` → this shared `LoginModel`; the first-run *wizard itself* (non-setup) manages only the primary/legacy account but reads/writes the SAME store (`codex_auth_store.go`) and the SAME OAuth entrypoint (`startOAuthFlow`). Account selection for "Add another Codex account" is `LoginModel.codexForceLogin` (true only when adding a new account while one already exists → `prompt=login`); re-auth and the first account reuse the active session. Guarded by `TestSetupAndFirstRunShareCodexAccountStore` and `TestLoginModel_CodexForceLoginDecision`. The contract is also restated as a header comment on `NewSetupCredentialsModel` in `login.go`. POOL WEIGHTS: each Codex OAuth row also renders its REAL `codex-auth-pool.json` state (`not in pool` / `pool disabled` / `pool weight: N`) from codex_pool_store.go — never a phantom default; `+`/`-`/`0` edit the flat weight with membership-aware semantics (`adjustCodexPoolWeight`: `-` never enrolls an absent account), pool edits never rewrite presets, and a malformed pool file shows a warning while being left untouched. When the pool is MODEL-CLASSIFIED (v2 `models` map, see codex_pool_store.go), flat state would be a lie: rows render `pool: model-classified`, the explain line becomes a note with the category count and a hand-edit hint, the `+`/`-`/`0` keys only re-show that note (no write), and the pool-hint footer segment is suppressed (`poolModelClassified`/`poolModelCount`; guarded by `TestLoginModel_ModelClassifiedPoolRendersClassified` and `TestLoginModel_ModelClassifiedPoolRefusesFlatEdit`). @@ -105,7 +108,7 @@ Screen routing is centralized in the `App` struct (`app.go`), which holds every - `notification.go`: read-only `/notification` screen backed by `internal/sqlitelog`, querying the focused agent's latest 10 `notification_block_injected` snapshots from `logs/log.sqlite`. Each snapshot carries the actual canonical `_meta` envelope the model saw and is shown through the shared `MarkdownViewerModel`: left/right still navigate snapshots newest-first, the left sidebar selects the four `_meta` envelope blocks (`all blocks`, `overview`, `_meta.tool_meta`, `_meta.agent_meta`, `_meta.guidance`, `_meta.notification_guidance`, `_meta.notifications`, and per-channel `_meta.notifications.` payloads), and the right pane is scrollable markdown/fenced JSON. Modern kernel rows persist a single top-level `fields_json._meta` envelope with all four blocks (`tool_meta`, `agent_meta`, `guidance`, `notifications`/`notification_guidance`); `sqlitelog.parseNotificationBlockSnapshotFields` parses that first and falls back to the legacy pre-envelope shape (top-level `payload` with `_tool`/`_runtime.state`/`_runtime.guidance`/`_notification_guidance` plus a separate `meta` dict) for older rows, mapping both onto the same `_meta.*` display labels. Fallback message is shown when no `notification_block_injected` rows exist (log predates this feature). r reload and Esc/q back-to-chat are preserved. `notification_pair_injected` compact-summary rows are NOT used here; they remain available via `QueryNotificationBlocks` for chat-replay/session-summary callers. - **`tui/internal/tui/goal.go:14-62`** — `/goal` filesystem writer. `writeGoalRequestNotification` appends a `source="goal.request"` event to the current agent's `.notification/system.json`, preserving existing `data.events`, capping at 20, and writing via temp-file + rename. The event asks the agent to read the goal manual, guide objective/criteria/reminder/cancel semantics with the human, and only then create `.notification/goal.json` after confirmation. - **`tui/internal/tui/nirvana.go:47-209`** — `NirvanaModel`. Confirmation screen for wiping `.lingtai/`. Constructor: `NewNirvanaModel` (`tui/internal/tui/nirvana.go:56`). Emits `NirvanaDoneMsg` → triggers first-run wizard flow. -- **`tui/internal/tui/projects.go:36-1323`** — `ProjectsModel`. `/projects` running-agent switcher. Registry mode scans `internal/inventory` and renders project-grouped running-agent rows with current/original/visiting markers, disabled/error rows, admin-only enterability, and refresh via `r`/`ctrl+r` (`tui/internal/tui/projects.go:197-223`, `tui/internal/tui/projects.go:263-395`). **Three concepts stay visibly and semantically separate — keyboard cursor/selection, App-authoritative current identity, and runtime lifecycle/health.** The App owns the current identity and passes it in via `ProjectsContext.FocusedAgentDir`/`CurrentAgentName` (= `a.orchDir`/`a.orchName`, which during a visit name the visited agent) (`tui/internal/tui/projects.go:58-81`, `tui/internal/tui/app.go:131-141`); the view never infers the current agent from process state, role, PID, row order, or the cursor. A dedicated current-agent block leads the overview pane (`renderCurrentAgentBlock`, `tui/internal/tui/projects.go:848-908`): it names the authoritative current agent and, when that agent is process-visible (matched by normalized `AgentDir` via `isCurrentAgentRow`/`currentAgentRecord`, `tui/internal/tui/projects.go:817-846`), surfaces its live `State` (colored by `StateColor`) and heartbeat (colored by `heartbeatStyled`) as prominent separately-labeled fields; when the current agent is absent from the snapshot it shows a localized `projects.current_agent_unavailable` status instead of a fabricated lifecycle state. In the list itself the row prefix carries two independent fixed-column signals so cursor and current never collapse: column 0 is the current-agent marker `projectsCurrentMarker` (`◆`, `tui/internal/tui/projects.go:799`), column 2 is the `>` cursor marker (`tui/internal/tui/projects.go:745-763`). The process-visible section adds a localized dim legend for `◆` current, `>` cursor, and `!` non-enterable rows. Network groups carry the exact rendered record count (`· N`) and are separated by real unselectable `projectRowSpacer` rows, preserving the one-model-row/one-render-line invariant so `registryListTopLines` and `selectedRenderedLine` keep scroll-to-cursor in lockstep with the prepended block and group spacing (`tui/internal/tui/projects.go:515-553`, `tui/internal/tui/projects.go:706-817`). Left pane is the overview: each row answers who/role/live-state/heartbeat and, when authoritative context exists and the pane is at least `projectsOverviewContextMinWidth` wide, a compact context percentage — but never operational detail (PID, process uptime), which lives only in Details (`tui/internal/tui/projects.go:692-790`). The right pane is Details (no generic Status block, no `Role:`/`State:`/`Address:` rows restating the overview): an identity header (display name plus the address/agent identity as a dim line, dropped when it would echo the header) with validation warnings/disabled/phantom/status surfaced immediately beneath it, then `Lifecycle` (created + derived lifetime folded onto one line via `lifecycleCreatedLine`, process uptime, molt count), `Network` (project, orchestrator, and the two project-scoped topology counts — computed only from the loaded `Snapshot.Records` — folded onto one members/admins line), and `Runtime` (PID, plus exact authoritative `total / window (pct)` context with a small `renderShareBar` meter when the pane is legibly wide — the meter is suppressed, but never the exact numeric line, when the quantized 12-cell bar would show zero filled cells so a low nonzero percentage cannot render an all-empty bar that contradicts the text — and optional IM/lock) (`tui/internal/tui/projects.go:910-1004`). Raw project/agent paths are omitted, and widths below 90 columns degrade to the selectable list only — the current-agent block and its markers still render there (`tui/internal/tui/projects.go:642-690`). Enter on a valid admin/orchestrator row emits `ProjectsAgentSelectedMsg` for the root `App` to perform the context transition (`tui/internal/tui/projects.go:432-466`). `NewAgoraProjectsModel` still preserves the `/agora` recipe-import source path (`tui/internal/tui/projects.go:100-112`, `tui/internal/tui/projects.go:197-216`). +- **`tui/internal/tui/projects.go:36-1323`** — `ProjectsModel`. `/projects` running-agent switcher. Registry mode scans `internal/inventory` and renders project-grouped running-agent rows with current/original/visiting markers, disabled/error rows, admin-only enterability, and refresh via `r`/`ctrl+r` (`tui/internal/tui/projects.go:197-223`, `tui/internal/tui/projects.go:263-395`). **Three concepts stay visibly and semantically separate — keyboard cursor/selection, App-authoritative current identity, and runtime lifecycle/health.** The App owns the current identity and passes it in via `ProjectsContext.FocusedAgentDir`/`CurrentAgentName` (= `a.orchDir`/`a.orchName`, which during a visit name the visited agent) (`tui/internal/tui/projects.go:58-81`, `tui/internal/tui/app.go:146-169`); the view never infers the current agent from process state, role, PID, row order, or the cursor. A dedicated current-agent block leads the overview pane (`renderCurrentAgentBlock`, `tui/internal/tui/projects.go:848-908`): it names the authoritative current agent and, when that agent is process-visible (matched by normalized `AgentDir` via `isCurrentAgentRow`/`currentAgentRecord`, `tui/internal/tui/projects.go:817-846`), surfaces its live `State` (colored by `StateColor`) and heartbeat (colored by `heartbeatStyled`) as prominent separately-labeled fields; when the current agent is absent from the snapshot it shows a localized `projects.current_agent_unavailable` status instead of a fabricated lifecycle state. In the list itself the row prefix carries two independent fixed-column signals so cursor and current never collapse: column 0 is the current-agent marker `projectsCurrentMarker` (`◆`, `tui/internal/tui/projects.go:799`), column 2 is the `>` cursor marker (`tui/internal/tui/projects.go:745-763`). The process-visible section adds a localized dim legend for `◆` current, `>` cursor, and `!` non-enterable rows. Network groups carry the exact rendered record count (`· N`) and are separated by real unselectable `projectRowSpacer` rows, preserving the one-model-row/one-render-line invariant so `registryListTopLines` and `selectedRenderedLine` keep scroll-to-cursor in lockstep with the prepended block and group spacing (`tui/internal/tui/projects.go:515-553`, `tui/internal/tui/projects.go:706-817`). Left pane is the overview: each row answers who/role/live-state/heartbeat and, when authoritative context exists and the pane is at least `projectsOverviewContextMinWidth` wide, a compact context percentage — but never operational detail (PID, process uptime), which lives only in Details (`tui/internal/tui/projects.go:692-790`). The right pane is Details (no generic Status block, no `Role:`/`State:`/`Address:` rows restating the overview): an identity header (display name plus the address/agent identity as a dim line, dropped when it would echo the header) with validation warnings/disabled/phantom/status surfaced immediately beneath it, then `Lifecycle` (created + derived lifetime folded onto one line via `lifecycleCreatedLine`, process uptime, molt count), `Network` (project, orchestrator, and the two project-scoped topology counts — computed only from the loaded `Snapshot.Records` — folded onto one members/admins line), and `Runtime` (PID, plus exact authoritative `total / window (pct)` context with a small `renderShareBar` meter when the pane is legibly wide — the meter is suppressed, but never the exact numeric line, when the quantized 12-cell bar would show zero filled cells so a low nonzero percentage cannot render an all-empty bar that contradicts the text — and optional IM/lock) (`tui/internal/tui/projects.go:910-1004`). Raw project/agent paths are omitted, and widths below 90 columns degrade to the selectable list only — the current-agent block and its markers still render there (`tui/internal/tui/projects.go:642-690`). Enter on a valid admin/orchestrator row emits `ProjectsAgentSelectedMsg` for the root `App` to perform the context transition (`tui/internal/tui/projects.go:432-466`). `NewAgoraProjectsModel` still preserves the `/agora` recipe-import source path (`tui/internal/tui/projects.go:100-112`, `tui/internal/tui/projects.go:197-216`). - **`tui/internal/tui/mdviewer.go:40-518`** — `MarkdownViewerModel`. Generic markdown display with sidebar navigation. Used by `/skills` detail views, recipe previews, and `/help`. - **`tui/internal/tui/help.go:1-79`** — `HelpModel`. Thin `MarkdownViewerModel` wrapper for `/help`. No embedded help docs of its own: it reads the slash-command guide from the bundled `lingtai-tui-help` skill via `preset.ReadBundledSkillFile`, picking the asset for the current UI language (`i18n.Lang()` → `assets/slash-commands..md`, English fallback). Constructor: `NewHelpModel` (`tui/internal/tui/help.go:66`). Wired to `/help`. - **`tui/internal/tui/setup.go:42-242`** — `SetupModel` (legacy). Provider/API-key prompt embedded by `FirstRunModel` when bootstrap finds no presets (`tui/internal/tui/firstrun.go:1020-1024`). Constructor: `NewSetupModel` (`tui/internal/tui/setup.go:55`). It is not a root-routed `/setup` screen; plain `/setup` enters `NewSetupModeModel`. @@ -119,20 +122,20 @@ Screen routing is centralized in the `App` struct (`app.go`), which holds every - **`tui/internal/tui/mailbox_entries.go:17-321`** — `buildMailboxEntries`: reads per-agent mailbox folders, converts to `MarkdownEntry` slices for the `MailboxModel`. - **`tui/internal/tui/daemons.go`** — daemon artifact readers, split into a lightweight list path and a lazy detail path so opening `/daemons` (and Ctrl+R refresh) with many runs stays cheap. `loadDaemonSummaries` → `readDaemonSummary` reads only each run's `daemon.json` + directory stat (plus the cheap daemon.json token snapshot). The heavy per-run files — `logs/events.jsonl`, `history/chat_history.jsonl`, `result.txt`, and the authoritative `logs/token_ledger.jsonl` — are read by `loadDaemonDetail` only for the selected run, triggered from `ensureDetailLoaded` inside `syncContent`; results cache on `daemonSummary.DetailLoaded` so each run is read at most once per reload. - **`tui/internal/tui/recipe_entries.go:15-103`** — `buildRecipeEntries`: resolves canonical `.recipe/` files (recipe.json plus greet/comment/covenant/procedures) and manifest-named recipe libraries for the preview viewer. -- **`tui/internal/tui/recipe_save.go:14-202`** — Recipe save helpers: `recipeUsesCustomDir`, `sourceBundleDir`, `saveCustomRecipe`, `ApplyRecipeToAgent`. +- **`tui/internal/tui/recipe_save.go:14-199`** — Recipe save helpers: `recipeUsesCustomDir`, `sourceBundleDir`, `saveCustomRecipe`, `ApplyRecipeToAgent`. - **`tui/internal/tui/skill_files.go:1-188`** — `SkillFilesModel`: embedded sub-model for browsing `.library/` skill directories within the wizard. - **`tui/internal/tui/oauth.go:35-705`** — Codex auth transport helpers shared by first-run and `/login` (the single OAuth entrypoint both paths use — see the unification note on `login.go` below): browser OAuth uses the allowlisted localhost ports and automatic callback completion (`startOAuthFlow`, `tui/internal/tui/oauth.go:169-314`). The browser launch goes through the process-global `oauthBrowserOpener` var (`tui/internal/tui/oauth.go:648`, defaults to `openBrowser`) so tests can stub it — without that seam the OAuth tests called `startOAuthFlow`→`openBrowser` directly and `go test ./...` opened real `auth.openai.com` pages (issue #474 comment 1); `TestStartOAuthFlow_DoesNotLaunchRealBrowser` guards the seam and `stubOAuthBrowserOpener` is used by every `startOAuthFlow` test. device-code login follows the official Codex endpoints (`requestCodexDeviceCode`, `tui/internal/tui/oauth.go:373-425`; `pollCodexDeviceAuth`, `tui/internal/tui/oauth.go:456-517`) before exchanging the issued authorization code with the normal token endpoint (`exchangeCodeForTokens`, `tui/internal/tui/oauth.go:553-595`). `buildAuthorizeURL` (`tui/internal/tui/oauth.go:532-549`) takes a `forceLogin bool`: when true it adds `prompt=login` so OpenAI's auth server shows the login page instead of silently reusing the active ChatGPT session — required for "Add another Codex account" (without it the second add re-adds the already-signed-in account). `startOAuthFlow` forwards it; only `LoginModel.codexForceLogin` (add-new-account-while-one-exists) passes true — first-run and existing-account re-auth pass false. Pinned by `TestBuildAuthorizeURL` (no prompt) and `TestBuildAuthorizeURL_ForceLogin` (prompt=login). - **`tui/internal/tui/claude_auth.go:11-84`** — Claude Code auth detector for the `claude-agent-sdk` preset: runs `claude auth status --json` with a short timeout, parses `loggedIn`, falls back to conservative text parsing, and never reads/stores Claude credentials. - **`tui/internal/tui/wizard_footer.go:16-61`** — `renderWizardFooter`: Back/Next button row shared by all wizard pages. -- **Utilities** — orchestrator detection lives in `tui/internal/tui/detect.go:11-157`; command export is `tui/internal/tui/palette.go:80`, Codex startup auth validation is `tui/internal/tui/app.go:1758`, and recipe placeholder substitution is `tui/internal/tui/recipe_save.go:135`. +- **Utilities** — orchestrator detection lives in `tui/internal/tui/detect.go:11-146`; command export is `tui/internal/tui/palette.go:80`, Codex launch-time token refresh validation is `tui/internal/tui/app.go:2096`, returning-user agent-bound validation is `tui/internal/tui/app.go:2178`, and recipe placeholder substitution is `tui/internal/tui/recipe_save.go:135`. - **`lock_unix.go` / `lock_windows.go`** — `tryLock`: platform-specific file locking for agent suspend/restart coordination. ## Connections -- **Called from:** `tui/main.go:354` creates the `App` via `tui.NewApp(...)` and wraps it in `tea.NewProgram`. +- **Called from:** `tui/main.go:330` creates the `App` via `tui.NewApp(...)` and wraps it in `tea.NewProgram`. - **Calls out (read):** `tui/internal/fs/` (agent state, heartbeat, mail, session, signal, network), `tui/internal/inventory/` (typed running-agent inventory for `/projects`), `tui/internal/preset/` (load/save/apply presets, recipes, bootstrap, utility skills), `tui/internal/config/` (global config, venv, upgrade checks), `tui/internal/process/` (agent launch), `tui/internal/migrate/` (addon comment detection), `tui/i18n/` (all screen strings). - **Calls out (write):** signal files (`.sleep`, `.suspend`, `.interrupt`, `.clear`, `.prompt`, `.refresh`, `.inquiry`, `.forget`), `init.json` via `preset.GenerateInitJSON`, human outbox via `fs.WriteOutboxMessage`, `.lingtai/.tui-asset/settings.json` (per-project settings). -- **Cross-view messages:** `App.Update` root-routes Mail-owned `mailRefreshMsg` and `homeTelemetryMsg` through the preserved Mail model even while Projects/Help is active (`tui/internal/tui/app.go:301-309`); generation checks stay in `MailModel.Update` (`tui/internal/tui/mail.go:698-821`). Other routes include `ViewChangeMsg`, `ProjectsAgentSelectedMsg` (root-owned visit transition), `FirstRunDoneMsg`, `NirvanaDoneMsg`, `SetupSavedMsg`, `AddonSavedMsg`, and `MarkdownViewerCloseMsg`. +- **Cross-view/project-mail messages:** `MailModel.requestMailRefresh` emits only a generation-tagged request (`tui/internal/tui/mail.go:267-283`). `App.Update` owns `projectMailRefreshRequestMsg`, `projectMailRefreshMsg`, and `projectMailTickMsg`; it asks `ProjectMailStore` to launch/accept work and forwards only a current-generation accepted immutable snapshot plus the embedded presentation result to `MailModel` (`tui/internal/tui/app.go:347-379`, `tui/internal/tui/project_mail_store.go:230-314`). An exact same-store old-generation completion releases the physical slot but publishes nothing; a current-generation authoritative initial queues behind either a steady scan or an older-generation initial, while unrelated stale identities remain no-ops. Mail-local continuations (`mailRefreshMsg`, `mailPersistMsg`, history pages/counts, `homeTelemetryMsg`) still root-route through the preserved model while Projects/Help is active (`tui/internal/tui/app.go:381-387`), with generation and cache-identity gates in `MailModel.Update` (`tui/internal/tui/mail.go:844-974`). Other routes include `ViewChangeMsg`, `ProjectsAgentSelectedMsg` (root-owned visit transition), `FirstRunDoneMsg`, `NirvanaDoneMsg`, `SetupSavedMsg`, `AddonSavedMsg`, and `MarkdownViewerCloseMsg`. - **Palette dispatch:** `PaletteSelectMsg` from the `PaletteModel` carries a slash-command string; `App.Update()` maps it to `handlePaletteCommand`. ## Composition @@ -140,13 +143,13 @@ Screen routing is centralized in the `App` struct (`app.go`), which holds every - **Parent:** `tui/` (`tui/ANATOMY.md`) - **Subfolders:** none — the package is intentionally flat. - **Siblings in `tui/internal/`:** `preset/`, `migrate/`, `globalmigrate/`, `fs/`, `inventory/`, `config/`, `process/`, `postman/`. -- **File count:** ~43 non-test `.go` files (20 screen models + supporting types + helpers, including `layout.go` for the root layout budget). +- **File count:** 53 non-test `.go` files (screen models plus supporting types/helpers, including `layout.go` for the root layout budget and `project_mail_store.go` for project-lifetime Mail ownership). ## State -- **Writes:** per-project `settings.json` (orchestrator selection, mail page size, theme, language), signal files, and `init.json` during setup/preset edits. Mail's accepted initial completion is the narrow derived-cache exception: after its generation gate it persists human `logs/session.jsonl`; detached/stale rebuilds do not write (`tui/internal/tui/mail.go:698-713`). +- **Writes:** per-project `settings.json` (orchestrator selection, mail page size, theme, language), signal files, and `init.json` during setup/preset edits. `ProjectMailStore` is the sole human-location updater owner: it first accepts a detached current-generation refresh in memory, then its delayed command revalidates the shared activation/version token immediately before the side effect (`tui/internal/tui/project_mail_store.go:286-338`). Mail's accepted initial completion is the narrow derived-cache exception: a post-frame continuation must pass generation and cache-identity gates before persisting human `logs/session.jsonl`; detached/stale rebuilds do not write (`tui/internal/tui/mail.go:844-974`). - **Reads:** agent working directories (`.agent.json`, `.agent.heartbeat`, `init.json`, `knowledge//KNOWLEDGE.md` (legacy `codex/codex.json` / `knowledge/knowledge.json` only via one-time migration), `mailbox/`, `logs/token_ledger.jsonl`, `history/chat_history.jsonl`, `system/*.md`, `.library/`). Global config (`~/.lingtai-tui/config.json`, `presets/`, `runtime/`). -- **Ephemeral:** `App.currentView`, `App.startupBanner`, `App.recoveryMode`, visit state (`visiting`, original context, target context, double-Esc timing), and `mailGeneration`. All screens maintain local cursor positions, scroll offsets, and input buffers — lost on process exit (Bubble Tea is stateless across launches). +- **Ephemeral:** root view/recovery state; `mailGeneration`; the active and optionally suspended `ProjectMailStore` identities, activation/source versions, accepted snapshots, in-flight generation/initial/pending refresh arbitration, tick chain, and shared atomic execution token; the process-wide data-less physical-scan serialization gate; `MailModel`'s accepted snapshot/generation plus presentation/session/UI caches; and visit state (the temporary return adapter, its retained presentation references, target context, and double-Esc timing). All screens' cursors, scroll offsets, and input buffers are lost on process exit (Bubble Tea is stateless across launches). ## Notes diff --git a/tui/internal/tui/app.go b/tui/internal/tui/app.go index 334cc2a7..c483c824 100644 --- a/tui/internal/tui/app.go +++ b/tui/internal/tui/app.go @@ -47,6 +47,20 @@ const doubleEscReturnWindow = 600 * time.Millisecond var appNow = time.Now +// visitReturnState is the one temporary App visit adapter retained by PR2. +// Owner: App visit coordinator. Reason: preserve the current back-navigation +// target/UI state until ThreadState exists. Expiry: PR7. MailModel no longer +// contains a project cache or mail tick; the suspended ProjectMailStore remains +// separately root-owned below. +type visitReturnState struct { + projectDir string + orchDir string + orchName string + mail MailModel + projects ProjectsModel + view appView +} + // App is the root Bubble Tea model. Routes between views via slash commands. type App struct { currentView appView @@ -95,21 +109,18 @@ type App struct { // it so the two badges can't both show. selectMode bool - mailGeneration uint64 - projectsActivationID uint64 - - visiting bool - visitOriginalProjectDir string - visitOriginalOrchDir string - visitOriginalOrchName string - visitOriginalMail MailModel - visitOriginalProjects ProjectsModel - visitOriginalView appView - visitTargetProjectDir string - visitTargetAgentDir string - visitTargetAgentName string - doubleEscArmed bool - doubleEscFirstAt time.Time + mailGeneration uint64 + projectsActivationID uint64 + mailStore ProjectMailStore + suspendedHomeMailStore *ProjectMailStore + + visiting bool + visitReturn *visitReturnState + visitTargetProjectDir string + visitTargetAgentDir string + visitTargetAgentName string + doubleEscArmed bool + doubleEscFirstAt time.Time } func humanAddr(projectDir string) string { @@ -117,8 +128,17 @@ func humanAddr(projectDir string) string { } func (a *App) installMailModel(m MailModel) { + if !a.mailStore.matches(m.baseDir, m.humanDir) { + // Invalidate the shared execution token before discarding a current + // store so any delayed location command becomes a no-op. + if a.mailStore.id != 0 && a.mailStore.active { + a.mailStore.suspend() + } + a.mailStore = newProjectMailStore(m.baseDir, m.humanDir) + } a.mailGeneration++ m.generation = a.mailGeneration + m.acceptedSnapshot = a.mailStore.snapshot a.mail = m } @@ -135,13 +155,16 @@ func (a App) projectsContext() ProjectsContext { Visiting: a.visiting, } if a.visiting { - ctx.OriginalProjectDir = a.visitOriginalProjectDir - ctx.OriginalAgentDir = a.visitOriginalOrchDir + if a.visitReturn != nil { + ctx.OriginalProjectDir = a.visitReturn.projectDir + ctx.OriginalAgentDir = a.visitReturn.orchDir + } } return ctx } func (a App) openProjectsView() (App, tea.Cmd) { + a.pauseProjectMail() a.currentView = appViewProjects a.projectsActivationID++ if a.projectsActivationID == 0 { @@ -258,7 +281,7 @@ func (a App) Init() tea.Cmd { case appViewFirstRun: cmds = append(cmds, a.firstRun.Init()) case appViewMail: - cmds = append(cmds, a.mail.Init()) + cmds = append(cmds, a.mail.Init(), a.mail.requestMailRefresh(a.mail.initialLoading)) } if a.tuiConfig.AutoRefreshEnabled() { // Init runs once on a value copy; the autoRefreshTickMsg handler owns @@ -282,6 +305,27 @@ func (a App) startAutoRefresh() (App, tea.Cmd) { return a, autoRefreshTick() } +func (a *App) beginProjectMailRefresh(initial bool) tea.Cmd { + if a == nil || a.mail.generation == 0 { + return nil + } + return a.mailStore.beginRefresh(a.mail, initial) +} + +func (a *App) resumeProjectMail(initial bool) tea.Cmd { + refreshCmd := a.beginProjectMailRefresh(initial) + tickCmd := a.mailStore.resumeTick() + return tea.Batch(refreshCmd, tickCmd, pulseTick(a.mail.generation), a.sendSize()) +} + +func (a *App) initProjectMail() tea.Cmd { + return tea.Batch(a.mail.Init(), a.beginProjectMailRefresh(true), a.mailStore.resumeTick(), a.sendSize()) +} + +func (a *App) pauseProjectMail() { + a.mailStore.pauseTick() +} + func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case childWindowSizeMsg: @@ -300,6 +344,39 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil // === Cross-view messages === + case projectMailRefreshRequestMsg: + if msg.generation != a.mail.generation || !a.mailStore.active || a.currentView != appViewMail { + return a, nil + } + return a, tea.Batch(a.beginProjectMailRefresh(msg.initial), a.mailStore.resumeTick()) + + case projectMailRefreshMsg: + snapshot, accepted, completed := a.mailStore.acceptRefresh(msg, a.mail.generation) + if !completed { + return a, nil + } + // A physically completed old-generation result releases the slot but + // publishes nothing. Preserve and start any queued authoritative initial + // for the current generation. + if !accepted { + return a, a.mailStore.beginPendingInitialRefresh(a.mail) + } + msg.mail.snapshot = snapshot + var mailCmd tea.Cmd + a.mail, mailCmd = a.mail.Update(msg.mail) + // Capture the accepted target/status state in the deferred authoritative + // rebuild rather than the pre-refresh model snapshot. + pendingInitialCmd := a.mailStore.beginPendingInitialRefresh(a.mail) + return a, tea.Batch(mailCmd, pendingInitialCmd, a.mailStore.locationUpdateCmd()) + + case projectMailTickMsg: + if !a.mailStore.acceptsTick(msg) || a.currentView != appViewMail { + return a, nil + } + refreshCmd := a.beginProjectMailRefresh(false) + nextTick := a.mailStore.nextTick() + telemetryCmd := a.mail.maybeScheduleHomeTelemetry(time.Now()) + return a, tea.Batch(refreshCmd, nextTick, telemetryCmd) case mailRefreshMsg, mailPersistMsg, mailHistoryCountMsg, mailOlderPageMsg, homeTelemetryMsg: // Mail content/count rebuilds, older pages, post-frame persistence, and @@ -322,7 +399,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Likewise clear any global select mode left on by the view we came from // (mail owns its own copyMode; the two must never both be active). a.selectMode = false - return a, tea.Batch(a.mail.refreshMail, tickEvery(a.mail.pollRate, a.mail.generation), pulseTick(a.mail.generation), a.sendSize()) + return a, a.resumeProjectMail(false) case doctorResultMsg: if a.currentView == appViewDoctor { @@ -391,7 +468,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { a.mail.AddSystemMessage(i18n.T("mail.refreshed")) } - cmds := []tea.Cmd{a.mail.refreshMail} + cmds := []tea.Cmd{a.beginProjectMailRefresh(false)} if a.currentView == appViewKnowledge { var kcmd tea.Cmd a.knowledge, kcmd = a.knowledge.reloadVisible() @@ -410,7 +487,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { a.mail.AddSystemMessage(i18n.T("mail.clear_requested")) } - return a, a.mail.refreshMail + return a, a.beginProjectMailRefresh(false) case refreshAllDoneMsg: if msg.generation != 0 && msg.generation != a.mail.generation { @@ -421,7 +498,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { a.mail.AddSystemMessage(i18n.TF("mail.refresh_all", msg.count)) } - return a, a.mail.refreshMail + return a, a.beginProjectMailRefresh(false) case PaletteSelectMsg: return a.handlePaletteCommand(msg.Command, msg.Args) @@ -453,7 +530,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { addr := humanAddr(a.projectDir) a.installMailModel(NewMailModel(humanDir, addr, a.projectDir, "", "", a.tuiConfig.MailPageSize, a.globalDir, a.tuiConfig.Language, a.tuiConfig.Insights, a.tuiConfig.ToolCallTruncate)) a.mail.AddSystemMessage(i18n.TF("mail.launch_failed", err)) - return a, tea.Batch(a.mail.Init(), a.sendSize()) + return a, a.initProjectMail() } a.orchDir = msg.OrchDir a.orchName = msg.OrchName @@ -501,7 +578,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { addr := humanAddr(a.projectDir) a.installMailModel(NewMailModel(humanDir, addr, a.projectDir, a.orchDir, a.orchName, a.tuiConfig.MailPageSize, a.globalDir, a.tuiConfig.Language, a.tuiConfig.Insights, a.tuiConfig.ToolCallTruncate)) a.mail.messages = append(a.mail.messages, ChatMessage{From: i18n.T("mail.system_sender"), Body: i18n.TF("mail.recipe_reapply_failed", err), Type: "mail"}) - return a, tea.Batch(a.mail.Init(), a.sendSize()) + return a, a.initProjectMail() } fmt.Fprintf(os.Stderr, "recipe re-applied before first-run launch (%d agent(s))\n", applied) } @@ -522,11 +599,12 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if launchErr != "" { a.mail.messages = append(a.mail.messages, ChatMessage{From: i18n.T("mail.system_sender"), Body: launchErr, Type: "mail"}) } - return a, tea.Batch(a.mail.Init(), a.sendSize()) + return a, a.initProjectMail() case RecipeFreshStartMsg: a.pendingRecipe = msg.Recipe a.pendingCustomDir = msg.CustomDir + a.pauseProjectMail() a.currentView = appViewNirvana a.nirvana = NewNirvanaModel(a.projectDir) return a, tea.Batch(a.nirvana.Init(), a.sendSize()) @@ -535,6 +613,8 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Nirvana complete: .lingtai/ wiped, go to first-run. // Re-init project to recreate the human folder so agents can // deliver mail once the new orchestrator starts. + a.mailStore.suspend() + a.mailStore = ProjectMailStore{} process.InitProject(a.projectDir) a.orchDir = "" a.orchName = "" @@ -576,7 +656,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.mail.AddSystemMessage(i18n.TF("mail.launch_failed", err)) } } - return a, tea.Batch(a.mail.Init(), a.sendSize()) + return a, a.initProjectMail() } PropagateOrchestratorConfig(a.projectDir, a.orchDir) a.mail.AddSystemMessage(i18n.T("setup.saved_refresh")) @@ -619,7 +699,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if launchErr != "" { a.mail.messages = append(a.mail.messages, ChatMessage{From: i18n.T("mail.system_sender"), Body: launchErr, Type: "mail"}) } - return a, tea.Batch(a.mail.Init(), a.sendSize()) + return a, a.initProjectMail() case autoRefreshTickMsg: // Single app-level auto-refresh tick. If disabled, let the loop lapse — @@ -755,6 +835,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (a App) openSetupCredentials() (App, tea.Cmd) { + a.pauseProjectMail() a.currentView = appViewLogin a.login = NewSetupCredentialsModel(a.orchDir, a.globalDir) return a, tea.Batch(a.login.Init(), a.sendSize()) @@ -906,6 +987,7 @@ func (a App) handlePaletteCommand(command, args string) (tea.Model, tea.Cmd) { return a, nil case "doctor": if targetDir != "" { + a.pauseProjectMail() a.currentView = appViewDoctor a.doctor = NewDoctorModel(targetDir, a.globalDir) return a, tea.Batch(a.doctor.Init(), a.sendSize()) @@ -913,6 +995,7 @@ func (a App) handlePaletteCommand(command, args string) (tea.Model, tea.Cmd) { return a, nil case "update": if targetDir != "" { + a.pauseProjectMail() a.currentView = appViewUpdate a.update = NewUpdateModel(targetDir, a.globalDir) return a, tea.Batch(a.update.Init(), a.sendSize()) @@ -920,6 +1003,7 @@ func (a App) handlePaletteCommand(command, args string) (tea.Model, tea.Cmd) { return a, nil case "update-tui": if a.globalDir != "" { + a.pauseProjectMail() a.currentView = appViewUpdateTUI a.updateTUI = NewUpdateTUIModel(a.globalDir) return a, tea.Batch(a.updateTUI.Init(), a.sendSize()) @@ -939,6 +1023,7 @@ func (a App) handlePaletteCommand(command, args string) (tea.Model, tea.Cmd) { return a, nil case "mcp": if a.orchDir != "" { + a.pauseProjectMail() a.currentView = appViewAddon a.addon = NewAddonModel(a.projectDir) return a, tea.Batch(a.addon.Init(), a.sendSize()) @@ -951,27 +1036,33 @@ func (a App) handlePaletteCommand(command, args string) (tea.Model, tea.Cmd) { if strings.EqualFold(trimmedArgs, "credentials") || strings.EqualFold(trimmedArgs, "login") { return a.openSetupCredentials() } + a.pauseProjectMail() a.currentView = appViewFirstRun a.firstRun = NewSetupModeModel(a.projectDir, a.globalDir, a.orchDir, a.orchName) return a, tea.Batch(a.firstRun.Init(), a.sendSize()) case "settings": + a.pauseProjectMail() a.currentView = appViewSettings tuiCfg := config.LoadTUIConfig(a.globalDir) a.settings = NewSettingsModel(a.globalDir, a.projectDir, a.orchDir, tuiCfg) return a, tea.Batch(a.settings.Init(), a.sendSize()) case "nirvana": + a.pauseProjectMail() a.currentView = appViewNirvana a.nirvana = NewNirvanaModel(a.projectDir) return a, tea.Batch(a.nirvana.Init(), a.sendSize()) case "kanban": + a.pauseProjectMail() a.currentView = appViewProps a.props = NewPropsModel(a.projectDir, a.orchDir, a.globalDir) return a, tea.Batch(a.props.Init(), a.sendSize()) case "daemons": + a.pauseProjectMail() a.currentView = appViewDaemons a.daemons = NewDaemonsModel(a.projectDir, a.orchDir) return a, tea.Batch(a.daemons.Init(), a.sendSize()) case "notification": + a.pauseProjectMail() a.currentView = appViewNotification a.notification = NewNotificationModel(a.orchDir) return a, tea.Batch(a.notification.Init(), a.sendSize()) @@ -992,6 +1083,7 @@ func (a App) handlePaletteCommand(command, args string) (tea.Model, tea.Cmd) { addMsg(i18n.TF("mail.goal_sent", eventID)) return a, nil case "skills": + a.pauseProjectMail() a.currentView = appViewLibrary // Agent-scoped: mirror what the skills capability would inject for // this agent. Scans /.library/ plus every Tier-1 path declared @@ -1001,18 +1093,22 @@ func (a App) handlePaletteCommand(command, args string) (tea.Model, tea.Cmd) { case "projects": return a.openProjectsView() case "knowledge", "library", "codex": + a.pauseProjectMail() a.currentView = appViewKnowledge a.knowledge = NewKnowledgeModel(a.projectDir, a.orchDir) return a, tea.Batch(a.knowledge.Init(), a.sendSize()) case "system": + a.pauseProjectMail() a.currentView = appViewSystem a.system = NewSystemModel(a.projectDir, a.orchDir) return a, tea.Batch(a.system.Init(), a.sendSize()) case "mailbox": + a.pauseProjectMail() a.currentView = appViewMailbox a.mailbox = NewMailboxModel(a.projectDir) return a, tea.Batch(a.mailbox.Init(), a.sendSize()) case "presets": + a.pauseProjectMail() a.currentView = appViewPresets // Agent-scoped: shows only the presets in this agent's // manifest.preset.allowed list — these are exactly the ones @@ -1089,6 +1185,7 @@ func (a App) handlePaletteCommand(command, args string) (tea.Model, tea.Cmd) { } return a, nil case "help": + a.pauseProjectMail() a.currentView = appViewHelp a.help = NewHelpModel() return a, tea.Batch(a.help.Init(), a.sendSize()) @@ -1514,12 +1611,21 @@ func (a App) enterVisitedAgent(msg ProjectsAgentSelectedMsg) (App, tea.Cmd) { } if !a.visiting { a.visiting = true - a.visitOriginalProjectDir = a.projectDir - a.visitOriginalOrchDir = a.orchDir - a.visitOriginalOrchName = a.orchName - a.visitOriginalMail = a.mail - a.visitOriginalProjects = a.projects - a.visitOriginalView = a.currentView + a.visitReturn = &visitReturnState{ + projectDir: a.projectDir, + orchDir: a.orchDir, + orchName: a.orchName, + mail: a.mail, + projects: a.projects, + view: a.currentView, + } + home := a.mailStore + home.suspend() + a.suspendedHomeMailStore = &home + } else { + // A visit-to-visit switch stops and discards the former visited store. + // The original home store remains suspended in its root-owned slot. + a.mailStore.suspend() } a.projectDir = filepath.Join(r.Project, ".lingtai") a.orchDir = r.AgentDir @@ -1532,49 +1638,64 @@ func (a App) enterVisitedAgent(msg ProjectsAgentSelectedMsg) (App, tea.Cmd) { a.doubleEscArmed = false a.installMailModel(a.newMailForCurrentContext()) a.mail.visitExitHint = true - return a, tea.Batch(a.mail.Init(), a.sendSize()) + return a, tea.Batch(a.mail.Init(), a.beginProjectMailRefresh(true), a.mailStore.resumeTick(), a.sendSize()) } func (a App) returnFromVisit() (App, tea.Cmd) { if !a.visiting { return a, nil } - restored := a.visitOriginalMail + if a.visitReturn == nil || a.suspendedHomeMailStore == nil { + return a, nil + } + a.mailStore.suspend() // stop the visited owner before restoring home + ret := *a.visitReturn + restored := ret.mail restored.copyMode = false restored.visitExitHint = false - a.projectDir = a.visitOriginalProjectDir - a.orchDir = a.visitOriginalOrchDir - a.orchName = a.visitOriginalOrchName - a.currentView = a.visitOriginalView + // Do not publish the suspended snapshot. Home becomes visible again only + // after a fresh store-accepted initial refresh reconstructs the same Main + // projection/session from current disk state. + restored.acceptedSnapshot = nil + restored.messages = nil + restored.initialLoading = true + a.projectDir = ret.projectDir + a.orchDir = ret.orchDir + a.orchName = ret.orchName + a.currentView = ret.view + a.mailStore = *a.suspendedHomeMailStore + a.mailStore.activate() a.selectMode = false a.visiting = false - a.visitOriginalProjectDir = "" - a.visitOriginalOrchDir = "" - a.visitOriginalOrchName = "" - a.visitOriginalView = appViewMail + a.visitReturn = nil + a.suspendedHomeMailStore = nil a.visitTargetProjectDir = "" a.visitTargetAgentDir = "" a.visitTargetAgentName = "" a.doubleEscArmed = false - resumeCmd := a.resumeMailModel(restored) if a.currentView == appViewProjects { - a.projects = a.visitOriginalProjects - a.visitOriginalProjects = ProjectsModel{} - return a, resumeCmd + a.projects = ret.projects + a.installMailModel(restored) + // installMailModel binds the restored owner snapshot for ordinary model + // replacement. Visit return deliberately withholds it until Mail is opened + // and a fresh authoritative initial refresh is accepted. + a.mail.acceptedSnapshot = nil + a.mail.homeTelemetryInFlight = false + a.pauseProjectMail() + return a, nil } - a.visitOriginalProjects = ProjectsModel{} a.currentView = appViewMail - return a, resumeCmd + return a, a.resumeMailModel(restored) } func (a *App) resumeMailModel(restored MailModel) tea.Cmd { a.installMailModel(restored) + // Do not reattach the suspended snapshot that returnFromVisit cleared. The + // restored store may reuse its cache internally, but UI publication waits + // for the fresh initial result. + a.mail.acceptedSnapshot = nil a.mail.homeTelemetryInFlight = false - refreshCmd := a.mail.refreshMail - if a.mail.initialLoading { - refreshCmd = a.mail.initialRebuild - } - return tea.Batch(refreshCmd, tickEvery(a.mail.pollRate, a.mail.generation), pulseTick(a.mail.generation), a.sendSize()) + return a.resumeProjectMail(a.mail.initialLoading) } func (a App) maybeHandleVisitEsc(msg tea.KeyPressMsg) (App, tea.Cmd, bool) { @@ -1623,10 +1744,19 @@ type refreshAllDoneMsg struct { } func (a App) switchToView(viewName string) (tea.Model, tea.Cmd) { - // Global select mode is scoped to the current view; clear it on any + // App is a value model, so retain the exact pre-route state for unknown or + // unavailable destinations. A no-op navigation must not invalidate Mail's + // root-owned tick chain or clear view-scoped UI state. + original := a + // Global select mode is scoped to the current view; clear it on successful // navigation so it never leaks into the destination (and so entering mail // hands ctrl+y back to the mail model's own copyMode). a.selectMode = false + // Login and Projects own their pause in shared helpers used by both palette + // and ViewChangeMsg routing. Every other non-Mail destination pauses here. + if viewName != "mail" && viewName != "login" && viewName != "projects" { + a.pauseProjectMail() + } switch viewName { case "mail": a.currentView = appViewMail @@ -1644,7 +1774,11 @@ func (a App) switchToView(viewName string) (tea.Model, tea.Cmd) { a.mail.toolCallTruncate = a.tuiConfig.ToolCallTruncate // Re-apply theme to textarea (settings may have changed it) a.mail.input.ApplyTheme() - mailCmd := a.mail.refreshMail + // A visit return through Projects deliberately leaves Mail loading with + // its suspended snapshot withheld. Preserve that authoritative-initial + // requirement when Mail is opened later; a page-size change also requires + // the same rebuild below. + initial := a.mail.initialLoading if pageSizeChanged { // The page size owns both visible batching and the bounded content // snapshot. A preserved cache built with the previous setting cannot be @@ -1652,14 +1786,15 @@ func (a App) switchToView(viewName string) (tea.Model, tea.Cmd) { // new page so old count/older-page completions are rejected. a.mail.initialLoading = true a.installMailModel(a.mail) - mailCmd = a.mail.initialRebuild + initial = true } - // Restart mail tick + refresh + pulse (ticks die when another view is active). + // Resume the one root-owned mail tick + refresh pipeline. A still-running + // chain is left alone; a paused chain gets one new invalidating generation. // Also (re)start the app-level auto-refresh ticker: this is the path // taken when leaving /settings, where auto refresh may have just been // toggled back on. startAutoRefresh is a no-op if it is already armed. a, arCmd := a.startAutoRefresh() - return a, tea.Batch(mailCmd, tickEvery(a.mail.pollRate, a.mail.generation), pulseTick(a.mail.generation), a.sendSize(), arCmd) + return a, tea.Batch(a.resumeProjectMail(initial), arCmd) case "setup": a.currentView = appViewFirstRun a.firstRun = NewSetupModeModel(a.projectDir, a.globalDir, a.orchDir, a.orchName) @@ -1727,7 +1862,7 @@ func (a App) switchToView(viewName string) (tea.Model, tea.Cmd) { a.addon = NewAddonModel(a.projectDir) return a, tea.Batch(a.addon.Init(), a.sendSize()) } - return a, nil + return original, nil case "welcome": a.currentView = appViewFirstRun a.firstRun = NewFirstRunModel(a.projectDir, a.globalDir, true, "") @@ -1738,7 +1873,7 @@ func (a App) switchToView(viewName string) (tea.Model, tea.Cmd) { a.help = NewHelpModel() return a, tea.Batch(a.help.Init(), a.sendSize()) } - return a, nil + return original, nil } func (a App) View() tea.View { diff --git a/tui/internal/tui/app_visit_test.go b/tui/internal/tui/app_visit_test.go index 4bf81152..cd595211 100644 --- a/tui/internal/tui/app_visit_test.go +++ b/tui/internal/tui/app_visit_test.go @@ -90,8 +90,8 @@ func TestRepeatedVisitedSwitchPreservesFirstOriginal(t *testing.T) { first, _ := a.enterVisitedAgent(ProjectsAgentSelectedMsg{Record: visitRecord(filepath.Join(filepath.Dir(filepath.Dir(a.projectDir)), "target1"), "a", "A")}) second, _ := first.enterVisitedAgent(ProjectsAgentSelectedMsg{Record: visitRecord(filepath.Join(filepath.Dir(filepath.Dir(a.projectDir)), "target2"), "b", "B")}) - if second.visitOriginalProjectDir != originalProject || second.visitOriginalOrchDir != originalAgent { - t.Fatalf("original changed after repeated switch: project=%q agent=%q", second.visitOriginalProjectDir, second.visitOriginalOrchDir) + if second.visitReturn == nil || second.visitReturn.projectDir != originalProject || second.visitReturn.orchDir != originalAgent { + t.Fatalf("original changed after repeated switch: return=%+v", second.visitReturn) } if second.orchName != "B" { t.Fatalf("target should update to second selection, got %q", second.orchName) diff --git a/tui/internal/tui/auto_refresh.go b/tui/internal/tui/auto_refresh.go index 4f84fded..54e46f07 100644 --- a/tui/internal/tui/auto_refresh.go +++ b/tui/internal/tui/auto_refresh.go @@ -7,14 +7,14 @@ import ( ) // Auto-refresh: a single, app-level 1s tick that periodically asks the active -// view to reload its on-disk data. This generalizes the mail view's existing -// poll loop (mail.go: tickEvery/tickMsg) to the other reloadable views so the -// human sees fresh network/agent state without pressing Ctrl+R. +// non-mail view to reload its own on-disk data. Project mailbox refresh is NOT +// part of this loop: ProjectMailStore is the sole mailbox cache/scan/tick owner. +// Do not add mail refresh routing here or a second mailbox polling chain will +// exist beside project_mail_store.go. // // Design notes: -// - One ticker lives on the App, not per-view. The App routes the tick to -// whichever view is current. Views never start their own auto tick (mail -// is the historical exception and keeps its own loop). +// - One view-refresh ticker lives on the App, not per-view. It is independent +// from the root-owned ProjectMailStore ticker and must never scan mail. // - Each participating view reuses the same reload path as Ctrl+R. Views opt // out for an individual tick when reloading would interrupt the user // (picker open, drill-in/detail/editor open, or in-flight doctor run). @@ -22,12 +22,12 @@ import ( // calls the same reload path on a timer. // autoRefreshInterval is the cadence for the app-level auto-refresh tick. It -// matches the mail view's poll rate (mail.go: pollRate = 1s) so all reloadable +// matches the ProjectMailStore poll rate so all reloadable // views feel equally live. const autoRefreshInterval = 1 * time.Second // autoRefreshTickMsg is delivered every autoRefreshInterval while auto refresh -// is enabled. It is distinct from mail's tickMsg so the two loops never alias. +// is enabled. It is distinct from projectMailTickMsg so the loops never alias. type autoRefreshTickMsg time.Time // autoRefreshTick schedules the next auto-refresh tick. diff --git a/tui/internal/tui/home_telemetry.go b/tui/internal/tui/home_telemetry.go index 85d20473..bbbede44 100644 --- a/tui/internal/tui/home_telemetry.go +++ b/tui/internal/tui/home_telemetry.go @@ -81,7 +81,7 @@ type homeTelemetry struct { // homeTelemetryTTL is the minimum wall-clock interval between two completed // background telemetry fetches. It is deliberately close to the mail poll cadence -// (m.pollRate, ~1s): the underlying data (token ledger, .status.json) is rewritten +// (ProjectMailStore.pollRate, ~1s): the underlying data (token ledger, .status.json) is rewritten // by the kernel on a similar cadence, so fetching faster only burns I/O without // showing newer numbers. Repeated render/keypress within the TTL reuses the cached // snapshot with no I/O at all. @@ -97,7 +97,7 @@ type homeTelemetryMsg struct { // fetchHomeTelemetry is the background worker: it performs all telemetry I/O // (sqlite/ledger/status/manifest) off the UI thread and returns a homeTelemetryMsg. // It is a value-receiver tea.Cmd, so it captures a snapshot of the model (orchestrator -// path + session cache) exactly like refreshMail/initialRebuild — the running +// path + session cache) independently of the project mail refresh — the running // command never touches live model state. func (m MailModel) fetchHomeTelemetry() tea.Msg { return homeTelemetryMsg{generation: m.generation, t: m.gatherHomeTelemetry()} diff --git a/tui/internal/tui/home_telemetry_context_source_test.go b/tui/internal/tui/home_telemetry_context_source_test.go index 4845d39e..c73d746e 100644 --- a/tui/internal/tui/home_telemetry_context_source_test.go +++ b/tui/internal/tui/home_telemetry_context_source_test.go @@ -82,7 +82,7 @@ func TestHomeTelemetryContextVisibleWithoutCtrlO(t *testing.T) { m := NewMailModel(humanDir, "human@local", "~", orchDir, "TestOrch", 50, dir, "en", false, 0) // Drive the deferred initial rebuild so the session cache is populated from // events.jsonl — exactly the normal launch path, no Ctrl+O. - msg := m.initialRebuild() + msg := acceptedInitialMailRefresh(m) m, _ = m.Update(msg) if m.verbose != verboseOff { @@ -150,7 +150,7 @@ func TestHomeTelemetryContextStableAcrossVerbose(t *testing.T) { } m := NewMailModel(humanDir, "human@local", "~", orchDir, "TestOrch", 50, dir, "en", false, 0) - m, _ = m.Update(m.initialRebuild()) + m, _ = m.Update(acceptedInitialMailRefresh(m)) m, _ = m.Update(tea.WindowSizeMsg{Width: 100, Height: 24}) atOff := m.gatherHomeTelemetry().contextUsage diff --git a/tui/internal/tui/home_telemetry_kanban_consistency_test.go b/tui/internal/tui/home_telemetry_kanban_consistency_test.go index bfc6af7f..45d4feb6 100644 --- a/tui/internal/tui/home_telemetry_kanban_consistency_test.go +++ b/tui/internal/tui/home_telemetry_kanban_consistency_test.go @@ -47,7 +47,7 @@ func newTelemetryModel(t *testing.T, dir, orchDir string) MailModel { t.Fatal(err) } m := NewMailModel(humanDir, "human@local", "~", orchDir, "TestOrch", 50, dir, "en", false, 0) - m, _ = m.Update(m.initialRebuild()) + m, _ = m.Update(acceptedInitialMailRefresh(m)) return m } diff --git a/tui/internal/tui/home_telemetry_ui_no_io_test.go b/tui/internal/tui/home_telemetry_ui_no_io_test.go index 03b2b8aa..4d4d70ca 100644 --- a/tui/internal/tui/home_telemetry_ui_no_io_test.go +++ b/tui/internal/tui/home_telemetry_ui_no_io_test.go @@ -37,7 +37,7 @@ func TestHomeTelemetryUIPathReadsCacheNotDisk(t *testing.T) { m := NewMailModel(humanDir, "human@local", "~", orchDir, "TestOrch", 50, dir, "en", false, 0) m, _ = m.Update(tea.WindowSizeMsg{Width: w, Height: h}) - m, _ = m.Update(m.initialRebuild()) + m, _ = m.Update(acceptedInitialMailRefresh(m)) // Async fetch round-trip: this is the ONE place telemetry I/O happens. m, _ = m.Update(m.fetchHomeTelemetry()) if !m.hasHomeTelemetry() { diff --git a/tui/internal/tui/home_telemetry_view_test.go b/tui/internal/tui/home_telemetry_view_test.go index 24be879c..e6890ff9 100644 --- a/tui/internal/tui/home_telemetry_view_test.go +++ b/tui/internal/tui/home_telemetry_view_test.go @@ -57,7 +57,7 @@ func newReadyMailModelWithTelemetry(t *testing.T, w, h int) MailModel { m, _ = m.Update(tea.WindowSizeMsg{Width: w, Height: h}) // Drive the deferred initial rebuild (the normal launch path) so the // notification populates the session cache. No Ctrl+O, verbose stays off. - m, _ = m.Update(m.initialRebuild()) + m, _ = m.Update(acceptedInitialMailRefresh(m)) // Home telemetry is now resolved asynchronously: gathering it does I/O off the // UI path via the fetchHomeTelemetry command, and the model only shows the row // once the resulting homeTelemetryMsg has landed. Drive that round-trip here @@ -83,7 +83,7 @@ func TestHomeViewKeepsStatusBarWhenTelemetryShows(t *testing.T) { } base := NewMailModel(dir, "human@local", "~", baseOrch, "TestOrch", 50, dir, "en", false, 0) base, _ = base.Update(tea.WindowSizeMsg{Width: w, Height: h}) - base, _ = base.Update(base.initialRebuild()) + base, _ = base.Update(acceptedInitialMailRefresh(base)) if base.hasHomeTelemetry() { t.Skip("environment unexpectedly has session telemetry data; skipping the baseline comparison") } diff --git a/tui/internal/tui/mail.go b/tui/internal/tui/mail.go index af775557..647d1888 100644 --- a/tui/internal/tui/mail.go +++ b/tui/internal/tui/mail.go @@ -71,7 +71,7 @@ func pulseTick(generation uint64) tea.Cmd { type mailRefreshMsg struct { generation uint64 - cache fs.MailCache // incrementally updated cache + snapshot *ProjectMailSnapshot sessionCache *fs.SessionCache // command-local authoritative rebuild; installed only after generation acceptance alive bool state string // active, idle, stuck, asleep, suspended, or "" @@ -112,23 +112,12 @@ type mailHistoryCountMsg struct { err error } -type tickMsg struct { - generation uint64 - at time.Time -} - // EditorDoneMsg carries the final text from the external editor. type EditorDoneMsg struct { Text string Generation uint64 } -func tickEvery(d time.Duration, generation uint64) tea.Cmd { - return tea.Every(d, func(t time.Time) tea.Msg { - return tickMsg{generation: generation, at: t} - }) -} - // MailModel is the main chat view — a single chronological stream. // verboseLevel controls what events.jsonl entries are shown type verboseLevel int @@ -173,17 +162,16 @@ type MailModel struct { baseDir string // .lingtai/ directory visitExitHint bool // append subtle Esc-Esc return hint to the title row verbose verboseLevel - messages []ChatMessage // derived from cache on each refresh - cache fs.MailCache // incremental mail cache - pageSize int // max messages shown (from settings) - loadedExtra int // additional older messages loaded via ctrl+u + messages []ChatMessage // derived from cache on each refresh + acceptedSnapshot *ProjectMailSnapshot // immutable project-store snapshot; MailModel never refreshes it + pageSize int // max messages shown (from settings) + loadedExtra int // additional older messages loaded via ctrl+u viewport viewport.Model input InputModel palette PaletteModel width int height int ready bool - pollRate time.Duration // refresh interval orchAlive bool orchState string // agent state from .agent.json networkActivity fs.NetworkActivity @@ -253,8 +241,6 @@ func NewMailModel(humanDir, humanAddr, baseDir, orchDir, orchName string, pageSi orchName: orchName, input: input, palette: palette, - pollRate: 1 * time.Second, - cache: fs.NewMailCache(humanDir), pageSize: pageSize, globalDir: globalDir, quoteIdx: -1, @@ -262,7 +248,7 @@ func NewMailModel(humanDir, humanAddr, baseDir, orchDir, orchName string, pageSi toolCallTruncate: toolCallTruncate, dismissedInsights: make(map[string]bool), sessionCache: fs.NewSessionCache(humanDir, filepath.Dir(baseDir)), - // The authoritative session rebuild is deferred to initialRebuild() (see + // The authoritative session rebuild is deferred to ProjectMailStore (see // below), so the first frames render before history is loaded. Show a // loading banner at the top of the stream until that rebuild's refresh // lands; the mailRefreshMsg handler clears it on the initial message. @@ -272,47 +258,28 @@ func NewMailModel(humanDir, humanAddr, baseDir, orchDir, orchName string, pageSi // intentionally NOT done here. NewMailModel runs on the synchronous launch // path (NewApp, before tea.Program.Run), so even the newest content window // would delay the first frame on content-heavy projects. The rebuild is - // deferred to initialRebuild(), a command run by Init(), so the first frame + // requested by App.Init and run by ProjectMailStore, so the first frame // paints immediately (empty) and the newest mail_page_size entries fill in a // beat later. Exact full-history metadata counting remains separate and async. return m } -// initialRebuild performs the one-time authoritative bounded content rebuild off -// the synchronous launch path. It refreshes mail, loads the newest -// mail_page_size canonical event entries plus current auxiliary sources, and -// merges them chronologically. Exact full-history event counts run separately. -// Running this as a tea.Cmd keeps the first frame instant. It returns a -// mailRefreshMsg carrying command-local mail and session caches; the live model -// installs and persists them only after accepting the message's generation, so -// a late rebuild cannot mutate a newer activation. -func (m MailModel) initialRebuild() tea.Msg { - if m.beforeRebuild != nil { - m.beforeRebuild() - } - // Refresh mail cache before session rebuild so mail entries are included. - cache := m.cache.Refresh() - // Always rebuild from authoritative sources on launch. Keep both the in-memory - // snapshot and its session.jsonl write command-local until Update accepts this - // generation; stale work must have no effect on the installed cache. - // - // mail_page_size directly owns both the initial newest content window and the - // visible/reveal batch. Exact full-history metadata is launched separately - // after this bounded content result is accepted. +// rebuildSession builds the target/session portion of an initial project-store +// refresh. The ProjectMailStore owns and supplies the detached mailbox cache; +// this helper never scans mailbox directories itself. +func (m MailModel) rebuildSession(cache fs.MailCache) *fs.SessionCache { sessionCache := fs.NewSessionCache(m.humanDir, filepath.Dir(m.baseDir)) sessionCache.RebuildFromSourcesWindowedInMemory(cache, m.humanAddr, m.orchestrator, m.orchDisplayName(), m.pageSize) - m.cache = cache - // Tag the resulting refresh as the initial one so the handler can clear the - // loading banner. Only this rebuild flips initialLoading off; periodic ticks - // produce untagged mailRefreshMsg values and never re-show the banner. - msg := m.refreshMail() - if rm, ok := msg.(mailRefreshMsg); ok { - rm.initial = true - rm.generation = m.generation - rm.sessionCache = sessionCache - return rm - } - return msg + return sessionCache +} + +// requestMailRefresh asks the root ProjectMailStore to run its sole refresh +// pipeline. It performs no mailbox I/O and cannot start a ticker. +func (m MailModel) requestMailRefresh(initial bool) tea.Cmd { + generation := m.generation + return func() tea.Msg { + return projectMailRefreshRequestMsg{generation: generation, initial: initial} + } } // requestOlderPage starts an asynchronous load of the next older page of history. @@ -340,7 +307,7 @@ func (m MailModel) requestOlderPage() (MailModel, tea.Cmd) { // and api-call-group consistent. The rebuilt cache is command-local until Update // accepts this generation. func (m MailModel) olderPageCmd(window int, generation uint64) tea.Msg { - cache := m.cache.Refresh() + cache := m.snapshotCache() sessionCache := fs.NewSessionCache(m.humanDir, filepath.Dir(m.baseDir)) sessionCache.RebuildFromSourcesWindowedInMemory(cache, m.humanAddr, m.orchestrator, m.orchDisplayName(), window) return mailOlderPageMsg{ @@ -541,11 +508,10 @@ func (m MailModel) showChatTailHint() bool { return m.chatTailRemainingLines() > m.viewport.Height() } -func (m MailModel) refreshMail() tea.Msg { - // Incremental cache refresh — only reads new messages from disk. Human - // location refresh is launched by Update only after generation acceptance. - cache := m.cache.Refresh() - +// collectRefreshState reads target/project status that travels beside a store +// refresh. Mailbox scanning is deliberately absent: ProjectMailStore is the +// only caller of MailCache.Refresh. +func (m MailModel) collectRefreshState() mailRefreshMsg { alive := m.orchestrator != "" && fs.IsAlive(m.orchestrator, 3.0) var activity fs.NetworkActivity if m.baseDir != "" { @@ -572,7 +538,14 @@ func (m MailModel) refreshMail() tea.Msg { state = "suspended" } } - return mailRefreshMsg{generation: m.generation, cache: cache, alive: alive, state: state, activity: activity, orchName: orchName, orchNickname: orchNickname} + return mailRefreshMsg{generation: m.generation, alive: alive, state: state, activity: activity, orchName: orchName, orchNickname: orchNickname} +} + +func (m MailModel) snapshotCache() fs.MailCache { + if m.acceptedSnapshot == nil { + return fs.NewMailCache(m.humanDir) + } + return m.acceptedSnapshot.cache } // orchDisplayName returns the nickname if set, otherwise the agent name. @@ -590,7 +563,8 @@ func (m MailModel) orchDisplayName() string { // partial event window or be rendered twice. func (m *MailModel) buildMessages() { // Ingest new entries from all sources into session.jsonl. - m.sessionCache.Refresh(m.cache, m.humanAddr, m.orchestrator, m.orchDisplayName()) + cache := m.snapshotCache() + m.sessionCache.Refresh(cache, m.humanAddr, m.orchestrator, m.orchDisplayName()) if m.historyCountLoaded { // Refresh incrementally advances the accepted exact metadata at EOF. m.historyStats = m.sessionCache.HistoryStats() @@ -652,7 +626,7 @@ func (m *MailModel) buildMessages() { cm := sessionEntryToChatMessage(e, m.humanAddr) chatMsgs = append(chatMsgs, cm) } - for _, msg := range m.cache.Messages { + for _, msg := range cache.Messages { chatMsgs = append(chatMsgs, mailMessageToChatMessage(msg, m.humanAddr, m.orchDisplayName())) m.auxiliaryMessages++ } @@ -812,13 +786,8 @@ func mailMessageToChatMessage(msg fs.MailMessage, humanAddr, orchName string) Ch func (m MailModel) Init() tea.Cmd { return tea.Batch( m.input.Init(), - // initialRebuild does the one-time authoritative session rebuild off the - // synchronous launch path (see initialRebuild and NewMailModel). It - // returns a mailRefreshMsg, so the standard refresh handler builds the - // view once history is loaded. The periodic tick below then keeps it - // current via the incremental Refresh path. - m.initialRebuild, - tickEvery(m.pollRate, m.generation), + // ProjectMailStore owns initial/steady mailbox refresh and the mail tick. + // MailModel keeps only its target/UI pulse animation. pulseTick(m.generation), ) } @@ -879,9 +848,6 @@ func (m MailModel) Update(msg tea.Msg) (MailModel, tea.Cmd) { if msg.generation != m.generation { return m, nil } - // The detached command is read-only. Launch this best-effort network/cache - // refresh only after the generation gate; it remains off the Update path. - go fs.UpdateHumanLocation(m.humanDir) var persistCmd tea.Cmd var countCmd tea.Cmd if msg.sessionCache != nil { @@ -926,7 +892,7 @@ func (m MailModel) Update(msg tea.Msg) (MailModel, tea.Cmd) { // drop the loading banner. Periodic refreshes leave this untouched. m.initialLoading = false } - m.cache = msg.cache + m.acceptedSnapshot = msg.snapshot m.orchAlive = msg.alive m.orchState = msg.state m.networkActivity = msg.activity @@ -1119,19 +1085,6 @@ func (m MailModel) Update(msg tea.Msg) (MailModel, tea.Cmd) { } return m, pulseTick(m.generation) - case tickMsg: - if msg.generation != m.generation { - return m, nil - } - // Steady-state driver: alongside the incremental mail refresh, schedule a - // background telemetry fetch (debounced by in-flight + TTL). All telemetry - // I/O funnels through maybeScheduleHomeTelemetry — the UI path never gathers. - cmds = append(cmds, m.refreshMail, tickEvery(m.pollRate, m.generation)) - if cmd := m.maybeScheduleHomeTelemetry(time.Now()); cmd != nil { - cmds = append(cmds, cmd) - } - return m, tea.Batch(cmds...) - case homeTelemetryMsg: if msg.generation != m.generation { return m, nil @@ -1178,7 +1131,7 @@ func (m MailModel) Update(msg tea.Msg) (MailModel, tea.Cmd) { os.Remove(filepath.Join(m.baseDir, ".tui-asset", ".insight.done")) m.input.Reset() m.syncViewportHeight() - return m, m.refreshMail + return m, m.requestMailRefresh(false) } return m, nil @@ -1199,7 +1152,7 @@ func (m MailModel) Update(msg tea.Msg) (MailModel, tea.Cmd) { // Refresh viewport and force a full repaint after the terminal returns from // the external editor; editors such as vim can leave the alt screen visually // stale until Bubble Tea draws a clean frame. - return m, tea.Batch(m.refreshMail, tea.ClearScreen) + return m, tea.Batch(m.requestMailRefresh(false), tea.ClearScreen) case PaletteSelectMsg: m.input.Reset() @@ -1290,7 +1243,7 @@ func (m MailModel) Update(msg tea.Msg) (MailModel, tea.Cmd) { // Refresh the mail thread and agent state from disk. ctrl+r is a // control key, so it does not interfere with typing `r` into the // compose textarea (which falls through to the default branch). - return m, m.refreshMail + return m, m.requestMailRefresh(false) case "ctrl+o": // Cycle: normal → thinking → extended → normal switch m.verbose { @@ -1310,7 +1263,7 @@ func (m MailModel) Update(msg tea.Msg) (MailModel, tea.Cmd) { m.viewport.GotoBottom() return m, nil } - return m, m.refreshMail + return m, m.requestMailRefresh(false) case "ctrl+u": if m.ready && m.viewport.AtTop() { diff --git a/tui/internal/tui/mail_generation_test.go b/tui/internal/tui/mail_generation_test.go index 703ccaa8..8702ca21 100644 --- a/tui/internal/tui/mail_generation_test.go +++ b/tui/internal/tui/mail_generation_test.go @@ -12,19 +12,26 @@ import ( func runMailPersistCmd(t *testing.T, cmd tea.Cmd) mailPersistMsg { t.Helper() + if persist, ok := findMailPersistCmd(cmd); ok { + return persist + } + t.Fatalf("post-frame command produced %T without mailPersistMsg", runCmd(cmd)) + return mailPersistMsg{} +} + +func findMailPersistCmd(cmd tea.Cmd) (mailPersistMsg, bool) { msg := runCmd(cmd) if persist, ok := msg.(mailPersistMsg); ok { - return persist + return persist, true } if batch, ok := msg.(tea.BatchMsg); ok { for _, child := range batch { - if persist, ok := runCmd(child).(mailPersistMsg); ok { - return persist + if persist, ok := findMailPersistCmd(child); ok { + return persist, true } } } - t.Fatalf("post-frame command produced %T without mailPersistMsg", msg) - return mailPersistMsg{} + return mailPersistMsg{}, false } func TestMailModelIgnoresOldGenerationAsyncMessages(t *testing.T) { @@ -36,7 +43,6 @@ func TestMailModelIgnoresOldGenerationAsyncMessages(t *testing.T) { cases := []tea.Msg{ mailRefreshMsg{generation: 1, initial: true, state: "active"}, mailPersistMsg{generation: 1, sessionCache: m.sessionCache}, - tickMsg{generation: 1}, pulseTickMsg{generation: 1}, homeTelemetryMsg{generation: 1, t: homeTelemetry{apiCalls: 9}}, EditorDoneMsg{Generation: 1, Text: "old editor text"}, @@ -82,19 +88,16 @@ func TestReturnFromVisitResumesInitialLoadingWithNewGenerationRebuild(t *testing if restored.mail.generation == origGen || restored.mail.generation == targetGen { t.Fatalf("restore generation = %d, want new generation beyond orig %d and target %d", restored.mail.generation, origGen, targetGen) } - cmds := resumeBatchCommands(t, resumeCmd) - if len(cmds) != 4 { - t.Fatalf("resume should arm one rebuild/refresh, one poll, one pulse, and size; got %d commands", len(cmds)) - } - msg, ok := cmds[0]().(mailRefreshMsg) + storeMsg, ok := findProjectMailRefresh(resumeCmd) if !ok { - t.Fatalf("first resume command produced %T, want mailRefreshMsg", cmds[0]()) + t.Fatal("resume did not schedule a ProjectMailStore refresh") } + msg := storeMsg.mail if !msg.initial || msg.generation != restored.mail.generation { t.Fatalf("resume command = initial %v generation %d, want initial true generation %d", msg.initial, msg.generation, restored.mail.generation) } - updated, _ := restored.mail.Update(msg) - if updated.initialLoading { + model, _ = restored.Update(storeMsg) + if model.(App).mail.initialLoading { t.Fatal("new-generation initial rebuild should clear loading") } @@ -126,16 +129,13 @@ func TestReturnFromVisitClearsTelemetryInFlightAndAllowsNewFetch(t *testing.T) { if restored.mail.homeTelemetryInFlight { t.Fatal("resume should clear activation-local telemetry in-flight flag") } - cmds := resumeBatchCommands(t, resumeCmd) - if len(cmds) != 4 { - t.Fatalf("resume should arm one refresh, one poll, one pulse, and size; got %d commands", len(cmds)) - } - msg, ok := cmds[0]().(mailRefreshMsg) + storeMsg, ok := findProjectMailRefresh(resumeCmd) if !ok { - t.Fatalf("first resume command produced %T, want mailRefreshMsg", cmds[0]()) + t.Fatal("resume did not schedule a ProjectMailStore refresh") } - if msg.initial { - t.Fatal("non-loading resume should start ordinary refresh, not initial rebuild") + msg := storeMsg.mail + if !msg.initial { + t.Fatal("visit return must fresh-rebuild before publishing restored home") } if msg.generation != restored.mail.generation { t.Fatalf("refresh generation = %d, want %d", msg.generation, restored.mail.generation) @@ -168,7 +168,7 @@ func TestBlockedInitialRebuildDoesNotBlockRootInteraction(t *testing.T) { close(started) <-release } - load := a.mail.initialRebuild + load := a.beginProjectMailRefresh(true) go func() { completed <- load() }() <-started @@ -258,7 +258,7 @@ func TestInitialRebuildDoesNotMutateInstalledCacheBeforeAcceptance(t *testing.T) m := NewMailModel(humanDir, "human", root, orchDir, "agent", 2000, "", "en", false, 0) installed := m.sessionCache - msg := m.initialRebuild() + msg := acceptedInitialMailRefresh(m) if got := installed.Len(); got != 0 { t.Fatalf("initial rebuild mutated the installed cache before acceptance: got %d entries", got) @@ -312,7 +312,7 @@ func TestAppRoutesInitialMailCompletionWhileProjectsActive(t *testing.T) { a := visitTestApp(t) a.mail.verbose = verboseThinking writeMailGenerationEvent(t, a.orchDir, "projects-time completion") - msg := a.mail.initialRebuild() + msg := detachedAppProjectMailRefresh(&a, true) model, _ := a.Update(ViewChangeMsg{View: "projects"}) got := model.(App) @@ -421,20 +421,19 @@ func TestLateInitialRebuildCannotMutateCurrentGenerationCache(t *testing.T) { a := App{currentView: appViewMail} a.installMailModel(NewMailModel(humanDir, "human", t.TempDir(), orchDir, "agent", 2000, "", "en", false, 0)) a.mail.verbose = verboseThinking - lateA := a.mail.initialRebuild + lateA := acceptedInitialMailRefresh(a.mail) // Install generation B from the same preserved model. This mirrors returning // to a preserved mail model: generations differ, but the pre-fix cache pointer // was shared by both command closures. a.installMailModel(a.mail) - model, persistCmd := a.Update(a.mail.initialRebuild()) - a = model.(App) + var persistCmd tea.Cmd + a.mail, persistCmd = a.mail.Update(acceptedInitialMailRefresh(a.mail)) if persistCmd == nil { t.Fatal("generation B initial rebuild did not schedule persistence") } persistMsg := runMailPersistCmd(t, persistCmd) - model, _ = a.Update(persistMsg) - a = model.(App) + a.mail, _ = a.mail.Update(persistMsg) beforeEntries := a.mail.sessionCache.Entries() beforeMessages := append([]ChatMessage(nil), a.mail.messages...) sessionPath := filepath.Join(humanDir, "logs", "session.jsonl") @@ -444,7 +443,7 @@ func TestLateInitialRebuildCannotMutateCurrentGenerationCache(t *testing.T) { } appendMailGenerationEvent(t, orchDir, "late generation A") - staleMsg := lateA() + staleMsg := lateA if got := a.mail.sessionCache.Entries(); !reflect.DeepEqual(got, beforeEntries) { t.Fatalf("late generation A mutated generation B cache before acceptance:\n got %#v\nwant %#v", got, beforeEntries) } @@ -454,16 +453,15 @@ func TestLateInitialRebuildCannotMutateCurrentGenerationCache(t *testing.T) { t.Fatal("late generation A rewrote generation B's persisted session cache before acceptance") } - model, cmd := a.Update(staleMsg) + updated, cmd := a.mail.Update(staleMsg) if cmd != nil { t.Fatalf("stale initial completion returned command %T", runCmd(cmd)) } - got := model.(App) - if !reflect.DeepEqual(got.mail.sessionCache.Entries(), beforeEntries) { + if !reflect.DeepEqual(updated.sessionCache.Entries(), beforeEntries) { t.Fatal("rejected generation A changed generation B cache") } - if !reflect.DeepEqual(got.mail.messages, beforeMessages) { - t.Fatalf("rejected generation A changed generation B visible projection:\n got %#v\nwant %#v", got.mail.messages, beforeMessages) + if !reflect.DeepEqual(updated.messages, beforeMessages) { + t.Fatalf("rejected generation A changed generation B visible projection:\n got %#v\nwant %#v", updated.messages, beforeMessages) } if afterFile, err := os.ReadFile(sessionPath); err != nil { t.Fatal(err) diff --git a/tui/internal/tui/mail_launch_defer_test.go b/tui/internal/tui/mail_launch_defer_test.go index 39ddbea2..8cfff074 100644 --- a/tui/internal/tui/mail_launch_defer_test.go +++ b/tui/internal/tui/mail_launch_defer_test.go @@ -38,11 +38,11 @@ func TestNewMailModelDefersSessionRebuild(t *testing.T) { } } -// TestMailInitRunsRebuild verifies that Init()'s command performs the deferred +// TestProjectMailStoreRunsRebuild verifies that the root store performs the deferred // rebuild and that feeding its message into Update populates the message stream. // This is the other half of the deferral: the work still happens, just off the // synchronous launch path. -func TestMailInitRunsRebuild(t *testing.T) { +func TestProjectMailStoreRunsRebuild(t *testing.T) { humanDir := t.TempDir() orchDir := t.TempDir() if err := os.MkdirAll(filepath.Join(orchDir, "logs"), 0o755); err != nil { @@ -60,16 +60,16 @@ func TestMailInitRunsRebuild(t *testing.T) { m.verbose = verboseThinking // Run the initial rebuild command (the deferred heavy work). - msg := m.initialRebuild() + msg := acceptedInitialMailRefresh(m) if msg == nil { - t.Fatal("initialRebuild returned nil msg") + t.Fatal("project store initial refresh returned nil msg") } if got := m.sessionCache.Len(); got != 0 { - t.Fatalf("initialRebuild mutated the installed session cache before acceptance; got %d entries", got) + t.Fatalf("project store rebuild mutated the installed session cache before acceptance; got %d entries", got) } rm, ok := msg.(mailRefreshMsg) if !ok || rm.sessionCache == nil || rm.sessionCache.Len() == 0 { - t.Fatalf("initialRebuild did not return a populated command-local session cache: %#v", rm.sessionCache) + t.Fatalf("project store rebuild did not return a populated command-local session cache: %#v", rm.sessionCache) } // Feed the resulting message through Update — acceptance installs the rebuilt @@ -86,12 +86,13 @@ func TestMailInitRunsRebuild(t *testing.T) { } } -// TestMailInitIncludesRebuildCmd verifies Init() actually schedules the rebuild. -func TestMailInitIncludesRebuildCmd(t *testing.T) { +// TestAppInitRequestsProjectStoreRebuild verifies root Init schedules the store. +func TestAppInitRequestsProjectStoreRebuild(t *testing.T) { dir := t.TempDir() - m := NewMailModel(dir, "human@local", dir, dir, "orch", 20, dir, "en", false, 0) - if cmd := m.Init(); cmd == nil { - t.Fatal("MailModel.Init returned nil cmd; expected at least the rebuild + refresh batch") + a := App{currentView: appViewMail} + a.installMailModel(NewMailModel(dir, "human@local", dir, dir, "orch", 20, dir, "en", false, 0)) + if cmd := a.Init(); cmd == nil { + t.Fatal("App.Init returned nil cmd; expected a project-store refresh request") } _ = tea.Batch // keep the bubbletea import meaningful even if Batch isn't referenced directly } diff --git a/tui/internal/tui/mail_loading_state_test.go b/tui/internal/tui/mail_loading_state_test.go index d254c7f4..a0f85309 100644 --- a/tui/internal/tui/mail_loading_state_test.go +++ b/tui/internal/tui/mail_loading_state_test.go @@ -82,10 +82,10 @@ func TestMailLoadingBannerClearsAfterInitialRebuild(t *testing.T) { } // Run the deferred rebuild and apply its (initial-tagged) message. - msg := m.initialRebuild() + msg := acceptedInitialMailRefresh(m) rm, ok := msg.(mailRefreshMsg) if !ok { - t.Fatalf("initialRebuild returned %T; expected mailRefreshMsg", msg) + t.Fatalf("store initial refresh returned %T; expected mailRefreshMsg", msg) } if !rm.initial { t.Fatal("initialRebuild's mailRefreshMsg must be tagged initial=true") @@ -125,7 +125,7 @@ func TestMailPeriodicRefreshDoesNotReshowLoading(t *testing.T) { m = sizeMail(t, m) // Apply the initial rebuild to clear loading. - m, _ = m.Update(m.initialRebuild()) + m, _ = m.Update(acceptedInitialMailRefresh(m)) if m.initialLoading { t.Fatal("loading should be cleared by the initial rebuild") } @@ -135,7 +135,7 @@ func TestMailPeriodicRefreshDoesNotReshowLoading(t *testing.T) { } // A periodic refresh (untagged) must leave loading cleared. - periodic := m.refreshMail() + periodic := acceptedSteadyMailRefresh(m) if rm, ok := periodic.(mailRefreshMsg); ok && rm.initial { t.Fatal("periodic refreshMail must not produce an initial-tagged message") } diff --git a/tui/internal/tui/mail_mailbox_source_test.go b/tui/internal/tui/mail_mailbox_source_test.go index cc786041..f83abc2c 100644 --- a/tui/internal/tui/mail_mailbox_source_test.go +++ b/tui/internal/tui/mail_mailbox_source_test.go @@ -39,7 +39,7 @@ func TestMailProjectionUsesMailboxExactlyOnceBeyondEventWindow(t *testing.T) { }) m := NewMailModel(humanDir, "human", t.TempDir(), orchDir, "agent", 200, "", "en", false, 0) - m, _ = m.Update(m.initialRebuild()) + m, _ = m.Update(acceptedInitialMailRefresh(m)) matches := 0 for _, message := range m.messages { @@ -72,7 +72,7 @@ func TestMailProjectionKeepsExpandedEventHistoryWhileMailStaysSingleSource(t *te }) m := NewMailModel(humanDir, "human", t.TempDir(), orchDir, "agent", 100, "", "en", false, 0) - m, _ = m.Update(m.initialRebuild()) + m, _ = m.Update(acceptedInitialMailRefresh(m)) m.verbose = verboseThinking m.buildMessages() diff --git a/tui/internal/tui/mail_window_test.go b/tui/internal/tui/mail_window_test.go index 7247c1de..3fe604ee 100644 --- a/tui/internal/tui/mail_window_test.go +++ b/tui/internal/tui/mail_window_test.go @@ -108,9 +108,9 @@ func appendWindowedIndexedEvent(t *testing.T, orchDir, text string) { func installInitialWindow(t *testing.T, m MailModel) MailModel { t.Helper() - rm, ok := m.initialRebuild().(mailRefreshMsg) + rm, ok := acceptedInitialMailRefresh(m).(mailRefreshMsg) if !ok { - t.Fatalf("initialRebuild returned %T", rm) + t.Fatalf("store initial refresh returned %T", rm) } var cmd tea.Cmd m, cmd = m.Update(rm) @@ -141,7 +141,7 @@ func TestInitialContentWindowEqualsConfiguredPageSize(t *testing.T) { orchDir := buildWindowedAgentDir(t, 405) m := NewMailModel(t.TempDir(), "human", t.TempDir(), orchDir, "agent", 200, "", "en", false, 0) m.verbose = verboseThinking - rm := m.initialRebuild().(mailRefreshMsg) + rm := acceptedInitialMailRefresh(m).(mailRefreshMsg) if got := rm.sessionCache.Len(); got != 200 { t.Fatalf("initial content cache = %d, want configured page size 200", got) } diff --git a/tui/internal/tui/project_mail_store.go b/tui/internal/tui/project_mail_store.go new file mode 100644 index 00000000..4764cd79 --- /dev/null +++ b/tui/internal/tui/project_mail_store.go @@ -0,0 +1,338 @@ +package tui + +import ( + "path/filepath" + "sync" + "sync/atomic" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/anthropics/lingtai-tui/internal/fs" +) + +// ProjectMailSnapshot is an immutable, accepted view of one project's human +// mailbox. A refresh result becomes visible only after ProjectMailStore accepts +// its store identity, activation, and source version. The cache remains private +// so callers cannot turn a snapshot into a second refresh owner. +type ProjectMailSnapshot struct { + version uint64 + cache fs.MailCache +} + +func (s *ProjectMailSnapshot) Version() uint64 { + if s == nil { + return 0 + } + return s.version +} + +type projectMailScanner interface { + Refresh(fs.MailCache) fs.MailCache +} + +type filesystemProjectMailScanner struct{} + +func (filesystemProjectMailScanner) Refresh(cache fs.MailCache) fs.MailCache { + return cache.Refresh() +} + +type projectMailLocationUpdater func(string) + +type projectMailRefreshMsg struct { + storeID uint64 + projectID string + activation uint64 + sourceVersion uint64 + cache fs.MailCache + mail mailRefreshMsg +} + +type projectMailTickMsg struct { + storeID uint64 + activation uint64 + chain uint64 + at time.Time +} + +type projectMailRefreshRequestMsg struct { + generation uint64 + initial bool +} + +var projectMailStoreSequence atomic.Uint64 + +// projectMailScanSingleflight serializes the physical refresh body across store +// identities. A suspended home command cannot be cancelled once Bubble Tea has +// started it, so a newly active visited store waits here instead of scanning in +// parallel. The gate owns no project data, accepted state, or tick lifecycle. +var projectMailScanSingleflight sync.Mutex + +// projectMailRuntimeGate is shared by value copies of one store. It lets a +// delayed side-effect command re-check the live activation/version at execution +// time even though Bubble Tea returns App values by copy. +type projectMailRuntimeGate struct { + active atomic.Bool + activation atomic.Uint64 + version atomic.Uint64 +} + +// ProjectMailStore is the root-owned project-lifetime mailbox owner. It owns +// exactly one MailCache, one accepted-snapshot sequence, one refresh pipeline, +// and one invalidatable polling chain. Bubble Tea serializes its mutations on +// App.Update; background commands receive detached cache values and can only +// publish through the identity/version acceptance gate below. +type ProjectMailStore struct { + id uint64 + projectID string + projectDir string + humanDir string + cache fs.MailCache + snapshot *ProjectMailSnapshot + version uint64 + activation uint64 + tickChain uint64 + active bool + tickRunning bool + refreshInFlight bool + refreshInitial bool + refreshGeneration uint64 + initialRefreshPending bool + pollRate time.Duration + scanner projectMailScanner + updateLocation projectMailLocationUpdater + runtime *projectMailRuntimeGate +} + +func canonicalProjectMailIdentity(projectDir string) string { + if projectDir == "" { + return "" + } + abs, err := filepath.Abs(projectDir) + if err != nil { + return filepath.Clean(projectDir) + } + return filepath.Clean(abs) +} + +func newProjectMailStore(projectDir, humanDir string) ProjectMailStore { + return newProjectMailStoreWithDeps(projectDir, humanDir, filesystemProjectMailScanner{}, fs.UpdateHumanLocation) +} + +func newProjectMailStoreWithDeps(projectDir, humanDir string, scanner projectMailScanner, updateLocation projectMailLocationUpdater) ProjectMailStore { + if scanner == nil { + scanner = filesystemProjectMailScanner{} + } + if updateLocation == nil { + updateLocation = func(string) {} + } + store := ProjectMailStore{ + id: projectMailStoreSequence.Add(1), + projectID: canonicalProjectMailIdentity(projectDir), + projectDir: projectDir, + humanDir: humanDir, + cache: fs.NewMailCache(humanDir), + activation: 1, + active: true, + pollRate: time.Second, + scanner: scanner, + updateLocation: updateLocation, + runtime: &projectMailRuntimeGate{}, + } + store.syncRuntime() + return store +} + +func (s *ProjectMailStore) syncRuntime() { + if s == nil || s.id == 0 { + return + } + if s.runtime == nil { + s.runtime = &projectMailRuntimeGate{} + } + s.runtime.active.Store(s.active) + s.runtime.activation.Store(s.activation) + s.runtime.version.Store(s.version) +} + +func (s ProjectMailStore) matches(projectDir, humanDir string) bool { + return s.id != 0 && + s.projectID == canonicalProjectMailIdentity(projectDir) && + filepath.Clean(s.humanDir) == filepath.Clean(humanDir) +} + +func (s *ProjectMailStore) suspend() { + if s == nil || s.id == 0 { + return + } + s.pauseTick() + s.active = false + s.activation++ + s.refreshInFlight = false + s.refreshInitial = false + s.refreshGeneration = 0 + s.initialRefreshPending = false + s.syncRuntime() +} + +func (s *ProjectMailStore) activate() { + if s == nil || s.id == 0 { + return + } + s.active = true + s.activation++ + s.refreshInFlight = false + s.refreshInitial = false + s.refreshGeneration = 0 + s.initialRefreshPending = false + s.tickRunning = false + s.syncRuntime() +} + +// pauseTick invalidates the outstanding chain even if its tea.Every command +// has already fired. A late message therefore cannot pass acceptTick and re-arm. +func (s *ProjectMailStore) pauseTick() { + if s == nil || s.id == 0 { + return + } + s.tickChain++ + s.tickRunning = false +} + +// resumeTick creates at most one chain for the current activation. +func (s *ProjectMailStore) resumeTick() tea.Cmd { + if s == nil || s.id == 0 || !s.active || s.tickRunning { + return nil + } + s.tickChain++ + s.tickRunning = true + return projectMailTickEvery(s.pollRate, s.id, s.activation, s.tickChain) +} + +func projectMailTickEvery(d time.Duration, storeID, activation, chain uint64) tea.Cmd { + return tea.Every(d, func(t time.Time) tea.Msg { + return projectMailTickMsg{storeID: storeID, activation: activation, chain: chain, at: t} + }) +} + +func (s ProjectMailStore) acceptsTick(msg projectMailTickMsg) bool { + return s.id != 0 && s.active && s.tickRunning && + msg.storeID == s.id && msg.activation == s.activation && msg.chain == s.tickChain +} + +func (s ProjectMailStore) nextTick() tea.Cmd { + if !s.active || !s.tickRunning { + return nil + } + return projectMailTickEvery(s.pollRate, s.id, s.activation, s.tickChain) +} + +// beginRefresh coalesces every project-mail refresh path onto the one active +// store pipeline. The command works on detached cache/session values; only +// acceptRefresh can install its result. +func (s *ProjectMailStore) beginRefresh(mail MailModel, initial bool) tea.Cmd { + if s == nil || s.id == 0 || !s.active { + return nil + } + if s.refreshInFlight { + // A steady scan may be reused only as a cache warm-up. An initial scan + // satisfies only the MailModel generation that launched it; a replacement + // generation still needs its own authoritative session rebuild. + if initial && (!s.refreshInitial || s.refreshGeneration != mail.generation) { + s.initialRefreshPending = true + } + return nil + } + s.refreshInFlight = true + s.refreshInitial = initial + s.refreshGeneration = mail.generation + storeID := s.id + projectID := s.projectID + activation := s.activation + sourceVersion := s.version + cache := s.cache + scanner := s.scanner + return func() tea.Msg { + // Bubble Tea commands are not cancellable after launch. Serialize the + // complete physical refresh/rebuild body so a newly active visited store + // waits for a suspended home command instead of scanning concurrently. + projectMailScanSingleflight.Lock() + defer projectMailScanSingleflight.Unlock() + if initial && mail.beforeRebuild != nil { + mail.beforeRebuild() + } + refreshed := scanner.Refresh(cache) + refresh := mail.collectRefreshState() + refresh.generation = mail.generation + refresh.initial = initial + if initial { + refresh.sessionCache = mail.rebuildSession(refreshed) + } + return projectMailRefreshMsg{ + storeID: storeID, + projectID: projectID, + activation: activation, + sourceVersion: sourceVersion, + cache: refreshed, + mail: refresh, + } + } +} + +// acceptRefresh distinguishes physical completion from publication. A result +// for the current store/source releases the one in-flight slot even when its +// MailModel generation was superseded, but only the current generation may +// replace root cache/version/snapshot state. +func (s *ProjectMailStore) acceptRefresh(msg projectMailRefreshMsg, generation uint64) (*ProjectMailSnapshot, bool, bool) { + if s == nil || s.id == 0 || !s.active || + msg.storeID != s.id || msg.projectID != s.projectID || + msg.activation != s.activation || msg.sourceVersion != s.version { + return nil, false, false + } + s.refreshInFlight = false + s.refreshInitial = false + s.refreshGeneration = 0 + if msg.mail.generation != generation { + return nil, false, true + } + s.cache = msg.cache + s.version++ + s.snapshot = &ProjectMailSnapshot{version: s.version, cache: msg.cache} + s.syncRuntime() + return s.snapshot, true, true +} + +// beginPendingInitialRefresh starts the authoritative rebuild deferred behind +// an older steady scan. The completed steady result may itself be rejected for +// a superseded MailModel generation; the pending current initial still starts +// after that exact physical slot is released. +func (s *ProjectMailStore) beginPendingInitialRefresh(mail MailModel) tea.Cmd { + if s == nil || !s.active || !s.initialRefreshPending || s.refreshInFlight { + return nil + } + s.initialRefreshPending = false + return s.beginRefresh(mail, true) +} + +func (s ProjectMailStore) locationUpdateCmd() tea.Cmd { + if s.id == 0 || !s.active || s.updateLocation == nil || s.runtime == nil { + return nil + } + humanDir := s.humanDir + update := s.updateLocation + runtime := s.runtime + activation := s.activation + version := s.version + return func() tea.Msg { + // The command may execute after a visit switch, store suspension, or a + // newer accepted snapshot. Re-check the shared live token immediately + // before the side effect so only the current accepted owner may update. + if !runtime.active.Load() || + runtime.activation.Load() != activation || + runtime.version.Load() != version { + return nil + } + update(humanDir) + return nil + } +} diff --git a/tui/internal/tui/project_mail_store_contract_test.go b/tui/internal/tui/project_mail_store_contract_test.go new file mode 100644 index 00000000..848b9e5d --- /dev/null +++ b/tui/internal/tui/project_mail_store_contract_test.go @@ -0,0 +1,534 @@ +package tui + +import ( + "bytes" + "os" + "path/filepath" + "reflect" + "sync/atomic" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/anthropics/lingtai-tui/internal/fs" +) + +type countingProjectMailScanner struct{ scans atomic.Int64 } + +func (s *countingProjectMailScanner) Refresh(cache fs.MailCache) fs.MailCache { + s.scans.Add(1) + return cache.Refresh() +} + +type blockingProjectMailScanner struct { + started chan struct{} + release chan struct{} + active atomic.Int64 + maxActive atomic.Int64 +} + +func newBlockingProjectMailScanner() *blockingProjectMailScanner { + return &blockingProjectMailScanner{ + started: make(chan struct{}, 2), + release: make(chan struct{}, 2), + } +} + +func (s *blockingProjectMailScanner) Refresh(cache fs.MailCache) fs.MailCache { + active := s.active.Add(1) + for { + maximum := s.maxActive.Load() + if active <= maximum || s.maxActive.CompareAndSwap(maximum, active) { + break + } + } + s.started <- struct{}{} + <-s.release + s.active.Add(-1) + return cache +} + +func waitProjectMailSignal(t *testing.T, ch <-chan struct{}, label string) { + t.Helper() + select { + case <-ch: + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for %s", label) + } +} + +// TestProjectMailStoreOwnsTheOnlyGlobalCache is the structural and scanner +// ownership contract. Constructing N target models keeps one root store, and +// coalesced refresh requests perform one mailbox scan. +func TestProjectMailStoreOwnsTheOnlyGlobalCache(t *testing.T) { + mailCacheType := reflect.TypeOf(fs.MailCache{}) + mailModelType := reflect.TypeOf(MailModel{}) + for i := 0; i < mailModelType.NumField(); i++ { + field := mailModelType.Field(i) + if field.Type == mailCacheType { + t.Fatalf("MailModel still owns project-wide cache field %q; N target models can create N global scan owners", field.Name) + } + } + + a := visitTestApp(t) + scanner := &countingProjectMailScanner{} + a.mailStore = newProjectMailStoreWithDeps(a.projectDir, a.mail.humanDir, scanner, func(string) {}) + storeID := a.mailStore.id + for i := 0; i < 8; i++ { + a.installMailModel(NewMailModel(a.mail.humanDir, "human", a.projectDir, a.orchDir, "target", 20, a.globalDir, "en", false, 0)) + if a.mailStore.id != storeID { + t.Fatalf("target construction %d replaced project store: got %d want %d", i, a.mailStore.id, storeID) + } + } + first := a.beginProjectMailRefresh(false) + if first == nil { + t.Fatal("first root refresh was not scheduled") + } + if duplicate := a.beginProjectMailRefresh(false); duplicate != nil { + t.Fatal("same-store refresh was not coalesced") + } + _ = first() + if got := scanner.scans.Load(); got != 1 { + t.Fatalf("N target models performed %d mailbox scans, want one root scan", got) + } +} + +// TestShortMailOtherMailCycleRejectsLateTick exercises a cycle shorter than +// the polling interval. The old chain has not fired yet when mail resumes, so +// its eventual tick must be rejected and must not re-arm itself. +func TestShortMailOtherMailCycleRejectsLateTick(t *testing.T) { + a := visitTestApp(t) + _ = a.mailStore.resumeTick() + old := projectMailTickMsg{storeID: a.mailStore.id, activation: a.mailStore.activation, chain: a.mailStore.tickChain, at: time.Now()} + + model, _ := a.switchToView("help") + a = model.(App) + model, _ = a.switchToView("mail") + a = model.(App) + + _, cmd := a.Update(old) + if cmd != nil { + t.Fatalf("late tick from pre-pause chain re-armed after mail→other→mail; command produced %T", runCmd(cmd)) + } +} + +// TestRepeatedMarkdownCloseDoesNotRestartMailTick verifies that idempotent +// close/return delivery cannot create an additional polling chain. +func TestRepeatedMarkdownCloseDoesNotRestartMailTick(t *testing.T) { + a := visitTestApp(t) + _ = a.mailStore.resumeTick() + a.pauseProjectMail() + a.currentView = appViewHelp + a.help = NewHelpModel() + + model, _ := a.Update(MarkdownViewerCloseMsg{}) + a = model.(App) + chain := a.mailStore.tickChain + model, _ = a.Update(MarkdownViewerCloseMsg{}) + a = model.(App) + if a.mailStore.tickChain != chain { + t.Fatalf("repeated markdown close created another chain: got %d want %d", a.mailStore.tickChain, chain) + } +} + +// TestVisitStoreIdentityRejectsWrongProjectRefresh proves project identity is +// independent of MailModel's target generation. +func TestVisitStoreIdentityRejectsWrongProjectRefresh(t *testing.T) { + a := visitTestApp(t) + homeRefresh := detachedAppProjectMailRefresh(&a, false) + visited, _ := a.enterVisitedAgent(ProjectsAgentSelectedMsg{Record: visitRecord(t.TempDir(), "worker", "Worker")}) + visitedVersion := visited.mailStore.version + + model, cmd := visited.Update(homeRefresh) + if cmd != nil { + t.Fatalf("wrong-project refresh returned a command: %T", runCmd(cmd)) + } + got := model.(App) + if got.mailStore.version != visitedVersion || got.mail.acceptedSnapshot != nil { + t.Fatal("visited project accepted a refresh from the suspended home store") + } +} + +// TestHomeVisitedBackHasOneActiveStore checks the explicit visit lifecycle. +func TestHomeVisitedBackHasOneActiveStore(t *testing.T) { + a := visitTestApp(t) + homeRefresh := detachedAppProjectMailRefresh(&a, false) + homeSnapshot, accepted, _ := a.mailStore.acceptRefresh(homeRefresh, a.mail.generation) + if !accepted { + t.Fatal("failed to seed the accepted home snapshot") + } + a.mail.acceptedSnapshot = homeSnapshot + homeID := a.mailStore.id + visited, _ := a.enterVisitedAgent(ProjectsAgentSelectedMsg{Record: visitRecord(t.TempDir(), "worker", "Worker")}) + if visited.mailStore.id == homeID || !visited.mailStore.active { + t.Fatal("visited project did not become the sole active store") + } + if visited.suspendedHomeMailStore == nil || visited.suspendedHomeMailStore.active || visited.suspendedHomeMailStore.tickRunning { + t.Fatal("home store was not suspended with its tick stopped") + } + visitedID := visited.mailStore.id + restored, _ := visited.returnFromVisit() + if restored.mailStore.id != homeID || !restored.mailStore.active { + t.Fatal("back did not reactivate the original home store") + } + if restored.mailStore.id == visitedID || restored.suspendedHomeMailStore != nil || restored.visiting { + t.Fatal("back retained visited ownership or suspended-home state") + } + if !restored.mail.initialLoading || restored.mail.acceptedSnapshot != nil { + t.Fatal("home state was published before its fresh accepted restore refresh") + } +} + +// TestVisitReturnToProjectsKeepsHomeMailPaused preserves the ordinary +// non-Mail lifecycle: restoring project ownership must not start mailbox work +// until the user actually opens Mail again. +func TestVisitReturnToProjectsKeepsHomeMailPaused(t *testing.T) { + a := visitTestApp(t) + a.currentView = appViewProjects + a.mailStore.pauseTick() + + visited, _ := a.enterVisitedAgent(ProjectsAgentSelectedMsg{Record: visitRecord(t.TempDir(), "worker", "Worker")}) + restored, cmd := visited.returnFromVisit() + if restored.currentView != appViewProjects { + t.Fatalf("return view = %v, want Projects", restored.currentView) + } + if cmd != nil { + t.Fatal("return to Projects started mailbox work before Mail became current") + } + if !restored.mailStore.active || restored.mailStore.tickRunning || restored.mailStore.refreshInFlight { + t.Fatal("restored home store did not remain active-but-paused in Projects") + } +} + +// TestVisitReturnToProjectsThenMailRequiresInitialRefresh closes the publication +// barrier: when the restored Projects view later opens Mail, the suspended home +// snapshot must still be withheld until an authoritative initial rebuild lands. +func TestVisitReturnToProjectsThenMailRequiresInitialRefresh(t *testing.T) { + a := visitTestApp(t) + a.currentView = appViewProjects + a.mailStore.pauseTick() + + visited, _ := a.enterVisitedAgent(ProjectsAgentSelectedMsg{Record: visitRecord(t.TempDir(), "worker", "Worker")}) + restored, _ := visited.returnFromVisit() + if !restored.mail.initialLoading || restored.mail.acceptedSnapshot != nil { + t.Fatal("precondition: Projects return did not retain the restore publication barrier") + } + + model, cmd := restored.switchToView("mail") + got := model.(App) + refresh, ok := findProjectMailRefresh(cmd) + if !ok { + t.Fatal("opening Mail after Projects return did not schedule a project refresh") + } + if !refresh.mail.initial || refresh.mail.sessionCache == nil { + t.Fatal("opening Mail after Projects return used a steady refresh instead of the required authoritative initial rebuild") + } + if !got.mailStore.tickRunning { + t.Fatal("opening Mail after Projects return did not resume the sole store tick") + } +} + +// TestUnknownViewDoesNotPauseProjectMailStore preserves no-op navigation. A +// removed, stale, or otherwise unknown view request must not invalidate the +// current Mail polling chain when the App remains in Mail. +func TestUnknownViewDoesNotPauseProjectMailStore(t *testing.T) { + a := visitTestApp(t) + _ = a.mailStore.resumeTick() + chain := a.mailStore.tickChain + + model, cmd := a.switchToView("agora") + got := model.(App) + if cmd != nil { + t.Fatal("unknown view returned a command") + } + if got.currentView != appViewMail { + t.Fatalf("unknown view changed current view to %v", got.currentView) + } + if !got.mailStore.tickRunning || got.mailStore.tickChain != chain { + t.Fatalf("unknown no-op navigation invalidated Mail tick: running=%v chain=%d want=%d", got.mailStore.tickRunning, got.mailStore.tickChain, chain) + } +} + +func TestProjectMailStoreRejectsStaleRefreshAndUpdatesLocationOnce(t *testing.T) { + mail := NewMailModel(t.TempDir(), "human", t.TempDir(), "", "agent", 20, "", "en", false, 0) + var locations atomic.Int64 + store := newProjectMailStoreWithDeps(mail.baseDir, mail.humanDir, filesystemProjectMailScanner{}, func(string) { + locations.Add(1) + }) + old := store.beginRefresh(mail, false)().(projectMailRefreshMsg) + store.suspend() + store.activate() + current := store.beginRefresh(mail, false)().(projectMailRefreshMsg) + + if _, ok, _ := store.acceptRefresh(old, mail.generation); ok { + t.Fatal("stale pre-suspend refresh was accepted") + } + if _, ok, _ := store.acceptRefresh(current, mail.generation); !ok { + t.Fatal("current refresh was rejected") + } + if cmd := store.locationUpdateCmd(); cmd == nil { + t.Fatal("accepted active store did not own location update") + } else { + _ = cmd() + } + if got := locations.Load(); got != 1 { + t.Fatalf("accepted refresh produced %d location updates, want one", got) + } +} + +// TestDelayedRefreshRequestOutsideMailCannotRestartStore covers a command that +// was emitted while Mail was current but delivered after the user left Mail. +// The request must not scan or restart a polling chain in an irrelevant view. +func TestDelayedRefreshRequestOutsideMailCannotRestartStore(t *testing.T) { + a := visitTestApp(t) + delayed := a.mail.requestMailRefresh(false)().(projectMailRefreshRequestMsg) + + model, _ := a.switchToView("help") + a = model.(App) + if a.mailStore.tickRunning { + t.Fatal("leaving Mail did not pause the project-mail tick") + } + + model, cmd := a.Update(delayed) + got := model.(App) + if cmd != nil { + t.Fatal("delayed Mail refresh request returned work in Help") + } + if got.mailStore.tickRunning || got.mailStore.refreshInFlight { + t.Fatal("delayed Mail refresh request reactivated tick or refresh ownership outside Mail") + } +} + +// TestInitialRefreshQueuesBehindSteadyRefresh proves coalescing cannot discard +// a required authoritative rebuild. Once the older steady scan completes, the +// store must schedule the pending initial refresh rather than leave Mail in its +// loading state indefinitely. +func TestInitialRefreshQueuesBehindSteadyRefresh(t *testing.T) { + a := visitTestApp(t) + steadyCmd := a.beginProjectMailRefresh(false) + if steadyCmd == nil { + t.Fatal("steady refresh was not scheduled") + } + if cmd := a.beginProjectMailRefresh(true); cmd != nil { + t.Fatal("required initial refresh bypassed the one in-flight pipeline") + } + + steady := steadyCmd().(projectMailRefreshMsg) + model, cmd := a.Update(steady) + got := model.(App) + initial, ok := findProjectMailRefresh(cmd) + if !ok { + t.Fatal("required initial refresh was lost behind the older steady refresh") + } + if !initial.mail.initial || initial.mail.sessionCache == nil { + t.Fatal("queued refresh did not perform the authoritative initial session rebuild") + } + if !got.mailStore.refreshInFlight { + t.Fatal("store did not retain ownership of the queued initial refresh") + } +} + +// TestSupersededMailGenerationCannotInstallRootRefresh covers a same-store +// replacement such as a mail-page-size change. The physical steady refresh may +// finish, but its old MailModel generation must release the pipeline without +// mutating the root cache/version/snapshot or running a location update. A +// queued authoritative initial for the new generation must then proceed. +func TestSupersededMailGenerationCannotInstallRootRefresh(t *testing.T) { + a := visitTestApp(t) + var locations atomic.Int64 + a.mailStore = newProjectMailStoreWithDeps(a.projectDir, a.mail.humanDir, filesystemProjectMailScanner{}, func(string) { + locations.Add(1) + }) + a.mail.acceptedSnapshot = nil + + steadyCmd := a.beginProjectMailRefresh(false) + if steadyCmd == nil { + t.Fatal("pre-replacement steady refresh was not scheduled") + } + steady := steadyCmd().(projectMailRefreshMsg) + oldGeneration := steady.mail.generation + beforeVersion := a.mailStore.version + + a.installMailModel(NewMailModel(a.mail.humanDir, "human", a.projectDir, a.orchDir, a.orchName, 500, a.globalDir, "en", false, 0)) + if a.mail.generation == oldGeneration { + t.Fatal("precondition: replacement did not create a new MailModel generation") + } + if cmd := a.beginProjectMailRefresh(true); cmd != nil { + t.Fatal("new-generation initial bypassed the in-flight steady refresh") + } + + model, followup := a.Update(steady) + got := model.(App) + if got.mailStore.version != beforeVersion || got.mailStore.snapshot != nil { + t.Fatalf("old generation installed root state before MailModel rejection: version=%d want=%d snapshot=%v", got.mailStore.version, beforeVersion, got.mailStore.snapshot != nil) + } + initial, ok := findProjectMailRefresh(followup) + if !ok { + t.Fatal("old generation did not release the pipeline for the queued authoritative initial") + } + if !initial.mail.initial || initial.mail.generation != got.mail.generation || initial.mail.sessionCache == nil { + t.Fatalf("follow-up refresh = initial %v generation %d sessionCache %v; want authoritative generation %d", initial.mail.initial, initial.mail.generation, initial.mail.sessionCache != nil, got.mail.generation) + } + if locations.Load() != 0 { + t.Fatal("rejected old-generation refresh ran a human-location update") + } +} + +// TestSupersededInitialRefreshQueuesReplacementInitial covers the same-store +// replacement ordering where the old generation already owns an authoritative +// initial rebuild. The replacement generation still needs its own initial +// rebuild; otherwise only steady polling remains and Mail stays loading forever. +func TestSupersededInitialRefreshQueuesReplacementInitial(t *testing.T) { + a := visitTestApp(t) + a.mail.acceptedSnapshot = nil + + oldInitialCmd := a.beginProjectMailRefresh(true) + if oldInitialCmd == nil { + t.Fatal("pre-replacement initial refresh was not scheduled") + } + oldInitial := oldInitialCmd().(projectMailRefreshMsg) + oldGeneration := oldInitial.mail.generation + + a.installMailModel(NewMailModel(a.mail.humanDir, "human", a.projectDir, a.orchDir, a.orchName, 500, a.globalDir, "en", false, 0)) + if a.mail.generation == oldGeneration { + t.Fatal("precondition: replacement did not create a new MailModel generation") + } + if !a.mail.initialLoading { + t.Fatal("precondition: replacement MailModel did not start in loading state") + } + if cmd := a.beginProjectMailRefresh(true); cmd != nil { + t.Fatal("new-generation initial bypassed the in-flight old-generation initial") + } + + model, followup := a.Update(oldInitial) + got := model.(App) + initial, ok := findProjectMailRefresh(followup) + if !ok { + t.Fatal("replacement generation lost its authoritative initial behind the old-generation initial") + } + if !initial.mail.initial || initial.mail.generation != got.mail.generation || initial.mail.sessionCache == nil { + t.Fatalf("follow-up refresh = initial %v generation %d sessionCache %v; want authoritative generation %d", initial.mail.initial, initial.mail.generation, initial.mail.sessionCache != nil, got.mail.generation) + } + + model, _ = got.Update(initial) + got = model.(App) + if got.mail.initialLoading { + t.Fatal("replacement generation remained loading after its authoritative initial refresh") + } +} + +// TestProjectMailStoresNeverScanConcurrently uses two active store instances to +// model the physical handoff between suspended home and visited ownership. A +// second refresh command may wait, but its scanner must not enter until the +// first scanner has returned. +func TestProjectMailStoresNeverScanConcurrently(t *testing.T) { + scanner := newBlockingProjectMailScanner() + homeProject := t.TempDir() + visitedProject := t.TempDir() + homeMail := NewMailModel(filepath.Join(homeProject, "human"), "human", homeProject, "", "home", 20, "", "en", false, 0) + visitedMail := NewMailModel(filepath.Join(visitedProject, "human"), "human", visitedProject, "", "visited", 20, "", "en", false, 0) + home := newProjectMailStoreWithDeps(homeProject, homeMail.humanDir, scanner, func(string) {}) + visited := newProjectMailStoreWithDeps(visitedProject, visitedMail.humanDir, scanner, func(string) {}) + homeCmd := home.beginRefresh(homeMail, false) + visitedCmd := visited.beginRefresh(visitedMail, true) + if homeCmd == nil || visitedCmd == nil { + t.Fatal("precondition: both store refresh commands must be scheduled") + } + + homeDone := make(chan struct{}) + visitedDone := make(chan struct{}) + go func() { + _ = homeCmd() + close(homeDone) + }() + waitProjectMailSignal(t, scanner.started, "home scanner start") + go func() { + _ = visitedCmd() + close(visitedDone) + }() + + overlapped := false + select { + case <-scanner.started: + overlapped = true + scanner.release <- struct{}{} + scanner.release <- struct{}{} + case <-time.After(250 * time.Millisecond): + scanner.release <- struct{}{} + waitProjectMailSignal(t, homeDone, "home scanner completion") + waitProjectMailSignal(t, scanner.started, "visited scanner start after home") + scanner.release <- struct{}{} + } + waitProjectMailSignal(t, homeDone, "home refresh completion") + waitProjectMailSignal(t, visitedDone, "visited refresh completion") + if overlapped || scanner.maxActive.Load() != 1 { + t.Fatalf("home and visited scanners overlapped: max active=%d", scanner.maxActive.Load()) + } +} + +// TestHumanLocationUpdateRevalidatesAtExecution proves a command accepted for +// one activation/version cannot update after that store is suspended or after a +// newer accepted snapshot supersedes it. +func TestHumanLocationUpdateRevalidatesAtExecution(t *testing.T) { + newStore := func(t *testing.T) (*ProjectMailStore, MailModel, *atomic.Int64) { + t.Helper() + projectDir := t.TempDir() + mail := NewMailModel(filepath.Join(projectDir, "human"), "human", projectDir, "", "agent", 20, "", "en", false, 0) + updates := &atomic.Int64{} + store := newProjectMailStoreWithDeps(projectDir, mail.humanDir, filesystemProjectMailScanner{}, func(string) { + updates.Add(1) + }) + return &store, mail, updates + } + + t.Run("suspended activation", func(t *testing.T) { + store, mail, updates := newStore(t) + msg := store.beginRefresh(mail, false)().(projectMailRefreshMsg) + if _, accepted, _ := store.acceptRefresh(msg, mail.generation); !accepted { + t.Fatal("precondition: current refresh was rejected") + } + cmd := store.locationUpdateCmd() + store.suspend() + _ = cmd() + if updates.Load() != 0 { + t.Fatal("suspended store executed a stale human-location update") + } + }) + + t.Run("superseded version", func(t *testing.T) { + store, mail, updates := newStore(t) + first := store.beginRefresh(mail, false)().(projectMailRefreshMsg) + if _, accepted, _ := store.acceptRefresh(first, mail.generation); !accepted { + t.Fatal("precondition: first refresh was rejected") + } + staleCmd := store.locationUpdateCmd() + second := store.beginRefresh(mail, false)().(projectMailRefreshMsg) + if _, accepted, _ := store.acceptRefresh(second, mail.generation); !accepted { + t.Fatal("precondition: second refresh was rejected") + } + currentCmd := store.locationUpdateCmd() + _ = staleCmd() + if updates.Load() != 0 { + t.Fatal("superseded store version executed a stale human-location update") + } + _ = currentCmd() + if updates.Load() != 1 { + t.Fatalf("current accepted store version produced %d updates, want one", updates.Load()) + } + }) +} + +// TestMainDoesNotOwnHumanLocationUpdate protects the one-updater boundary: TUI +// startup may construct the App, but only an accepted ProjectMailStore refresh +// may call fs.UpdateHumanLocation. +func TestMainDoesNotOwnHumanLocationUpdate(t *testing.T) { + mainSource, err := os.ReadFile(filepath.Join("..", "..", "main.go")) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(mainSource, []byte("fs.UpdateHumanLocation(")) { + t.Fatal("tui/main.go retains a second human-location updater outside ProjectMailStore acceptance") + } +} + +var _ tea.Msg = projectMailTickMsg{} diff --git a/tui/internal/tui/project_mail_store_test_helpers_test.go b/tui/internal/tui/project_mail_store_test_helpers_test.go new file mode 100644 index 00000000..da70cbcf --- /dev/null +++ b/tui/internal/tui/project_mail_store_test_helpers_test.go @@ -0,0 +1,46 @@ +package tui + +import tea "charm.land/bubbletea/v2" + +// acceptedInitialMailRefresh runs the root store pipeline synchronously for +// MailModel-focused tests, accepts its snapshot, and returns the target payload +// that production App.Update would route to MailModel.Update. +func acceptedInitialMailRefresh(m MailModel) tea.Msg { + store := newProjectMailStore(m.baseDir, m.humanDir) + cmd := store.beginRefresh(m, true) + msg := cmd().(projectMailRefreshMsg) + snapshot, _, _ := store.acceptRefresh(msg, m.generation) + msg.mail.snapshot = snapshot + return msg.mail +} + +func acceptedSteadyMailRefresh(m MailModel) tea.Msg { + store := newProjectMailStore(m.baseDir, m.humanDir) + cmd := store.beginRefresh(m, false) + msg := cmd().(projectMailRefreshMsg) + snapshot, _, _ := store.acceptRefresh(msg, m.generation) + msg.mail.snapshot = snapshot + return msg.mail +} + +func detachedAppProjectMailRefresh(a *App, initial bool) projectMailRefreshMsg { + return a.beginProjectMailRefresh(initial)().(projectMailRefreshMsg) +} + +func findProjectMailRefresh(cmd tea.Cmd) (projectMailRefreshMsg, bool) { + if cmd == nil { + return projectMailRefreshMsg{}, false + } + msg := cmd() + if refresh, ok := msg.(projectMailRefreshMsg); ok { + return refresh, true + } + if batch, ok := msg.(tea.BatchMsg); ok { + for _, child := range batch { + if refresh, ok := findProjectMailRefresh(child); ok { + return refresh, true + } + } + } + return projectMailRefreshMsg{}, false +} diff --git a/tui/internal/tui/recipe_save.go b/tui/internal/tui/recipe_save.go index e1be738b..d4614b8e 100644 --- a/tui/internal/tui/recipe_save.go +++ b/tui/internal/tui/recipe_save.go @@ -179,10 +179,8 @@ func substituteGreetPlaceholders(template, humanAddr, humanDir, lang, soulDelay if len(parts) > 0 { loc = strings.Join(parts, ", ") } - // Also persist it to human's .agent.json so next time it's cached - if humanDir != "" { - go fs.UpdateHumanLocation(humanDir) - } + // ProjectMailStore owns publishing human location on its accepted + // refresh cadence; recipe substitution only consumes this resolved value. } } out = strings.ReplaceAll(out, "{{location}}", loc) @@ -199,4 +197,3 @@ func substituteGreetPlaceholders(template, humanAddr, humanDir, lang, soulDelay return out } - diff --git a/tui/internal/tui/settings_page_size_test.go b/tui/internal/tui/settings_page_size_test.go index bd6d470b..660d3290 100644 --- a/tui/internal/tui/settings_page_size_test.go +++ b/tui/internal/tui/settings_page_size_test.go @@ -4,7 +4,6 @@ import ( "reflect" "testing" - tea "charm.land/bubbletea/v2" "github.com/anthropics/lingtai-tui/internal/config" ) @@ -66,7 +65,7 @@ func TestReturningToMailAfterPageSizeChangeRebuildsExactWindow(t *testing.T) { orchName: "agent", } a.installMailModel(NewMailModel(t.TempDir(), "human", projectDir, orchDir, "agent", 200, globalDir, "en", false, 0)) - initial := a.mail.initialRebuild().(mailRefreshMsg) + initial := acceptedInitialMailRefresh(a.mail).(mailRefreshMsg) a.mail, _ = a.mail.Update(initial) if a.mail.sessionCache.Len() != 200 { t.Fatalf("precondition cache = %d, want 200", a.mail.sessionCache.Len()) @@ -78,14 +77,11 @@ func TestReturningToMailAfterPageSizeChangeRebuildsExactWindow(t *testing.T) { if got.mail.pageSize != 100 || got.mail.generation == oldGeneration || !got.mail.initialLoading { t.Fatalf("page-size change did not start fresh Mail generation: page=%d generation=%d old=%d loading=%v", got.mail.pageSize, got.mail.generation, oldGeneration, got.mail.initialLoading) } - batch, ok := cmd().(tea.BatchMsg) - if !ok || len(batch) == 0 { - t.Fatalf("return-to-Mail command = %T, want non-empty tea.BatchMsg", cmd()) - } - refresh, ok := batch[0]().(mailRefreshMsg) + storeRefresh, ok := findProjectMailRefresh(cmd) if !ok { - t.Fatalf("first return-to-Mail command = %T, want fresh mailRefreshMsg", batch[0]()) + t.Fatal("return-to-Mail command did not schedule a fresh project-store refresh") } + refresh := storeRefresh.mail if got := refresh.sessionCache.Len(); got != 100 { t.Fatalf("new page-size content cache = %d, want exactly 100", got) } diff --git a/tui/internal/tui/status_hint_copy_test.go b/tui/internal/tui/status_hint_copy_test.go index 22245226..cb4fc6b1 100644 --- a/tui/internal/tui/status_hint_copy_test.go +++ b/tui/internal/tui/status_hint_copy_test.go @@ -36,7 +36,7 @@ func newEnglishHomeModel(t *testing.T, w, h int) MailModel { } m := NewMailModel(humanDir, "human@local", "~", orchDir, "TestOrch", 50, dir, "en", false, 0) m, _ = m.Update(tea.WindowSizeMsg{Width: w, Height: h}) - m, _ = m.Update(m.initialRebuild()) + m, _ = m.Update(acceptedInitialMailRefresh(m)) return m } diff --git a/tui/main.go b/tui/main.go index 23c12458..bbc812f7 100644 --- a/tui/main.go +++ b/tui/main.go @@ -313,9 +313,6 @@ func main() { fmt.Fprintf(os.Stderr, "warning: recipe reconcile failed: %v\n", err) } } - // Resolve human location in background (ipinfo.io, cached 1h) - humanDir := filepath.Join(lingtaiDir, "human") - go fs.UpdateHumanLocation(humanDir) } // If needsFirstRun: welcome page goroutine handles everything From 083ff62c92fedb14644ff16097d77dd06a472d30 Mon Sep 17 00:00:00 2001 From: TZZheng Date: Tue, 14 Jul 2026 10:06:59 -0500 Subject: [PATCH 3/7] test(tui): define async envelope inventory contract --- .../async_envelope_source_inventory_test.go | 822 ++++++++++++++++++ 1 file changed, 822 insertions(+) create mode 100644 tui/internal/tui/async_envelope_source_inventory_test.go diff --git a/tui/internal/tui/async_envelope_source_inventory_test.go b/tui/internal/tui/async_envelope_source_inventory_test.go new file mode 100644 index 00000000..078914e0 --- /dev/null +++ b/tui/internal/tui/async_envelope_source_inventory_test.go @@ -0,0 +1,822 @@ +package tui + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +type asyncSourceLogicalPath struct { + label string + kind string + completion string +} + +// asyncSourceLogicalPaths is deliberately the issue's logical inventory rather +// than a scan for every tea.Msg in this large package. Initial and steady +// refresh are two lifetimes but share one completion struct. +var asyncSourceLogicalPaths = []asyncSourceLogicalPath{ + {label: "initial rebuild", kind: "asyncInitialRebuild", completion: "projectMailRefreshMsg"}, + {label: "steady refresh", kind: "asyncSteadyRefresh", completion: "projectMailRefreshMsg"}, + {label: "session persist", kind: "asyncSessionPersist", completion: "mailPersistMsg"}, + {label: "older-page history", kind: "asyncOlderPage", completion: "mailOlderPageMsg"}, + {label: "exact history count", kind: "asyncExactHistoryCount", completion: "mailHistoryCountMsg"}, + {label: "refresh tick", kind: "asyncRefreshTick", completion: "projectMailTickMsg"}, + {label: "liveness pulse", kind: "asyncLivenessPulse", completion: "pulseTickMsg"}, + {label: "external editor completion", kind: "asyncEditorDone", completion: "EditorDoneMsg"}, +} + +var asyncSourceCompletionProducers = map[string][]string{ + "projectMailRefreshMsg": {"ProjectMailStore.beginRefresh"}, + "mailPersistMsg": {"MailModel.Update"}, + "mailOlderPageMsg": {"MailModel.olderPageCmd"}, + "mailHistoryCountMsg": {"MailModel.historyCountCmd"}, + "projectMailTickMsg": {"projectMailTickEvery"}, + "pulseTickMsg": {"pulseTick"}, + "EditorDoneMsg": {"MailModel.launchEditor"}, +} + +type asyncSourceConsumerContract struct { + message string + owner string +} + +var asyncSourceConsumers = []asyncSourceConsumerContract{ + {message: "projectMailRefreshMsg", owner: "App.Update"}, + {message: "projectMailTickMsg", owner: "App.Update"}, + {message: "mailPersistMsg", owner: "MailModel.Update"}, + {message: "mailOlderPageMsg", owner: "MailModel.Update"}, + {message: "mailHistoryCountMsg", owner: "MailModel.Update"}, + {message: "pulseTickMsg", owner: "MailModel.Update"}, + {message: "EditorDoneMsg", owner: "MailModel.Update"}, +} + +type asyncSourceExclusion struct { + message string + reason string +} + +// These are the two real asynchronous messages explicitly outside PR4. Keeping +// the names and reasons here prevents "every completion" from becoming either +// an unbounded whole-package promise or a silent target-mail loophole. +var asyncSourceExclusions = []asyncSourceExclusion{ + {message: "homeTelemetryMsg", reason: "telemetry binding is later milestone work"}, + {message: "autoRefreshTickMsg", reason: "unrelated app-level non-mail refresh loop"}, +} + +type asyncSourceInventory struct { + fset *token.FileSet + files map[string]*ast.File + types map[string]*ast.TypeSpec + funcs []*ast.FuncDecl +} + +func loadAsyncSourceInventory(t *testing.T) *asyncSourceInventory { + t.Helper() + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("read production TUI source directory: %v", err) + } + + inv := &asyncSourceInventory{ + fset: token.NewFileSet(), + files: make(map[string]*ast.File), + types: make(map[string]*ast.TypeSpec), + } + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + file, err := parser.ParseFile(inv.fset, name, nil, parser.ParseComments) + if err != nil { + t.Fatalf("parse production source %s: %v", name, err) + } + inv.files[name] = file + for _, decl := range file.Decls { + switch decl := decl.(type) { + case *ast.GenDecl: + if decl.Tok != token.TYPE { + continue + } + for _, spec := range decl.Specs { + if typeSpec, ok := spec.(*ast.TypeSpec); ok { + inv.types[typeSpec.Name.Name] = typeSpec + } + } + case *ast.FuncDecl: + inv.funcs = append(inv.funcs, decl) + } + } + } + return inv +} + +func TestAsyncEnvelopeSourceInventoryHasExactlyEightLogicalKinds(t *testing.T) { + inv := loadAsyncSourceInventory(t) + if _, ok := inv.files["async_envelope.go"]; !ok { + t.Fatalf("async_envelope.go missing: want one shared protocol defining the exact eight target-mail logical kinds") + } + if _, ok := inv.types["asyncKind"]; !ok { + t.Fatalf("async_envelope.go: asyncKind type missing") + } + + want := make([]string, 0, len(asyncSourceLogicalPaths)) + for _, path := range asyncSourceLogicalPaths { + want = append(want, path.kind) + } + got := inv.constantsOfType("asyncKind") + if missing, unexpected := setDifference(want, got), setDifference(got, want); len(missing) != 0 || len(unexpected) != 0 { + t.Fatalf("asyncKind inventory mismatch: got %v; missing %v; unexpected %v; want exactly the issue's eight logical paths", got, missing, unexpected) + } +} + +func TestAsyncEnvelopeSourceInventoryEveryCompletionCarriesEnvelope(t *testing.T) { + inv := loadAsyncSourceInventory(t) + for _, name := range asyncCompletionNames() { + assertExactNamedField(t, inv, name, "envelope", "asyncEnvelope") + } + + assertExactNamedField(t, inv, "EditorDoneMsg", "Text", "string") + if got := inv.namedFieldTypes("EditorDoneMsg", "Generation"); len(got) != 0 { + t.Errorf("EditorDoneMsg.Generation is a forbidden generation-only identity; target/address/generation must be carried only by envelope asyncEnvelope") + } + if got := inv.namedFieldTypes("EditorDoneMsg", "generation"); len(got) != 0 { + t.Errorf("EditorDoneMsg.generation is a forbidden generation-only identity; target/address/generation must be carried only by envelope asyncEnvelope") + } +} + +func TestAsyncEnvelopeSourceInventoryEveryProducerCapturesEnvelope(t *testing.T) { + inv := loadAsyncSourceInventory(t) + var issues []string + for _, completion := range asyncCompletionNames() { + issues = append(issues, inv.producerIssues(completion, asyncSourceCompletionProducers[completion])...) + } + issues = append(issues, inv.refreshKindCaptureIssues("ProjectMailStore.beginRefresh")...) + if len(issues) != 0 { + sort.Strings(issues) + t.Fatalf("async completion producer inventory is not envelope-complete:\n - %s", strings.Join(issues, "\n - ")) + } +} + +func TestAsyncEnvelopeSourceInventoryEveryConsumerCallsSharedPredicate(t *testing.T) { + inv := loadAsyncSourceInventory(t) + var issues []string + for _, contract := range asyncSourceConsumers { + issues = append(issues, inv.consumerIssues(contract.owner, contract.message)...) + } + if len(issues) != 0 { + sort.Strings(issues) + t.Fatalf("target-mail consumer inventory does not route every completion through one rejecting acceptAsync guard before visible mutation:\n - %s", strings.Join(issues, "\n - ")) + } +} + +func TestAsyncEnvelopeSourceInventoryForbidsLegacyIdentityPolicies(t *testing.T) { + inv := loadAsyncSourceInventory(t) + var surviving []string + + for _, fn := range inv.funcs { + switch fn.Name.Name { + case "acceptRefresh", "acceptsTick": + surviving = append(surviving, "symbol "+functionOwner(fn)) + } + } + if _, ok := inv.types["projectMailRuntimeGate"]; ok { + surviving = append(surviving, "type projectMailRuntimeGate") + } + + // Payload/cache/lifecycle fields are intentionally not banned here. This list + // is only the old message-local identity vocabulary that must move into the + // shared envelope. + identityFields := []string{"generation", "storeID", "projectID", "activation", "sourceVersion", "chain"} + types := append(asyncCompletionNames(), "mailRefreshMsg", "projectMailRefreshRequestMsg") + for _, typeName := range uniqueStrings(types) { + for _, fieldName := range identityFields { + if got := inv.namedFieldTypes(typeName, fieldName); len(got) != 0 { + surviving = append(surviving, "field "+typeName+"."+fieldName) + } + } + } + if got := inv.namedFieldTypes("EditorDoneMsg", "Generation"); len(got) != 0 { + surviving = append(surviving, "field EditorDoneMsg.Generation") + } + + if len(surviving) != 0 { + surviving = uniqueStrings(surviving) + t.Fatalf("legacy async identity policies remain (payload fields and lifecycle booleans are not part of this ban): %s", strings.Join(surviving, ", ")) + } +} + +func TestAsyncEnvelopeSourceInventoryScopesNonMilestoneAsyncMessagesExplicitly(t *testing.T) { + inv := loadAsyncSourceInventory(t) + var issues []string + + completionSet := make(map[string]bool) + for _, completion := range asyncCompletionNames() { + completionSet[completion] = true + } + if len(asyncSourceLogicalPaths) != 8 || len(completionSet) != 7 { + t.Fatalf("invalid test contract: eight logical paths must map to seven completion structs; got %d paths and %d structs", len(asyncSourceLogicalPaths), len(completionSet)) + } + + for _, exclusion := range asyncSourceExclusions { + if exclusion.reason == "" { + issues = append(issues, exclusion.message+": exclusion reason missing") + } + if _, ok := inv.types[exclusion.message]; !ok { + issues = append(issues, exclusion.message+": documented non-milestone async type missing; update the explicit scope inventory if it was intentionally renamed or removed") + } + if completionSet[exclusion.message] { + issues = append(issues, exclusion.message+": explicit exclusion was accidentally added to the seven target-mail completion structs") + } + if got := inv.namedFieldTypes(exclusion.message, "envelope"); len(got) != 0 { + issues = append(issues, exclusion.message+": explicit PR4 exclusion unexpectedly carries the target-mail envelope") + } + } + + // The refresh request is coordination, not a ninth completion. It must still + // carry/capture an initial-or-steady envelope and be accepted before work starts. + if completionSet["projectMailRefreshRequestMsg"] { + issues = append(issues, "projectMailRefreshRequestMsg: request must not become a ninth completion kind") + } + issues = append(issues, exactNamedFieldIssues(inv, "projectMailRefreshRequestMsg", "envelope", "asyncEnvelope")...) + issues = append(issues, inv.producerIssues("projectMailRefreshRequestMsg", []string{"MailModel.requestMailRefresh"})...) + issues = append(issues, inv.refreshKindCaptureIssues("MailModel.requestMailRefresh")...) + issues = append(issues, inv.consumerIssues("App.Update", "projectMailRefreshRequestMsg")...) + + // The delayed human-location write is a post-accept side effect, not a ninth + // message kind. It captures the accepted refresh envelope and re-runs the same + // predicate immediately before the side effect. + location := inv.findFunction("ProjectMailStore.locationUpdateCmd") + if location == nil { + issues = append(issues, "ProjectMailStore.locationUpdateCmd: post-accept human-location side-effect fence missing") + } else { + if got := countFieldsOfType(location.Type.Params, "asyncEnvelope"); got != 1 { + issues = append(issues, fmt.Sprintf("ProjectMailStore.locationUpdateCmd: got %d asyncEnvelope parameters, want exactly 1 captured accepted refresh envelope", got)) + } + if got := countCalls(location.Body, "acceptAsync"); got != 1 { + issues = append(issues, fmt.Sprintf("ProjectMailStore.locationUpdateCmd: got %d acceptAsync calls, want exactly 1 immediately before the delayed side effect", got)) + } + } + + if len(issues) != 0 { + sort.Strings(issues) + t.Fatalf("non-milestone/coordination async scope is incomplete:\n - %s", strings.Join(issues, "\n - ")) + } +} + +func asyncCompletionNames() []string { + var names []string + seen := make(map[string]bool) + for _, path := range asyncSourceLogicalPaths { + if !seen[path.completion] { + seen[path.completion] = true + names = append(names, path.completion) + } + } + return names +} + +func (inv *asyncSourceInventory) constantsOfType(typeName string) []string { + var names []string + fileNames := sortedFileNames(inv.files) + for _, fileName := range fileNames { + file := inv.files[fileName] + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.CONST { + continue + } + currentType := "" + for _, spec := range gen.Specs { + value, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + if value.Type != nil { + currentType = expressionName(value.Type) + } + if currentType != typeName { + continue + } + for _, name := range value.Names { + names = append(names, name.Name) + } + } + } + } + return uniqueStrings(names) +} + +func assertExactNamedField(t *testing.T, inv *asyncSourceInventory, typeName, fieldName, wantType string) { + t.Helper() + for _, issue := range exactNamedFieldIssues(inv, typeName, fieldName, wantType) { + t.Error(issue) + } +} + +func exactNamedFieldIssues(inv *asyncSourceInventory, typeName, fieldName, wantType string) []string { + if _, ok := inv.types[typeName]; !ok { + return []string{typeName + ": production type missing"} + } + got := inv.namedFieldTypes(typeName, fieldName) + if len(got) != 1 || got[0] != wantType { + return []string{fmt.Sprintf("%s: field %s must appear exactly once with type %s; got %v", typeName, fieldName, wantType, got)} + } + return nil +} + +func (inv *asyncSourceInventory) namedFieldTypes(typeName, fieldName string) []string { + typeSpec := inv.types[typeName] + if typeSpec == nil { + return nil + } + structure, ok := typeSpec.Type.(*ast.StructType) + if !ok { + return nil + } + var types []string + for _, field := range structure.Fields.List { + for _, name := range field.Names { + if name.Name == fieldName { + types = append(types, expressionName(field.Type)) + } + } + } + return types +} + +type asyncLiteralSite struct { + file string + owner string + literal *ast.CompositeLit + safeNames map[string]bool + position token.Position +} + +func (inv *asyncSourceInventory) literalSites(typeName string) []asyncLiteralSite { + var sites []asyncLiteralSite + for _, fileName := range sortedFileNames(inv.files) { + file := inv.files[fileName] + seen := make(map[*ast.CompositeLit]bool) + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + safe := captureDerivedNames(fn.Body) + ast.Inspect(fn.Body, func(node ast.Node) bool { + literal, ok := node.(*ast.CompositeLit) + if !ok || expressionName(literal.Type) != typeName { + return true + } + seen[literal] = true + sites = append(sites, asyncLiteralSite{ + file: fileName, + owner: functionOwner(fn), + literal: literal, + safeNames: safe, + position: inv.fset.Position(literal.Pos()), + }) + return true + }) + } + ast.Inspect(file, func(node ast.Node) bool { + literal, ok := node.(*ast.CompositeLit) + if !ok || seen[literal] || expressionName(literal.Type) != typeName { + return true + } + sites = append(sites, asyncLiteralSite{ + file: fileName, + owner: "", + literal: literal, + safeNames: map[string]bool{}, + position: inv.fset.Position(literal.Pos()), + }) + return true + }) + } + sort.Slice(sites, func(i, j int) bool { + if sites[i].file != sites[j].file { + return sites[i].file < sites[j].file + } + return sites[i].position.Offset < sites[j].position.Offset + }) + return sites +} + +func (inv *asyncSourceInventory) producerIssues(typeName string, wantOwners []string) []string { + sites := inv.literalSites(typeName) + if len(sites) == 0 { + return []string{typeName + ": no production composite-literal producer found"} + } + + var issues []string + var gotOwners []string + for _, site := range sites { + gotOwners = append(gotOwners, site.owner) + location := fmt.Sprintf("%s:%d (%s)", filepath.Base(site.position.Filename), site.position.Line, site.owner) + var envelopeValues []ast.Expr + unkeyed := false + for _, element := range site.literal.Elts { + keyed, ok := element.(*ast.KeyValueExpr) + if !ok { + unkeyed = true + continue + } + key, ok := keyed.Key.(*ast.Ident) + if ok && key.Name == "envelope" { + envelopeValues = append(envelopeValues, keyed.Value) + } + } + if unkeyed { + issues = append(issues, typeName+" producer "+location+": completion literals must be keyed so envelope capture cannot be positional or implicit") + } + if len(envelopeValues) != 1 { + issues = append(issues, fmt.Sprintf("%s producer %s: got %d keyed envelope fields, want exactly 1 captureAsync-derived envelope", typeName, location, len(envelopeValues))) + continue + } + if !expressionDerivedFromCapture(envelopeValues[0], site.safeNames) { + issues = append(issues, typeName+" producer "+location+": envelope is not initialized through captureAsync (zero/manual envelopes are forbidden)") + } + } + + gotOwners = uniqueStrings(gotOwners) + wantOwners = uniqueStrings(wantOwners) + if missing, unexpected := setDifference(wantOwners, gotOwners), setDifference(gotOwners, wantOwners); len(missing) != 0 || len(unexpected) != 0 { + issues = append(issues, fmt.Sprintf("%s producer symbols: got %v; missing %v; unexpected %v", typeName, gotOwners, missing, unexpected)) + } + return issues +} + +func captureDerivedNames(body *ast.BlockStmt) map[string]bool { + safe := make(map[string]bool) + changed := true + for changed { + changed = false + ast.Inspect(body, func(node ast.Node) bool { + switch node := node.(type) { + case *ast.AssignStmt: + if len(node.Lhs) != len(node.Rhs) { + return true + } + for i, lhs := range node.Lhs { + name, ok := lhs.(*ast.Ident) + if ok && !safe[name.Name] && expressionDerivedFromCapture(node.Rhs[i], safe) { + safe[name.Name] = true + changed = true + } + } + case *ast.ValueSpec: + if len(node.Names) != len(node.Values) { + return true + } + for i, name := range node.Names { + if !safe[name.Name] && expressionDerivedFromCapture(node.Values[i], safe) { + safe[name.Name] = true + changed = true + } + } + } + return true + }) + } + return safe +} + +func expressionDerivedFromCapture(expr ast.Expr, safeNames map[string]bool) bool { + derived := false + ast.Inspect(expr, func(node ast.Node) bool { + switch node := node.(type) { + case *ast.CallExpr: + if calledName(node.Fun) == "captureAsync" { + derived = true + return false + } + case *ast.Ident: + if safeNames[node.Name] { + derived = true + return false + } + } + return !derived + }) + return derived +} + +func (inv *asyncSourceInventory) refreshKindCaptureIssues(owner string) []string { + fn := inv.findFunction(owner) + if fn == nil { + return []string{owner + ": expected initial/steady producer symbol missing"} + } + if countCalls(fn.Body, "captureAsync") == 0 { + return []string{owner + ": missing captureAsync for the shared initial/steady envelope"} + } + if usesIdentifier(fn, "asyncInitialRebuild") && usesIdentifier(fn, "asyncSteadyRefresh") { + return nil + } + + // A small asyncKind-returning selector helper is allowed, but it must itself + // name both fixed refresh kinds. Do not chase captureAsync, whose global table + // necessarily mentions all kinds and would make this producer check vacuous. + for _, callName := range calledNames(fn.Body) { + if callName == "captureAsync" { + continue + } + for _, helper := range inv.findFunctionsBySimpleName(callName) { + if functionReturnsType(helper, "asyncKind") && usesIdentifier(helper, "asyncInitialRebuild") && usesIdentifier(helper, "asyncSteadyRefresh") { + return nil + } + } + } + return []string{owner + ": capture must select both asyncInitialRebuild and asyncSteadyRefresh; the shared completion/request is not a ninth kind"} +} + +func (inv *asyncSourceInventory) consumerIssues(owner, message string) []string { + fn := inv.findFunction(owner) + if fn == nil { + return []string{owner + ": consumer function missing for " + message} + } + clauses := typeSwitchClauses(fn, message) + if len(clauses) != 1 { + return []string{fmt.Sprintf("%s case %s: got %d type-switch consumer clauses, want exactly 1", owner, message, len(clauses))} + } + clause := clauses[0] + if got := countCalls(clause, "acceptAsync"); got != 1 { + return []string{fmt.Sprintf("%s case %s: got %d acceptAsync calls, want exactly 1 rejecting shared-predicate guard", owner, message, got)} + } + + guardIndex := -1 + for i, statement := range clause.Body { + ifStmt, ok := statement.(*ast.IfStmt) + if !ok || countCalls(ifStmt.Cond, "acceptAsync") != 1 { + continue + } + if !conditionRejectsAccept(ifStmt.Cond) || !blockEndsInReturn(ifStmt.Body) { + return []string{fmt.Sprintf("%s case %s: acceptAsync must be a fail-closed early-return guard", owner, message)} + } + guardIndex = i + break + } + if guardIndex < 0 { + return []string{fmt.Sprintf("%s case %s: acceptAsync is not a top-level rejecting guard that dominates later mutation", owner, message)} + } + for _, statement := range clause.Body[:guardIndex] { + if hasDirectStateMutation(statement) { + return []string{fmt.Sprintf("%s case %s: visible selector/index mutation occurs before acceptAsync; only non-publishing refresh settlement may precede the guard", owner, message)} + } + } + return nil +} + +func typeSwitchClauses(fn *ast.FuncDecl, message string) []*ast.CaseClause { + var clauses []*ast.CaseClause + ast.Inspect(fn.Body, func(node ast.Node) bool { + typeSwitch, ok := node.(*ast.TypeSwitchStmt) + if !ok { + return true + } + for _, statement := range typeSwitch.Body.List { + clause, ok := statement.(*ast.CaseClause) + if !ok { + continue + } + for _, expr := range clause.List { + if expressionName(expr) == message { + clauses = append(clauses, clause) + break + } + } + } + return true + }) + return clauses +} + +func conditionRejectsAccept(expr ast.Expr) bool { + switch expr := expr.(type) { + case *ast.ParenExpr: + return conditionRejectsAccept(expr.X) + case *ast.UnaryExpr: + return expr.Op == token.NOT && countCalls(expr.X, "acceptAsync") == 1 + case *ast.BinaryExpr: + leftCalls := countCalls(expr.X, "acceptAsync") == 1 + rightCalls := countCalls(expr.Y, "acceptAsync") == 1 + leftBool := booleanIdentifier(expr.X) + rightBool := booleanIdentifier(expr.Y) + return (leftCalls && ((expr.Op == token.EQL && rightBool == "false") || (expr.Op == token.NEQ && rightBool == "true"))) || + (rightCalls && ((expr.Op == token.EQL && leftBool == "false") || (expr.Op == token.NEQ && leftBool == "true"))) + default: + return false + } +} + +func booleanIdentifier(expr ast.Expr) string { + if ident, ok := expr.(*ast.Ident); ok && (ident.Name == "true" || ident.Name == "false") { + return ident.Name + } + return "" +} + +func blockEndsInReturn(block *ast.BlockStmt) bool { + return block != nil && len(block.List) != 0 && isTerminatingStatement(block.List[len(block.List)-1]) +} + +func isTerminatingStatement(statement ast.Stmt) bool { + switch statement := statement.(type) { + case *ast.ReturnStmt: + return true + case *ast.BlockStmt: + return blockEndsInReturn(statement) + default: + return false + } +} + +func hasDirectStateMutation(node ast.Node) bool { + mutates := false + ast.Inspect(node, func(child ast.Node) bool { + switch child := child.(type) { + case *ast.IncDecStmt: + mutates = true + return false + case *ast.AssignStmt: + for _, lhs := range child.Lhs { + switch lhs.(type) { + case *ast.SelectorExpr, *ast.IndexExpr, *ast.IndexListExpr: + mutates = true + return false + } + } + } + return !mutates + }) + return mutates +} + +func (inv *asyncSourceInventory) findFunction(owner string) *ast.FuncDecl { + for _, fn := range inv.funcs { + if functionOwner(fn) == owner { + return fn + } + } + return nil +} + +func (inv *asyncSourceInventory) findFunctionsBySimpleName(name string) []*ast.FuncDecl { + var funcs []*ast.FuncDecl + for _, fn := range inv.funcs { + if fn.Name.Name == name { + funcs = append(funcs, fn) + } + } + return funcs +} + +func functionOwner(fn *ast.FuncDecl) string { + if fn.Recv == nil || len(fn.Recv.List) == 0 { + return fn.Name.Name + } + return expressionName(fn.Recv.List[0].Type) + "." + fn.Name.Name +} + +func functionReturnsType(fn *ast.FuncDecl, typeName string) bool { + return fn.Type.Results != nil && countFieldsOfType(fn.Type.Results, typeName) != 0 +} + +func countFieldsOfType(fields *ast.FieldList, typeName string) int { + if fields == nil { + return 0 + } + count := 0 + for _, field := range fields.List { + if expressionName(field.Type) != typeName { + continue + } + if len(field.Names) == 0 { + count++ + } else { + count += len(field.Names) + } + } + return count +} + +func countCalls(node ast.Node, name string) int { + if node == nil { + return 0 + } + count := 0 + ast.Inspect(node, func(child ast.Node) bool { + call, ok := child.(*ast.CallExpr) + if ok && calledName(call.Fun) == name { + count++ + } + return true + }) + return count +} + +func calledNames(node ast.Node) []string { + var names []string + if node == nil { + return names + } + ast.Inspect(node, func(child ast.Node) bool { + if call, ok := child.(*ast.CallExpr); ok { + if name := calledName(call.Fun); name != "" { + names = append(names, name) + } + } + return true + }) + return uniqueStrings(names) +} + +func calledName(expr ast.Expr) string { + switch expr := expr.(type) { + case *ast.Ident: + return expr.Name + case *ast.SelectorExpr: + return expr.Sel.Name + case *ast.IndexExpr: + return calledName(expr.X) + case *ast.IndexListExpr: + return calledName(expr.X) + default: + return "" + } +} + +func usesIdentifier(node ast.Node, name string) bool { + found := false + ast.Inspect(node, func(child ast.Node) bool { + if ident, ok := child.(*ast.Ident); ok && ident.Name == name { + found = true + return false + } + return !found + }) + return found +} + +func expressionName(expr ast.Expr) string { + switch expr := expr.(type) { + case *ast.Ident: + return expr.Name + case *ast.StarExpr: + return expressionName(expr.X) + case *ast.SelectorExpr: + return expr.Sel.Name + case *ast.IndexExpr: + return expressionName(expr.X) + case *ast.IndexListExpr: + return expressionName(expr.X) + case *ast.ParenExpr: + return expressionName(expr.X) + default: + return "" + } +} + +func sortedFileNames(files map[string]*ast.File) []string { + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func setDifference(left, right []string) []string { + rightSet := make(map[string]bool, len(right)) + for _, item := range right { + rightSet[item] = true + } + var difference []string + for _, item := range left { + if !rightSet[item] { + difference = append(difference, item) + } + } + return uniqueStrings(difference) +} + +func uniqueStrings(values []string) []string { + set := make(map[string]bool, len(values)) + for _, value := range values { + set[value] = true + } + unique := make([]string, 0, len(set)) + for value := range set { + unique = append(unique, value) + } + sort.Strings(unique) + return unique +} From 8ec1870c4da826c00e8ba8e276ed79e17cb2318e Mon Sep 17 00:00:00 2001 From: TZZheng Date: Tue, 14 Jul 2026 10:30:01 -0500 Subject: [PATCH 4/7] test(tui): specify async envelope acceptance --- tui/internal/tui/async_envelope_test.go | 444 ++++++++++++++++++ .../tui/async_envelope_test_scaffold_test.go | 155 ++++++ 2 files changed, 599 insertions(+) create mode 100644 tui/internal/tui/async_envelope_test.go create mode 100644 tui/internal/tui/async_envelope_test_scaffold_test.go diff --git a/tui/internal/tui/async_envelope_test.go b/tui/internal/tui/async_envelope_test.go new file mode 100644 index 00000000..528dd0a7 --- /dev/null +++ b/tui/internal/tui/async_envelope_test.go @@ -0,0 +1,444 @@ +package tui + +import ( + "path/filepath" + "testing" + + "github.com/anthropics/lingtai-tui/internal/fs" + "github.com/anthropics/lingtai-tui/internal/inventory" +) + +type directAsyncKindCase struct { + name string + kind asyncKind +} + +var directAsyncKindCases = []directAsyncKindCase{ + {name: "initial_rebuild", kind: asyncInitialRebuild}, + {name: "steady_refresh", kind: asyncSteadyRefresh}, + {name: "session_persist", kind: asyncSessionPersist}, + {name: "older_page", kind: asyncOlderPage}, + {name: "exact_history_count", kind: asyncExactHistoryCount}, + {name: "refresh_tick", kind: asyncRefreshTick}, + {name: "liveness_pulse", kind: asyncLivenessPulse}, + {name: "editor_done", kind: asyncEditorDone}, +} + +func directAsyncRequiredMask(kind asyncKind) asyncFieldMask { + base := asyncHasOwner | asyncHasTarget | asyncHasGeneration + switch kind { + case asyncInitialRebuild, asyncSteadyRefresh: + return base | asyncHasStoreVersion + case asyncSessionPersist, asyncOlderPage: + return base | asyncHasSourceCache | asyncHasStoreVersion + case asyncExactHistoryCount: + return base | asyncHasSourceCache + case asyncRefreshTick, asyncLivenessPulse: + return base | asyncHasEpoch + case asyncEditorDone: + return base + default: + return 0 + } +} + +func directAsyncAddress(scope string) string { + return "async-address:" + scope +} + +func directAsyncCurrent(scope string, generation uint64) asyncCurrent { + projectRoot := inventory.NormalizePath(filepath.Join("testdata", "async-envelope", scope)) + projectID := canonicalProjectMailIdentity(filepath.Join(projectRoot, ".lingtai")) + targetDir := inventory.NormalizePath(filepath.Join(projectID, "Main")) + source := asyncSourceCache{ + cache: new(fs.SessionCache), + identity: "history-horizon:" + scope, + } + return asyncCurrent{ + binding: asyncBinding{ + owner: asyncOwner{ + projectID: projectID, + storeID: 31, + activation: 37, + }, + target: asyncTarget{ + directory: targetDir, + addressFingerprint: fs.AddressFingerprint(directAsyncAddress(scope)), + }, + generation: generation, + }, + sessionSource: source, + outstandingCount: source, + storeVersion: 41, + tickEpoch: 43, + pulseEpoch: 47, + } +} + +func directAssertAsyncAccepted(t *testing.T, kindName, scenario string, current asyncCurrent, got asyncEnvelope) { + t.Helper() + if !acceptAsync(current, got) { + t.Errorf("kind=%s scenario=%s: acceptAsync rejected a valid current envelope; want acceptance under the eventual production predicate", kindName, scenario) + } +} + +func directAssertAsyncRejected(t *testing.T, kindName, scenario string, current asyncCurrent, got asyncEnvelope) { + t.Helper() + if acceptAsync(current, got) { + t.Errorf("kind=%s scenario=%s: acceptAsync accepted stale or malformed identity; want rejection", kindName, scenario) + } +} + +func TestAsyncPredicateDirectRequiredFieldMatrix(t *testing.T) { + for _, tc := range directAsyncKindCases { + t.Run(tc.name, func(t *testing.T) { + current := directAsyncCurrent(tc.name, 7) + got := captureAsync(tc.kind, current) + wantMask := directAsyncRequiredMask(tc.kind) + if got.kind != tc.kind { + t.Errorf("kind=%s scenario=capture_kind: got kind %d, want %d", tc.name, got.kind, tc.kind) + } + if got.fields != wantMask { + t.Errorf("kind=%s scenario=required_field_matrix: fields=%06b want=%06b", tc.name, got.fields, wantMask) + } + directAssertAsyncAccepted(t, tc.name, "fully_matching_current_envelope", current, got) + }) + } + + for _, tc := range []directAsyncKindCase{ + {name: "initial_rebuild", kind: asyncInitialRebuild}, + {name: "steady_refresh", kind: asyncSteadyRefresh}, + {name: "session_persist", kind: asyncSessionPersist}, + {name: "older_page", kind: asyncOlderPage}, + } { + t.Run(tc.name+"_zero_store_version_present", func(t *testing.T) { + current := directAsyncCurrent(tc.name+"-zero", 7) + current.storeVersion = 0 + got := captureAsync(tc.kind, current) + if got.fields&asyncHasStoreVersion == 0 || got.storeVersion != 0 { + t.Errorf("kind=%s scenario=zero_store_version_present: fields=%06b storeVersion=%d; want presence bit set with valid value zero", tc.name, got.fields, got.storeVersion) + } + directAssertAsyncAccepted(t, tc.name, "zero_store_version_present", current, got) + }) + } + + countMask := directAsyncRequiredMask(asyncExactHistoryCount) + if countMask&asyncHasSourceCache == 0 || countMask&asyncHasStoreVersion != 0 { + t.Errorf("kind=exact_history_count scenario=matrix_specificity: mask=%06b; want source cache plus canonical horizon and no store version", countMask) + } +} + +func TestAsyncPredicateDirectMalformedPresenceAndUnknownKind(t *testing.T) { + knownBits := []struct { + name string + bit asyncFieldMask + }{ + {name: "owner", bit: asyncHasOwner}, + {name: "target", bit: asyncHasTarget}, + {name: "generation", bit: asyncHasGeneration}, + {name: "epoch", bit: asyncHasEpoch}, + {name: "source_cache", bit: asyncHasSourceCache}, + {name: "store_version", bit: asyncHasStoreVersion}, + } + for _, tc := range directAsyncKindCases { + t.Run(tc.name, func(t *testing.T) { + current := directAsyncCurrent(tc.name, 7) + valid := captureAsync(tc.kind, current) + want := directAsyncRequiredMask(tc.kind) + for _, field := range knownBits { + malformed := valid + if want&field.bit != 0 { + malformed.fields &^= field.bit + directAssertAsyncRejected(t, tc.name, "missing_required_presence_bit_"+field.name, current, malformed) + continue + } + malformed.fields |= field.bit + directAssertAsyncRejected(t, tc.name, "extra_illegal_presence_bit_"+field.name, current, malformed) + } + + undefined := valid + undefined.fields |= asyncFieldMask(1 << 15) + directAssertAsyncRejected(t, tc.name, "undefined_presence_bit_15", current, undefined) + }) + } + + current := directAsyncCurrent("unknown-kind", 7) + unknown := captureAsync(asyncInitialRebuild, current) + unknown.kind = asyncKind(255) + directAssertAsyncRejected(t, "unknown_255", "kind_not_in_exact_eight", current, unknown) +} + +func TestAsyncPredicateDirectGenerationABAAllKinds(t *testing.T) { + for _, tc := range directAsyncKindCases { + t.Run(tc.name, func(t *testing.T) { + a7 := directAsyncCurrent("A", 7) + oldA7 := captureAsync(tc.kind, a7) + directAssertAsyncAccepted(t, tc.name, "A7_current", a7, oldA7) + + b8 := directAsyncCurrent("B", 8) + directAssertAsyncRejected(t, tc.name, "A7_after_B8", b8, oldA7) + + a9 := directAsyncCurrent("A", 9) + directAssertAsyncRejected(t, tc.name, "A7_after_return_to_A9", a9, oldA7) + currentA9 := captureAsync(tc.kind, a9) + directAssertAsyncAccepted(t, tc.name, "A9_current", a9, currentA9) + }) + } +} + +func TestAsyncPredicateDirectTargetAddressProjectStoreActivation(t *testing.T) { + for _, tc := range directAsyncKindCases { + t.Run(tc.name, func(t *testing.T) { + base := directAsyncCurrent("identity", 7) + got := captureAsync(tc.kind, base) + + differentTarget := base + differentTarget.binding.target.directory = inventory.NormalizePath(filepath.Join(base.binding.owner.projectID, "Worker")) + directAssertAsyncRejected(t, tc.name, "same_generation_different_canonical_target", differentTarget, got) + + changedAddress := base + changedAddress.binding.target.addressFingerprint = fs.AddressFingerprint("replacement-address") + directAssertAsyncRejected(t, tc.name, "same_directory_changed_address_fingerprint", changedAddress, got) + + changedProject := base + changedProject.binding.owner.projectID = canonicalProjectMailIdentity(filepath.Join("testdata", "async-envelope", "other-project", ".lingtai")) + directAssertAsyncRejected(t, tc.name, "changed_project", changedProject, got) + + changedStore := base + changedStore.binding.owner.storeID++ + directAssertAsyncRejected(t, tc.name, "changed_store_instance", changedStore, got) + + changedActivation := base + changedActivation.binding.owner.activation++ + directAssertAsyncRejected(t, tc.name, "changed_store_activation", changedActivation, got) + }) + } +} + +func TestAsyncPredicateDirectSourceCacheHorizonAndStoreVersion(t *testing.T) { + for _, tc := range []directAsyncKindCase{ + {name: "session_persist", kind: asyncSessionPersist}, + {name: "older_page", kind: asyncOlderPage}, + {name: "exact_history_count", kind: asyncExactHistoryCount}, + } { + t.Run(tc.name, func(t *testing.T) { + current := directAsyncCurrent(tc.name, 7) + got := captureAsync(tc.kind, current) + + wrongCache := got + wrongCache.source.cache = new(fs.SessionCache) + directAssertAsyncRejected(t, tc.name, "stale_source_cache_instance", current, wrongCache) + + wrongHorizon := got + wrongHorizon.source.identity += ":stale" + directAssertAsyncRejected(t, tc.name, "stale_canonical_source_horizon", current, wrongHorizon) + }) + } + + for _, tc := range []directAsyncKindCase{ + {name: "initial_rebuild", kind: asyncInitialRebuild}, + {name: "steady_refresh", kind: asyncSteadyRefresh}, + {name: "session_persist", kind: asyncSessionPersist}, + {name: "older_page", kind: asyncOlderPage}, + } { + t.Run(tc.name+"_stale_store_version", func(t *testing.T) { + current := directAsyncCurrent(tc.name, 7) + got := captureAsync(tc.kind, current) + advanced := current + advanced.storeVersion++ + directAssertAsyncRejected(t, tc.name, "stale_store_version", advanced, got) + }) + } + + t.Run("exact_history_count_same_horizon_replacement", func(t *testing.T) { + current := directAsyncCurrent("count-replacement", 7) + got := captureAsync(asyncExactHistoryCount, current) + replaced := current + replaced.sessionSource.cache = new(fs.SessionCache) + directAssertAsyncAccepted(t, "exact_history_count", "same_horizon_current_cache_replacement", replaced, got) + + changedHorizon := replaced + changedHorizon.sessionSource.identity += ":new" + directAssertAsyncRejected(t, "exact_history_count", "current_canonical_horizon_changed", changedHorizon, got) + }) + + t.Run("exact_history_count_not_store_version_gated", func(t *testing.T) { + current := directAsyncCurrent("count-version", 7) + got := captureAsync(asyncExactHistoryCount, current) + if got.fields&asyncHasStoreVersion != 0 { + t.Errorf("kind=exact_history_count scenario=not_store_version_gated: capture fields=%06b unexpectedly include store version", got.fields) + } + advanced := current + advanced.storeVersion++ + directAssertAsyncAccepted(t, "exact_history_count", "store_version_advanced_same_source_horizon", advanced, got) + }) +} + +func TestAsyncPredicateDirectTickAndPulseExactEpochs(t *testing.T) { + t.Run("refresh_tick", func(t *testing.T) { + current := directAsyncCurrent("tick", 7) + got := captureAsync(asyncRefreshTick, current) + stale := current + stale.tickEpoch++ + directAssertAsyncRejected(t, "refresh_tick", "stale_refresh_tick_epoch", stale, got) + + unrelated := current + unrelated.pulseEpoch++ + directAssertAsyncAccepted(t, "refresh_tick", "unrelated_pulse_epoch_changed", unrelated, got) + }) + + t.Run("liveness_pulse", func(t *testing.T) { + current := directAsyncCurrent("pulse", 7) + got := captureAsync(asyncLivenessPulse, current) + stale := current + stale.pulseEpoch++ + directAssertAsyncRejected(t, "liveness_pulse", "stale_liveness_pulse_epoch", stale, got) + + unrelated := current + unrelated.tickEpoch++ + directAssertAsyncAccepted(t, "liveness_pulse", "unrelated_tick_epoch_changed", unrelated, got) + }) +} + +type directFakeInventoryResolver struct { + records []inventory.Record + calls int +} + +func (r *directFakeInventoryResolver) revalidate(owner asyncOwner, target asyncTarget) bool { + r.calls++ + for _, record := range r.records { + if inventory.NormalizePath(record.AgentDir) != target.directory { + continue + } + recordProjectID := canonicalProjectMailIdentity(filepath.Join(record.Project, ".lingtai")) + return recordProjectID == owner.projectID && + record.Enterable && + fs.AddressFingerprint(record.Address) == target.addressFingerprint + } + return false +} + +func directInventoryRecord(current asyncCurrent) inventory.Record { + return inventory.Record{ + Project: filepath.Dir(current.binding.owner.projectID), + AgentDir: current.binding.target.directory, + Address: directAsyncAddress("inventory"), + AgentName: "Main", + Nickname: "Original nickname", + Enterable: true, + } +} + +func directAssertResolverCalls(t *testing.T, kindName, scenario string, resolver *directFakeInventoryResolver, want int) { + t.Helper() + if resolver.calls != want { + t.Errorf("kind=%s scenario=%s: inventory resolver calls=%d want=%d", kindName, scenario, resolver.calls, want) + } +} + +func TestAsyncPredicateDirectInventoryRevalidation(t *testing.T) { + inventoryKinds := []directAsyncKindCase{ + {name: "initial_rebuild", kind: asyncInitialRebuild}, + {name: "steady_refresh", kind: asyncSteadyRefresh}, + {name: "session_persist", kind: asyncSessionPersist}, + {name: "older_page", kind: asyncOlderPage}, + {name: "exact_history_count", kind: asyncExactHistoryCount}, + {name: "refresh_tick", kind: asyncRefreshTick}, + {name: "editor_done", kind: asyncEditorDone}, + } + + for _, tc := range inventoryKinds { + t.Run(tc.name, func(t *testing.T) { + base := directAsyncCurrent("inventory", 7) + base.binding.target.inventoryBound = true + exactRecord := directInventoryRecord(base) + + exactResolver := &directFakeInventoryResolver{records: []inventory.Record{exactRecord}} + exact := base + exact.revalidateTarget = exactResolver.revalidate + directAssertAsyncAccepted(t, tc.name, "inventory_exact_match", exact, captureAsync(tc.kind, exact)) + directAssertResolverCalls(t, tc.name, "inventory_exact_match", exactResolver, 1) + + missingResolver := &directFakeInventoryResolver{} + missing := base + missing.revalidateTarget = missingResolver.revalidate + directAssertAsyncRejected(t, tc.name, "inventory_record_missing", missing, captureAsync(tc.kind, missing)) + directAssertResolverCalls(t, tc.name, "inventory_record_missing", missingResolver, 1) + + wrongProjectRecord := exactRecord + wrongProjectRecord.Project = inventory.NormalizePath(filepath.Join("testdata", "async-envelope", "wrong-project")) + wrongProjectResolver := &directFakeInventoryResolver{records: []inventory.Record{wrongProjectRecord}} + wrongProject := base + wrongProject.revalidateTarget = wrongProjectResolver.revalidate + directAssertAsyncRejected(t, tc.name, "inventory_wrong_project", wrongProject, captureAsync(tc.kind, wrongProject)) + directAssertResolverCalls(t, tc.name, "inventory_wrong_project", wrongProjectResolver, 1) + + ineligibleRecord := exactRecord + ineligibleRecord.Enterable = false + ineligibleResolver := &directFakeInventoryResolver{records: []inventory.Record{ineligibleRecord}} + ineligible := base + ineligible.revalidateTarget = ineligibleResolver.revalidate + directAssertAsyncRejected(t, tc.name, "inventory_record_ineligible", ineligible, captureAsync(tc.kind, ineligible)) + directAssertResolverCalls(t, tc.name, "inventory_record_ineligible", ineligibleResolver, 1) + + addressChangedRecord := exactRecord + addressChangedRecord.Address = "replacement-inventory-address" + addressChangedResolver := &directFakeInventoryResolver{records: []inventory.Record{addressChangedRecord}} + addressChanged := base + addressChanged.revalidateTarget = addressChangedResolver.revalidate + directAssertAsyncRejected(t, tc.name, "inventory_address_changed", addressChanged, captureAsync(tc.kind, addressChanged)) + directAssertResolverCalls(t, tc.name, "inventory_address_changed", addressChangedResolver, 1) + + nicknameChangedRecord := exactRecord + nicknameChangedRecord.Nickname = "Display-only rename" + nicknameChangedResolver := &directFakeInventoryResolver{records: []inventory.Record{nicknameChangedRecord}} + nicknameChanged := base + nicknameChanged.revalidateTarget = nicknameChangedResolver.revalidate + directAssertAsyncAccepted(t, tc.name, "inventory_nickname_only_changed", nicknameChanged, captureAsync(tc.kind, nicknameChanged)) + directAssertResolverCalls(t, tc.name, "inventory_nickname_only_changed", nicknameChangedResolver, 1) + }) + } + + t.Run("liveness_pulse_skips_process_inventory", func(t *testing.T) { + current := directAsyncCurrent("inventory", 7) + current.binding.target.inventoryBound = true + resolver := &directFakeInventoryResolver{} + current.revalidateTarget = resolver.revalidate + directAssertAsyncAccepted(t, "liveness_pulse", "animation_only_pulse_uses_binding_not_process_scan", current, captureAsync(asyncLivenessPulse, current)) + directAssertResolverCalls(t, "liveness_pulse", "animation_only_pulse_uses_binding_not_process_scan", resolver, 0) + }) +} + +func TestAsyncPredicateDirectNonInventoryMainAndCurrentPolicy(t *testing.T) { + for _, tc := range directAsyncKindCases { + t.Run(tc.name, func(t *testing.T) { + current := directAsyncCurrent("stopped-main", 7) + current.binding.target.inventoryBound = false + resolver := &directFakeInventoryResolver{} + current.revalidateTarget = resolver.revalidate + got := captureAsync(tc.kind, current) + + directAssertAsyncAccepted(t, tc.name, "non_inventory_bound_stopped_main_missing_live_record", current, got) + directAssertResolverCalls(t, tc.name, "non_inventory_bound_stopped_main_missing_live_record", resolver, 0) + + wrongDirectory := got + wrongDirectory.target.directory = inventory.NormalizePath(filepath.Join(current.binding.owner.projectID, "DifferentMain")) + directAssertAsyncRejected(t, tc.name, "non_inventory_main_wrong_canonical_directory", current, wrongDirectory) + + wrongAddress := got + wrongAddress.target.addressFingerprint = fs.AddressFingerprint("different-main-address") + directAssertAsyncRejected(t, tc.name, "non_inventory_main_wrong_address_fingerprint", current, wrongAddress) + }) + } + + t.Run("captured_work_cannot_weaken_current_inventory_policy", func(t *testing.T) { + current := directAsyncCurrent("inventory", 7) + current.binding.target.inventoryBound = true + resolver := &directFakeInventoryResolver{} + current.revalidateTarget = resolver.revalidate + got := captureAsync(asyncEditorDone, current) + got.target.inventoryBound = false + directAssertAsyncRejected(t, "editor_done", "captured_inventory_bound_false_cannot_bypass_current_bound_policy", current, got) + }) +} diff --git a/tui/internal/tui/async_envelope_test_scaffold_test.go b/tui/internal/tui/async_envelope_test_scaffold_test.go new file mode 100644 index 00000000..f2f9c8fb --- /dev/null +++ b/tui/internal/tui/async_envelope_test_scaffold_test.go @@ -0,0 +1,155 @@ +package tui + +import "github.com/anthropics/lingtai-tui/internal/fs" + +// TEMPORARY TEST-ONLY PRODUCTION-SHAPED SCAFFOLD. +// +// This file exists only so the direct PR4 predicate contract can compile before +// async_envelope.go exists. Delete it when the production protocol lands. In +// particular, acceptAsync is deliberately fail-closed: even a fully current, +// well-formed capture returns false so the positive contract assertions stay RED. + +type asyncKind uint8 + +const ( + asyncInitialRebuild asyncKind = iota + 1 + asyncSteadyRefresh + asyncSessionPersist + asyncOlderPage + asyncExactHistoryCount + asyncRefreshTick + asyncLivenessPulse + asyncEditorDone +) + +type asyncFieldMask uint16 + +const ( + asyncHasOwner asyncFieldMask = 1 << iota + asyncHasTarget + asyncHasGeneration + asyncHasEpoch + asyncHasSourceCache + asyncHasStoreVersion +) + +type asyncOwner struct { + projectID string + storeID uint64 + activation uint64 +} + +type asyncTarget struct { + directory string + addressFingerprint string + inventoryBound bool +} + +type asyncGeneration struct { + thread uint64 + epoch uint64 +} + +type asyncSourceCache struct { + cache *fs.SessionCache + identity string +} + +type asyncBinding struct { + owner asyncOwner + target asyncTarget + generation uint64 +} + +type asyncEnvelope struct { + kind asyncKind + fields asyncFieldMask + owner asyncOwner + target asyncTarget + generation asyncGeneration + source asyncSourceCache + storeVersion uint64 +} + +type asyncCurrent struct { + binding asyncBinding + sessionSource asyncSourceCache + outstandingCount asyncSourceCache + storeVersion uint64 + tickEpoch uint64 + pulseEpoch uint64 + revalidateTarget func(asyncOwner, asyncTarget) bool +} + +func temporaryAsyncRequiredMask(kind asyncKind) (asyncFieldMask, bool) { + base := asyncHasOwner | asyncHasTarget | asyncHasGeneration + switch kind { + case asyncInitialRebuild, asyncSteadyRefresh: + return base | asyncHasStoreVersion, true + case asyncSessionPersist, asyncOlderPage: + return base | asyncHasSourceCache | asyncHasStoreVersion, true + case asyncExactHistoryCount: + return base | asyncHasSourceCache, true + case asyncRefreshTick, asyncLivenessPulse: + return base | asyncHasEpoch, true + case asyncEditorDone: + return base, true + default: + return 0, false + } +} + +func captureAsync(kind asyncKind, current asyncCurrent) asyncEnvelope { + fields, known := temporaryAsyncRequiredMask(kind) + if !known { + return asyncEnvelope{kind: kind} + } + envelope := asyncEnvelope{ + kind: kind, + fields: fields, + owner: current.binding.owner, + target: current.binding.target, + generation: asyncGeneration{thread: current.binding.generation}, + } + if fields&asyncHasStoreVersion != 0 { + envelope.storeVersion = current.storeVersion + } + switch kind { + case asyncSessionPersist, asyncOlderPage: + envelope.source = current.sessionSource + case asyncExactHistoryCount: + envelope.source = current.outstandingCount + case asyncRefreshTick: + envelope.generation.epoch = current.tickEpoch + case asyncLivenessPulse: + envelope.generation.epoch = current.pulseEpoch + } + return envelope +} + +func temporaryAsyncNeedsInventoryRevalidation(kind asyncKind) bool { + switch kind { + case asyncInitialRebuild, + asyncSteadyRefresh, + asyncSessionPersist, + asyncOlderPage, + asyncExactHistoryCount, + asyncRefreshTick, + asyncEditorDone: + return true + default: + return false + } +} + +func acceptAsync(current asyncCurrent, got asyncEnvelope) bool { + // Exercise the injectable seam so resolver-call assertions can already pin + // the seven substantive paths. The liveness pulse intentionally performs no + // process-inventory scan. No resolver result can make this scaffold accept. + if current.binding.target.inventoryBound && + temporaryAsyncNeedsInventoryRevalidation(got.kind) && + current.revalidateTarget != nil { + _ = current.revalidateTarget(current.binding.owner, current.binding.target) + } + return false +} From 79c808b88a7b6deca1a3af032a05a730a7b402d5 Mon Sep 17 00:00:00 2001 From: TZZheng Date: Tue, 14 Jul 2026 10:40:08 -0500 Subject: [PATCH 5/7] feat(tui): add unified async acceptance predicate --- tui/internal/tui/async_envelope.go | 235 ++++++++++++++++++ .../tui/async_envelope_test_scaffold_test.go | 155 ------------ 2 files changed, 235 insertions(+), 155 deletions(-) create mode 100644 tui/internal/tui/async_envelope.go delete mode 100644 tui/internal/tui/async_envelope_test_scaffold_test.go diff --git a/tui/internal/tui/async_envelope.go b/tui/internal/tui/async_envelope.go new file mode 100644 index 00000000..f7355a5f --- /dev/null +++ b/tui/internal/tui/async_envelope.go @@ -0,0 +1,235 @@ +package tui + +import ( + "encoding/hex" + "path/filepath" + "strings" + + "github.com/anthropics/lingtai-tui/internal/fs" + "github.com/anthropics/lingtai-tui/internal/inventory" +) + +type asyncKind uint8 + +const ( + asyncInitialRebuild asyncKind = iota + 1 + asyncSteadyRefresh + asyncSessionPersist + asyncOlderPage + asyncExactHistoryCount + asyncRefreshTick + asyncLivenessPulse + asyncEditorDone +) + +type asyncFieldMask uint16 + +const ( + asyncHasOwner asyncFieldMask = 1 << iota + asyncHasTarget + asyncHasGeneration + asyncHasEpoch + asyncHasSourceCache + asyncHasStoreVersion +) + +type asyncOwner struct { + projectID string + storeID uint64 + activation uint64 +} + +type asyncTarget struct { + directory string + addressFingerprint string + inventoryBound bool +} + +type asyncGeneration struct { + thread uint64 + epoch uint64 +} + +type asyncSourceCache struct { + cache *fs.SessionCache + identity string +} + +type asyncBinding struct { + owner asyncOwner + target asyncTarget + generation uint64 +} + +type asyncEnvelope struct { + kind asyncKind + fields asyncFieldMask + owner asyncOwner + target asyncTarget + generation asyncGeneration + source asyncSourceCache + storeVersion uint64 +} + +type asyncCurrent struct { + binding asyncBinding + sessionSource asyncSourceCache + outstandingCount asyncSourceCache + storeVersion uint64 + tickEpoch uint64 + pulseEpoch uint64 + revalidateTarget func(asyncOwner, asyncTarget) bool +} + +func asyncRequiredMask(kind asyncKind) (asyncFieldMask, bool) { + base := asyncHasOwner | asyncHasTarget | asyncHasGeneration + switch kind { + case asyncInitialRebuild, asyncSteadyRefresh: + return base | asyncHasStoreVersion, true + case asyncSessionPersist, asyncOlderPage: + return base | asyncHasSourceCache | asyncHasStoreVersion, true + case asyncExactHistoryCount: + return base | asyncHasSourceCache, true + case asyncRefreshTick, asyncLivenessPulse: + return base | asyncHasEpoch, true + case asyncEditorDone: + return base, true + default: + return 0, false + } +} + +func captureAsync(kind asyncKind, current asyncCurrent) asyncEnvelope { + fields, known := asyncRequiredMask(kind) + if !known { + return asyncEnvelope{kind: kind} + } + envelope := asyncEnvelope{ + kind: kind, + fields: fields, + owner: current.binding.owner, + target: current.binding.target, + generation: asyncGeneration{thread: current.binding.generation}, + } + if fields&asyncHasStoreVersion != 0 { + envelope.storeVersion = current.storeVersion + } + switch kind { + case asyncSessionPersist, asyncOlderPage: + envelope.source = current.sessionSource + case asyncExactHistoryCount: + envelope.source = current.outstandingCount + case asyncRefreshTick: + envelope.generation.epoch = current.tickEpoch + case asyncLivenessPulse: + envelope.generation.epoch = current.pulseEpoch + } + return envelope +} + +func asyncNeedsInventoryRevalidation(kind asyncKind) bool { + switch kind { + case asyncInitialRebuild, + asyncSteadyRefresh, + asyncSessionPersist, + asyncOlderPage, + asyncExactHistoryCount, + asyncRefreshTick, + asyncEditorDone: + return true + default: + return false + } +} + +func acceptAsync(current asyncCurrent, got asyncEnvelope) bool { + fields, known := asyncRequiredMask(got.kind) + if !known || got.fields != fields { + return false + } + + binding := current.binding + if !validAsyncOwner(binding.owner) || !validAsyncTarget(binding.owner, binding.target) || binding.generation == 0 || + got.owner != binding.owner || got.target != binding.target || got.generation.thread != binding.generation { + return false + } + + if fields&asyncHasEpoch == 0 { + if got.generation.epoch != 0 { + return false + } + } else { + epoch := current.tickEpoch + if got.kind == asyncLivenessPulse { + epoch = current.pulseEpoch + } + if epoch == 0 || got.generation.epoch != epoch { + return false + } + } + + if fields&asyncHasSourceCache == 0 { + if got.source.cache != nil || got.source.identity != "" { + return false + } + } else { + switch got.kind { + case asyncSessionPersist, asyncOlderPage: + if !validAsyncSource(current.sessionSource) || got.source != current.sessionSource { + return false + } + case asyncExactHistoryCount: + // Count work stays bound to its originating outstanding cache, while a + // same-horizon replacement of the installed session cache remains valid. + if !validAsyncSource(current.outstandingCount) || !validAsyncSource(current.sessionSource) || + got.source != current.outstandingCount || got.source.identity != current.sessionSource.identity { + return false + } + default: + return false + } + } + + if fields&asyncHasStoreVersion == 0 { + if got.storeVersion != 0 { + return false + } + } else if got.storeVersion != current.storeVersion { + // Zero is a valid version when the presence bit requires this coordinate. + return false + } + + // Current state owns inventory policy: captured work cannot weaken it. Pulse + // is animation-only and deliberately avoids a four-times-per-second scan. + if binding.target.inventoryBound && asyncNeedsInventoryRevalidation(got.kind) { + return current.revalidateTarget != nil && current.revalidateTarget(binding.owner, binding.target) + } + return true +} + +func validAsyncOwner(owner asyncOwner) bool { + return owner.projectID != "" && owner.projectID == canonicalProjectMailIdentity(owner.projectID) && + owner.storeID != 0 && owner.activation != 0 +} + +func validAsyncTarget(owner asyncOwner, target asyncTarget) bool { + if target.directory == "" || target.directory != inventory.NormalizePath(target.directory) || + !validAsyncAddressFingerprint(target.addressFingerprint) { + return false + } + rel, err := filepath.Rel(owner.projectID, target.directory) + return err == nil && rel != "." && rel != ".." && !filepath.IsAbs(rel) && + !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func validAsyncAddressFingerprint(fingerprint string) bool { + if len(fingerprint) != 64 || fingerprint == fs.AddressFingerprint("") || fingerprint != strings.ToLower(fingerprint) { + return false + } + decoded, err := hex.DecodeString(fingerprint) + return err == nil && len(decoded) == 32 +} + +func validAsyncSource(source asyncSourceCache) bool { + return source.cache != nil && strings.TrimSpace(source.identity) != "" +} diff --git a/tui/internal/tui/async_envelope_test_scaffold_test.go b/tui/internal/tui/async_envelope_test_scaffold_test.go deleted file mode 100644 index f2f9c8fb..00000000 --- a/tui/internal/tui/async_envelope_test_scaffold_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package tui - -import "github.com/anthropics/lingtai-tui/internal/fs" - -// TEMPORARY TEST-ONLY PRODUCTION-SHAPED SCAFFOLD. -// -// This file exists only so the direct PR4 predicate contract can compile before -// async_envelope.go exists. Delete it when the production protocol lands. In -// particular, acceptAsync is deliberately fail-closed: even a fully current, -// well-formed capture returns false so the positive contract assertions stay RED. - -type asyncKind uint8 - -const ( - asyncInitialRebuild asyncKind = iota + 1 - asyncSteadyRefresh - asyncSessionPersist - asyncOlderPage - asyncExactHistoryCount - asyncRefreshTick - asyncLivenessPulse - asyncEditorDone -) - -type asyncFieldMask uint16 - -const ( - asyncHasOwner asyncFieldMask = 1 << iota - asyncHasTarget - asyncHasGeneration - asyncHasEpoch - asyncHasSourceCache - asyncHasStoreVersion -) - -type asyncOwner struct { - projectID string - storeID uint64 - activation uint64 -} - -type asyncTarget struct { - directory string - addressFingerprint string - inventoryBound bool -} - -type asyncGeneration struct { - thread uint64 - epoch uint64 -} - -type asyncSourceCache struct { - cache *fs.SessionCache - identity string -} - -type asyncBinding struct { - owner asyncOwner - target asyncTarget - generation uint64 -} - -type asyncEnvelope struct { - kind asyncKind - fields asyncFieldMask - owner asyncOwner - target asyncTarget - generation asyncGeneration - source asyncSourceCache - storeVersion uint64 -} - -type asyncCurrent struct { - binding asyncBinding - sessionSource asyncSourceCache - outstandingCount asyncSourceCache - storeVersion uint64 - tickEpoch uint64 - pulseEpoch uint64 - revalidateTarget func(asyncOwner, asyncTarget) bool -} - -func temporaryAsyncRequiredMask(kind asyncKind) (asyncFieldMask, bool) { - base := asyncHasOwner | asyncHasTarget | asyncHasGeneration - switch kind { - case asyncInitialRebuild, asyncSteadyRefresh: - return base | asyncHasStoreVersion, true - case asyncSessionPersist, asyncOlderPage: - return base | asyncHasSourceCache | asyncHasStoreVersion, true - case asyncExactHistoryCount: - return base | asyncHasSourceCache, true - case asyncRefreshTick, asyncLivenessPulse: - return base | asyncHasEpoch, true - case asyncEditorDone: - return base, true - default: - return 0, false - } -} - -func captureAsync(kind asyncKind, current asyncCurrent) asyncEnvelope { - fields, known := temporaryAsyncRequiredMask(kind) - if !known { - return asyncEnvelope{kind: kind} - } - envelope := asyncEnvelope{ - kind: kind, - fields: fields, - owner: current.binding.owner, - target: current.binding.target, - generation: asyncGeneration{thread: current.binding.generation}, - } - if fields&asyncHasStoreVersion != 0 { - envelope.storeVersion = current.storeVersion - } - switch kind { - case asyncSessionPersist, asyncOlderPage: - envelope.source = current.sessionSource - case asyncExactHistoryCount: - envelope.source = current.outstandingCount - case asyncRefreshTick: - envelope.generation.epoch = current.tickEpoch - case asyncLivenessPulse: - envelope.generation.epoch = current.pulseEpoch - } - return envelope -} - -func temporaryAsyncNeedsInventoryRevalidation(kind asyncKind) bool { - switch kind { - case asyncInitialRebuild, - asyncSteadyRefresh, - asyncSessionPersist, - asyncOlderPage, - asyncExactHistoryCount, - asyncRefreshTick, - asyncEditorDone: - return true - default: - return false - } -} - -func acceptAsync(current asyncCurrent, got asyncEnvelope) bool { - // Exercise the injectable seam so resolver-call assertions can already pin - // the seven substantive paths. The liveness pulse intentionally performs no - // process-inventory scan. No resolver result can make this scaffold accept. - if current.binding.target.inventoryBound && - temporaryAsyncNeedsInventoryRevalidation(got.kind) && - current.revalidateTarget != nil { - _ = current.revalidateTarget(current.binding.owner, current.binding.target) - } - return false -} From ba01e82192190956202dff6904de2478373f6d81 Mon Sep 17 00:00:00 2001 From: TZZheng Date: Tue, 14 Jul 2026 11:10:28 -0500 Subject: [PATCH 6/7] test(tui): specify async envelope installation --- .../tui/async_envelope_installation_test.go | 1107 +++++++++++++++++ 1 file changed, 1107 insertions(+) create mode 100644 tui/internal/tui/async_envelope_installation_test.go diff --git a/tui/internal/tui/async_envelope_installation_test.go b/tui/internal/tui/async_envelope_installation_test.go new file mode 100644 index 00000000..20c507a5 --- /dev/null +++ b/tui/internal/tui/async_envelope_installation_test.go @@ -0,0 +1,1107 @@ +package tui + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + "unsafe" + + tea "charm.land/bubbletea/v2" + + "github.com/anthropics/lingtai-tui/internal/config" + "github.com/anthropics/lingtai-tui/internal/fs" +) + +// installationEnvelope reads the future unexported envelope field without a +// compile-time reference to that field. The unsafe operation is deliberately +// confined to this reflection bridge: reflect does not permit Interface on an +// unexported field, even from a same-package test. Missing/unexpected fields are +// ordinary testing failures, so the unwired tree remains a valid assertion RED. +func installationEnvelope[T any](t *testing.T, msg *T) asyncEnvelope { + t.Helper() + field := installationEnvelopeField(t, msg) + return *(*asyncEnvelope)(unsafe.Pointer(field.UnsafeAddr())) +} + +func installationEnvelopeField[T any](t *testing.T, msg *T) reflect.Value { + t.Helper() + value := reflect.ValueOf(msg) + if value.Kind() != reflect.Pointer || value.IsNil() { + t.Fatalf("message type %T is not an addressable value", msg) + } + field := value.Elem().FieldByName("envelope") + if !field.IsValid() { + var zero T + t.Fatalf("message type %T has no asyncEnvelope field named envelope", zero) + } + if field.Type() != reflect.TypeOf(asyncEnvelope{}) { + var zero T + t.Fatalf("message type %T envelope field has type %v, want asyncEnvelope", zero, field.Type()) + } + if !field.CanAddr() { + var zero T + t.Fatalf("message type %T envelope field is not addressable", zero) + } + return field +} + +func installationWithEnvelope[T any](t *testing.T, msg T, envelope asyncEnvelope) T { + t.Helper() + field := installationEnvelopeField(t, &msg) + *(*asyncEnvelope)(unsafe.Pointer(field.UnsafeAddr())) = envelope + return msg +} + +func installationProducedEnvelope[T any](t *testing.T, msg *T, wantKind asyncKind) asyncEnvelope { + t.Helper() + envelope := installationEnvelope(t, msg) + wantFields, ok := asyncRequiredMask(wantKind) + if !ok { + t.Fatalf("test requested unknown async kind %d", wantKind) + } + if envelope.kind != wantKind || envelope.fields != wantFields { + t.Fatalf("real producer returned envelope kind=%d fields=%06b, want kind=%d fields=%06b", envelope.kind, envelope.fields, wantKind, wantFields) + } + return envelope +} + +func installationTestStart(t *testing.T) { + t.Helper() + t.Logf("INSTALLATION-TEST-START %s", t.Name()) +} + +type installationScriptedScanner struct { + scans atomic.Int64 + messages []fs.MailMessage +} + +func (s *installationScriptedScanner) Refresh(cache fs.MailCache) fs.MailCache { + s.scans.Add(1) + cache.Messages = append([]fs.MailMessage(nil), s.messages...) + return cache +} + +type installationLocationRecorder struct{ calls atomic.Int64 } + +func (r *installationLocationRecorder) update(string) { r.calls.Add(1) } + +func installationWriteAgent(t *testing.T, dir, address, name, nickname string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := fmt.Sprintf(`{"address":%q,"agent_name":%q,"nickname":%q,"state":"IDLE"}`, address, name, nickname) + if err := os.WriteFile(filepath.Join(dir, ".agent.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func installationWriteEvents(t *testing.T, dir string, count int, prefix string) { + t.Helper() + logs := filepath.Join(dir, "logs") + if err := os.MkdirAll(logs, 0o755); err != nil { + t.Fatal(err) + } + var body strings.Builder + for i := 0; i < count; i++ { + fmt.Fprintf(&body, `{"ts":%d,"type":"text_output","text":%q}`+"\n", i+1, fmt.Sprintf("%s-%03d", prefix, i)) + } + if err := os.WriteFile(filepath.Join(logs, "events.jsonl"), []byte(body.String()), 0o644); err != nil { + t.Fatal(err) + } +} + +func installationAppendEvent(t *testing.T, dir, text string) { + t.Helper() + path := filepath.Join(dir, "logs", "events.jsonl") + file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + t.Fatal(err) + } + if _, err := fmt.Fprintf(file, `{"ts":999999,"type":"text_output","text":%q}`+"\n", text); err != nil { + _ = file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } +} + +func installationNewApp(t *testing.T, eventCount int) (App, *installationScriptedScanner, *installationLocationRecorder) { + t.Helper() + root := t.TempDir() + projectDir := filepath.Join(root, "project", ".lingtai") + orchDir := filepath.Join(projectDir, "Main") + humanDir := filepath.Join(projectDir, "human") + installationWriteAgent(t, orchDir, "main@installation.test", "Main", "Fixture Main") + installationWriteEvents(t, orchDir, eventCount, "installation") + + scanner := &installationScriptedScanner{messages: []fs.MailMessage{{ + ID: "installation-mail", + MailboxID: "installation-mail", + From: "fixture-agent", + To: "human", + Subject: "runtime installation", + Message: "mail projection sentinel", + Type: "normal", + ReceivedAt: "2026-07-14T12:00:00Z", + Identity: map[string]interface{}{"agent_name": "Fixture Agent"}, + Delivered: true, + }}} + locations := &installationLocationRecorder{} + + a := App{ + currentView: appViewMail, + globalDir: filepath.Join(root, "global"), + projectDir: projectDir, + orchDir: orchDir, + orchName: "Main", + tuiConfig: config.DefaultTUIConfig(), + width: 100, + height: 30, + } + a.mailStore = newProjectMailStoreWithDeps(projectDir, humanDir, scanner, locations.update) + a.installMailModel(NewMailModel(humanDir, "human", projectDir, orchDir, "Main", 200, a.globalDir, "en", false, 0)) + a.mail.verbose = verboseThinking + return a, scanner, locations +} + +func installationRefreshResult(t *testing.T, app *App, initial bool) projectMailRefreshMsg { + t.Helper() + cmd := app.beginProjectMailRefresh(initial) + if cmd == nil { + t.Fatal("real ProjectMailStore refresh producer returned nil") + } + msg, ok := cmd().(projectMailRefreshMsg) + if !ok { + t.Fatalf("real ProjectMailStore refresh producer returned %T, want projectMailRefreshMsg", cmd()) + } + return msg +} + +func installationDeliverApp(t *testing.T, app App, msg tea.Msg) (App, tea.Cmd) { + t.Helper() + model, cmd := app.Update(msg) + updated, ok := model.(App) + if !ok { + t.Fatalf("App.Update returned model %T, want App", model) + } + return updated, cmd +} + +type installationAppState struct { + projectDir string + orchDir string + orchName string + storeVersion uint64 + storeSnapshot *ProjectMailSnapshot + storeCache string + refreshInFlight bool + initialRefreshPending bool + acceptedSnapshot *ProjectMailSnapshot + sessionCache *fs.SessionCache + mailMessages string + initialLoading bool + orchState string + orchAlive bool + historyCountLoading bool + historyCountLoaded bool + olderLoadInFlight bool + ingestWindow int + loadedExtra int + locationCalls int64 +} + +func installationSnapshot(app App, locations *installationLocationRecorder) installationAppState { + return installationAppState{ + projectDir: app.projectDir, + orchDir: app.orchDir, + orchName: app.orchName, + storeVersion: app.mailStore.version, + storeSnapshot: app.mailStore.snapshot, + storeCache: fmt.Sprintf("%#v", app.mailStore.cache.Messages), + refreshInFlight: app.mailStore.refreshInFlight, + initialRefreshPending: app.mailStore.initialRefreshPending, + acceptedSnapshot: app.mail.acceptedSnapshot, + sessionCache: app.mail.sessionCache, + mailMessages: fmt.Sprintf("%#v", app.mail.messages), + initialLoading: app.mail.initialLoading, + orchState: app.mail.orchState, + orchAlive: app.mail.orchAlive, + historyCountLoading: app.mail.historyCountLoading, + historyCountLoaded: app.mail.historyCountLoaded, + olderLoadInFlight: app.mail.olderLoadInFlight, + ingestWindow: app.mail.ingestWindow, + loadedExtra: app.mail.loadedExtra, + locationCalls: locations.calls.Load(), + } +} + +func installationAssertAppState(t *testing.T, scenario string, got App, locations *installationLocationRecorder, want installationAppState) { + t.Helper() + after := installationSnapshot(got, locations) + if after != want { + t.Errorf("scenario=%s: rejected completion mutated App/Mail/store state\n got: %+v\nwant: %+v", scenario, after, want) + } +} + +func installationMutateProject(envelope *asyncEnvelope) { + envelope.owner.projectID = canonicalProjectMailIdentity(filepath.Join(filepath.Dir(envelope.owner.projectID), "wrong-project", ".lingtai")) +} + +func installationMutateTarget(envelope *asyncEnvelope) { + envelope.target.directory = filepath.Join(envelope.owner.projectID, "OtherTarget") +} + +func installationMutateAddress(envelope *asyncEnvelope) { + envelope.target.addressFingerprint = fs.AddressFingerprint("replacement@installation.test") +} + +func installationAcceptInitial(t *testing.T, app App) (App, tea.Cmd) { + t.Helper() + msg := installationRefreshResult(t, &app, true) + updated, cmd := installationDeliverApp(t, app, msg) + if updated.mail.initialLoading || updated.mail.acceptedSnapshot == nil || updated.mail.sessionCache == nil { + t.Fatalf("real initial refresh did not install through App.Update: loading=%v snapshot=%v cache=%v", updated.mail.initialLoading, updated.mail.acceptedSnapshot != nil, updated.mail.sessionCache != nil) + } + return updated, cmd +} + +func installationPersistFixture(t *testing.T) (App, mailPersistMsg, *installationPersistenceRecorder) { + t.Helper() + app, _, _ := installationNewApp(t, 1) + app, postFrame := installationAcceptInitial(t, app) + persist, ok := findMailPersistCmd(postFrame) + if !ok { + t.Fatalf("accepted real refresh did not produce mailPersistMsg; command produced %T", runCmd(postFrame)) + } + recorder := &installationPersistenceRecorder{path: filepath.Join(app.mail.humanDir, "logs", "session.jsonl")} + recorder.seed(t, []byte("sentinel aggregate\n")) + return app, persist, recorder +} + +// installationPersistenceRecorder is a test-local adapter around the existing +// SessionCache.Persist side effect. The temporary humanDir injects an isolated +// destination; byte transitions record writes without changing production or +// allowing any filesystem effect outside t.TempDir. +type installationPersistenceRecorder struct { + path string + writes int + last []byte +} + +func (r *installationPersistenceRecorder) seed(t *testing.T, body []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(r.path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(r.path, body, 0o644); err != nil { + t.Fatal(err) + } + r.last = append([]byte(nil), body...) +} + +func (r *installationPersistenceRecorder) observe(t *testing.T) { + t.Helper() + body, err := os.ReadFile(r.path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(body, r.last) { + r.writes++ + r.last = append(r.last[:0], body...) + } +} + +func installationOlderFixture(t *testing.T) (App, mailOlderPageMsg, *installationPersistenceRecorder) { + t.Helper() + app, _, _ := installationNewApp(t, 405) + app, _ = installationAcceptInitial(t, app) + if !app.mail.cacheIsPartial() { + t.Fatal("precondition: 405-event initial page must be partial") + } + launched, cmd := app.mail.requestOlderPage() + if cmd == nil || !launched.olderLoadInFlight { + t.Fatal("real older-page producer did not launch") + } + app.mail = launched + msg, ok := cmd().(mailOlderPageMsg) + if !ok { + t.Fatalf("real older-page command returned %T, want mailOlderPageMsg", cmd()) + } + recorder := &installationPersistenceRecorder{path: filepath.Join(app.mail.humanDir, "logs", "session.jsonl")} + recorder.seed(t, []byte("older-page sentinel\n")) + return app, msg, recorder +} + +func installationHistoryFixture(t *testing.T) (App, mailHistoryCountMsg) { + t.Helper() + app, _, _ := installationNewApp(t, 405) + app, _ = installationAcceptInitial(t, app) + if app.mail.historyCountCache == nil || !app.mail.historyCountLoading { + t.Fatal("real initial refresh did not start an exact-count task") + } + msg, ok := app.mail.historyCountCmd(app.mail.historyCountCache, app.mail.generation)().(mailHistoryCountMsg) + if !ok { + t.Fatal("real count command did not return mailHistoryCountMsg") + } + return app, msg +} + +func installationTickFixture(t *testing.T) (App, projectMailTickMsg, *installationScriptedScanner) { + t.Helper() + app, scanner, _ := installationNewApp(t, 1) + app.mail.homeTelemetryInFlight = true // keep the tick result to refresh + rearm only + app.mailStore.pollRate = time.Nanosecond + cmd := app.mailStore.resumeTick() + if cmd == nil { + t.Fatal("real ProjectMailStore tick producer returned nil") + } + msg, ok := cmd().(projectMailTickMsg) + if !ok { + t.Fatalf("real tick command returned %T, want projectMailTickMsg", cmd()) + } + return app, msg, scanner +} + +func installationRunBatch(t *testing.T, cmd tea.Cmd) []tea.Msg { + t.Helper() + if cmd == nil { + return nil + } + return installationFlattenBatch(t, cmd()) +} + +func installationFlattenBatch(t *testing.T, msg tea.Msg) []tea.Msg { + t.Helper() + if batch, ok := msg.(tea.BatchMsg); ok { + var messages []tea.Msg + for _, child := range batch { + if child == nil { + continue + } + messages = append(messages, installationFlattenBatch(t, child())...) + } + return messages + } + return []tea.Msg{msg} +} + +func installationVisitedApp(t *testing.T, eventCount int) (App, *installationScriptedScanner, *installationLocationRecorder) { + t.Helper() + home, _, _ := installationNewApp(t, 1) + root := filepath.Dir(filepath.Dir(home.projectDir)) + targetRoot := filepath.Join(root, "visited") + record := visitRecord(targetRoot, "worker", "Worker") + record.Address = "worker@installation.test" + installationWriteAgent(t, record.AgentDir, record.Address, record.AgentName, "Original nickname") + installationWriteEvents(t, record.AgentDir, eventCount, "visited") + visited, _ := home.enterVisitedAgent(ProjectsAgentSelectedMsg{Record: record}) + + // enterVisitedAgent creates the real visited owner and marks its returned + // initial command in-flight. Tests below launch their own observable command, + // so release only that unexecuted test fixture slot without touching accepted + // cache/version/snapshot state. + visited.mailStore.refreshInFlight = false + visited.mailStore.refreshInitial = false + visited.mailStore.refreshGeneration = 0 + visited.mailStore.initialRefreshPending = false + + scanner := &installationScriptedScanner{messages: []fs.MailMessage{{ + ID: "visited-mail", + MailboxID: "visited-mail", + From: record.Address, + To: "human", + Message: "visited mail sentinel", + ReceivedAt: "2026-07-14T12:01:00Z", + Delivered: true, + }}} + locations := &installationLocationRecorder{} + visited.mailStore.scanner = scanner + visited.mailStore.updateLocation = locations.update + return visited, scanner, locations +} + +type installationPulseProducer interface { + asyncPulseCmd() tea.Cmd +} + +func installationPulseFixture(t *testing.T) (App, pulseTickMsg) { + t.Helper() + app, _, _ := installationVisitedApp(t, 1) + app.mail.orchState = "ACTIVE" + app.mail.pulseTick = 11 + + producer, ok := any(app.mail).(installationPulseProducer) + if !ok { + t.Fatal("runtime installation seam missing: MailModel has no asyncPulseCmd() tea.Cmd; a generation-only pulseTick call cannot capture owner/target/address/epoch") + } + cmd := producer.asyncPulseCmd() + if cmd == nil { + t.Fatal("real liveness-pulse producer returned nil") + } + msg, ok := cmd().(pulseTickMsg) + if !ok { + t.Fatalf("real pulse command returned %T, want pulseTickMsg", cmd()) + } + return app, msg +} + +func installationInstallSameGenerationTarget(t *testing.T, app App, targetDir, targetName string) App { + t.Helper() + generation := app.mail.generation + if generation == 0 { + t.Fatal("same-generation target fixture requires a non-zero generation") + } + app.orchDir = targetDir + app.orchName = targetName + app.mailGeneration = generation - 1 + app.installMailModel(NewMailModel(app.mail.humanDir, "human", app.projectDir, targetDir, targetName, 200, app.globalDir, "en", false, 0)) + if app.mail.generation != generation { + t.Fatalf("same-generation target fixture installed generation %d, want %d", app.mail.generation, generation) + } + return app +} + +// installationFakeEditorDone is the requested no-process editor seam. It uses a +// real steady-refresh request producer to obtain the current owner/target/thread +// binding, then narrows that captured envelope to the editor kind. No external +// editor or subprocess is launched; App/Mail still consume the real EditorDoneMsg. +func installationFakeEditorDone(t *testing.T, mail MailModel, text string) EditorDoneMsg { + t.Helper() + request, ok := mail.requestMailRefresh(false)().(projectMailRefreshRequestMsg) + if !ok { + t.Fatal("real refresh-request producer did not return projectMailRefreshRequestMsg") + } + envelope := installationProducedEnvelope(t, &request, asyncSteadyRefresh) + envelope.kind = asyncEditorDone + envelope.fields, _ = asyncRequiredMask(asyncEditorDone) + envelope.generation.epoch = 0 + envelope.source = asyncSourceCache{} + envelope.storeVersion = 0 + return installationWithEnvelope(t, EditorDoneMsg{Text: text}, envelope) +} + +type installationResolvedTarget struct { + present bool + projectID string + directory string + addressFingerprint string + eligible bool + nickname string +} + +type installationInventoryResolver struct { + calls atomic.Int64 + record installationResolvedTarget +} + +func (r *installationInventoryResolver) resolve(owner asyncOwner, target asyncTarget) bool { + r.calls.Add(1) + record := r.record + return record.present && record.eligible && record.projectID == owner.projectID && + record.directory == target.directory && record.addressFingerprint == target.addressFingerprint +} + +func installationExactResolvedTarget(envelope asyncEnvelope) installationResolvedTarget { + return installationResolvedTarget{ + present: true, + projectID: envelope.owner.projectID, + directory: envelope.target.directory, + addressFingerprint: envelope.target.addressFingerprint, + eligible: true, + nickname: "Original nickname", + } +} + +// Wiring needs one deterministic resolver injection at the App/store boundary. +// Interface assertions keep the currently absent seam compile-safe and make its +// absence an ordinary, explicit failure rather than inventing a parallel test +// acceptance implementation. +func installationInjectResolver(t *testing.T, app *App, resolver func(asyncOwner, asyncTarget) bool) { + t.Helper() + type appResolverSetter interface { + setAsyncTargetRevalidator(func(asyncOwner, asyncTarget) bool) + } + type storeResolverSetter interface { + setAsyncTargetRevalidator(func(asyncOwner, asyncTarget) bool) + } + if setter, ok := any(app).(appResolverSetter); ok { + setter.setAsyncTargetRevalidator(resolver) + return + } + if setter, ok := any(&app.mailStore).(storeResolverSetter); ok { + setter.setAsyncTargetRevalidator(resolver) + return + } + t.Fatalf("runtime installation seam missing: App/ProjectMailStore has no setAsyncTargetRevalidator(func(asyncOwner, asyncTarget) bool); inventory-bound completion tests must not call the live process scanner") +} + +func installationApplyResolverScenario(record installationResolvedTarget, scenario string) installationResolvedTarget { + switch scenario { + case "disappeared": + record.present = false + case "changed_project": + record.projectID = canonicalProjectMailIdentity(filepath.Join(filepath.Dir(record.projectID), "replacement-project", ".lingtai")) + case "became_ineligible": + record.eligible = false + case "changed_address": + record.addressFingerprint = fs.AddressFingerprint("changed-address@installation.test") + case "nickname_only": + record.nickname = "Display-only rename" + } + return record +} + +func TestAsyncRefreshCompletionRejectsWrongProjectStoreTargetAddressGenerationAndVersion(t *testing.T) { + installationTestStart(t) + gateApp, _, _ := installationNewApp(t, 1) + gateMsg := installationRefreshResult(t, &gateApp, true) + _ = installationProducedEnvelope(t, &gateMsg, asyncInitialRebuild) + + variants := []struct { + name string + mutate func(*asyncEnvelope) + }{ + {name: "wrong_project", mutate: installationMutateProject}, + {name: "wrong_store", mutate: func(e *asyncEnvelope) { e.owner.storeID++ }}, + {name: "wrong_target", mutate: installationMutateTarget}, + {name: "wrong_address", mutate: installationMutateAddress}, + {name: "wrong_generation", mutate: func(e *asyncEnvelope) { e.generation.thread++ }}, + {name: "wrong_store_version", mutate: func(e *asyncEnvelope) { e.storeVersion++ }}, + } + for _, variant := range variants { + t.Run(variant.name, func(t *testing.T) { + app, scanner, locations := installationNewApp(t, 1) + msg := installationRefreshResult(t, &app, true) + envelope := installationProducedEnvelope(t, &msg, asyncInitialRebuild) + variant.mutate(&envelope) + msg = installationWithEnvelope(t, msg, envelope) + before := installationSnapshot(app, locations) + + got, cmd := installationDeliverApp(t, app, msg) + if cmd != nil { + t.Errorf("scenario=%s: rejected refresh returned follow-up command producing %T", variant.name, runCmd(cmd)) + } + installationAssertAppState(t, variant.name, got, locations, before) + if scanner.scans.Load() != 1 { + t.Errorf("scenario=%s: scanner calls=%d, want exactly the producer's one detached scan", variant.name, scanner.scans.Load()) + } + }) + } +} + +func TestAsyncRefreshCompletionA7B8A9CannotMutateA9(t *testing.T) { + installationTestStart(t) + app, _, locations := installationNewApp(t, 1) + app.mailGeneration = 6 + app.installMailModel(app.mail) + if app.mail.generation != 7 { + t.Fatalf("A fixture generation=%d, want 7", app.mail.generation) + } + a7 := installationRefreshResult(t, &app, true) + _ = installationProducedEnvelope(t, &a7, asyncInitialRebuild) + + targetRoot := filepath.Join(filepath.Dir(filepath.Dir(app.projectDir)), "B") + record := visitRecord(targetRoot, "worker", "B") + installationWriteAgent(t, record.AgentDir, record.Address, record.AgentName, "B nickname") + b8, _ := app.enterVisitedAgent(ProjectsAgentSelectedMsg{Record: record}) + if b8.mail.generation != 8 { + t.Fatalf("B fixture generation=%d, want 8", b8.mail.generation) + } + a9, _ := b8.returnFromVisit() + if a9.mail.generation != 9 { + t.Fatalf("returned A fixture generation=%d, want 9", a9.mail.generation) + } + before := installationSnapshot(a9, locations) + + got, cmd := installationDeliverApp(t, a9, a7) + if cmd != nil { + t.Errorf("A7 completion delivered to A9 returned command producing %T", runCmd(cmd)) + } + installationAssertAppState(t, "A7_after_B8_then_A9", got, locations, before) +} + +func TestAsyncPersistRejectsStaleTargetSourceAndStoreVersionWithoutWriting(t *testing.T) { + installationTestStart(t) + _, gatePersist, _ := installationPersistFixture(t) + _ = installationProducedEnvelope(t, &gatePersist, asyncSessionPersist) + + variants := []struct { + name string + mutate func(*asyncEnvelope) + }{ + {name: "wrong_target", mutate: installationMutateTarget}, + {name: "wrong_address", mutate: installationMutateAddress}, + {name: "wrong_source_cache", mutate: func(e *asyncEnvelope) { e.source.cache = new(fs.SessionCache) }}, + {name: "wrong_source_horizon", mutate: func(e *asyncEnvelope) { e.source.identity += ":stale" }}, + {name: "wrong_store_version", mutate: func(e *asyncEnvelope) { e.storeVersion++ }}, + } + for _, variant := range variants { + t.Run(variant.name, func(t *testing.T) { + app, persist, recorder := installationPersistFixture(t) + envelope := installationProducedEnvelope(t, &persist, asyncSessionPersist) + variant.mutate(&envelope) + persist = installationWithEnvelope(t, persist, envelope) + beforeCache := app.mail.sessionCache + + got, cmd := installationDeliverApp(t, app, persist) + recorder.observe(t) + if cmd != nil { + t.Errorf("scenario=%s: rejected persist returned command producing %T", variant.name, runCmd(cmd)) + } + if recorder.writes != 0 { + t.Errorf("scenario=%s: stale persist changed aggregate %d time(s), want zero", variant.name, recorder.writes) + } + if got.mail.sessionCache != beforeCache { + t.Errorf("scenario=%s: rejected persist replaced current session cache", variant.name) + } + }) + } + + t.Run("matching_main_writer_writes_once", func(t *testing.T) { + app, persist, recorder := installationPersistFixture(t) + got, cmd := installationDeliverApp(t, app, persist) + recorder.observe(t) + if recorder.writes != 1 { + t.Fatalf("matching Main persist writes=%d, want exactly one", recorder.writes) + } + if got.mail.sessionCache != app.mail.sessionCache { + t.Fatal("matching persist unexpectedly replaced the installed cache") + } + _ = cmd // matching persistence may schedule one telemetry continuation + }) +} + +func TestAsyncOlderPageRejectsStaleTargetSourceAndStoreVersion(t *testing.T) { + installationTestStart(t) + _, gatePage, _ := installationOlderFixture(t) + _ = installationProducedEnvelope(t, &gatePage, asyncOlderPage) + + variants := []struct { + name string + mutate func(*asyncEnvelope) + }{ + {name: "wrong_target", mutate: installationMutateTarget}, + {name: "wrong_address", mutate: installationMutateAddress}, + {name: "wrong_source_cache", mutate: func(e *asyncEnvelope) { e.source.cache = new(fs.SessionCache) }}, + {name: "wrong_source_horizon", mutate: func(e *asyncEnvelope) { e.source.identity += ":stale" }}, + {name: "wrong_store_version", mutate: func(e *asyncEnvelope) { e.storeVersion++ }}, + } + for _, variant := range variants { + t.Run(variant.name, func(t *testing.T) { + app, page, recorder := installationOlderFixture(t) + envelope := installationProducedEnvelope(t, &page, asyncOlderPage) + variant.mutate(&envelope) + page = installationWithEnvelope(t, page, envelope) + before := installationSnapshot(app, &installationLocationRecorder{}) + + got, cmd := installationDeliverApp(t, app, page) + recorder.observe(t) + if cmd != nil { + t.Errorf("scenario=%s: rejected older page returned command producing %T", variant.name, runCmd(cmd)) + } + // Use a zero recorder in both snapshots; persistence is asserted separately. + installationAssertAppState(t, variant.name, got, &installationLocationRecorder{}, before) + if recorder.writes != 0 { + t.Errorf("scenario=%s: rejected older page changed aggregate %d time(s)", variant.name, recorder.writes) + } + }) + } + + t.Run("matching_page_installs_one_window_without_persisting_partial_cache", func(t *testing.T) { + app, page, recorder := installationOlderFixture(t) + beforeCache := app.mail.sessionCache + beforeMessages := len(app.mail.messages) + got, cmd := installationDeliverApp(t, app, page) + recorder.observe(t) + if got.mail.sessionCache == beforeCache || got.mail.olderLoadInFlight || got.mail.ingestWindow != 400 || got.mail.loadedExtra != 200 { + t.Fatalf("matching older page did not install exactly one window: cacheChanged=%v inFlight=%v window=%d extra=%d", got.mail.sessionCache != beforeCache, got.mail.olderLoadInFlight, got.mail.ingestWindow, got.mail.loadedExtra) + } + if len(got.mail.messages) <= beforeMessages { + t.Fatalf("matching older page messages=%d, want more than initial %d", len(got.mail.messages), beforeMessages) + } + if recorder.writes != 0 || cmd != nil { + t.Fatalf("matching still-partial page persisted or scheduled work: writes=%d cmd=%T", recorder.writes, runCmd(cmd)) + } + }) +} + +func TestAsyncHistoryCountRejectsTargetAddressGenerationAndOriginCache(t *testing.T) { + installationTestStart(t) + _, gateCount := installationHistoryFixture(t) + _ = installationProducedEnvelope(t, &gateCount, asyncExactHistoryCount) + + variants := []struct { + name string + mutate func(*asyncEnvelope) + }{ + {name: "wrong_target", mutate: installationMutateTarget}, + {name: "wrong_address", mutate: installationMutateAddress}, + {name: "wrong_thread_generation", mutate: func(e *asyncEnvelope) { e.generation.thread++ }}, + {name: "wrong_origin_cache", mutate: func(e *asyncEnvelope) { e.source.cache = new(fs.SessionCache) }}, + {name: "wrong_origin_horizon", mutate: func(e *asyncEnvelope) { e.source.identity += ":different-horizon" }}, + } + for _, variant := range variants { + t.Run(variant.name, func(t *testing.T) { + app, count := installationHistoryFixture(t) + envelope := installationProducedEnvelope(t, &count, asyncExactHistoryCount) + variant.mutate(&envelope) + count = installationWithEnvelope(t, count, envelope) + beforeLoading := app.mail.historyCountLoading + beforeLoaded := app.mail.historyCountLoaded + beforeStats := app.mail.historyStats + beforeCache := app.mail.historyCountCache + + got, cmd := installationDeliverApp(t, app, count) + if cmd != nil { + t.Errorf("scenario=%s: rejected count returned command producing %T", variant.name, runCmd(cmd)) + } + if got.mail.historyCountLoading != beforeLoading || got.mail.historyCountLoaded != beforeLoaded || + got.mail.historyStats != beforeStats || got.mail.historyCountCache != beforeCache { + t.Errorf("scenario=%s: rejected count changed loading/loaded/stats/origin", variant.name) + } + }) + } + + t.Run("same_horizon_replacement_accepts_and_uses_current_tail_delta", func(t *testing.T) { + app, count := installationHistoryFixture(t) + origin := app.mail.historyCountCache + launched, pageCmd := app.mail.requestOlderPage() + if pageCmd == nil { + t.Fatal("same-horizon fixture did not launch older page") + } + app.mail = launched + page := pageCmd().(mailOlderPageMsg) + _ = installationProducedEnvelope(t, &page, asyncOlderPage) + app, pageFollowup := installationDeliverApp(t, app, page) + if pageFollowup != nil { + t.Fatalf("same-horizon partial replacement unexpectedly scheduled %T", runCmd(pageFollowup)) + } + if app.mail.sessionCache == origin || app.mail.historyCountCache != origin { + t.Fatal("same-horizon page did not replace installed cache while retaining count origin") + } + + installationAppendEvent(t, app.orchDir, "tail delta after replacement") + mail := app.mail + mail.buildMessages() + app.mail = mail + if got := app.mail.sessionCache.HistoryStats().Detailed; got != 1 { + t.Fatalf("current replacement cache tail delta=%d, want 1", got) + } + if got := origin.HistoryStats().Detailed; got != 0 { + t.Fatalf("detached count origin received tail delta=%d, want 0", got) + } + + app, cmd := installationDeliverApp(t, app, count) + if cmd != nil { + t.Fatalf("accepted exact count returned command producing %T", runCmd(cmd)) + } + if !app.mail.historyCountLoaded || app.mail.historyCountLoading { + t.Fatalf("same-horizon count not accepted: loaded=%v loading=%v", app.mail.historyCountLoaded, app.mail.historyCountLoading) + } + if want := count.stats.Detailed + 1; app.mail.historyStats.Detailed != want { + t.Fatalf("accepted count detailed=%d, want origin %d + current tail 1 = %d", app.mail.historyStats.Detailed, count.stats.Detailed, want) + } + }) +} + +func TestAsyncRefreshTickRejectsOldBindingWithoutRefreshOrRearm(t *testing.T) { + installationTestStart(t) + _, gateTick, _ := installationTickFixture(t) + _ = installationProducedEnvelope(t, &gateTick, asyncRefreshTick) + + variants := []struct { + name string + mutate func(*asyncEnvelope) + }{ + {name: "wrong_project", mutate: installationMutateProject}, + {name: "wrong_store", mutate: func(e *asyncEnvelope) { e.owner.storeID++ }}, + {name: "wrong_activation", mutate: func(e *asyncEnvelope) { e.owner.activation++ }}, + {name: "wrong_target", mutate: installationMutateTarget}, + {name: "wrong_address", mutate: installationMutateAddress}, + {name: "wrong_thread", mutate: func(e *asyncEnvelope) { e.generation.thread++ }}, + {name: "wrong_epoch", mutate: func(e *asyncEnvelope) { e.generation.epoch++ }}, + } + for _, variant := range variants { + t.Run(variant.name, func(t *testing.T) { + app, tick, scanner := installationTickFixture(t) + envelope := installationProducedEnvelope(t, &tick, asyncRefreshTick) + variant.mutate(&envelope) + tick = installationWithEnvelope(t, tick, envelope) + beforeChain := app.mailStore.tickChain + + got, cmd := installationDeliverApp(t, app, tick) + if cmd != nil { + t.Errorf("scenario=%s: stale tick rearmed/launched command producing %T", variant.name, runCmd(cmd)) + } + if got.mailStore.refreshInFlight || got.mailStore.tickChain != beforeChain || scanner.scans.Load() != 0 { + t.Errorf("scenario=%s: stale tick changed ownership: refresh=%v chain=%d/%d scans=%d", variant.name, got.mailStore.refreshInFlight, got.mailStore.tickChain, beforeChain, scanner.scans.Load()) + } + }) + } + + t.Run("matching_tick_launches_one_refresh_and_one_rearm", func(t *testing.T) { + app, tick, scanner := installationTickFixture(t) + got, cmd := installationDeliverApp(t, app, tick) + if !got.mailStore.refreshInFlight { + t.Fatal("matching tick did not claim one refresh slot") + } + messages := installationRunBatch(t, cmd) + refreshes, ticks := 0, 0 + for _, msg := range messages { + switch msg.(type) { + case projectMailRefreshMsg: + refreshes++ + case projectMailTickMsg: + ticks++ + default: + t.Errorf("matching tick returned unexpected command message %T", msg) + } + } + if refreshes != 1 || ticks != 1 || scanner.scans.Load() != 1 { + t.Fatalf("matching tick effects: refresh messages=%d next ticks=%d scanner calls=%d; want 1/1/1", refreshes, ticks, scanner.scans.Load()) + } + }) +} + +func TestAsyncLivenessPulseRejectsOldBindingWithoutAnimationOrRearm(t *testing.T) { + installationTestStart(t) + // Keep the unwired baseline RED at the shared missing-field boundary. Once the + // field exists, installationPulseFixture requires the real full-binding seam. + gatePulse := pulseTickMsg{} + _ = installationEnvelope(t, &gatePulse) + _, gatePulse = installationPulseFixture(t) + _ = installationProducedEnvelope(t, &gatePulse, asyncLivenessPulse) + + variants := []struct { + name string + mutate func(*asyncEnvelope) + }{ + {name: "wrong_target", mutate: installationMutateTarget}, + {name: "wrong_address", mutate: installationMutateAddress}, + {name: "wrong_thread", mutate: func(e *asyncEnvelope) { e.generation.thread++ }}, + {name: "wrong_epoch", mutate: func(e *asyncEnvelope) { e.generation.epoch++ }}, + } + for _, variant := range variants { + t.Run(variant.name, func(t *testing.T) { + app, pulse := installationPulseFixture(t) + envelope := installationProducedEnvelope(t, &pulse, asyncLivenessPulse) + variant.mutate(&envelope) + pulse = installationWithEnvelope(t, pulse, envelope) + before := app.mail.pulseTick + + got, cmd := installationDeliverApp(t, app, pulse) + if cmd != nil { + t.Errorf("scenario=%s: stale pulse returned next-pulse command producing %T", variant.name, runCmd(cmd)) + } + if got.mail.pulseTick != before { + t.Errorf("scenario=%s: stale pulse counter=%d, want %d", variant.name, got.mail.pulseTick, before) + } + }) + } + + t.Run("matching_inventory_bound_pulse_animates_without_inventory_scan", func(t *testing.T) { + app, pulse := installationPulseFixture(t) + before := app.mail.pulseTick + got, cmd := installationDeliverApp(t, app, pulse) + if got.mail.pulseTick != before+1 { + t.Fatalf("matching active pulse counter=%d, want %d", got.mail.pulseTick, before+1) + } + if cmd == nil { + t.Fatal("matching pulse returned no next pulse") + } + next, ok := cmd().(pulseTickMsg) + if !ok { + t.Fatalf("matching pulse rearm produced %T, want exactly one pulseTickMsg", cmd()) + } + _ = installationProducedEnvelope(t, &next, asyncLivenessPulse) + }) +} + +func TestEditorDoneCarriesAndValidatesTargetIdentityAddress(t *testing.T) { + installationTestStart(t) + // Gate the exact completion type before the no-process fake producer adapter. + gate := EditorDoneMsg{Text: "field gate"} + _ = installationEnvelope(t, &gate) + + app, _, _ := installationNewApp(t, 1) + project := app.projectDir + targetA := filepath.Join(project, "A") + targetB := filepath.Join(project, "B") + installationWriteAgent(t, targetA, "a@installation.test", "A", "A nickname") + installationWriteAgent(t, targetB, "b@installation.test", "B", "B nickname") + installationWriteEvents(t, targetA, 1, "A") + installationWriteEvents(t, targetB, 1, "B") + app.mailGeneration = 6 + app = installationInstallSameGenerationTarget(t, app, targetA, "A") + text := strings.Repeat("editor line\n", 12) + done := installationFakeEditorDone(t, app.mail, text) + + t.Run("same_generation_target_switch_rejects", func(t *testing.T) { + current := installationInstallSameGenerationTarget(t, app, targetB, "B") + current.mail, _ = current.mail.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + current.mail.pendingMessage = "B draft" + current.mail.input.SetValue("B input") + beforeLines := current.mail.input.LineCount() + beforeViewport := current.mail.viewport.Height() + beforeStatus := current.mail.statusFlash + + got, cmd := installationDeliverApp(t, current, done) + if cmd != nil { + t.Fatalf("stale target editor completion returned refresh/clear command producing %T", runCmd(cmd)) + } + if got.mail.pendingMessage != "B draft" || got.mail.input.Value() != "B input" || + got.mail.input.LineCount() != beforeLines || got.mail.viewport.Height() != beforeViewport || got.mail.statusFlash != beforeStatus { + t.Fatal("stale target editor completion changed B draft/input/hint/geometry") + } + }) + + t.Run("same_directory_address_replacement_rejects", func(t *testing.T) { + installationWriteAgent(t, targetA, "replacement-a@installation.test", "A", "A nickname") + current := installationInstallSameGenerationTarget(t, app, targetA, "A") + current.mail.pendingMessage = "replacement draft" + current.mail.input.SetValue("replacement input") + got, cmd := installationDeliverApp(t, current, done) + if cmd != nil || got.mail.pendingMessage != "replacement draft" || got.mail.input.Value() != "replacement input" { + t.Fatalf("old-address editor completion installed after directory reuse: pending=%q input=%q cmd=%T", got.mail.pendingMessage, got.mail.input.Value(), runCmd(cmd)) + } + }) + + t.Run("matching_completion_preserves_editor_effects", func(t *testing.T) { + installationWriteAgent(t, targetA, "a@installation.test", "A", "A nickname") + current := installationInstallSameGenerationTarget(t, app, targetA, "A") + current.mail, _ = current.mail.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + beforeLines := current.mail.input.LineCount() + beforeViewport := current.mail.viewport.Height() + got, cmd := installationDeliverApp(t, current, done) + if got.mail.pendingMessage != text || got.mail.input.Value() != text { + t.Fatalf("matching editor text not preserved: pending=%q input=%q", got.mail.pendingMessage, got.mail.input.Value()) + } + if got.mail.statusFlash == "" { + t.Fatal("matching multiline editor completion did not preserve max-height editor hint") + } + if got.mail.input.LineCount() <= beforeLines || got.mail.viewport.Height() >= beforeViewport { + t.Fatalf("matching editor geometry did not update: lines %d->%d viewport %d->%d", beforeLines, got.mail.input.LineCount(), beforeViewport, got.mail.viewport.Height()) + } + + messages := installationRunBatch(t, cmd) + refreshRequests, clearScreens := 0, 0 + clearType := fmt.Sprintf("%T", tea.ClearScreen()) + for _, msg := range messages { + switch typed := msg.(type) { + case projectMailRefreshRequestMsg: + refreshRequests++ + _ = installationProducedEnvelope(t, &typed, asyncSteadyRefresh) + default: + if fmt.Sprintf("%T", msg) == clearType { + clearScreens++ + } else { + t.Errorf("matching editor completion returned unexpected effect %T", msg) + } + } + } + if refreshRequests != 1 || clearScreens != 1 { + t.Fatalf("matching editor effects: steady refresh=%d clear-screen=%d, want 1/1", refreshRequests, clearScreens) + } + }) +} + +func TestAsyncInventoryDisappearanceRejectsInstallForVisitedTarget(t *testing.T) { + installationTestStart(t) + gateApp, _, _ := installationVisitedApp(t, 3) + gateRefresh := installationRefreshResult(t, &gateApp, true) + gateEnvelope := installationProducedEnvelope(t, &gateRefresh, asyncInitialRebuild) + if !gateEnvelope.target.inventoryBound { + t.Fatal("visited target producer did not capture inventoryBound=true") + } + + // Fail transparently until the real App/store resolver injection exists. This + // is intentionally not replaced by a test-side acceptAsync wrapper. + probeResolver := &installationInventoryResolver{record: installationExactResolvedTarget(gateEnvelope)} + installationInjectResolver(t, &gateApp, probeResolver.resolve) + + for _, scenario := range []string{"disappeared", "changed_project", "became_ineligible", "changed_address", "nickname_only"} { + wantAccept := scenario == "nickname_only" + t.Run(scenario, func(t *testing.T) { + t.Run("refresh_install", func(t *testing.T) { + app, _, locations := installationVisitedApp(t, 3) + refresh := installationRefreshResult(t, &app, true) + envelope := installationProducedEnvelope(t, &refresh, asyncInitialRebuild) + resolver := &installationInventoryResolver{record: installationExactResolvedTarget(envelope)} + installationInjectResolver(t, &app, resolver.resolve) + resolver.record = installationApplyResolverScenario(resolver.record, scenario) + before := installationSnapshot(app, locations) + + got, cmd := installationDeliverApp(t, app, refresh) + if wantAccept { + if got.mailStore.version != before.storeVersion+1 || got.mail.acceptedSnapshot == nil || got.mail.initialLoading { + t.Fatalf("nickname-only change rejected valid refresh: version=%d snapshot=%v loading=%v", got.mailStore.version, got.mail.acceptedSnapshot != nil, got.mail.initialLoading) + } + } else { + if cmd != nil { + t.Errorf("%s exact physical refresh rejection returned unexpected queued command producing %T", scenario, runCmd(cmd)) + } + // The exact detached scan did finish, so it must release its physical + // in-flight slot even though acceptAsync forbids publication. + want := before + want.refreshInFlight = false + installationAssertAppState(t, scenario+"/refresh", got, locations, want) + } + if resolver.calls.Load() != 1 { + t.Errorf("refresh resolver calls=%d, want 1", resolver.calls.Load()) + } + }) + + t.Run("history_count_install", func(t *testing.T) { + app, _, _ := installationVisitedApp(t, 405) + initial := installationRefreshResult(t, &app, true) + envelope := installationProducedEnvelope(t, &initial, asyncInitialRebuild) + resolver := &installationInventoryResolver{record: installationExactResolvedTarget(envelope)} + installationInjectResolver(t, &app, resolver.resolve) + app, _ = installationDeliverApp(t, app, initial) + count := app.mail.historyCountCmd(app.mail.historyCountCache, app.mail.generation)().(mailHistoryCountMsg) + _ = installationProducedEnvelope(t, &count, asyncExactHistoryCount) + resolver.record = installationApplyResolverScenario(resolver.record, scenario) + callsBefore := resolver.calls.Load() + + got, _ := installationDeliverApp(t, app, count) + if got.mail.historyCountLoaded != wantAccept { + t.Fatalf("history acceptance=%v, want %v for %s", got.mail.historyCountLoaded, wantAccept, scenario) + } + if resolver.calls.Load() != callsBefore+1 { + t.Errorf("history resolver calls advanced %d, want 1", resolver.calls.Load()-callsBefore) + } + }) + + t.Run("editor_install", func(t *testing.T) { + app, _, _ := installationVisitedApp(t, 3) + done := installationFakeEditorDone(t, app.mail, "visited editor text") + envelope := installationEnvelope(t, &done) + resolver := &installationInventoryResolver{record: installationExactResolvedTarget(envelope)} + installationInjectResolver(t, &app, resolver.resolve) + resolver.record = installationApplyResolverScenario(resolver.record, scenario) + app.mail.pendingMessage = "visited draft" + app.mail.input.SetValue("visited input") + + got, cmd := installationDeliverApp(t, app, done) + if wantAccept { + if got.mail.pendingMessage != "visited editor text" || cmd == nil { + t.Fatalf("nickname-only editor completion rejected: pending=%q cmd=%v", got.mail.pendingMessage, cmd != nil) + } + } else if got.mail.pendingMessage != "visited draft" || got.mail.input.Value() != "visited input" || cmd != nil { + t.Fatalf("%s editor completion installed: pending=%q input=%q cmd=%T", scenario, got.mail.pendingMessage, got.mail.input.Value(), runCmd(cmd)) + } + if resolver.calls.Load() != 1 { + t.Errorf("editor resolver calls=%d, want 1", resolver.calls.Load()) + } + }) + }) + } +} From aaf1021c0cd25eda70f4f89b1b48def6336ec307 Mon Sep 17 00:00:00 2001 From: TZZheng Date: Tue, 14 Jul 2026 13:57:24 -0500 Subject: [PATCH 7/7] feat(tui): install async envelope validation --- tui/internal/tui/ANATOMY.md | 44 ++- tui/internal/tui/app.go | 116 +++++- .../tui/async_envelope_installation_test.go | 103 ++--- .../async_envelope_source_inventory_test.go | 159 ++++++++ .../tui/home_telemetry_context_source_test.go | 4 +- .../home_telemetry_kanban_consistency_test.go | 2 +- .../tui/home_telemetry_ui_no_io_test.go | 2 +- tui/internal/tui/home_telemetry_view_test.go | 4 +- tui/internal/tui/mail.go | 235 ++++++----- tui/internal/tui/mail_activity_footer_test.go | 4 +- .../tui/mail_activity_indicator_test.go | 8 +- tui/internal/tui/mail_generation_test.go | 93 +++-- tui/internal/tui/mail_launch_defer_test.go | 4 +- tui/internal/tui/mail_loading_state_test.go | 22 +- tui/internal/tui/mail_mailbox_source_test.go | 4 +- tui/internal/tui/mail_window_test.go | 63 ++- tui/internal/tui/nirvana.go | 22 +- tui/internal/tui/project_mail_store.go | 332 ++++++++++------ .../tui/project_mail_store_contract_test.go | 374 +++++++++++++++++- .../project_mail_store_test_helpers_test.go | 114 +++++- tui/internal/tui/settings_page_size_test.go | 8 +- tui/internal/tui/status_hint_copy_test.go | 2 +- 22 files changed, 1304 insertions(+), 415 deletions(-) diff --git a/tui/internal/tui/ANATOMY.md b/tui/internal/tui/ANATOMY.md index 9f120502..85d47059 100644 --- a/tui/internal/tui/ANATOMY.md +++ b/tui/internal/tui/ANATOMY.md @@ -8,6 +8,10 @@ related_files: - tui/main.go - tui/internal/tui/app.go - tui/internal/tui/app_test.go + - tui/internal/tui/async_envelope.go + - tui/internal/tui/async_envelope_test.go + - tui/internal/tui/async_envelope_source_inventory_test.go + - tui/internal/tui/async_envelope_installation_test.go - tui/internal/tui/layout.go - tui/internal/tui/layout_test.go - tui/internal/tui/auto_refresh.go @@ -70,16 +74,17 @@ Screen routing is centralized in the `App` struct (`app.go`), which holds every - **`tui/internal/tui/app.go:24-42`** — `appView` enum: 18 view constants (`appViewFirstRun` through `appViewHelp`), including `appViewNotification` for `/notification`. - **`tui/internal/tui/app.go:50-124`** — `App` struct: holds every root-routed screen model plus routing state (`currentView`, `orchDir`, `orchName`, `recoveryMode`). Mail lifetime is root-owned: the active `ProjectMailStore`, an optional suspended home store during a cross-project visit, and the generation token sit beside `MailModel`, which is only the snapshot/UI consumer. `visitReturnState` is the one temporary App-owned back-navigation/UI adapter retained until PR7; it owns no mailbox cache, refresh, or tick execution, although its retained `MailModel` may still reference accepted snapshot/session-cache presentation state. -- **`tui/internal/tui/app.go:177-272`** — `NewApp`: constructor deciding initial view — mail view (returning user), first-run wizard (new user or rehydration), or recovery mode (global config lost, agents intact). Returning-user startup installs a `MailModel` and its matching root `ProjectMailStore` without synchronous mailbox scanning. -- **`tui/internal/tui/app.go:274-293`** — `App.Init()`: delegates to the initial view's `Init()`, asks the root store for the deferred authoritative mail refresh, and, when enabled, arms the separate app-level auto-refresh ticker (see `auto_refresh.go`). +- **`tui/internal/tui/app.go:206-293`** — `NewApp`: constructor deciding initial view — mail view (returning user), first-run wizard (new user or rehydration), or recovery mode (global config lost, agents intact). Returning-user startup installs a `MailModel` and its matching root `ProjectMailStore` without synchronous mailbox scanning. +- **`tui/internal/tui/app.go:295-313`** — `App.Init()`: delegates to the initial view's `Init()`, asks the root store for the deferred authoritative mail refresh, and, when enabled, arms the separate app-level auto-refresh ticker (see `auto_refresh.go`). - **`auto_refresh.go`** — app-level auto-refresh: a single 1s `autoRefreshTick` (`autoRefreshTickMsg`) currently routed only to the kanban (`PropsModel`) because keyboard/markdown/picker-heavy views such as `/mailbox`, `/system`, and `/daemons` must not be reloaded every second while the human is selecting or scrolling. The kanban Ctrl+D detail layer is live too: the tick refreshes its detail caches in place before reloading the outer dashboard. Enabled by default; toggled off via `/settings` (`config.TUIConfig.AutoRefreshOff`). `App.startAutoRefresh()` arms exactly one ticker (guarded by `autoRefreshArmed`). -- **`tui/internal/tui/app.go:329-836`** — `App.Update()`: the central dispatcher. It records full terminal size only for raw `tea.WindowSizeMsg`, then forwards the reduced child budget (`tui/internal/tui/app.go:329-340`). Project-mail request/result/tick messages are root-owned (`tui/internal/tui/app.go:347-379`): requests are generation/activation/current-Mail gated, and a refresh must pass store/project/activation/source-version plus current-`MailModel` generation checks before root cache/version/snapshot publication. An exact old-generation physical completion releases its slot without publication so a queued current authoritative initial can proceed; only an accepted tick can re-arm the one chain. Mail-local async continuations (`mailRefreshMsg`, `mailPersistMsg`, history pages/counts, telemetry) remain root-routed to the preserved Mail model even while another view is active (`tui/internal/tui/app.go:381-387`), so the active child is neither changed nor double-dispatched. -- **`tui/internal/tui/app.go:837-1200`** — `openSetupCredentials` plus `handlePaletteCommand`: maps slash-command strings to view transitions (`/doctor` → `appViewDoctor`, `/daemons` → `appViewDaemons`, `/notification` → `appViewNotification`, `/knowledge` → `appViewKnowledge` (canonical; hidden `/library` and `/codex` aliases), `/skills` → `appViewLibrary`, etc.) and direct actions (`/suspend`, `/cpr`, `/refresh`, `/clear`, `/molt`, `/btw`, `/goal`, `/export`). `/login` is a compatibility shortcut into Setup → Credentials; `/setup credentials` jumps there directly while plain `/setup` opens `FirstRunModel` setup mode. -- **`tui/internal/tui/app.go:1606-1724`** — visit context helpers: `enterVisitedAgent` preserves exactly one original UI/back-navigation context, suspends the root-owned home `ProjectMailStore`, installs a fresh visited-project store, and opens Mail. `returnFromVisit` suspends the visited owner, activates the retained home owner, but deliberately withholds its suspended snapshot until a fresh authoritative initial result is accepted; return-to-Projects keeps that owner active-but-paused (`tui/internal/tui/app.go:1644-1688`). `maybeHandleVisitEsc` implements the 600ms double-Esc return window. -- **`tui/internal/tui/app.go:1746-1877`** — `switchToView(viewName string)`: the canonical route-to-view dispatcher used by `ViewChangeMsg` and palette commands. A successful departure from Mail invalidates its store tick chain (unknown or unavailable routes are exact no-ops); re-entering Mail reloads presentation settings and resumes the one root refresh/tick pipeline (`tui/internal/tui/app.go:1746-1797`). Other screens reconstruct fresh on entry; `/projects` receives App-owned context rather than owning project transitions. -- **`tui/internal/tui/app.go:1879-1943`** — `App.View()`: delegates to the current child, then wraps it with root-owned chrome via `composeWithChrome` before building `tea.NewView`. Startup warnings and the non-mail select-mode indicator consume rows yielded by the child. The visit indication stays inline in the target Mail title. Mouse defaults to `MouseModeCellMotion`, while mail copy mode or global select mode switches it to `MouseModeNone` (`tui/internal/tui/app.go:1927-1934`). -- **`tui/internal/tui/app.go:1596-1604`** — `App.sendSize()`: returns a `tea.Cmd` carrying the *child* window size (terminal size minus root chrome rows) in a root-internal `childWindowSizeMsg`, so a freshly-routed view and a resized view agree on height without making the root treat a synthetic child size as a raw terminal resize. -- **`tui/internal/tui/app.go:1951-2074`** — portal launch helper; `SetTUIVersion` lives with doctor version diagnostics (`tui/internal/tui/doctor.go:30-35`). +- **`tui/internal/tui/app.go:359-376`** — `invalidateProjectMailForReset`: the root event-loop reset boundary. It synchronously suspends both possible store owners, invalidates Mail-local async coordinates, zeroes the stores, and clears visit ownership before the project lifetime can be destroyed; the final transition reuses the same boundary defensively. +- **`tui/internal/tui/app.go:378-444`** — `App.Update()` owns project-mail requests, refresh results, and refresh ticks. Each target-mail message runs the shared `acceptAsync` guard exactly once before launch/publication. Refresh settlement is separate: only the exact captured in-flight envelope can release the physical slot and start an already-queued initial; rejection publishes no cache/snapshot/model/location state. Accepted refresh payloads are then synchronous `mailRefreshPayload` values, while Mail-local persist/history continuations remain root-routed even when another view is active. +- **`tui/internal/tui/app.go:913-1276`** — `openSetupCredentials` plus `handlePaletteCommand`: maps slash-command strings to view transitions (`/doctor` → `appViewDoctor`, `/daemons` → `appViewDaemons`, `/notification` → `appViewNotification`, `/knowledge` → `appViewKnowledge` (canonical; hidden `/library` and `/codex` aliases), `/skills` → `appViewLibrary`, etc.) and direct actions (`/suspend`, `/cpr`, `/refresh`, `/clear`, `/molt`, `/btw`, `/goal`, `/export`). `/login` is a compatibility shortcut into Setup → Credentials; `/setup credentials` jumps there directly while plain `/setup` opens `FirstRunModel` setup mode. +- **`tui/internal/tui/app.go:1682-1802`** — visit context helpers: `enterVisitedAgent` preserves exactly one original UI/back-navigation context, suspends the root-owned home `ProjectMailStore`, installs a fresh visited-project store, and opens Mail. `returnFromVisit` suspends the visited owner, activates the retained home owner, but deliberately withholds its suspended snapshot until a fresh authoritative initial result is accepted; return-to-Projects keeps that owner active-but-paused (`tui/internal/tui/app.go:1720-1765`). `maybeHandleVisitEsc` implements the 600ms double-Esc return window. +- **`tui/internal/tui/app.go:1822-1953`** — `switchToView(viewName string)`: the canonical route-to-view dispatcher used by `ViewChangeMsg` and palette commands. A successful departure from Mail invalidates its store tick chain (unknown or unavailable routes are exact no-ops); re-entering Mail reloads presentation settings and resumes the one root refresh/tick pipeline (`tui/internal/tui/app.go:1822-1873`). Other screens reconstruct fresh on entry; `/projects` receives App-owned context rather than owning project transitions. +- **`tui/internal/tui/app.go:1955-2019`** — `App.View()`: delegates to the current child, then wraps it with root-owned chrome via `composeWithChrome` before building `tea.NewView`. Startup warnings and the non-mail select-mode indicator consume rows yielded by the child. The visit indication stays inline in the target Mail title. Mouse defaults to `MouseModeCellMotion`, while mail copy mode or global select mode switches it to `MouseModeNone` (`tui/internal/tui/app.go:2003-2011`). +- **`tui/internal/tui/app.go:1677-1680`** — `App.sendSize()`: returns a `tea.Cmd` carrying the *child* window size (terminal size minus root chrome rows) in a root-internal `childWindowSizeMsg`, so a freshly-routed view and a resized view agree on height without making the root treat a synthetic child size as a raw terminal resize. +- **`tui/internal/tui/app.go:2054-2106`** — portal launch helper; `SetTUIVersion` lives with doctor version diagnostics (`tui/internal/tui/doctor.go:30-35`). ### Root layout budget (`layout.go`) @@ -88,8 +93,9 @@ Screen routing is centralized in the `App` struct (`app.go`), which holds every ### Screens - **`tui/internal/tui/firstrun.go:136-4483`** — `FirstRunModel`. Multi-step wizard with constructors for three flows: `NewFirstRunModel` (new project), `NewSetupModeModel` (reconfiguring an existing agent), and `NewRehydrateModel` (cloned network). Its background bootstrap calls `config.EnsureRuntimeQuiet`, so first-run venv creation is followed by the same non-blocking Python `lingtai` upgrade check as returning-user startup. Steps (`firstRunStep` enum, `tui/internal/tui/firstrun.go:62-77`): Welcome → API Key → Pick Preset → Edit Preset → Preset Key → Capabilities → Agent Presets → Agent Name/Dir → Recipe → (optional) Rehydrate Propagate → Launching. The Codex credential row behaviour is mode-split: in first-run (non-setupMode) it opens the inline two-method login chooser (`tui/internal/tui/firstrun.go:1059-1074`, rendered at `tui/internal/tui/firstrun.go:2355-2369`) — browser OAuth/localhost or device code — both routed through `startCodexLogin` (`tui/internal/tui/firstrun.go:603-628`, which always passes `forceLogin=false` to `startOAuthFlow` because first-run only manages the primary/legacy account) with epoch-gated stale-completion drops; in setupMode (`/setup`) it emits `ViewChangeMsg{View:"login"}` (`tui/internal/tui/firstrun.go:1155-1157`) to hand off to the dedicated Setup → Credentials subpage instead. Pressing Esc while a Codex login is mid-flight or the method chooser is open cancels that login and stays on the picker (clearing the spinner/URL/code) rather than exiting to home. Codex preset selectability is judged per-preset by `codexPresetAuthValid` — it resolves the preset's own `manifest.llm.codex_auth_path` (empty → legacy fallback) and validates that specific account file, so one missing Codex account never blocks a differently-bound codex preset. First-run manages the primary (legacy) account; additional accounts are added from Setup → Credentials. The Claude Code auth row that previously rendered below Codex is hidden for now (that auth path is unsupported); detection (`refreshClaudeCodeAuth` / `claudeCodeAuthConfigured`) still runs and feeds the `claude-agent-sdk` credential guard, only the user-visible row is suppressed. Emits `FirstRunDoneMsg` when complete. -- **`ProjectMailStore`** (`tui/internal/tui/project_mail_store.go:63-105`, `tui/internal/tui/project_mail_store.go:118-229`, `tui/internal/tui/project_mail_store.go:230-338`) — Root-owned project-lifetime mailbox state: exactly one private `fs.MailCache`, immutable accepted-snapshot sequence, in-flight refresh pipeline, invalidatable polling chain, and human-location updater. Background commands receive detached values; the data-less process-wide `projectMailScanSingleflight` serializes their uncancellable physical refresh/rebuild bodies across home and visited store identities but owns no project data, accepted state, or tick lifecycle. Each in-flight refresh records whether it is initial and which `MailModel` generation launched it, so a current authoritative initial queues behind either a steady scan or an older-generation initial instead of treating that old rebuild as sufficient. A result installs only after store/project/activation/source-version and current-`MailModel` generation acceptance. An exact old-generation completion releases its physical slot without publishing root state, allowing the queued current authoritative initial rebuild to start; unrelated stale results cannot release the slot. Suspend/activate and accepted versions update a shared atomic runtime token, which delayed location commands re-check immediately before the sole updater side effect. App view/visit routing decides whether the one tick chain may run (`tui/internal/tui/app.go:308-379`, `tui/internal/tui/app.go:1606-1688`, `tui/internal/tui/app.go:1746-1797`). -- **`MailModel` / windowed history** (`tui/internal/tui/mail.go:155-283`, `tui/internal/tui/mail.go:544-644`, `tui/internal/tui/mail.go:786-974`) — The network home screen consumes only an immutable `ProjectMailSnapshot`; it owns bounded session reconstruction, chronological message rendering, compose input, and finite history reveal, but no mailbox refresh or polling chain. Displayed mail rows are projected exactly once from the accepted snapshot's private cache in both the default and `ctrl+o` layers; derived `SessionCache` mail copies are skipped while non-mail event replay remains unchanged. The same mail row renderer is used in every layer: no verbose-only metadata header is added, and each live mail row shows its full local date/time (`tui/internal/tui/mail.go:585-673`, `tui/internal/tui/mail.go:1708-1736`). `mail_page_size` is the single owner of both the newest content-cache window and the UI render/Ctrl+U batch (100/200/500/1000/2000; default 200). Initial content paints from exactly one page; every real Ctrl+U requests exactly one further page from the same canonical JSONL horizon, and completeness is accepted only when that scan reaches the beginning. One canonical JSONL metadata task runs asynchronously per activation/source/horizon, so the first content frame never waits for a full-history scan and the UI keeps a neutral loading label until exact metadata arrives. Same-horizon older rebuilds reuse the accepted/in-flight task; a genuinely newer horizon supersedes it once. Accepted counts are generation/cache/current-horizon-gated and advanced by EOF refresh; partial caches keep EOF additions memory-only and never write the derived disk cache, while complete caches use the existing generation/cache-identity persistence gate. Every current Main session cache—the empty constructor cache, the store-supplied authoritative rebuild, and the snapshot-backed older-page rebuild—is explicitly constructed as `fs.MainAggregateWriter` (`tui/internal/tui/mail.go:250`, `tui/internal/tui/mail.go:270-272`, `tui/internal/tui/mail.go:309-312`); the fs rewrite and append primitives independently enforce that role, while completeness remains only a window/truncation-safety fact. +- **Shared async-envelope protocol** (`tui/internal/tui/async_envelope.go:12-100`, `tui/internal/tui/async_envelope.go:102-208`) — `asyncEnvelope` defines exactly eight logical kinds and one centralized required-field matrix. `captureAsync` binds canonical project/store/activation, canonical target directory/address fingerprint/current inventory policy, local thread generation, and the applicable source-cache, accepted-store-version, tick-epoch, or pulse-epoch coordinates. `acceptAsync` is the sole publication/target-launch predicate; inventory-bound substantive work calls one injected fresh target resolver, non-inventory-bound original Main keeps its filesystem binding, and the 250ms pulse deliberately does not scan inventory. The direct predicate, permanent AST inventory, and runtime installation contracts live in the three adjacent async-envelope test files. +- **`ProjectMailStore`** (`tui/internal/tui/project_mail_store.go:93-239`, `tui/internal/tui/project_mail_store.go:247-430`) — Root-owned project-lifetime mailbox state: exactly one private `fs.MailCache`, immutable accepted-snapshot sequence, in-flight refresh pipeline, invalidatable polling chain, deterministic target-revalidation seam, and human-location updater. Detached commands capture envelopes; `settleRefreshWork` may release only the exact physical token and never publishes, while `installRefresh` is reached only after shared acceptance. A direct App launch and the queued authoritative initial each capture their own current envelope and earn fresh `acceptAsync` permission before entering the physical pipeline (`tui/internal/tui/app.go:329-339`, `tui/internal/tui/project_mail_store.go:397-414`); exact settlement grants no permission to the queued launch. Initial/steady refresh otherwise retain their single queued-initial arbitration, and the process-wide data-less singleflight still serializes uncancellable scans. Delayed location work receives the accepted refresh envelope and reruns `acceptAsync` against the atomic/current binding immediately before the sole side effect; no parallel activation/version gate remains. +- **`MailModel` / windowed history** (`tui/internal/tui/mail.go:61-70`, `tui/internal/tui/mail.go:149-375`, `tui/internal/tui/mail.go:795-1196`, `tui/internal/tui/mail.go:1880-1903`) — The network home screen consumes only an immutable `ProjectMailSnapshot`; it owns bounded session reconstruction, chronological message rendering, compose input, finite history reveal, and the full-binding 250ms pulse, but no mailbox refresh or polling chain. Persist, older-page, exact-count, pulse, and editor producers capture envelopes, and their consumers run `acceptAsync` once before publication or target-specific side effects. If an older-page completion is rejected, only the exact stored physical envelope may release its non-publishing debounce token; an unrelated or mutated stale completion cannot (`tui/internal/tui/mail.go:333-354`, `tui/internal/tui/mail.go:1044-1057`). Exact count remains bound to its originating outstanding cache plus canonical horizon (not store version), including same-horizon cache replacement/tail deltas; snapshot-derived persist and older-page work bind the accepted store version. Every current Main session cache remains explicitly `fs.MainAggregateWriter`-authorized, and the fs write primitives independently enforce that role. - **`tui/internal/tui/props.go:22-1593`** — `PropsModel` (KANBAN). Agent dashboard: status, heartbeat, token ledger visualization, Ctrl+D detail with current and last molt-session API/cache stats (including Codex transfer-mode `full` / `incremental` counts when present) plus lifecycle `tool_call` counts and `tool_calls/api_call` averages rendered by the same `appendSessionAPIStats` (`tui/internal/tui/props.go:1546`) — the tool counts are sourced independently from the events store via `fs.SumMoltSessionToolCalls`, overlaid in `loadDetail` so their freshness never rides the token-ledger cache, recent-call lanes split between selected-agent calls and stacked daemon calls (each row showing cached hits, cache `miss` = input − cached via `cacheMiss` at `tui/internal/tui/props.go:1297`, and cache-hit rate), retained context statistics, selected-agent daemon run counts, network activity, session history, signal controls, and preset health using both Codex OAuth and Claude Code CLI auth state. Constructor: `NewPropsModel` (`tui/internal/tui/props.go:84`). **Soul flow is shown as an on/off status, not a raw delay** — `renderSoulFlowValue` (`tui/internal/tui/props.go:698`) reads the opt-in from the agent's `env_file` (`config.SoulFlowEnabledInEnvFile`, resolved by `soulFlowEnvPath`, `tui/internal/tui/props.go:723`): disabled → localized "disabled · opt in via `LINGTAI_SOUL_FLOW_ENABLED` · see soul-manual"; enabled → cadence from `soul.delay` (or a "default cadence" label when omitted). The huge delay sentinel is never surfaced as an off switch. - **`tui/internal/tui/library.go:615-989`** — `LibraryModel`. Skill catalog browser, agent-scoped — scans `/.library/` plus all effective `skills.paths`: `readLibraryPaths` (`tui/internal/tui/library.go:270`) prefers the kernel-published resolved-manifest artifact `/system/manifest.resolved.json` (kernel issue #259) and falls back to raw `init.json` when the artifact is absent or malformed (stopped / never-booted agents). Constructor: `NewLibraryModel` (`tui/internal/tui/library.go:650`). Renders skill metadata in a sidebar list with markdown content pane. - **`tui/internal/tui/doctor.go:66-1485`** — `DoctorModel`. Health check screen. First runs `config.RunDoctorUpdate` (forced TUI + Python upgrade) and refreshes `preset.Bootstrap`, utility skills, and `ExportCommandsJSON`; then continues with the traditional diagnostics — version drift, heartbeat stats, capability validation, Python venv path, kernel CLI probe, LLM reachability. Constructor: `NewDoctorModel` (`tui/internal/tui/doctor.go:88`). Once complete it keeps an in-memory `doctorreport.Draft` and offers `[r] save report` (`canSaveReport`/`saveReportCmd`, `tui/internal/tui/doctor.go:114`) to write a redacted bundle under `/reports/` via `internal/doctorreport` without rerunning the diagnostic; it captures no event logs. The same forced-update routine is reachable from the shell as `lingtai-tui doctor`, useful when the TUI cannot start. @@ -107,8 +113,8 @@ Screen routing is centralized in the `App` struct (`app.go`), which holds every - **`tui/internal/tui/daemons.go:30-1049`** — `DaemonsModel`. Read-only daemon run browser for `/daemons`: discovers agents with `fs.BuildNetwork`, opens a Ctrl+T agent picker, scans `/daemons/em-*/daemon.json`, `logs/events.jsonl`, `logs/token_ledger.jsonl`, `history/chat_history.jsonl`, and `result.txt`, then renders a left run list and right detail pane with task, metadata (including batch group_id, finished_at/completed_at, duration, last event timestamp, and token totals), full chat_history interactions, full event/tool records, and full result text. The panes are independently scrollable: ↑↓/jk keeps selecting daemon runs while tab/←/→ changes which pane receives page/mouse scroll, and selection changes reset only the detail pane while keeping the selected list row visible. Constructor: `NewDaemonsModel` (`tui/internal/tui/daemons.go:115`). Wired to `/daemons`. - `notification.go`: read-only `/notification` screen backed by `internal/sqlitelog`, querying the focused agent's latest 10 `notification_block_injected` snapshots from `logs/log.sqlite`. Each snapshot carries the actual canonical `_meta` envelope the model saw and is shown through the shared `MarkdownViewerModel`: left/right still navigate snapshots newest-first, the left sidebar selects the four `_meta` envelope blocks (`all blocks`, `overview`, `_meta.tool_meta`, `_meta.agent_meta`, `_meta.guidance`, `_meta.notification_guidance`, `_meta.notifications`, and per-channel `_meta.notifications.` payloads), and the right pane is scrollable markdown/fenced JSON. Modern kernel rows persist a single top-level `fields_json._meta` envelope with all four blocks (`tool_meta`, `agent_meta`, `guidance`, `notifications`/`notification_guidance`); `sqlitelog.parseNotificationBlockSnapshotFields` parses that first and falls back to the legacy pre-envelope shape (top-level `payload` with `_tool`/`_runtime.state`/`_runtime.guidance`/`_notification_guidance` plus a separate `meta` dict) for older rows, mapping both onto the same `_meta.*` display labels. Fallback message is shown when no `notification_block_injected` rows exist (log predates this feature). r reload and Esc/q back-to-chat are preserved. `notification_pair_injected` compact-summary rows are NOT used here; they remain available via `QueryNotificationBlocks` for chat-replay/session-summary callers. - **`tui/internal/tui/goal.go:14-62`** — `/goal` filesystem writer. `writeGoalRequestNotification` appends a `source="goal.request"` event to the current agent's `.notification/system.json`, preserving existing `data.events`, capping at 20, and writing via temp-file + rename. The event asks the agent to read the goal manual, guide objective/criteria/reminder/cancel semantics with the human, and only then create `.notification/goal.json` after confirmation. -- **`tui/internal/tui/nirvana.go:47-209`** — `NirvanaModel`. Confirmation screen for wiping `.lingtai/`. Constructor: `NewNirvanaModel` (`tui/internal/tui/nirvana.go:56`). Emits `NirvanaDoneMsg` → triggers first-run wizard flow. -- **`tui/internal/tui/projects.go:36-1323`** — `ProjectsModel`. `/projects` running-agent switcher. Registry mode scans `internal/inventory` and renders project-grouped running-agent rows with current/original/visiting markers, disabled/error rows, admin-only enterability, and refresh via `r`/`ctrl+r` (`tui/internal/tui/projects.go:197-223`, `tui/internal/tui/projects.go:263-395`). **Three concepts stay visibly and semantically separate — keyboard cursor/selection, App-authoritative current identity, and runtime lifecycle/health.** The App owns the current identity and passes it in via `ProjectsContext.FocusedAgentDir`/`CurrentAgentName` (= `a.orchDir`/`a.orchName`, which during a visit name the visited agent) (`tui/internal/tui/projects.go:58-81`, `tui/internal/tui/app.go:146-169`); the view never infers the current agent from process state, role, PID, row order, or the cursor. A dedicated current-agent block leads the overview pane (`renderCurrentAgentBlock`, `tui/internal/tui/projects.go:848-908`): it names the authoritative current agent and, when that agent is process-visible (matched by normalized `AgentDir` via `isCurrentAgentRow`/`currentAgentRecord`, `tui/internal/tui/projects.go:817-846`), surfaces its live `State` (colored by `StateColor`) and heartbeat (colored by `heartbeatStyled`) as prominent separately-labeled fields; when the current agent is absent from the snapshot it shows a localized `projects.current_agent_unavailable` status instead of a fabricated lifecycle state. In the list itself the row prefix carries two independent fixed-column signals so cursor and current never collapse: column 0 is the current-agent marker `projectsCurrentMarker` (`◆`, `tui/internal/tui/projects.go:799`), column 2 is the `>` cursor marker (`tui/internal/tui/projects.go:745-763`). The process-visible section adds a localized dim legend for `◆` current, `>` cursor, and `!` non-enterable rows. Network groups carry the exact rendered record count (`· N`) and are separated by real unselectable `projectRowSpacer` rows, preserving the one-model-row/one-render-line invariant so `registryListTopLines` and `selectedRenderedLine` keep scroll-to-cursor in lockstep with the prepended block and group spacing (`tui/internal/tui/projects.go:515-553`, `tui/internal/tui/projects.go:706-817`). Left pane is the overview: each row answers who/role/live-state/heartbeat and, when authoritative context exists and the pane is at least `projectsOverviewContextMinWidth` wide, a compact context percentage — but never operational detail (PID, process uptime), which lives only in Details (`tui/internal/tui/projects.go:692-790`). The right pane is Details (no generic Status block, no `Role:`/`State:`/`Address:` rows restating the overview): an identity header (display name plus the address/agent identity as a dim line, dropped when it would echo the header) with validation warnings/disabled/phantom/status surfaced immediately beneath it, then `Lifecycle` (created + derived lifetime folded onto one line via `lifecycleCreatedLine`, process uptime, molt count), `Network` (project, orchestrator, and the two project-scoped topology counts — computed only from the loaded `Snapshot.Records` — folded onto one members/admins line), and `Runtime` (PID, plus exact authoritative `total / window (pct)` context with a small `renderShareBar` meter when the pane is legibly wide — the meter is suppressed, but never the exact numeric line, when the quantized 12-cell bar would show zero filled cells so a low nonzero percentage cannot render an all-empty bar that contradicts the text — and optional IM/lock) (`tui/internal/tui/projects.go:910-1004`). Raw project/agent paths are omitted, and widths below 90 columns degrade to the selectable list only — the current-agent block and its markers still render there (`tui/internal/tui/projects.go:642-690`). Enter on a valid admin/orchestrator row emits `ProjectsAgentSelectedMsg` for the root `App` to perform the context transition (`tui/internal/tui/projects.go:432-466`). `NewAgoraProjectsModel` still preserves the `/agora` recipe-import source path (`tui/internal/tui/projects.go:100-112`, `tui/internal/tui/projects.go:197-216`). +- **`tui/internal/tui/nirvana.go:42-145`** — `NirvanaModel`. Confirmation screen for wiping `.lingtai/`; constructor: `NewNirvanaModel` (`tui/internal/tui/nirvana.go:62`). Confirm marks the screen as cleaning and emits `nirvanaCleanStartMsg`, while Cancel/Esc still return to Mail without crossing the destructive boundary (`tui/internal/tui/nirvana.go:71-125`). The root accepts that start message only for the active confirmed screen, consumes the handoff exactly once, calls `invalidateProjectMailForReset` synchronously, and only then returns `doClean`, whose command removes `.lingtai/` (`tui/internal/tui/app.go:669-679`, `tui/internal/tui/nirvana.go:127-145`). The completed screen may remain open indefinitely; its later Enter emits `NirvanaDoneMsg`, which the root accepts only from that active completed screen before defensively repeating invalidation, recreating the project, and entering first-run (`tui/internal/tui/app.go:681-707`). +- **`tui/internal/tui/projects.go:36-1323`** — `ProjectsModel`. `/projects` running-agent switcher. Registry mode scans `internal/inventory` and renders project-grouped running-agent rows with current/original/visiting markers, disabled/error rows, admin-only enterability, and refresh via `r`/`ctrl+r` (`tui/internal/tui/projects.go:197-223`, `tui/internal/tui/projects.go:263-395`). **Three concepts stay visibly and semantically separate — keyboard cursor/selection, App-authoritative current identity, and runtime lifecycle/health.** The App owns the current identity and passes it in via `ProjectsContext.FocusedAgentDir`/`CurrentAgentName` (= `a.orchDir`/`a.orchName`, which during a visit name the visited agent) (`tui/internal/tui/projects.go:58-81`, `tui/internal/tui/app.go:172-185`); the view never infers the current agent from process state, role, PID, row order, or the cursor. A dedicated current-agent block leads the overview pane (`renderCurrentAgentBlock`, `tui/internal/tui/projects.go:848-908`): it names the authoritative current agent and, when that agent is process-visible (matched by normalized `AgentDir` via `isCurrentAgentRow`/`currentAgentRecord`, `tui/internal/tui/projects.go:817-846`), surfaces its live `State` (colored by `StateColor`) and heartbeat (colored by `heartbeatStyled`) as prominent separately-labeled fields; when the current agent is absent from the snapshot it shows a localized `projects.current_agent_unavailable` status instead of a fabricated lifecycle state. In the list itself the row prefix carries two independent fixed-column signals so cursor and current never collapse: column 0 is the current-agent marker `projectsCurrentMarker` (`◆`, `tui/internal/tui/projects.go:799`), column 2 is the `>` cursor marker (`tui/internal/tui/projects.go:745-763`). The process-visible section adds a localized dim legend for `◆` current, `>` cursor, and `!` non-enterable rows. Network groups carry the exact rendered record count (`· N`) and are separated by real unselectable `projectRowSpacer` rows, preserving the one-model-row/one-render-line invariant so `registryListTopLines` and `selectedRenderedLine` keep scroll-to-cursor in lockstep with the prepended block and group spacing (`tui/internal/tui/projects.go:515-553`, `tui/internal/tui/projects.go:706-817`). Left pane is the overview: each row answers who/role/live-state/heartbeat and, when authoritative context exists and the pane is at least `projectsOverviewContextMinWidth` wide, a compact context percentage — but never operational detail (PID, process uptime), which lives only in Details (`tui/internal/tui/projects.go:692-790`). The right pane is Details (no generic Status block, no `Role:`/`State:`/`Address:` rows restating the overview): an identity header (display name plus the address/agent identity as a dim line, dropped when it would echo the header) with validation warnings/disabled/phantom/status surfaced immediately beneath it, then `Lifecycle` (created + derived lifetime folded onto one line via `lifecycleCreatedLine`, process uptime, molt count), `Network` (project, orchestrator, and the two project-scoped topology counts — computed only from the loaded `Snapshot.Records` — folded onto one members/admins line), and `Runtime` (PID, plus exact authoritative `total / window (pct)` context with a small `renderShareBar` meter when the pane is legibly wide — the meter is suppressed, but never the exact numeric line, when the quantized 12-cell bar would show zero filled cells so a low nonzero percentage cannot render an all-empty bar that contradicts the text — and optional IM/lock) (`tui/internal/tui/projects.go:910-1004`). Raw project/agent paths are omitted, and widths below 90 columns degrade to the selectable list only — the current-agent block and its markers still render there (`tui/internal/tui/projects.go:642-690`). Enter on a valid admin/orchestrator row emits `ProjectsAgentSelectedMsg` for the root `App` to perform the context transition (`tui/internal/tui/projects.go:432-466`). `NewAgoraProjectsModel` still preserves the `/agora` recipe-import source path (`tui/internal/tui/projects.go:100-112`, `tui/internal/tui/projects.go:197-216`). - **`tui/internal/tui/mdviewer.go:40-518`** — `MarkdownViewerModel`. Generic markdown display with sidebar navigation. Used by `/skills` detail views, recipe previews, and `/help`. - **`tui/internal/tui/help.go:1-79`** — `HelpModel`. Thin `MarkdownViewerModel` wrapper for `/help`. No embedded help docs of its own: it reads the slash-command guide from the bundled `lingtai-tui-help` skill via `preset.ReadBundledSkillFile`, picking the asset for the current UI language (`i18n.Lang()` → `assets/slash-commands..md`, English fallback). Constructor: `NewHelpModel` (`tui/internal/tui/help.go:66`). Wired to `/help`. - **`tui/internal/tui/setup.go:42-242`** — `SetupModel` (legacy). Provider/API-key prompt embedded by `FirstRunModel` when bootstrap finds no presets (`tui/internal/tui/firstrun.go:1020-1024`). Constructor: `NewSetupModel` (`tui/internal/tui/setup.go:55`). It is not a root-routed `/setup` screen; plain `/setup` enters `NewSetupModeModel`. @@ -127,7 +133,7 @@ Screen routing is centralized in the `App` struct (`app.go`), which holds every - **`tui/internal/tui/oauth.go:35-705`** — Codex auth transport helpers shared by first-run and `/login` (the single OAuth entrypoint both paths use — see the unification note on `login.go` below): browser OAuth uses the allowlisted localhost ports and automatic callback completion (`startOAuthFlow`, `tui/internal/tui/oauth.go:169-314`). The browser launch goes through the process-global `oauthBrowserOpener` var (`tui/internal/tui/oauth.go:648`, defaults to `openBrowser`) so tests can stub it — without that seam the OAuth tests called `startOAuthFlow`→`openBrowser` directly and `go test ./...` opened real `auth.openai.com` pages (issue #474 comment 1); `TestStartOAuthFlow_DoesNotLaunchRealBrowser` guards the seam and `stubOAuthBrowserOpener` is used by every `startOAuthFlow` test. device-code login follows the official Codex endpoints (`requestCodexDeviceCode`, `tui/internal/tui/oauth.go:373-425`; `pollCodexDeviceAuth`, `tui/internal/tui/oauth.go:456-517`) before exchanging the issued authorization code with the normal token endpoint (`exchangeCodeForTokens`, `tui/internal/tui/oauth.go:553-595`). `buildAuthorizeURL` (`tui/internal/tui/oauth.go:532-549`) takes a `forceLogin bool`: when true it adds `prompt=login` so OpenAI's auth server shows the login page instead of silently reusing the active ChatGPT session — required for "Add another Codex account" (without it the second add re-adds the already-signed-in account). `startOAuthFlow` forwards it; only `LoginModel.codexForceLogin` (add-new-account-while-one-exists) passes true — first-run and existing-account re-auth pass false. Pinned by `TestBuildAuthorizeURL` (no prompt) and `TestBuildAuthorizeURL_ForceLogin` (prompt=login). - **`tui/internal/tui/claude_auth.go:11-84`** — Claude Code auth detector for the `claude-agent-sdk` preset: runs `claude auth status --json` with a short timeout, parses `loggedIn`, falls back to conservative text parsing, and never reads/stores Claude credentials. - **`tui/internal/tui/wizard_footer.go:16-61`** — `renderWizardFooter`: Back/Next button row shared by all wizard pages. -- **Utilities** — orchestrator detection lives in `tui/internal/tui/detect.go:11-146`; command export is `tui/internal/tui/palette.go:80`, Codex launch-time token refresh validation is `tui/internal/tui/app.go:2096`, returning-user agent-bound validation is `tui/internal/tui/app.go:2178`, and recipe placeholder substitution is `tui/internal/tui/recipe_save.go:135`. +- **Utilities** — orchestrator detection lives in `tui/internal/tui/detect.go:11-146`; command export is `tui/internal/tui/palette.go:80`, Codex launch-time token refresh validation is `tui/internal/tui/app.go:2172`, returning-user agent-bound validation is `tui/internal/tui/app.go:2254`, and recipe placeholder substitution is `tui/internal/tui/recipe_save.go:135`. - **`lock_unix.go` / `lock_windows.go`** — `tryLock`: platform-specific file locking for agent suspend/restart coordination. ## Connections @@ -135,7 +141,7 @@ Screen routing is centralized in the `App` struct (`app.go`), which holds every - **Called from:** `tui/main.go:330` creates the `App` via `tui.NewApp(...)` and wraps it in `tea.NewProgram`. - **Calls out (read):** `tui/internal/fs/` (agent state, heartbeat, mail, session, signal, network), `tui/internal/inventory/` (typed running-agent inventory for `/projects`), `tui/internal/preset/` (load/save/apply presets, recipes, bootstrap, utility skills), `tui/internal/config/` (global config, venv, upgrade checks), `tui/internal/process/` (agent launch), `tui/internal/migrate/` (addon comment detection), `tui/i18n/` (all screen strings). - **Calls out (write):** signal files (`.sleep`, `.suspend`, `.interrupt`, `.clear`, `.prompt`, `.refresh`, `.inquiry`, `.forget`), `init.json` via `preset.GenerateInitJSON`, human outbox via `fs.WriteOutboxMessage`, `.lingtai/.tui-asset/settings.json` (per-project settings). -- **Cross-view/project-mail messages:** `MailModel.requestMailRefresh` emits only a generation-tagged request (`tui/internal/tui/mail.go:267-283`). `App.Update` owns `projectMailRefreshRequestMsg`, `projectMailRefreshMsg`, and `projectMailTickMsg`; it asks `ProjectMailStore` to launch/accept work and forwards only a current-generation accepted immutable snapshot plus the embedded presentation result to `MailModel` (`tui/internal/tui/app.go:347-379`, `tui/internal/tui/project_mail_store.go:230-314`). An exact same-store old-generation completion releases the physical slot but publishes nothing; a current-generation authoritative initial queues behind either a steady scan or an older-generation initial, while unrelated stale identities remain no-ops. Mail-local continuations (`mailRefreshMsg`, `mailPersistMsg`, history pages/counts, `homeTelemetryMsg`) still root-route through the preserved model while Projects/Help is active (`tui/internal/tui/app.go:381-387`), with generation and cache-identity gates in `MailModel.Update` (`tui/internal/tui/mail.go:844-974`). Other routes include `ViewChangeMsg`, `ProjectsAgentSelectedMsg` (root-owned visit transition), `FirstRunDoneMsg`, `NirvanaDoneMsg`, `SetupSavedMsg`, `AddonSavedMsg`, and `MarkdownViewerCloseMsg`. +- **Cross-view/project-mail messages:** `MailModel.requestMailRefresh` captures an initial/steady envelope (`tui/internal/tui/mail.go:323-328`). `App.Update` owns request/result/tick acceptance and root publication (`tui/internal/tui/app.go:395-444`); exact refresh settlement may release the physical slot but cannot publish or authorize queued work, whose fresh envelope must independently pass the same predicate before launch. The accepted nested `mailRefreshPayload` has no identity gate. Persist/history/count continuations root-route through the preserved Mail model while Projects/Help is active, and Mail applies the same shared predicate (`tui/internal/tui/mail.go:1006-1093`), with exact older-page rejection settling only its matching non-publishing debounce token. `homeTelemetryMsg` and app-level `autoRefreshTickMsg` remain explicitly outside this eight-path protocol. - **Palette dispatch:** `PaletteSelectMsg` from the `PaletteModel` carries a slash-command string; `App.Update()` maps it to `handlePaletteCommand`. ## Composition @@ -147,9 +153,9 @@ Screen routing is centralized in the `App` struct (`app.go`), which holds every ## State -- **Writes:** per-project `settings.json` (orchestrator selection, mail page size, theme, language), signal files, and `init.json` during setup/preset edits. `ProjectMailStore` is the sole human-location updater owner: it first accepts a detached current-generation refresh in memory, then its delayed command revalidates the shared activation/version token immediately before the side effect (`tui/internal/tui/project_mail_store.go:286-338`). Main Mail's accepted authoritative session completion is the narrow derived-cache exception: a post-frame continuation must pass generation and cache-identity gates before its explicitly `fs.MainAggregateWriter`-bound cache may persist human `logs/session.jsonl`; detached/stale rebuilds do not write (`tui/internal/tui/mail.go:844-974`, `tui/internal/tui/mail.go:1012-1075`). +- **Writes:** per-project `settings.json` (orchestrator selection, mail page size, theme, language), signal files, and `init.json` during setup/preset edits. `ProjectMailStore` remains the sole human-location updater owner: the delayed command carries the accepted refresh envelope and reruns shared acceptance immediately before the side effect (`tui/internal/tui/project_mail_store.go:415-428`). Main Mail's derived-cache write remains separately authorized by `fs.MainAggregateWriter`; the persist completion must also pass the shared binding/source/store-version envelope before `Persist` (`tui/internal/tui/mail.go:1006-1013`). - **Reads:** agent working directories (`.agent.json`, `.agent.heartbeat`, `init.json`, `knowledge//KNOWLEDGE.md` (legacy `codex/codex.json` / `knowledge/knowledge.json` only via one-time migration), `mailbox/`, `logs/token_ledger.jsonl`, `history/chat_history.jsonl`, `system/*.md`, `.library/`). Global config (`~/.lingtai-tui/config.json`, `presets/`, `runtime/`). -- **Ephemeral:** root view/recovery state; `mailGeneration`; the active and optionally suspended `ProjectMailStore` identities, activation/source versions, accepted snapshots, in-flight generation/initial/pending refresh arbitration, tick chain, and shared atomic execution token; the process-wide data-less physical-scan serialization gate; `MailModel`'s accepted snapshot/generation plus presentation/session/UI caches; and visit state (the temporary return adapter, its retained presentation references, target context, and double-Esc timing). All screens' cursors, scroll offsets, and input buffers are lost on process exit (Bubble Tea is stateless across launches). +- **Ephemeral:** root view/recovery state; `mailGeneration`; the active and optionally suspended store owner identity/activation/version/snapshot; canonical target directory/address fingerprint/current inventory policy; exact in-flight refresh envelope plus initial/pending arbitration; the exact older-page envelope used only for non-publishing debounce settlement; tick and pulse epochs; session-cache/outstanding-count horizon coordinates; and the atomic/current binding used only to rerun shared acceptance for delayed location work. The data-less physical-scan singleflight serializes work but owns none of this state. Rejection is a stale drop, not true cancellation; Bubble Tea commands remain uncancellable once launched. ## Notes @@ -158,8 +164,8 @@ Screen routing is centralized in the `App` struct (`app.go`), which holds every - **All screens are eagerly constructed in `App` as zero-value fields** — only the active view gets a `New*Model()` call. When switching views, `switchToView` reconstructs the model fresh. - **Paste delivery requires forwarding `tea.PasteMsg`** alongside `tea.KeyPressMsg`. The `InputModel` handles this; any text widget embedded in another model must forward paste messages in its host's `Update()`. - **`textarea` over `textinput`** for paste-friendly fields (API keys, base URLs). Apply `themedTextareaStyles()` from `styles.go` — bare `textarea.New()` renders a dark cursor that clashes with the warm theme. -- **Mail-view chat replay: verbosity gate + render switch.** `MailModel.verbose` (`tui/internal/tui/mail.go:82-88`) is a 3-state enum (`verboseOff` → `verboseThinking` → `verboseExtended`) cycled by `ctrl+o`, but only two verbose layers exist beyond normal mail. Two coupled switches gate every rendered event: `shouldShow` (`tui/internal/tui/mail.go:472-503`) decides which `SessionEntry` types are visible at the current level, and `renderMessages` (`tui/internal/tui/mail.go:943-1248`) maps each `ChatMessage.Type` to a styled block. `verboseThinking` shows text/inner-state/kernel diagnostics — `thinking`, `diary`, `text_input`, `text_output`, `soul_flow`, `notification`, `aed` — plus compact progress markers: `tool_call` uses the first rendered line capped after the first 400 runes, while `tool_result` uses the first rendered line. `verboseExtended` shows full `tool_call` and `tool_result` bodies (still subject to render-time truncation). Verbose LLM-round-trip entries inherit the current `api_call_id` and are visually separated when that id changes (`apiCallGroupSeparatorBefore` in `toolcall_display.go`), so separate API responses do not run together in ctrl+o while mixed entries from the same API call stay grouped. Tool entries get a faint local `15:04` timestamp (`formatToolTimestamp`); `tool_call` uses `compactToolCallSummary` at level 1, `tool_result` uses `firstRenderedLine` at level 1, and both use `truncateToolBody` at level 2 (all in `toolcall_display.go`). Legacy tool entries without grouping metadata keep the old `tool_result`→`tool_call` fallback (`toolGroupSeparatorBefore`). `fs/session.go` carries full tool args/results into the mail replay; its `appendToolResultMetaBlocks` detects whether a tool_result carries any of the `_meta` envelope blocks (`_tool`, `_runtime.state`, `_runtime.guidance`, `notifications`, `_notification_guidance`) and, rather than expanding that noise inline, emits a single localized hint line (`i18n.T("mail.meta_hidden_hint")`) pointing the user at `/notification`, where the full canonical `_meta.*` envelope is paged on demand (see `notification.go`). The raw metadata stays in the underlying event and is stripped from the `result:` body by `displayToolResultValue`, so nothing is lost — only hidden. Render-time truncation is still applied by `MailModel.verbose` and `MailModel.toolCallTruncate`. **Per-round token usage footer:** `fs/session.go`'s `parseEventMap` lifts the four scalar token fields (`input_tokens`, `output_tokens`, `cached_tokens`, `estimated`) off each `llm_response` event into `SessionEntry.TokenUsage` (`fs.TokenUsage`) — never the hidden `_meta` envelope. The `llm_response` carrier sits at the *top* of its `api_call_id` group in stream order, so `renderMessages` defers a single faint footer line (`formatTokenUsageFooter`, `tui/internal/tui/token_usage_footer.go:25`) to the *bottom* of the group via a `pendingUsage`/`flushTokenFooter` accumulator: `tokens: input … · cache miss … · output … · cache rate …` (localized `mail.token_usage_footer`, counts humanized by `humanizeTokenCount`, miss/rate reuse `cacheMiss`/`formatCacheRate` from `props.go`). `shouldShow` lets an `llm_response` through only when it carries `TokenUsage` and `verbose >= verboseThinking`; it never renders as a raw `[llm_response]` block. Soul flow and notification share the green palette (`ColorAccent` bold header, `ColorAgent` italic body indented 4) — they're agent-side reflections. AED uses the orange `ColorTool` palette to mark kernel distress (LLM empty-response retries, recovery timeouts) so it stands out from normal inner state. **A-priori summary (`summary=true`, kernel PR #586):** when an agent runs `bash`/`read`/`grep` with `summary=true`, the kernel preserves the RAW result in the `tool_result` event (so the raw stdout still renders unchanged) and replaces the *model-visible* payload with a generated summary artifact (`lingtai_apriori_tool_result_summary`). `fs/session.go` surfaces that artifact two ways: it promotes the kernel's `apriori_summary_generated`/`apriori_summary_cap_refused`/`apriori_summary_failed`/`apriori_summary_empty`/`apriori_summary_no_summarizer` lifecycle events into a first-class `apriori_summary` `SessionEntry` (carrying `SessionEntry.Summary` = `fs.AprioriSummary` with kind/tool_call_id/char-counts, and — as of kernel #3833 — the generated summary text on the success path via `generated_summary`; pre-#3833 logs lack it and `Text` stays empty), and — defensively — detects the same artifact when it appears as a `tool_result` event's `result` map (`aprioriSummaryFromArtifact`), attaching it to that `tool_result` entry. `renderMessages` renders the model-visible summary as a distinct labelled block (`renderAprioriSummaryBlock` in `toolcall_display.go`) right after the corresponding raw `tool_result`: `ColorAccent` bold label (`mail.apriori_summary_label`), italic `ColorAgent` summary text, and a faint compression footer (`tool · orig → summary chars · raw preserved`). Cap-refusal / fail-closed variants (`Unavailable`) switch to the `ColorTool` palette and the `mail.apriori_summary_unavailable_label`. The generated summary text itself follows the two-layer ctrl+o convention via `renderAprioriSummaryBlock`'s `preview` arg (passed `m.verbose != verboseExtended`): the FIRST layer (`verboseThinking`) shows only a 200-rune preview of the summary text (`previewSummaryText`, `aprioriSummaryPreviewLimit`, trailing `…` only when longer), and the DEEPER layer (`verboseExtended`) shows the full summary. Only the generated text is previewed — the label, footer, fallback no-text note, and cap/error messages always render whole. The summary shares its raw result's `api_call_id` (`apriori_summary` is in `isApiGroupedVerboseMessageType`), so it stays grouped directly under the raw block with no separator; `shouldShow` gates it at `verboseThinking` like the tool entries it accompanies. To add a new event type to the chat replay, extend `fs/session.go`'s `parseEvent` (allow-list + body extractor), then both `shouldShow` and `renderMessages` here. +- **Mail-view chat replay: verbosity gate + render switch.** `MailModel.verbose` (`tui/internal/tui/mail.go:117-123`) is a 3-state enum (`verboseOff` → `verboseThinking` → `verboseExtended`) cycled by `ctrl+o`, but only two verbose layers exist beyond normal mail. Two coupled switches gate every rendered event: `shouldShow` (`tui/internal/tui/mail.go:690-734`) decides which `SessionEntry` types are visible at the current level, and `renderMessages` (`tui/internal/tui/mail.go:1421-1768`) maps each `ChatMessage.Type` to a styled block. `verboseThinking` shows text/inner-state/kernel diagnostics — `thinking`, `diary`, `text_input`, `text_output`, `soul_flow`, `notification`, `aed` — plus compact progress markers: `tool_call` uses the first rendered line capped after the first 400 runes, while `tool_result` uses the first rendered line. `verboseExtended` shows full `tool_call` and `tool_result` bodies (still subject to render-time truncation). Verbose LLM-round-trip entries inherit the current `api_call_id` and are visually separated when that id changes (`apiCallGroupSeparatorBefore` in `toolcall_display.go`), so separate API responses do not run together in ctrl+o while mixed entries from the same API call stay grouped. Tool entries get a faint local `15:04` timestamp (`formatToolTimestamp`); `tool_call` uses `compactToolCallSummary` at level 1, `tool_result` uses `firstRenderedLine` at level 1, and both use `truncateToolBody` at level 2 (all in `toolcall_display.go`). Legacy tool entries without grouping metadata keep the old `tool_result`→`tool_call` fallback (`toolGroupSeparatorBefore`). `fs/session.go` carries full tool args/results into the mail replay; its `appendToolResultMetaBlocks` detects whether a tool_result carries any of the `_meta` envelope blocks (`_tool`, `_runtime.state`, `_runtime.guidance`, `notifications`, `_notification_guidance`) and, rather than expanding that noise inline, emits a single localized hint line (`i18n.T("mail.meta_hidden_hint")`) pointing the user at `/notification`, where the full canonical `_meta.*` envelope is paged on demand (see `notification.go`). The raw metadata stays in the underlying event and is stripped from the `result:` body by `displayToolResultValue`, so nothing is lost — only hidden. Render-time truncation is still applied by `MailModel.verbose` and `MailModel.toolCallTruncate`. **Per-round token usage footer:** `fs/session.go`'s `parseEventMap` lifts the four scalar token fields (`input_tokens`, `output_tokens`, `cached_tokens`, `estimated`) off each `llm_response` event into `SessionEntry.TokenUsage` (`fs.TokenUsage`) — never the hidden `_meta` envelope. The `llm_response` carrier sits at the *top* of its `api_call_id` group in stream order, so `renderMessages` defers a single faint footer line (`formatTokenUsageFooter`, `tui/internal/tui/token_usage_footer.go:25`) to the *bottom* of the group via a `pendingUsage`/`flushTokenFooter` accumulator: `tokens: input … · cache miss … · output … · cache rate …` (localized `mail.token_usage_footer`, counts humanized by `humanizeTokenCount`, miss/rate reuse `cacheMiss`/`formatCacheRate` from `props.go`). `shouldShow` lets an `llm_response` through only when it carries `TokenUsage` and `verbose >= verboseThinking`; it never renders as a raw `[llm_response]` block. Soul flow and notification share the green palette (`ColorAccent` bold header, `ColorAgent` italic body indented 4) — they're agent-side reflections. AED uses the orange `ColorTool` palette to mark kernel distress (LLM empty-response retries, recovery timeouts) so it stands out from normal inner state. **A-priori summary (`summary=true`, kernel PR #586):** when an agent runs `bash`/`read`/`grep` with `summary=true`, the kernel preserves the RAW result in the `tool_result` event (so the raw stdout still renders unchanged) and replaces the *model-visible* payload with a generated summary artifact (`lingtai_apriori_tool_result_summary`). `fs/session.go` surfaces that artifact two ways: it promotes the kernel's `apriori_summary_generated`/`apriori_summary_cap_refused`/`apriori_summary_failed`/`apriori_summary_empty`/`apriori_summary_no_summarizer` lifecycle events into a first-class `apriori_summary` `SessionEntry` (carrying `SessionEntry.Summary` = `fs.AprioriSummary` with kind/tool_call_id/char-counts, and — as of kernel #3833 — the generated summary text on the success path via `generated_summary`; pre-#3833 logs lack it and `Text` stays empty), and — defensively — detects the same artifact when it appears as a `tool_result` event's `result` map (`aprioriSummaryFromArtifact`), attaching it to that `tool_result` entry. `renderMessages` renders the model-visible summary as a distinct labelled block (`renderAprioriSummaryBlock` in `toolcall_display.go`) right after the corresponding raw `tool_result`: `ColorAccent` bold label (`mail.apriori_summary_label`), italic `ColorAgent` summary text, and a faint compression footer (`tool · orig → summary chars · raw preserved`). Cap-refusal / fail-closed variants (`Unavailable`) switch to the `ColorTool` palette and the `mail.apriori_summary_unavailable_label`. The generated summary text itself follows the two-layer ctrl+o convention via `renderAprioriSummaryBlock`'s `preview` arg (passed `m.verbose != verboseExtended`): the FIRST layer (`verboseThinking`) shows only a 200-rune preview of the summary text (`previewSummaryText`, `aprioriSummaryPreviewLimit`, trailing `…` only when longer), and the DEEPER layer (`verboseExtended`) shows the full summary. Only the generated text is previewed — the label, footer, fallback no-text note, and cap/error messages always render whole. The summary shares its raw result's `api_call_id` (`apriori_summary` is in `isApiGroupedVerboseMessageType`), so it stays grouped directly under the raw block with no separator; `shouldShow` gates it at `verboseThinking` like the tool entries it accompanies. To add a new event type to the chat replay, extend `fs/session.go`'s `parseEvent` (allow-list + body extractor), then both `shouldShow` and `renderMessages` here. -- **Home telemetry row (additive, current-session scope).** `MailModel.View()` (`tui/internal/tui/mail.go:1462`) appends one muted, high-density line BELOW the input box and ABOVE the path/shortcut status bar — additive, never replacing the pre-existing footer/status content. It shows the CURRENT SESSION's token economy plus live context pressure, led by a localized scope label and closed by a right-aligned `/kanban for details` affordance: `Session: api 42 tok 181.6k (miss 1.4k) cache 88% tok/api 4.3k ctx 186.5k/250.0k ▓▓▓░░ 75% /kanban for details`. The row is built in `home_telemetry.go` — `MailModel.gatherHomeTelemetry` (`tui/internal/tui/home_telemetry.go:84`) resolves the scalars from data the TUI already reads, all scoped to the **current molt session** (not the whole-ledger lifetime total, not a single round): API calls / session tokens / cached / input from `fs.SumMoltSessionTokenLedger(orchestrator).Current` (the same `SessionTokenStats` the props "Current session API" panel renders via `appendSessionAPIStats` in `props.go`). **Context usage + window come from the SAME live `.status.json` snapshot `/kanban` reads** — `fs.ReadStatus(orchestrator).Tokens.Context`, gated identically on `WindowSize > 0` (`tui/internal/tui/props.go:544-557`): `contextUsage = UsagePct/100`, `contextLimit = WindowSize`, `contextUsed = TotalTokens` (the same `total_tokens` field /kanban renders as the used/limit numerator at `tui/internal/tui/props.go:556`, so the home row's `used/limit` matches /kanban exactly). This is the PR #443 follow-up fix for Jason's "home ctx bar disagrees with /kanban" report: `.status.json` is rewritten by the kernel on a tight cadence and re-read every 1s poll, so the home row shows the exact same percentage and window /kanban does, instead of a notification value that only refreshes per molt round and lagged it by minutes (observed live 80% vs 74.6%). **Fallbacks** (only when no live `.status.json` — stopped / never-booted, where /kanban shows no context section either): context limit via `manifestContextLimit` (`tui/internal/tui/home_telemetry.go:152`), which reads `fs.ReadInitManifest` and checks **both** nestings because the two artifacts that function returns disagree — the kernel-resolved `system/manifest.resolved.json` carries `context_limit` at the **top level** (`llm.context_limit` is absent there; PR #441 read only the `llm` path and so always missed it), while the raw `init.json` fallback keeps the saved-preset canonical `llm.context_limit` (`internal/preset/preset.go` `NormalizeLegacyContextLimit`); and context usage via `latestContextUsage` (`tui/internal/tui/home_telemetry.go:128`), the freshest notification `Meta.Context.Usage` scanning the **UNFILTERED** `MailModel.sessionCache.Entries()`, NOT `m.messages` (`shouldShow` gates `notification` entries behind `verbose >= verboseThinking`, so reading the verbose-filtered `m.messages` would hide the `ctx` bar until the first `ctrl+o` — the post-#442 regression; the session cache keeps the fallback independent of view/verbose state). `formatHomeTelemetry` (`tui/internal/tui/home_telemetry.go:197`) leads the row with a localized scope label (`i18n.T("mail.telemetry_session")` — Jason's final follow-up trimmed the verbose "Current Session" to the compact `Session:` (en/wen) / `当前` (zh), never hard-coded; Jason msg 3217, zh wording follow-up). The trailing colon now carries the set-off the `·` middle-dot used to, so the bullet is dropped and the label reads `Session: api 42 …`. Then it renders the economy fragments (each dropped when its source is zero, so a missing piece never shows a `0`) — the `tok` total carries a `(miss …)` suffix glued directly after it (Jason's explicit placement: right after `tok …`, NOT after the cache percentage), the cache-miss token count = `input − cached` via `cacheMiss` (`props.go`, clamped ≥0 so `cached > input` never prints negative) humanized by `humanizeTokenCount`, gated on `inputTokens > 0` so a session with no recorded input never shows a bare `(miss 0)` (i18n key `mail.telemetry_miss`: `miss` en / `未命中` zh / `漏` wen) — cache rate via `formatCacheRate`, tok/api via `avgPerCall` (both `props.go`) — then the context segment `i18n.T("mail.telemetry_context")` (the technical abbreviation `ctx` in en/wen, `上下文` in zh — Jason's final follow-up trimming the verbose "Current Context") + `used/limit` + gauge + `N%` on the right of the bar (Jason's layout follow-up msg 3251: explicit scope label, then used/limit, then bar, then percentage — never the confusing percentage-first `N% / limit`), then `appendKanbanHint` (`tui/internal/tui/home_telemetry.go:276`) right-aligns the localized `i18n.T("mail.telemetry_kanban_hint")` affordance against the terminal width (status-bar pad style; dropped when there's <2 cols of gap so the metrics never clip), or returns "" when no data is available (graceful hide). `renderContextBar` (`tui/internal/tui/home_telemetry.go:316`) draws a `▓`/`░` gauge colored by pressure (`ColorActive` <70% → `ColorAccent` 70–89% → `ColorSuspended` ≥90%); the bar adapts to terminal width and is dropped below width 40. Scalar-only — never the hidden `_meta` envelope. **Height accounting:** because the row is additive, `mailFooterHeight` (`tui/internal/tui/home_telemetry.go:184`) is the single source of truth for footer rows (`sep + palette + input + optional telemetry + border + status`), and `syncViewportHeight` (`tui/internal/tui/mail.go:266`) reserves the telemetry line via `hasHomeTelemetry` (`tui/internal/tui/home_telemetry.go:174`) — and re-syncs when its visibility flips (`lastTelemetryRow`). Without this reservation the frame renders one line too tall and the bottom status bar (the `ctrl+o to expand` hint) is clipped off-screen (the #441 regression). **Status-bar hint copy:** the home/mail footer's right-aligned shortcut hint reads `ctrl+o to expand, / for commands` in the English UI (`hints.verbose`/`hints.verbose_on` + localized `hints.sep` + `hints.commands`, rendered in `mail.go` ~`1438-1453`); zh/wen keep the bullet separator and their own wording. These three i18n keys are home-footer-exclusive, so the copy is safe to change without touching other screens — the visible prompt no longer says `soul` (Jason msg #3254 follow-up to PR #445, which had cleaned only `ctrl+e editor`). +- **Home telemetry row (additive, current-session scope).** `MailModel.View()` (`tui/internal/tui/mail.go:2103-2110`) appends one muted, high-density line BELOW the input box and ABOVE the path/shortcut status bar — additive, never replacing the pre-existing footer/status content. It shows the CURRENT SESSION's token economy plus live context pressure, led by a localized scope label and closed by a right-aligned `/kanban for details` affordance: `Session: api 42 tok 181.6k (miss 1.4k) cache 88% tok/api 4.3k ctx 186.5k/250.0k ▓▓▓░░ 75% /kanban for details`. The row is built in `home_telemetry.go` — `MailModel.gatherHomeTelemetry` (`tui/internal/tui/home_telemetry.go:84`) resolves the scalars from data the TUI already reads, all scoped to the **current molt session** (not the whole-ledger lifetime total, not a single round): API calls / session tokens / cached / input from `fs.SumMoltSessionTokenLedger(orchestrator).Current` (the same `SessionTokenStats` the props "Current session API" panel renders via `appendSessionAPIStats` in `props.go`). **Context usage + window come from the SAME live `.status.json` snapshot `/kanban` reads** — `fs.ReadStatus(orchestrator).Tokens.Context`, gated identically on `WindowSize > 0` (`tui/internal/tui/props.go:544-557`): `contextUsage = UsagePct/100`, `contextLimit = WindowSize`, `contextUsed = TotalTokens` (the same `total_tokens` field /kanban renders as the used/limit numerator at `tui/internal/tui/props.go:556`, so the home row's `used/limit` matches /kanban exactly). This is the PR #443 follow-up fix for Jason's "home ctx bar disagrees with /kanban" report: `.status.json` is rewritten by the kernel on a tight cadence and re-read every 1s poll, so the home row shows the exact same percentage and window /kanban does, instead of a notification value that only refreshes per molt round and lagged it by minutes (observed live 80% vs 74.6%). **Fallbacks** (only when no live `.status.json` — stopped / never-booted, where /kanban shows no context section either): context limit via `manifestContextLimit` (`tui/internal/tui/home_telemetry.go:152`), which reads `fs.ReadInitManifest` and checks **both** nestings because the two artifacts that function returns disagree — the kernel-resolved `system/manifest.resolved.json` carries `context_limit` at the **top level** (`llm.context_limit` is absent there; PR #441 read only the `llm` path and so always missed it), while the raw `init.json` fallback keeps the saved-preset canonical `llm.context_limit` (`internal/preset/preset.go` `NormalizeLegacyContextLimit`); and context usage via `latestContextUsage` (`tui/internal/tui/home_telemetry.go:128`), the freshest notification `Meta.Context.Usage` scanning the **UNFILTERED** `MailModel.sessionCache.Entries()`, NOT `m.messages` (`shouldShow` gates `notification` entries behind `verbose >= verboseThinking`, so reading the verbose-filtered `m.messages` would hide the `ctx` bar until the first `ctrl+o` — the post-#442 regression; the session cache keeps the fallback independent of view/verbose state). `formatHomeTelemetry` (`tui/internal/tui/home_telemetry.go:197`) leads the row with a localized scope label (`i18n.T("mail.telemetry_session")` — Jason's final follow-up trimmed the verbose "Current Session" to the compact `Session:` (en/wen) / `当前` (zh), never hard-coded; Jason msg 3217, zh wording follow-up). The trailing colon now carries the set-off the `·` middle-dot used to, so the bullet is dropped and the label reads `Session: api 42 …`. Then it renders the economy fragments (each dropped when its source is zero, so a missing piece never shows a `0`) — the `tok` total carries a `(miss …)` suffix glued directly after it (Jason's explicit placement: right after `tok …`, NOT after the cache percentage), the cache-miss token count = `input − cached` via `cacheMiss` (`props.go`, clamped ≥0 so `cached > input` never prints negative) humanized by `humanizeTokenCount`, gated on `inputTokens > 0` so a session with no recorded input never shows a bare `(miss 0)` (i18n key `mail.telemetry_miss`: `miss` en / `未命中` zh / `漏` wen) — cache rate via `formatCacheRate`, tok/api via `avgPerCall` (both `props.go`) — then the context segment `i18n.T("mail.telemetry_context")` (the technical abbreviation `ctx` in en/wen, `上下文` in zh — Jason's final follow-up trimming the verbose "Current Context") + `used/limit` + gauge + `N%` on the right of the bar (Jason's layout follow-up msg 3251: explicit scope label, then used/limit, then bar, then percentage — never the confusing percentage-first `N% / limit`), then `appendKanbanHint` (`tui/internal/tui/home_telemetry.go:276`) right-aligns the localized `i18n.T("mail.telemetry_kanban_hint")` affordance against the terminal width (status-bar pad style; dropped when there's <2 cols of gap so the metrics never clip), or returns "" when no data is available (graceful hide). `renderContextBar` (`tui/internal/tui/home_telemetry.go:316`) draws a `▓`/`░` gauge colored by pressure (`ColorActive` <70% → `ColorAccent` 70–89% → `ColorSuspended` ≥90%); the bar adapts to terminal width and is dropped below width 40. Scalar-only — never the hidden `_meta` envelope. **Height accounting:** because the row is additive, `mailFooterHeight` (`tui/internal/tui/home_telemetry.go:184`) is the single source of truth for footer rows (`sep + palette + input + optional telemetry + border + status`), and `syncViewportHeight` (`tui/internal/tui/mail.go:404-436`) reserves the telemetry line via `hasHomeTelemetry` (`tui/internal/tui/home_telemetry.go:174`) — and re-syncs when its visibility flips (`lastTelemetryRow`). Without this reservation the frame renders one line too tall and the bottom status bar (the `ctrl+o to expand` hint) is clipped off-screen (the #441 regression). **Status-bar hint copy:** the home/mail footer's right-aligned shortcut hint reads `ctrl+o to expand, / for commands` in the English UI (`hints.verbose`/`hints.verbose_on` + localized `hints.sep` + `hints.commands`, rendered at `tui/internal/tui/mail.go:2071-2092`); zh/wen keep the bullet separator and their own wording. These three i18n keys are home-footer-exclusive, so the copy is safe to change without touching other screens — the visible prompt no longer says `soul` (Jason msg #3254 follow-up to PR #445, which had cleaned only `ctrl+e editor`). - `doctor_intrinsic.go`: `/doctor` shells out through the runtime venv to the kernel `lingtai-doctor` intrinsic script for per-agent state/MCP/log/notification diagnostics. diff --git a/tui/internal/tui/app.go b/tui/internal/tui/app.go index c483c824..284406e0 100644 --- a/tui/internal/tui/app.go +++ b/tui/internal/tui/app.go @@ -139,9 +139,30 @@ func (a *App) installMailModel(m MailModel) { a.mailGeneration++ m.generation = a.mailGeneration m.acceptedSnapshot = a.mailStore.snapshot + a.mailStore.bindMailModel(&m, a.visiting) a.mail = m } +func (a *App) setAsyncTargetRevalidator(revalidate func(asyncOwner, asyncTarget) bool) { + if a == nil { + return + } + a.mailStore.setAsyncTargetRevalidator(revalidate) + a.mail.revalidateTarget = a.mailStore.revalidateTarget + if a.suspendedHomeMailStore != nil { + a.suspendedHomeMailStore.setAsyncTargetRevalidator(revalidate) + } +} + +func (a App) asyncCurrent() asyncCurrent { + current := a.mail.asyncCurrent() + current.binding = a.mailStore.binding + current.storeVersion = a.mailStore.version + current.tickEpoch = a.mailStore.tickChain + current.revalidateTarget = a.mailStore.revalidateTarget + return current +} + func (a *App) newMailForCurrentContext() MailModel { humanDir := filepath.Join(a.projectDir, "human") addr := humanAddr(a.projectDir) @@ -309,13 +330,22 @@ func (a *App) beginProjectMailRefresh(initial bool) tea.Cmd { if a == nil || a.mail.generation == 0 { return nil } + current := a.asyncCurrent() + envelope := captureAsync(refreshAsyncKind(initial), current) + if !acceptAsync(current, envelope) { + return nil + } return a.mailStore.beginRefresh(a.mail, initial) } func (a *App) resumeProjectMail(initial bool) tea.Cmd { refreshCmd := a.beginProjectMailRefresh(initial) tickCmd := a.mailStore.resumeTick() - return tea.Batch(refreshCmd, tickCmd, pulseTick(a.mail.generation), a.sendSize()) + a.mail.pulseEpoch++ + if a.mail.pulseEpoch == 0 { + a.mail.pulseEpoch = 1 + } + return tea.Batch(refreshCmd, tickCmd, a.mail.asyncPulseCmd(), a.sendSize()) } func (a *App) initProjectMail() tea.Cmd { @@ -326,6 +356,25 @@ func (a *App) pauseProjectMail() { a.mailStore.pauseTick() } +// invalidateProjectMailForReset synchronously retires every owner and local +// coordinate from the project lifetime that is about to be destroyed. It must +// run on the root event loop before filesystem cleanup starts; background cleanup +// must never race a still-current refresh, persist, older-page, or location result. +func (a *App) invalidateProjectMailForReset() { + a.mailStore.suspend() + if a.suspendedHomeMailStore != nil { + a.suspendedHomeMailStore.suspend() + } + a.mail.invalidateAsync() + a.mailStore = ProjectMailStore{} + a.suspendedHomeMailStore = nil + a.visiting = false + a.visitReturn = nil + a.visitTargetProjectDir = "" + a.visitTargetAgentDir = "" + a.visitTargetAgentName = "" +} + func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case childWindowSizeMsg: @@ -345,43 +394,51 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // === Cross-view messages === case projectMailRefreshRequestMsg: - if msg.generation != a.mail.generation || !a.mailStore.active || a.currentView != appViewMail { + if !acceptAsync(a.asyncCurrent(), msg.envelope) { + return a, nil + } + if !a.mailStore.active || a.currentView != appViewMail { return a, nil } - return a, tea.Batch(a.beginProjectMailRefresh(msg.initial), a.mailStore.resumeTick()) + return a, tea.Batch(a.mailStore.beginRefresh(a.mail, msg.initial), a.mailStore.resumeTick()) case projectMailRefreshMsg: - snapshot, accepted, completed := a.mailStore.acceptRefresh(msg, a.mail.generation) - if !completed { + settled := a.mailStore.settleRefreshWork(msg.envelope) + if !acceptAsync(a.asyncCurrent(), msg.envelope) { + if settled { + return a, a.mailStore.beginPendingInitialRefresh(a.mail) + } return a, nil } - // A physically completed old-generation result releases the slot but - // publishes nothing. Preserve and start any queued authoritative initial - // for the current generation. - if !accepted { - return a, a.mailStore.beginPendingInitialRefresh(a.mail) + if !settled { + return a, nil } + snapshot := a.mailStore.installRefresh(msg) msg.mail.snapshot = snapshot + a.mail.asyncStoreVersion = snapshot.Version() var mailCmd tea.Cmd a.mail, mailCmd = a.mail.Update(msg.mail) // Capture the accepted target/status state in the deferred authoritative // rebuild rather than the pre-refresh model snapshot. pendingInitialCmd := a.mailStore.beginPendingInitialRefresh(a.mail) - return a, tea.Batch(mailCmd, pendingInitialCmd, a.mailStore.locationUpdateCmd()) + return a, tea.Batch(mailCmd, pendingInitialCmd, a.mailStore.locationUpdateCmd(msg.envelope)) case projectMailTickMsg: - if !a.mailStore.acceptsTick(msg) || a.currentView != appViewMail { + if !acceptAsync(a.asyncCurrent(), msg.envelope) { + return a, nil + } + if !a.mailStore.active || !a.mailStore.tickRunning || a.currentView != appViewMail { return a, nil } - refreshCmd := a.beginProjectMailRefresh(false) + refreshCmd := a.mailStore.beginRefresh(a.mail, false) nextTick := a.mailStore.nextTick() telemetryCmd := a.mail.maybeScheduleHomeTelemetry(time.Now()) return a, tea.Batch(refreshCmd, nextTick, telemetryCmd) - case mailRefreshMsg, mailPersistMsg, mailHistoryCountMsg, mailOlderPageMsg, homeTelemetryMsg: + case mailPersistMsg, mailHistoryCountMsg, mailOlderPageMsg, homeTelemetryMsg: // Mail content/count rebuilds, older pages, post-frame persistence, and // telemetry can outlive the view that launched them. Route all at the root so Projects/Help - // cannot drop Mail's state machine; MailModel owns generation acceptance. + // cannot drop Mail's state machine; MailModel owns shared envelope acceptance. var cmd tea.Cmd a.mail, cmd = a.mail.Update(msg) return a, cmd @@ -609,12 +666,31 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.nirvana = NewNirvanaModel(a.projectDir) return a, tea.Batch(a.nirvana.Init(), a.sendSize()) + case nirvanaCleanStartMsg: + if a.currentView != appViewNirvana || !a.nirvana.cleaning || a.nirvana.cleanupStarted || a.nirvana.done { + return a, nil + } + // Confirmation is the destructive lifetime boundary. Consume this + // handoff exactly once, then retire every project-mail owner synchronously + // before the returned command can remove the filesystem; the completed + // screen may remain open indefinitely. + a.nirvana.cleanupStarted = true + a.invalidateProjectMailForReset() + return a, a.nirvana.doClean() + case NirvanaDoneMsg: - // Nirvana complete: .lingtai/ wiped, go to first-run. - // Re-init project to recreate the human folder so agents can - // deliver mail once the new orchestrator starts. - a.mailStore.suspend() - a.mailStore = ProjectMailStore{} + if a.currentView != appViewNirvana || !a.nirvana.done { + return a, nil + } + // Nirvana complete: .lingtai/ wiped, go to first-run. Permanently + // invalidate every retained project-mail owner and the Mail-local binding + // before recreating the filesystem; late root-routed continuations from the + // destroyed lifetime must fail closed against the fresh project. + a.invalidateProjectMailForReset() + a.doubleEscArmed = false + a.doubleEscFirstAt = time.Time{} + // Re-init project to recreate the human folder so agents can deliver mail + // once the new orchestrator starts. process.InitProject(a.projectDir) a.orchDir = "" a.orchName = "" diff --git a/tui/internal/tui/async_envelope_installation_test.go b/tui/internal/tui/async_envelope_installation_test.go index 20c507a5..e2f609fc 100644 --- a/tui/internal/tui/async_envelope_installation_test.go +++ b/tui/internal/tui/async_envelope_installation_test.go @@ -5,12 +5,10 @@ import ( "fmt" "os" "path/filepath" - "reflect" "strings" "sync/atomic" "testing" "time" - "unsafe" tea "charm.land/bubbletea/v2" @@ -18,43 +16,56 @@ import ( "github.com/anthropics/lingtai-tui/internal/fs" ) -// installationEnvelope reads the future unexported envelope field without a -// compile-time reference to that field. The unsafe operation is deliberately -// confined to this reflection bridge: reflect does not permit Interface on an -// unexported field, even from a same-package test. Missing/unexpected fields are -// ordinary testing failures, so the unwired tree remains a valid assertion RED. +// installationEnvelope and installationWithEnvelope use direct typed access now +// that every production message has the contract field. The pre-wiring +// reflection/unsafe bridge is intentionally gone from the final test fixture. func installationEnvelope[T any](t *testing.T, msg *T) asyncEnvelope { t.Helper() - field := installationEnvelopeField(t, msg) - return *(*asyncEnvelope)(unsafe.Pointer(field.UnsafeAddr())) -} - -func installationEnvelopeField[T any](t *testing.T, msg *T) reflect.Value { - t.Helper() - value := reflect.ValueOf(msg) - if value.Kind() != reflect.Pointer || value.IsNil() { - t.Fatalf("message type %T is not an addressable value", msg) - } - field := value.Elem().FieldByName("envelope") - if !field.IsValid() { - var zero T - t.Fatalf("message type %T has no asyncEnvelope field named envelope", zero) + switch typed := any(msg).(type) { + case *projectMailRefreshMsg: + return typed.envelope + case *mailPersistMsg: + return typed.envelope + case *mailOlderPageMsg: + return typed.envelope + case *mailHistoryCountMsg: + return typed.envelope + case *projectMailTickMsg: + return typed.envelope + case *pulseTickMsg: + return typed.envelope + case *EditorDoneMsg: + return typed.envelope + case *projectMailRefreshRequestMsg: + return typed.envelope + default: + t.Fatalf("message type %T is not an async-envelope carrier", msg) + return asyncEnvelope{} } - if field.Type() != reflect.TypeOf(asyncEnvelope{}) { - var zero T - t.Fatalf("message type %T envelope field has type %v, want asyncEnvelope", zero, field.Type()) - } - if !field.CanAddr() { - var zero T - t.Fatalf("message type %T envelope field is not addressable", zero) - } - return field } func installationWithEnvelope[T any](t *testing.T, msg T, envelope asyncEnvelope) T { t.Helper() - field := installationEnvelopeField(t, &msg) - *(*asyncEnvelope)(unsafe.Pointer(field.UnsafeAddr())) = envelope + switch typed := any(&msg).(type) { + case *projectMailRefreshMsg: + typed.envelope = envelope + case *mailPersistMsg: + typed.envelope = envelope + case *mailOlderPageMsg: + typed.envelope = envelope + case *mailHistoryCountMsg: + typed.envelope = envelope + case *projectMailTickMsg: + typed.envelope = envelope + case *pulseTickMsg: + typed.envelope = envelope + case *EditorDoneMsg: + typed.envelope = envelope + case *projectMailRefreshRequestMsg: + typed.envelope = envelope + default: + t.Fatalf("message type %T is not an async-envelope carrier", msg) + } return msg } @@ -347,7 +358,7 @@ func installationHistoryFixture(t *testing.T) (App, mailHistoryCountMsg) { if app.mail.historyCountCache == nil || !app.mail.historyCountLoading { t.Fatal("real initial refresh did not start an exact-count task") } - msg, ok := app.mail.historyCountCmd(app.mail.historyCountCache, app.mail.generation)().(mailHistoryCountMsg) + msg, ok := app.mail.historyCountCmd(app.mail.historyCountCache)().(mailHistoryCountMsg) if !ok { t.Fatal("real count command did not return mailHistoryCountMsg") } @@ -410,7 +421,6 @@ func installationVisitedApp(t *testing.T, eventCount int) (App, *installationScr // cache/version/snapshot state. visited.mailStore.refreshInFlight = false visited.mailStore.refreshInitial = false - visited.mailStore.refreshGeneration = 0 visited.mailStore.initialRefreshPending = false scanner := &installationScriptedScanner{messages: []fs.MailMessage{{ @@ -1016,6 +1026,9 @@ func TestEditorDoneCarriesAndValidatesTargetIdentityAddress(t *testing.T) { func TestAsyncInventoryDisappearanceRejectsInstallForVisitedTarget(t *testing.T) { installationTestStart(t) gateApp, _, _ := installationVisitedApp(t, 3) + gateLaunchEnvelope := captureAsync(asyncInitialRebuild, gateApp.asyncCurrent()) + probeResolver := &installationInventoryResolver{record: installationExactResolvedTarget(gateLaunchEnvelope)} + installationInjectResolver(t, &gateApp, probeResolver.resolve) gateRefresh := installationRefreshResult(t, &gateApp, true) gateEnvelope := installationProducedEnvelope(t, &gateRefresh, asyncInitialRebuild) if !gateEnvelope.target.inventoryBound { @@ -1023,19 +1036,20 @@ func TestAsyncInventoryDisappearanceRejectsInstallForVisitedTarget(t *testing.T) } // Fail transparently until the real App/store resolver injection exists. This - // is intentionally not replaced by a test-side acceptAsync wrapper. - probeResolver := &installationInventoryResolver{record: installationExactResolvedTarget(gateEnvelope)} - installationInjectResolver(t, &gateApp, probeResolver.resolve) + // is intentionally not replaced by a test-side acceptAsync wrapper. The fake + // now allows the real launch gate as well as the later installation gate. for _, scenario := range []string{"disappeared", "changed_project", "became_ineligible", "changed_address", "nickname_only"} { wantAccept := scenario == "nickname_only" t.Run(scenario, func(t *testing.T) { t.Run("refresh_install", func(t *testing.T) { app, _, locations := installationVisitedApp(t, 3) - refresh := installationRefreshResult(t, &app, true) - envelope := installationProducedEnvelope(t, &refresh, asyncInitialRebuild) - resolver := &installationInventoryResolver{record: installationExactResolvedTarget(envelope)} + launchEnvelope := captureAsync(asyncInitialRebuild, app.asyncCurrent()) + resolver := &installationInventoryResolver{record: installationExactResolvedTarget(launchEnvelope)} installationInjectResolver(t, &app, resolver.resolve) + refresh := installationRefreshResult(t, &app, true) + _ = installationProducedEnvelope(t, &refresh, asyncInitialRebuild) + resolver.calls.Store(0) resolver.record = installationApplyResolverScenario(resolver.record, scenario) before := installationSnapshot(app, locations) @@ -1061,12 +1075,13 @@ func TestAsyncInventoryDisappearanceRejectsInstallForVisitedTarget(t *testing.T) t.Run("history_count_install", func(t *testing.T) { app, _, _ := installationVisitedApp(t, 405) - initial := installationRefreshResult(t, &app, true) - envelope := installationProducedEnvelope(t, &initial, asyncInitialRebuild) - resolver := &installationInventoryResolver{record: installationExactResolvedTarget(envelope)} + launchEnvelope := captureAsync(asyncInitialRebuild, app.asyncCurrent()) + resolver := &installationInventoryResolver{record: installationExactResolvedTarget(launchEnvelope)} installationInjectResolver(t, &app, resolver.resolve) + initial := installationRefreshResult(t, &app, true) + _ = installationProducedEnvelope(t, &initial, asyncInitialRebuild) app, _ = installationDeliverApp(t, app, initial) - count := app.mail.historyCountCmd(app.mail.historyCountCache, app.mail.generation)().(mailHistoryCountMsg) + count := app.mail.historyCountCmd(app.mail.historyCountCache)().(mailHistoryCountMsg) _ = installationProducedEnvelope(t, &count, asyncExactHistoryCount) resolver.record = installationApplyResolverScenario(resolver.record, scenario) callsBefore := resolver.calls.Load() diff --git a/tui/internal/tui/async_envelope_source_inventory_test.go b/tui/internal/tui/async_envelope_source_inventory_test.go index 078914e0..30960114 100644 --- a/tui/internal/tui/async_envelope_source_inventory_test.go +++ b/tui/internal/tui/async_envelope_source_inventory_test.go @@ -177,6 +177,15 @@ func TestAsyncEnvelopeSourceInventoryEveryConsumerCallsSharedPredicate(t *testin } } +func TestAsyncEnvelopeSourceInventoryRefreshSettlementIsNonPublishing(t *testing.T) { + inv := loadAsyncSourceInventory(t) + issues := append(inv.refreshSettlementIssues(), inv.refreshSettlementOrderingIssues()...) + if len(issues) != 0 { + sort.Strings(issues) + t.Fatalf("refresh settlement escaped its exact-token, non-publishing boundary:\n - %s", strings.Join(issues, "\n - ")) + } +} + func TestAsyncEnvelopeSourceInventoryForbidsLegacyIdentityPolicies(t *testing.T) { inv := loadAsyncSourceInventory(t) var surviving []string @@ -575,6 +584,156 @@ func (inv *asyncSourceInventory) consumerIssues(owner, message string) []string return nil } +func (inv *asyncSourceInventory) refreshSettlementIssues() []string { + const owner = "ProjectMailStore.settleRefreshWork" + fn := inv.findFunction(owner) + if fn == nil { + return []string{owner + ": exact-token non-publishing settlement function missing"} + } + + var issues []string + if len(fn.Body.List) == 0 { + return []string{owner + ": empty function cannot settle the exact physical token"} + } + guard, ok := fn.Body.List[0].(*ast.IfStmt) + if !ok || guard.Init != nil || guard.Else != nil || countExactEnvelopeComparisons(guard.Cond) != 1 || !blockReturnsBoolean(guard.Body, "false") { + issues = append(issues, owner+": first statement must be a fail-closed guard containing exactly one envelope != s.refreshInFlightEnvelope comparison") + } + + allowedWrites := map[string]bool{ + "refreshInFlight": true, + "refreshInitial": true, + "refreshInFlightEnvelope": true, + } + writeCounts := make(map[string]int, len(allowedWrites)) + ast.Inspect(fn.Body, func(node ast.Node) bool { + switch node := node.(type) { + case *ast.IncDecStmt: + issues = append(issues, owner+": increment/decrement mutation is forbidden; settlement may only clear the three exact in-flight bookkeeping fields") + case *ast.AssignStmt: + if len(node.Lhs) != len(node.Rhs) { + issues = append(issues, owner+": settlement assignments must pair each bookkeeping field with its explicit zero value") + } + for i, lhs := range node.Lhs { + selector, ok := lhs.(*ast.SelectorExpr) + if !ok || !isNamedSelector(selector, "s", selector.Sel.Name) || !allowedWrites[selector.Sel.Name] { + issues = append(issues, owner+": assignment outside s.refreshInFlight, s.refreshInitial, or s.refreshInFlightEnvelope is forbidden") + continue + } + writeCounts[selector.Sel.Name]++ + if i >= len(node.Rhs) || !isSettlementZeroValue(selector.Sel.Name, node.Rhs[i]) { + issues = append(issues, fmt.Sprintf("%s: s.%s must be assigned its explicit zero value", owner, selector.Sel.Name)) + } + } + } + return true + }) + for field := range allowedWrites { + if writeCounts[field] != 1 { + issues = append(issues, fmt.Sprintf("%s: s.%s must be cleared exactly once; got %d writes", owner, field, writeCounts[field])) + } + } + if calls := calledNames(fn.Body); len(calls) != 0 { + issues = append(issues, fmt.Sprintf("%s: helper/side-effect calls are forbidden (including installRefresh and the location updater); got %v", owner, calls)) + } + if !blockReturnsBoolean(fn.Body, "true") { + issues = append(issues, owner+": successful exact settlement must end by returning true") + } + return issues +} + +func (inv *asyncSourceInventory) refreshSettlementOrderingIssues() []string { + const owner = "App.Update" + const message = "projectMailRefreshMsg" + fn := inv.findFunction(owner) + if fn == nil { + return []string{owner + ": refresh-result consumer missing"} + } + clauses := typeSwitchClauses(fn, message) + if len(clauses) != 1 { + return []string{fmt.Sprintf("%s case %s: got %d clauses, want exactly 1", owner, message, len(clauses))} + } + clause := clauses[0] + guardIndex := -1 + for i, statement := range clause.Body { + ifStmt, ok := statement.(*ast.IfStmt) + if ok && countCalls(ifStmt.Cond, "acceptAsync") == 1 { + guardIndex = i + break + } + } + if guardIndex != 1 { + return []string{fmt.Sprintf("%s case %s: settleRefreshWork assignment must be the sole statement before acceptAsync; guard index is %d", owner, message, guardIndex)} + } + + assignment, ok := clause.Body[0].(*ast.AssignStmt) + if !ok || assignment.Tok != token.DEFINE || len(assignment.Lhs) != 1 || len(assignment.Rhs) != 1 { + return []string{owner + " case " + message + ": sole pre-accept statement must be settled := a.mailStore.settleRefreshWork(msg.envelope)"} + } + settled, ok := assignment.Lhs[0].(*ast.Ident) + call, callOK := assignment.Rhs[0].(*ast.CallExpr) + if !ok || settled.Name != "settled" || !callOK || !isMethodCallOnSelector(call, "a", "mailStore", "settleRefreshWork") || len(call.Args) != 1 || !isNamedSelector(call.Args[0], "msg", "envelope") { + return []string{owner + " case " + message + ": sole pre-accept statement must bind only a.mailStore.settleRefreshWork(msg.envelope)"} + } + return nil +} + +func countExactEnvelopeComparisons(node ast.Node) int { + count := 0 + ast.Inspect(node, func(child ast.Node) bool { + comparison, ok := child.(*ast.BinaryExpr) + if !ok || comparison.Op != token.NEQ { + return true + } + leftEnvelope, leftToken := isNamedIdentifier(comparison.X, "envelope"), isNamedSelector(comparison.X, "s", "refreshInFlightEnvelope") + rightEnvelope, rightToken := isNamedIdentifier(comparison.Y, "envelope"), isNamedSelector(comparison.Y, "s", "refreshInFlightEnvelope") + if (leftEnvelope && rightToken) || (leftToken && rightEnvelope) { + count++ + } + return true + }) + return count +} + +func isNamedIdentifier(expr ast.Expr, name string) bool { + ident, ok := expr.(*ast.Ident) + return ok && ident.Name == name +} + +func isNamedSelector(expr ast.Expr, receiver, field string) bool { + selector, ok := expr.(*ast.SelectorExpr) + return ok && selector.Sel.Name == field && isNamedIdentifier(selector.X, receiver) +} + +func isMethodCallOnSelector(call *ast.CallExpr, root, receiver, method string) bool { + selector, ok := call.Fun.(*ast.SelectorExpr) + return ok && selector.Sel.Name == method && isNamedSelector(selector.X, root, receiver) +} + +func isSettlementZeroValue(field string, expr ast.Expr) bool { + switch field { + case "refreshInFlight", "refreshInitial": + return isNamedIdentifier(expr, "false") + case "refreshInFlightEnvelope": + literal, ok := expr.(*ast.CompositeLit) + return ok && expressionName(literal.Type) == "asyncEnvelope" && len(literal.Elts) == 0 + default: + return false + } +} + +func blockReturnsBoolean(block *ast.BlockStmt, value string) bool { + if block == nil || len(block.List) == 0 { + return false + } + result, ok := block.List[len(block.List)-1].(*ast.ReturnStmt) + if !ok || len(result.Results) != 1 { + return false + } + ident, ok := result.Results[0].(*ast.Ident) + return ok && ident.Name == value +} + func typeSwitchClauses(fn *ast.FuncDecl, message string) []*ast.CaseClause { var clauses []*ast.CaseClause ast.Inspect(fn.Body, func(node ast.Node) bool { diff --git a/tui/internal/tui/home_telemetry_context_source_test.go b/tui/internal/tui/home_telemetry_context_source_test.go index c73d746e..d5337c26 100644 --- a/tui/internal/tui/home_telemetry_context_source_test.go +++ b/tui/internal/tui/home_telemetry_context_source_test.go @@ -82,7 +82,7 @@ func TestHomeTelemetryContextVisibleWithoutCtrlO(t *testing.T) { m := NewMailModel(humanDir, "human@local", "~", orchDir, "TestOrch", 50, dir, "en", false, 0) // Drive the deferred initial rebuild so the session cache is populated from // events.jsonl — exactly the normal launch path, no Ctrl+O. - msg := acceptedInitialMailRefresh(m) + msg := acceptedInitialMailRefresh(t, &m) m, _ = m.Update(msg) if m.verbose != verboseOff { @@ -150,7 +150,7 @@ func TestHomeTelemetryContextStableAcrossVerbose(t *testing.T) { } m := NewMailModel(humanDir, "human@local", "~", orchDir, "TestOrch", 50, dir, "en", false, 0) - m, _ = m.Update(acceptedInitialMailRefresh(m)) + m, _ = m.Update(acceptedInitialMailRefresh(t, &m)) m, _ = m.Update(tea.WindowSizeMsg{Width: 100, Height: 24}) atOff := m.gatherHomeTelemetry().contextUsage diff --git a/tui/internal/tui/home_telemetry_kanban_consistency_test.go b/tui/internal/tui/home_telemetry_kanban_consistency_test.go index 45d4feb6..98d718f2 100644 --- a/tui/internal/tui/home_telemetry_kanban_consistency_test.go +++ b/tui/internal/tui/home_telemetry_kanban_consistency_test.go @@ -47,7 +47,7 @@ func newTelemetryModel(t *testing.T, dir, orchDir string) MailModel { t.Fatal(err) } m := NewMailModel(humanDir, "human@local", "~", orchDir, "TestOrch", 50, dir, "en", false, 0) - m, _ = m.Update(acceptedInitialMailRefresh(m)) + m, _ = m.Update(acceptedInitialMailRefresh(t, &m)) return m } diff --git a/tui/internal/tui/home_telemetry_ui_no_io_test.go b/tui/internal/tui/home_telemetry_ui_no_io_test.go index 4d4d70ca..291ec865 100644 --- a/tui/internal/tui/home_telemetry_ui_no_io_test.go +++ b/tui/internal/tui/home_telemetry_ui_no_io_test.go @@ -37,7 +37,7 @@ func TestHomeTelemetryUIPathReadsCacheNotDisk(t *testing.T) { m := NewMailModel(humanDir, "human@local", "~", orchDir, "TestOrch", 50, dir, "en", false, 0) m, _ = m.Update(tea.WindowSizeMsg{Width: w, Height: h}) - m, _ = m.Update(acceptedInitialMailRefresh(m)) + m, _ = m.Update(acceptedInitialMailRefresh(t, &m)) // Async fetch round-trip: this is the ONE place telemetry I/O happens. m, _ = m.Update(m.fetchHomeTelemetry()) if !m.hasHomeTelemetry() { diff --git a/tui/internal/tui/home_telemetry_view_test.go b/tui/internal/tui/home_telemetry_view_test.go index e6890ff9..8e97a102 100644 --- a/tui/internal/tui/home_telemetry_view_test.go +++ b/tui/internal/tui/home_telemetry_view_test.go @@ -57,7 +57,7 @@ func newReadyMailModelWithTelemetry(t *testing.T, w, h int) MailModel { m, _ = m.Update(tea.WindowSizeMsg{Width: w, Height: h}) // Drive the deferred initial rebuild (the normal launch path) so the // notification populates the session cache. No Ctrl+O, verbose stays off. - m, _ = m.Update(acceptedInitialMailRefresh(m)) + m, _ = m.Update(acceptedInitialMailRefresh(t, &m)) // Home telemetry is now resolved asynchronously: gathering it does I/O off the // UI path via the fetchHomeTelemetry command, and the model only shows the row // once the resulting homeTelemetryMsg has landed. Drive that round-trip here @@ -83,7 +83,7 @@ func TestHomeViewKeepsStatusBarWhenTelemetryShows(t *testing.T) { } base := NewMailModel(dir, "human@local", "~", baseOrch, "TestOrch", 50, dir, "en", false, 0) base, _ = base.Update(tea.WindowSizeMsg{Width: w, Height: h}) - base, _ = base.Update(acceptedInitialMailRefresh(base)) + base, _ = base.Update(acceptedInitialMailRefresh(t, &base)) if base.hasHomeTelemetry() { t.Skip("environment unexpectedly has session telemetry data; skipping the baseline comparison") } diff --git a/tui/internal/tui/mail.go b/tui/internal/tui/mail.go index ffb934c9..e8ceac45 100644 --- a/tui/internal/tui/mail.go +++ b/tui/internal/tui/mail.go @@ -59,20 +59,22 @@ type ViewChangeMsg struct { } type pulseTickMsg struct { - generation uint64 - at time.Time + envelope asyncEnvelope + at time.Time } -func pulseTick(generation uint64) tea.Cmd { +func pulseTick(current asyncCurrent) tea.Cmd { + envelope := captureAsync(asyncLivenessPulse, current) return tea.Every(250*time.Millisecond, func(t time.Time) tea.Msg { - return pulseTickMsg{generation: generation, at: t} + return pulseTickMsg{envelope: envelope, at: t} }) } -type mailRefreshMsg struct { - generation uint64 +// mailRefreshPayload is synchronous, already accepted presentation data carried +// by projectMailRefreshMsg. It has no independent async identity or gate. +type mailRefreshPayload struct { snapshot *ProjectMailSnapshot - sessionCache *fs.SessionCache // command-local authoritative rebuild; installed only after generation acceptance + sessionCache *fs.SessionCache alive bool state string // active, idle, stuck, asleep, suspended, or "" activity fs.NetworkActivity @@ -81,41 +83,33 @@ type mailRefreshMsg struct { initial bool // true only for the deferred initial rebuild (clears the loading banner) } -// mailPersistMsg is the second, post-frame phase of an accepted authoritative -// rebuild. The command that emits it performs no I/O; Update re-checks both the -// activation generation and installed cache identity before writing, so an old -// rebuild cannot race a newer activation's canonical session cache. +// mailPersistMsg is the post-frame phase of an accepted authoritative rebuild. +// Its envelope is the sole permission to write the still-current source cache. type mailPersistMsg struct { - generation uint64 + envelope asyncEnvelope sessionCache *fs.SessionCache } -// mailOlderPageMsg carries an async older-history page: a command-local session -// cache rebuilt with a larger ingest window. Like the initial -// rebuild it is generation-gated — a stale page (from a superseded activation) -// must never replace the installed cache. It is only produced by explicit upward -// navigation (Ctrl+U / top paging), never on the first-frame critical path. +// mailOlderPageMsg carries an async older-history page rebuilt from the captured +// installed cache and accepted snapshot version. type mailOlderPageMsg struct { - generation uint64 + envelope asyncEnvelope sessionCache *fs.SessionCache - ingestWindow int // cumulative newest-session content window; grows by pageSize per key + ingestWindow int } -// mailHistoryCountMsg is the one asynchronous exact-count result for an -// activation/source/horizon. Its originating cache identity remains the gate even -// if Ctrl+U has since replaced the installed content cache. +// mailHistoryCountMsg carries one asynchronous exact-count result. The envelope +// binds its originating outstanding cache and canonical source horizon. type mailHistoryCountMsg struct { - generation uint64 - cache *fs.SessionCache - identity string - stats fs.SessionHistoryStats - err error + envelope asyncEnvelope + stats fs.SessionHistoryStats + err error } // EditorDoneMsg carries the final text from the external editor. type EditorDoneMsg struct { - Text string - Generation uint64 + envelope asyncEnvelope + Text string } // MailModel is the main chat view — a single chronological stream. @@ -199,15 +193,21 @@ type MailModel struct { initialLoading bool // true until the bounded initial content rebuild has been applied ingestWindow int // cumulative content window; initialized to pageSize and grows by pageSize auxiliaryMessages int // all renderable mail/inquiry entries, including older ones withheld across a partial event gap - olderLoadInFlight bool // true while an async older-page rebuild is running (debounce + generation gate) + olderLoadInFlight bool // true while an async older-page rebuild is running (debounce) + olderLoadEnvelope asyncEnvelope // exact physical completion token for non-publishing debounce settlement historyCountLoading bool // neutral banner while exact count metadata is in flight historyCountLoaded bool // exact stats are accepted for this activation/source/horizon historyCountCache *fs.SessionCache // originating cache identity gate for the one async count task historyCountIdentity string // canonical source/horizon identity captured by historyCountCache historyStats fs.SessionHistoryStats // accepted exact count, reused across every older-page rebuild copyMode bool // chat-only: disables mouse capture so the terminal can select/copy visible text - generation uint64 // activation token; stale async messages are ignored without rescheduling - beforeRebuild func() // optional deterministic test hook before deferred rebuild I/O + generation uint64 // activation token mirrored in asyncBinding + asyncBinding asyncBinding // canonical project/store/target/thread lifetime binding + asyncStoreVersion uint64 // accepted snapshot version for snapshot-derived work + asyncTickEpoch uint64 // current root refresh-tick chain (pulse has its own epoch below) + pulseEpoch uint64 // current 250ms liveness-pulse chain + revalidateTarget func(asyncOwner, asyncTarget) bool + beforeRebuild func() // optional deterministic test hook before deferred rebuild I/O // Home telemetry is resolved asynchronously off the render/input path (its // I/O reaches sqlite + the token ledger + .status.json, which can stall on a @@ -273,63 +273,105 @@ func (m MailModel) rebuildSession(cache fs.MailCache) *fs.SessionCache { return sessionCache } +func (m *MailModel) invalidateAsync() { + if m == nil { + return + } + // A destructive project reset has no successor Mail lifetime yet. Clear the + // canonical binding first so every late envelope fails closed, then release + // only Mail-local non-publishing/in-flight coordinates. A future install binds + // a fresh nonzero generation and owner/store/target identity. + m.generation = 0 + m.asyncBinding = asyncBinding{} + m.asyncStoreVersion = 0 + m.asyncTickEpoch = 0 + m.pulseEpoch = 0 + m.revalidateTarget = nil + m.olderLoadInFlight = false + m.olderLoadEnvelope = asyncEnvelope{} + m.historyCountLoading = false + m.historyCountCache = nil + m.historyCountIdentity = "" + m.homeTelemetryInFlight = false +} + +func (m MailModel) asyncCurrent() asyncCurrent { + current := asyncCurrent{ + binding: m.asyncBinding, + storeVersion: m.asyncStoreVersion, + tickEpoch: m.asyncTickEpoch, + pulseEpoch: m.pulseEpoch, + revalidateTarget: m.revalidateTarget, + } + if m.sessionCache != nil { + current.sessionSource = asyncSourceCache{ + cache: m.sessionCache, + identity: m.sessionCache.HistoryCountIdentity(), + } + } + if m.historyCountCache != nil { + current.outstandingCount = asyncSourceCache{ + cache: m.historyCountCache, + identity: m.historyCountIdentity, + } + } + return current +} + // requestMailRefresh asks the root ProjectMailStore to run its sole refresh // pipeline. It performs no mailbox I/O and cannot start a ticker. func (m MailModel) requestMailRefresh(initial bool) tea.Cmd { - generation := m.generation + envelope := captureAsync(refreshAsyncKind(initial), m.asyncCurrent()) return func() tea.Msg { - return projectMailRefreshRequestMsg{generation: generation, initial: initial} + return projectMailRefreshRequestMsg{envelope: envelope, initial: initial} } } // requestOlderPage starts an asynchronous load of the next older page of history. -// It is invoked only by explicit upward navigation (Ctrl+U at the top of a -// partial windowed cache) — never on the first-frame path. It marks a load -// in-flight (debounce) and returns a generation-tagged command that rebuilds the -// session cache with a window one page larger; the result is applied only after -// the mailOlderPageMsg passes the generation + in-flight gate in Update. Returns -// (m, nil) with no state change when there is nothing to load or a load is -// already running. +// It is invoked only by explicit upward navigation and debounces one in-flight +// rebuild. Shared envelope acceptance owns all target/source/version identity. func (m MailModel) requestOlderPage() (MailModel, tea.Cmd) { if m.olderLoadInFlight || !m.cacheIsPartial() { return m, nil } m.olderLoadInFlight = true + m.olderLoadEnvelope = captureAsync(asyncOlderPage, m.asyncCurrent()) nextWindow := m.ingestWindow + m.pageSize - generation := m.generation - return m, func() tea.Msg { return m.olderPageCmd(nextWindow, generation) } + return m, func() tea.Msg { return m.olderPageCmd(nextWindow) } } -// olderPageCmd performs the off-path windowed rebuild for an older page. It reuses -// the same authoritative merge/sort/dedup/api-grouping path as the initial -// rebuild and grows the content window by exactly one configured page per request, -// so older entries stay chronologically ordered, duplicate-free across the boundary, -// and api-call-group consistent. The rebuilt cache is command-local until Update -// accepts this generation. -func (m MailModel) olderPageCmd(window int, generation uint64) tea.Msg { +// olderPageCmd performs the off-path windowed rebuild for an older page. +func (m MailModel) olderPageCmd(window int) tea.Msg { + envelope := captureAsync(asyncOlderPage, m.asyncCurrent()) cache := m.snapshotCache() sessionCache := fs.NewSessionCache(m.humanDir, filepath.Dir(m.baseDir), fs.MainAggregateWriter) sessionCache.RebuildFromSourcesWindowedInMemory(cache, m.humanAddr, m.orchestrator, m.orchDisplayName(), window) return mailOlderPageMsg{ - generation: generation, + envelope: envelope, sessionCache: sessionCache, ingestWindow: window, } } -func (m MailModel) historyCountCmd(cache *fs.SessionCache, generation uint64) tea.Cmd { +func (m MailModel) historyCountCmd(cache *fs.SessionCache) tea.Cmd { + envelope := captureAsync(asyncExactHistoryCount, m.asyncCurrent()) return func() tea.Msg { stats, gotIdentity, err := cache.ExactHistoryStats() + if err == nil && gotIdentity != envelope.source.identity { + err = fmt.Errorf("history source changed while exact count was running") + } return mailHistoryCountMsg{ - generation: generation, - cache: cache, - identity: gotIdentity, - stats: stats, - err: err, + envelope: envelope, + stats: stats, + err: err, } } } +func (m MailModel) asyncPulseCmd() tea.Cmd { + return pulseTick(m.asyncCurrent()) +} + func (m MailModel) firstFrameWindow() int { return m.pageSize } func adaptiveInputMaxHeight(windowHeight int) int { @@ -511,7 +553,7 @@ func (m MailModel) showChatTailHint() bool { // collectRefreshState reads target/project status that travels beside a store // refresh. Mailbox scanning is deliberately absent: ProjectMailStore is the // only caller of MailCache.Refresh. -func (m MailModel) collectRefreshState() mailRefreshMsg { +func (m MailModel) collectRefreshState() mailRefreshPayload { alive := m.orchestrator != "" && fs.IsAlive(m.orchestrator, 3.0) var activity fs.NetworkActivity if m.baseDir != "" { @@ -538,7 +580,7 @@ func (m MailModel) collectRefreshState() mailRefreshMsg { state = "suspended" } } - return mailRefreshMsg{generation: m.generation, alive: alive, state: state, activity: activity, orchName: orchName, orchNickname: orchNickname} + return mailRefreshPayload{alive: alive, state: state, activity: activity, orchName: orchName, orchNickname: orchNickname} } func (m MailModel) snapshotCache() fs.MailCache { @@ -787,8 +829,8 @@ func (m MailModel) Init() tea.Cmd { return tea.Batch( m.input.Init(), // ProjectMailStore owns initial/steady mailbox refresh and the mail tick. - // MailModel keeps only its target/UI pulse animation. - pulseTick(m.generation), + // MailModel keeps only its full-binding target/UI pulse animation. + m.asyncPulseCmd(), ) } @@ -841,13 +883,9 @@ func (m MailModel) Update(msg tea.Msg) (MailModel, tea.Cmd) { } return m, nil - case mailRefreshMsg: - // Accept the activation before touching any model/cache state. In - // particular, stale initial rebuilds carry detached SessionCaches that must - // never replace or persist over the current generation. - if msg.generation != m.generation { - return m, nil - } + case mailRefreshPayload: + // App.Update has already accepted the outer projectMailRefreshMsg envelope. + // This nested value is synchronous presentation payload, not another gate. var persistCmd tea.Cmd var countCmd tea.Cmd if msg.sessionCache != nil { @@ -871,11 +909,12 @@ func (m MailModel) Update(msg tea.Msg) (MailModel, tea.Cmd) { // A superseding first frame cancels any older-page load and resets the // revealed-extra window; the fresh cache defines what is loaded. m.olderLoadInFlight = false + m.olderLoadEnvelope = asyncEnvelope{} m.loadedExtra = 0 - generation := msg.generation sessionCache := msg.sessionCache + persistEnvelope := captureAsync(asyncSessionPersist, m.asyncCurrent()) persistCmd = func() tea.Msg { - return mailPersistMsg{generation: generation, sessionCache: sessionCache} + return mailPersistMsg{envelope: persistEnvelope, sessionCache: sessionCache} } if msg.initial && !m.historyCountLoaded && m.historyCountCache == nil { identity := sessionCache.HistoryCountIdentity() @@ -883,7 +922,7 @@ func (m MailModel) Update(msg tea.Msg) (MailModel, tea.Cmd) { m.historyCountLoading = true m.historyCountCache = sessionCache m.historyCountIdentity = identity - countCmd = m.historyCountCmd(sessionCache, generation) + countCmd = m.historyCountCmd(sessionCache) } } } @@ -965,10 +1004,10 @@ func (m MailModel) Update(msg tea.Msg) (MailModel, tea.Cmd) { return m, nil case mailPersistMsg: - // Persist only the cache still installed for this activation. This runs on - // the serialized Update path after the accepted history has been painted, - // so no stale or concurrent writer can overtake a newer generation. - if msg.generation != m.generation || msg.sessionCache == nil || msg.sessionCache != m.sessionCache { + if !acceptAsync(m.asyncCurrent(), msg.envelope) { + return m, nil + } + if msg.sessionCache == nil || msg.sessionCache != msg.envelope.source.cache { return m, nil } msg.sessionCache.Persist() @@ -978,8 +1017,7 @@ func (m MailModel) Update(msg tea.Msg) (MailModel, tea.Cmd) { return m, nil case mailHistoryCountMsg: - if msg.generation != m.generation || msg.cache == nil || - msg.cache != m.historyCountCache || msg.identity != m.historyCountIdentity { + if !acceptAsync(m.asyncCurrent(), msg.envelope) { return m, nil } if msg.err != nil { @@ -987,14 +1025,8 @@ func (m MailModel) Update(msg tea.Msg) (MailModel, tea.Cmd) { // or retries the same source/horizon task on Ctrl+U. return m, nil } - // Ctrl+U may replace the bounded content cache while this count is running. - // Accept only against a current cache built from the same source/horizon, - // and take EOF-tail deltas from that currently refreshed cache rather than - // the detached origin cache (which stops receiving Refresh calls once it is - // replaced). A changed source/horizon starts a replacement count below. - if m.sessionCache == nil || m.sessionCache.HistoryCountIdentity() != msg.identity { - return m, nil - } + // Same-horizon replacement remains valid because acceptAsync compares the + // outstanding origin cache while requiring the current installed horizon. delta := m.sessionCache.HistoryStats() m.historyStats = fs.SessionHistoryStats{ Detailed: msg.stats.Detailed + delta.Detailed, @@ -1010,13 +1042,22 @@ func (m MailModel) Update(msg tea.Msg) (MailModel, tea.Cmd) { return m, nil case mailOlderPageMsg: - // An explicit older-page rebuild completed. Gate on generation and on a - // load actually being in flight, so a superseded activation (view switch, - // visit, or a fresh first frame) cannot install a stale enlarged cache. - if msg.generation != m.generation || !m.olderLoadInFlight || msg.sessionCache == nil { + exactPhysicalCompletion := m.olderLoadInFlight && msg.envelope == m.olderLoadEnvelope + if !acceptAsync(m.asyncCurrent(), msg.envelope) { + if exactPhysicalCompletion { + m.olderLoadInFlight = false + m.olderLoadEnvelope = asyncEnvelope{} + } + return m, nil + } + if !exactPhysicalCompletion { return m, nil } m.olderLoadInFlight = false + m.olderLoadEnvelope = asyncEnvelope{} + if msg.sessionCache == nil { + return m, nil + } complete := msg.sessionCache.Complete() // Reveal the newly-loaded older page by growing the render window in // lockstep with the ingest window (one page = pageSize messages). @@ -1048,7 +1089,7 @@ func (m MailModel) Update(msg tea.Msg) (MailModel, tea.Cmd) { m.historyCountCache = m.sessionCache m.historyCountIdentity = identity m.historyStats = fs.SessionHistoryStats{} - countCmd = m.historyCountCmd(m.sessionCache, msg.generation) + countCmd = m.historyCountCmd(m.sessionCache) case m.historyCountLoaded: m.sessionCache.SetHistoryStats(m.historyStats) } @@ -1065,10 +1106,10 @@ func (m MailModel) Update(msg tea.Msg) (MailModel, tea.Cmd) { // like an accepted initial rebuild. var persistCmd tea.Cmd if complete { - generation := msg.generation sessionCache := msg.sessionCache + persistEnvelope := captureAsync(asyncSessionPersist, m.asyncCurrent()) persistCmd = func() tea.Msg { - return mailPersistMsg{generation: generation, sessionCache: sessionCache} + return mailPersistMsg{envelope: persistEnvelope, sessionCache: sessionCache} } } if persistCmd != nil || countCmd != nil { @@ -1077,13 +1118,13 @@ func (m MailModel) Update(msg tea.Msg) (MailModel, tea.Cmd) { return m, nil case pulseTickMsg: - if msg.generation != m.generation { + if !acceptAsync(m.asyncCurrent(), msg.envelope) { return m, nil } if strings.EqualFold(m.orchState, "ACTIVE") { m.pulseTick++ } - return m, pulseTick(m.generation) + return m, m.asyncPulseCmd() case homeTelemetryMsg: if msg.generation != m.generation { @@ -1142,7 +1183,7 @@ func (m MailModel) Update(msg tea.Msg) (MailModel, tea.Cmd) { return m, nil case EditorDoneMsg: - if msg.Generation != m.generation { + if !acceptAsync(m.asyncCurrent(), msg.envelope) { return m, nil } m.pendingMessage = msg.Text @@ -1842,7 +1883,7 @@ func (m MailModel) launchEditor(text string) tea.Cmd { if err != nil { return nil } - generation := m.generation + envelope := captureAsync(asyncEditorDone, m.asyncCurrent()) tmpFile.WriteString(text) tmpFile.Close() editor := os.Getenv("EDITOR") @@ -1857,7 +1898,7 @@ func (m MailModel) launchEditor(text string) tea.Cmd { } content, _ := os.ReadFile(tmpFile.Name()) os.Remove(tmpFile.Name()) - return EditorDoneMsg{Text: string(content), Generation: generation} + return EditorDoneMsg{envelope: envelope, Text: string(content)} }) } diff --git a/tui/internal/tui/mail_activity_footer_test.go b/tui/internal/tui/mail_activity_footer_test.go index 387e6255..1c2082c1 100644 --- a/tui/internal/tui/mail_activity_footer_test.go +++ b/tui/internal/tui/mail_activity_footer_test.go @@ -31,7 +31,7 @@ func TestFooterShowsActivityIndicatorWhenActive(t *testing.T) { m := NewMailModel(dir, "human", dir, dir, "orch", 20, dir, "en", false, 0) m = sizeMail(t, m) - m, _ = m.Update(mailRefreshMsg{state: "active", alive: true}) + m, _ = m.Update(mailRefreshPayload{state: "active", alive: true}) footer := emailToLine(m.View()) if footer == "" { @@ -52,7 +52,7 @@ func TestFooterIndicatorIdleHasNoTimer(t *testing.T) { m := NewMailModel(dir, "human", dir, dir, "orch", 20, dir, "en", false, 0) m = sizeMail(t, m) - m, _ = m.Update(mailRefreshMsg{state: "idle", alive: true}) + m, _ = m.Update(mailRefreshPayload{state: "idle", alive: true}) footer := emailToLine(m.View()) if footer == "" { diff --git a/tui/internal/tui/mail_activity_indicator_test.go b/tui/internal/tui/mail_activity_indicator_test.go index c6a5d1ad..4f9ed219 100644 --- a/tui/internal/tui/mail_activity_indicator_test.go +++ b/tui/internal/tui/mail_activity_indicator_test.go @@ -72,27 +72,27 @@ func TestActiveElapsed(t *testing.T) { // TestActiveSinceLifecycle verifies the activeSince timestamp is set on entry to // ACTIVE, preserved while staying ACTIVE, and cleared on any non-ACTIVE refresh // (including the synthesized suspended/refreshing states, which arrive as -// non-ACTIVE through the same mailRefreshMsg path). +// non-ACTIVE through the same mailRefreshPayload path). func TestActiveSinceLifecycle(t *testing.T) { dir := t.TempDir() m := NewMailModel(dir, "human", dir, dir, "orch", 20, dir, "en", false, 0) m = sizeMail(t, m) // Enter ACTIVE → timer starts. - m, _ = m.Update(mailRefreshMsg{state: "active", alive: true}) + m, _ = m.Update(mailRefreshPayload{state: "active", alive: true}) if m.activeSince.IsZero() { t.Fatal("activeSince should be set on entering ACTIVE") } first := m.activeSince // Stay ACTIVE → timestamp preserved (not reset each refresh). - m, _ = m.Update(mailRefreshMsg{state: "active", alive: true}) + m, _ = m.Update(mailRefreshPayload{state: "active", alive: true}) if !m.activeSince.Equal(first) { t.Errorf("activeSince should be preserved while staying ACTIVE; was %v, now %v", first, m.activeSince) } // Leave ACTIVE → timer cleared so the badge drops the elapsed suffix. - m, _ = m.Update(mailRefreshMsg{state: "idle", alive: true}) + m, _ = m.Update(mailRefreshPayload{state: "idle", alive: true}) if !m.activeSince.IsZero() { t.Error("activeSince should be cleared when leaving ACTIVE") } diff --git a/tui/internal/tui/mail_generation_test.go b/tui/internal/tui/mail_generation_test.go index 8702ca21..612b97b8 100644 --- a/tui/internal/tui/mail_generation_test.go +++ b/tui/internal/tui/mail_generation_test.go @@ -36,16 +36,18 @@ func findMailPersistCmd(cmd tea.Cmd) (mailPersistMsg, bool) { func TestMailModelIgnoresOldGenerationAsyncMessages(t *testing.T) { m := NewMailModel("", "", "", "", "agent", 10, "", "en", false, 0) - m.generation = 2 + bindMailModelForAsyncTest(t, &m, 2) m.initialLoading = true m.homeTelemetryInFlight = true + stale := m.asyncCurrent() + stale.binding.generation = 1 + stale.sessionSource = asyncSourceCache{cache: m.sessionCache, identity: "stale-history"} cases := []tea.Msg{ - mailRefreshMsg{generation: 1, initial: true, state: "active"}, - mailPersistMsg{generation: 1, sessionCache: m.sessionCache}, - pulseTickMsg{generation: 1}, + mailPersistMsg{envelope: captureAsync(asyncSessionPersist, stale), sessionCache: m.sessionCache}, + pulseTickMsg{envelope: captureAsync(asyncLivenessPulse, stale)}, homeTelemetryMsg{generation: 1, t: homeTelemetry{apiCalls: 9}}, - EditorDoneMsg{Generation: 1, Text: "old editor text"}, + EditorDoneMsg{envelope: captureAsync(asyncEditorDone, stale), Text: "old editor text"}, } for _, msg := range cases { var cmd tea.Cmd @@ -55,7 +57,7 @@ func TestMailModelIgnoresOldGenerationAsyncMessages(t *testing.T) { } } if !m.initialLoading { - t.Fatal("stale initial refresh should not clear loading") + t.Fatal("stale async work should not clear loading") } if !m.homeTelemetryInFlight { t.Fatal("stale telemetry should not clear in-flight state") @@ -69,10 +71,21 @@ func TestReturnFromVisitResumesInitialLoadingWithNewGenerationRebuild(t *testing a := visitTestApp(t) origGen := a.mail.generation a.mail.initialLoading = true + staleOriginal := detachedAppProjectMailRefresh(&a, true) visited, _ := a.enterVisitedAgent(ProjectsAgentSelectedMsg{Record: visitRecord(filepath.Join(filepath.Dir(filepath.Dir(a.projectDir)), "target"), "worker", "Worker")}) + // The fixture uses a synthetic visited target that does not exist in the live + // process inventory. Install the visited owner first, then give that newly + // constructed store a deterministic eligible-target resolver before launching + // the same production initial-refresh wrapper. + visited.setAsyncTargetRevalidator(func(asyncOwner, asyncTarget) bool { return true }) + visitCmd := visited.beginProjectMailRefresh(true) targetGen := visited.mail.generation - model, cmd := visited.Update(mailRefreshMsg{generation: origGen, initial: true, state: "ACTIVE"}) + staleTarget, ok := findProjectMailRefresh(visitCmd) + if !ok { + t.Fatal("visit did not schedule its authoritative initial refresh") + } + model, cmd := visited.Update(staleOriginal) if cmd != nil { t.Fatalf("stale original initial completion returned cmd %T", runCmd(cmd)) } @@ -92,22 +105,23 @@ func TestReturnFromVisitResumesInitialLoadingWithNewGenerationRebuild(t *testing if !ok { t.Fatal("resume did not schedule a ProjectMailStore refresh") } - msg := storeMsg.mail - if !msg.initial || msg.generation != restored.mail.generation { - t.Fatalf("resume command = initial %v generation %d, want initial true generation %d", msg.initial, msg.generation, restored.mail.generation) + if !storeMsg.mail.initial || storeMsg.envelope.generation.thread != restored.mail.generation { + t.Fatalf("resume command = initial %v generation %d, want initial true generation %d", storeMsg.mail.initial, storeMsg.envelope.generation.thread, restored.mail.generation) } model, _ = restored.Update(storeMsg) - if model.(App).mail.initialLoading { + accepted := model.(App) + if accepted.mail.initialLoading { t.Fatal("new-generation initial rebuild should clear loading") } - stale := restored.mail - stale, cmd = stale.Update(mailRefreshMsg{generation: targetGen, initial: true, state: "ACTIVE"}) + beforeState := accepted.mail.orchState + model, cmd = accepted.Update(staleTarget) if cmd != nil { t.Fatalf("stale target refresh returned cmd %T", runCmd(cmd)) } - if !stale.initialLoading || stale.orchState == "ACTIVE" { - t.Fatalf("stale target refresh mutated restored mail: loading=%v state=%q", stale.initialLoading, stale.orchState) + stale := model.(App) + if stale.mail.initialLoading || stale.mail.orchState != beforeState { + t.Fatalf("stale target refresh mutated restored mail: loading=%v state=%q want=%q", stale.mail.initialLoading, stale.mail.orchState, beforeState) } } @@ -137,8 +151,8 @@ func TestReturnFromVisitClearsTelemetryInFlightAndAllowsNewFetch(t *testing.T) { if !msg.initial { t.Fatal("visit return must fresh-rebuild before publishing restored home") } - if msg.generation != restored.mail.generation { - t.Fatalf("refresh generation = %d, want %d", msg.generation, restored.mail.generation) + if storeMsg.envelope.generation.thread != restored.mail.generation { + t.Fatalf("refresh generation = %d, want %d", storeMsg.envelope.generation.thread, restored.mail.generation) } if telemetryCmd := restored.mail.maybeScheduleHomeTelemetry(time.Now()); telemetryCmd == nil { @@ -258,7 +272,7 @@ func TestInitialRebuildDoesNotMutateInstalledCacheBeforeAcceptance(t *testing.T) m := NewMailModel(humanDir, "human", root, orchDir, "agent", 2000, "", "en", false, 0) installed := m.sessionCache - msg := acceptedInitialMailRefresh(m) + msg := acceptedInitialMailRefresh(t, &m) if got := installed.Len(); got != 0 { t.Fatalf("initial rebuild mutated the installed cache before acceptance: got %d entries", got) @@ -292,11 +306,15 @@ func TestMailPersistRejectsReplacedCacheWithinGeneration(t *testing.T) { staleHumanDir := filepath.Join(root, "stale-human") currentHumanDir := filepath.Join(root, "current-human") m := NewMailModel(staleHumanDir, "human", root, "", "agent", 2000, "", "en", false, 0) + bindMailModelForAsyncTest(t, &m, 1) staleCache := m.sessionCache + staleCurrent := m.asyncCurrent() + staleCurrent.sessionSource = asyncSourceCache{cache: staleCache, identity: "stale-cache"} + staleEnvelope := captureAsync(asyncSessionPersist, staleCurrent) current := NewMailModel(currentHumanDir, "human", root, "", "agent", 2000, "", "en", false, 0) m.sessionCache = current.sessionCache - updated, cmd := m.Update(mailPersistMsg{generation: m.generation, sessionCache: staleCache}) + updated, cmd := m.Update(mailPersistMsg{envelope: staleEnvelope, sessionCache: staleCache}) if cmd != nil { t.Fatalf("replaced same-generation cache returned a command: %T", runCmd(cmd)) } @@ -414,26 +432,33 @@ func TestAppRoutesInitialMailCompletionWhileProjectsActive(t *testing.T) { } func TestLateInitialRebuildCannotMutateCurrentGenerationCache(t *testing.T) { - humanDir := t.TempDir() - orchDir := t.TempDir() + projectDir := filepath.Join(t.TempDir(), ".lingtai") + humanDir := filepath.Join(projectDir, "human") + orchDir := filepath.Join(projectDir, "Main") writeMailGenerationEvent(t, orchDir, "generation B") - a := App{currentView: appViewMail} - a.installMailModel(NewMailModel(humanDir, "human", t.TempDir(), orchDir, "agent", 2000, "", "en", false, 0)) + a := App{currentView: appViewMail, projectDir: projectDir, orchDir: orchDir, orchName: "agent"} + a.installMailModel(NewMailModel(humanDir, "human", projectDir, orchDir, "agent", 2000, "", "en", false, 0)) a.mail.verbose = verboseThinking - lateA := acceptedInitialMailRefresh(a.mail) + lateA := detachedAppProjectMailRefresh(&a, true) + // This fixture keeps the detached A result while allowing B to own a distinct + // physical slot; exact settlement behavior is covered by the store tests. + a.mailStore.refreshInFlight = false + a.mailStore.refreshInitial = false + a.mailStore.refreshInFlightEnvelope = asyncEnvelope{} // Install generation B from the same preserved model. This mirrors returning - // to a preserved mail model: generations differ, but the pre-fix cache pointer - // was shared by both command closures. + // to a preserved mail model while the detached A completion is still pending. a.installMailModel(a.mail) - var persistCmd tea.Cmd - a.mail, persistCmd = a.mail.Update(acceptedInitialMailRefresh(a.mail)) + currentB := detachedAppProjectMailRefresh(&a, true) + model, persistCmd := a.Update(currentB) + a = model.(App) if persistCmd == nil { t.Fatal("generation B initial rebuild did not schedule persistence") } persistMsg := runMailPersistCmd(t, persistCmd) - a.mail, _ = a.mail.Update(persistMsg) + model, _ = a.Update(persistMsg) + a = model.(App) beforeEntries := a.mail.sessionCache.Entries() beforeMessages := append([]ChatMessage(nil), a.mail.messages...) sessionPath := filepath.Join(humanDir, "logs", "session.jsonl") @@ -443,7 +468,6 @@ func TestLateInitialRebuildCannotMutateCurrentGenerationCache(t *testing.T) { } appendMailGenerationEvent(t, orchDir, "late generation A") - staleMsg := lateA if got := a.mail.sessionCache.Entries(); !reflect.DeepEqual(got, beforeEntries) { t.Fatalf("late generation A mutated generation B cache before acceptance:\n got %#v\nwant %#v", got, beforeEntries) } @@ -453,15 +477,16 @@ func TestLateInitialRebuildCannotMutateCurrentGenerationCache(t *testing.T) { t.Fatal("late generation A rewrote generation B's persisted session cache before acceptance") } - updated, cmd := a.mail.Update(staleMsg) + model, cmd := a.Update(lateA) + updated := model.(App) if cmd != nil { t.Fatalf("stale initial completion returned command %T", runCmd(cmd)) } - if !reflect.DeepEqual(updated.sessionCache.Entries(), beforeEntries) { + if !reflect.DeepEqual(updated.mail.sessionCache.Entries(), beforeEntries) { t.Fatal("rejected generation A changed generation B cache") } - if !reflect.DeepEqual(updated.messages, beforeMessages) { - t.Fatalf("rejected generation A changed generation B visible projection:\n got %#v\nwant %#v", updated.messages, beforeMessages) + if !reflect.DeepEqual(updated.mail.messages, beforeMessages) { + t.Fatalf("rejected generation A changed generation B visible projection:\n got %#v\nwant %#v", updated.mail.messages, beforeMessages) } if afterFile, err := os.ReadFile(sessionPath); err != nil { t.Fatal(err) diff --git a/tui/internal/tui/mail_launch_defer_test.go b/tui/internal/tui/mail_launch_defer_test.go index 8cfff074..75a6a54a 100644 --- a/tui/internal/tui/mail_launch_defer_test.go +++ b/tui/internal/tui/mail_launch_defer_test.go @@ -60,14 +60,14 @@ func TestProjectMailStoreRunsRebuild(t *testing.T) { m.verbose = verboseThinking // Run the initial rebuild command (the deferred heavy work). - msg := acceptedInitialMailRefresh(m) + msg := acceptedInitialMailRefresh(t, &m) if msg == nil { t.Fatal("project store initial refresh returned nil msg") } if got := m.sessionCache.Len(); got != 0 { t.Fatalf("project store rebuild mutated the installed session cache before acceptance; got %d entries", got) } - rm, ok := msg.(mailRefreshMsg) + rm, ok := msg.(mailRefreshPayload) if !ok || rm.sessionCache == nil || rm.sessionCache.Len() == 0 { t.Fatalf("project store rebuild did not return a populated command-local session cache: %#v", rm.sessionCache) } diff --git a/tui/internal/tui/mail_loading_state_test.go b/tui/internal/tui/mail_loading_state_test.go index a0f85309..2d36556a 100644 --- a/tui/internal/tui/mail_loading_state_test.go +++ b/tui/internal/tui/mail_loading_state_test.go @@ -56,7 +56,7 @@ func TestMailShowsInitialLoadingBanner(t *testing.T) { } // TestMailLoadingBannerClearsAfterInitialRebuild verifies the banner is a -// one-time intermediate state: once the deferred initial rebuild's mailRefreshMsg +// one-time intermediate state: once the deferred initial rebuild's mailRefreshPayload // is applied, the loading banner disappears and the rebuilt history is shown. func TestMailLoadingBannerClearsAfterInitialRebuild(t *testing.T) { humanDir := t.TempDir() @@ -82,13 +82,13 @@ func TestMailLoadingBannerClearsAfterInitialRebuild(t *testing.T) { } // Run the deferred rebuild and apply its (initial-tagged) message. - msg := acceptedInitialMailRefresh(m) - rm, ok := msg.(mailRefreshMsg) + msg := acceptedInitialMailRefresh(t, &m) + rm, ok := msg.(mailRefreshPayload) if !ok { - t.Fatalf("store initial refresh returned %T; expected mailRefreshMsg", msg) + t.Fatalf("store initial refresh returned %T; expected mailRefreshPayload", msg) } if !rm.initial { - t.Fatal("initialRebuild's mailRefreshMsg must be tagged initial=true") + t.Fatal("initialRebuild's mailRefreshPayload must be tagged initial=true") } m, _ = m.Update(msg) @@ -110,14 +110,14 @@ func TestMailLoadingBannerClearsAfterInitialRebuild(t *testing.T) { if !found { t.Fatalf("expected rebuilt history after initial rebuild; got %d messages", len(m.messages)) } - m, _ = m.Update(m.historyCountCmd(m.historyCountCache, m.generation)()) + m, _ = m.Update(m.historyCountCmd(m.historyCountCache)()) if strings.Contains(m.View(), loadingBannerFragment) { t.Fatal("neutral loading banner should clear after exact count metadata is accepted") } } // TestMailPeriodicRefreshDoesNotReshowLoading guards against stale/re-shown -// loading state: a periodic (untagged) mailRefreshMsg must not turn the loading +// loading state: a periodic (untagged) mailRefreshPayload must not turn the loading // banner back on after the initial rebuild has already cleared it. func TestMailPeriodicRefreshDoesNotReshowLoading(t *testing.T) { dir := t.TempDir() @@ -125,18 +125,18 @@ func TestMailPeriodicRefreshDoesNotReshowLoading(t *testing.T) { m = sizeMail(t, m) // Apply the initial rebuild to clear loading. - m, _ = m.Update(acceptedInitialMailRefresh(m)) + m, _ = m.Update(acceptedInitialMailRefresh(t, &m)) if m.initialLoading { t.Fatal("loading should be cleared by the initial rebuild") } - m, _ = m.Update(m.historyCountCmd(m.historyCountCache, m.generation)()) + m, _ = m.Update(m.historyCountCmd(m.historyCountCache)()) if m.historyCountLoading { t.Fatal("exact-count loading should clear after metadata acceptance") } // A periodic refresh (untagged) must leave loading cleared. - periodic := acceptedSteadyMailRefresh(m) - if rm, ok := periodic.(mailRefreshMsg); ok && rm.initial { + periodic := acceptedSteadyMailRefresh(t, &m) + if rm, ok := periodic.(mailRefreshPayload); ok && rm.initial { t.Fatal("periodic refreshMail must not produce an initial-tagged message") } m, _ = m.Update(periodic) diff --git a/tui/internal/tui/mail_mailbox_source_test.go b/tui/internal/tui/mail_mailbox_source_test.go index f83abc2c..d9f72b21 100644 --- a/tui/internal/tui/mail_mailbox_source_test.go +++ b/tui/internal/tui/mail_mailbox_source_test.go @@ -39,7 +39,7 @@ func TestMailProjectionUsesMailboxExactlyOnceBeyondEventWindow(t *testing.T) { }) m := NewMailModel(humanDir, "human", t.TempDir(), orchDir, "agent", 200, "", "en", false, 0) - m, _ = m.Update(acceptedInitialMailRefresh(m)) + m, _ = m.Update(acceptedInitialMailRefresh(t, &m)) matches := 0 for _, message := range m.messages { @@ -72,7 +72,7 @@ func TestMailProjectionKeepsExpandedEventHistoryWhileMailStaysSingleSource(t *te }) m := NewMailModel(humanDir, "human", t.TempDir(), orchDir, "agent", 100, "", "en", false, 0) - m, _ = m.Update(acceptedInitialMailRefresh(m)) + m, _ = m.Update(acceptedInitialMailRefresh(t, &m)) m.verbose = verboseThinking m.buildMessages() diff --git a/tui/internal/tui/mail_window_test.go b/tui/internal/tui/mail_window_test.go index 3fe604ee..b2713b25 100644 --- a/tui/internal/tui/mail_window_test.go +++ b/tui/internal/tui/mail_window_test.go @@ -108,7 +108,7 @@ func appendWindowedIndexedEvent(t *testing.T, orchDir, text string) { func installInitialWindow(t *testing.T, m MailModel) MailModel { t.Helper() - rm, ok := acceptedInitialMailRefresh(m).(mailRefreshMsg) + rm, ok := acceptedInitialMailRefresh(t, &m).(mailRefreshPayload) if !ok { t.Fatalf("store initial refresh returned %T", rm) } @@ -127,7 +127,7 @@ func acceptExactCount(t *testing.T, m MailModel) MailModel { if !m.historyCountLoading || m.historyCountCache == nil { t.Fatal("initial content did not start one async exact-count task") } - msg := m.historyCountCmd(m.historyCountCache, m.generation)() + msg := m.historyCountCmd(m.historyCountCache)() m, _ = m.Update(msg) if !m.historyCountLoaded || m.historyCountLoading { t.Fatal("exact-count result was not accepted") @@ -141,7 +141,7 @@ func TestInitialContentWindowEqualsConfiguredPageSize(t *testing.T) { orchDir := buildWindowedAgentDir(t, 405) m := NewMailModel(t.TempDir(), "human", t.TempDir(), orchDir, "agent", 200, "", "en", false, 0) m.verbose = verboseThinking - rm := acceptedInitialMailRefresh(m).(mailRefreshMsg) + rm := acceptedInitialMailRefresh(t, &m).(mailRefreshPayload) if got := rm.sessionCache.Len(); got != 200 { t.Fatalf("initial content cache = %d, want configured page size 200", got) } @@ -207,22 +207,67 @@ func TestCtrlULoadsOneConfiguredBatchPerRealKey(t *testing.T) { } } +func TestOlderPageExactStaleCompletionSettlesDebounceWithoutPublishing(t *testing.T) { + orchDir := buildWindowedAgentDir(t, 250) + m := NewMailModel(t.TempDir(), "human", t.TempDir(), orchDir, "agent", 100, "", "en", false, 0) + m.verbose = verboseThinking + m = installInitialWindow(t, m) + beforeCache := m.sessionCache + beforeWindow := m.ingestWindow + beforeLoadedExtra := m.loadedExtra + beforeMessages := len(m.messages) + + m.viewport.GotoTop() + var cmd tea.Cmd + m, cmd = m.Update(ctrlU()) + if cmd == nil || !m.olderLoadInFlight { + t.Fatal("Ctrl+U did not start one older-page load") + } + page, ok := cmd().(mailOlderPageMsg) + if !ok { + t.Fatalf("older-page command returned %T", cmd()) + } + if page.envelope.storeVersion != m.asyncStoreVersion { + t.Fatalf("captured store version = %d, want current %d", page.envelope.storeVersion, m.asyncStoreVersion) + } + + // Model the next accepted steady refresh installing while this exact physical + // older-page rebuild is still running. Its result is no longer publishable, + // but completion must release the debounce slot so the user can retry. + m.asyncStoreVersion++ + got, next := m.Update(page) + if next != nil { + t.Fatalf("stale older-page completion scheduled %T", next()) + } + if got.olderLoadInFlight { + t.Fatal("exact stale older-page completion left the debounce slot engaged") + } + if got.sessionCache != beforeCache || got.ingestWindow != beforeWindow || got.loadedExtra != beforeLoadedExtra || len(got.messages) != beforeMessages { + t.Fatalf("stale older-page completion published state: cacheChanged=%v window=%d extra=%d messages=%d", got.sessionCache != beforeCache, got.ingestWindow, got.loadedExtra, len(got.messages)) + } + + retry, retryCmd := got.requestOlderPage() + if retryCmd == nil || !retry.olderLoadInFlight { + t.Fatal("settled stale older-page completion did not permit one retry") + } +} + func TestHistoryCountMessageGenerationAndCacheIdentityGates(t *testing.T) { m := NewMailModel(t.TempDir(), "human", t.TempDir(), buildWindowedAgentDir(t, 250), "agent", 100, "", "en", false, 0) m.generation = 9 m.verbose = verboseThinking m = installInitialWindow(t, m) - valid := m.historyCountCmd(m.historyCountCache, m.generation)().(mailHistoryCountMsg) + valid := m.historyCountCmd(m.historyCountCache)().(mailHistoryCountMsg) staleGeneration := valid - staleGeneration.generation-- + staleGeneration.envelope.generation.thread-- got, _ := m.Update(staleGeneration) if got.historyCountLoaded { t.Fatal("stale-generation count result was accepted") } staleCache := valid - staleCache.cache = NewMailModel(t.TempDir(), "human", t.TempDir(), "", "agent", 100, "", "en", false, 0).sessionCache + staleCache.envelope.source.cache = NewMailModel(t.TempDir(), "human", t.TempDir(), "", "agent", 100, "", "en", false, 0).sessionCache got, _ = m.Update(staleCache) if got.historyCountLoaded { t.Fatal("wrong-cache count result was accepted") @@ -240,7 +285,7 @@ func TestHistoryCountUsesCurrentCacheTailDeltaAfterCtrlU(t *testing.T) { m.verbose = verboseThinking m = installInitialWindow(t, m) originCache := m.historyCountCache - valid := m.historyCountCmd(originCache, m.generation)().(mailHistoryCountMsg) + valid := m.historyCountCmd(originCache)().(mailHistoryCountMsg) m.viewport.GotoTop() var cmd tea.Cmd @@ -250,7 +295,7 @@ func TestHistoryCountUsesCurrentCacheTailDeltaAfterCtrlU(t *testing.T) { } older := cmd().(mailOlderPageMsg) m, _ = m.Update(older) - if m.sessionCache == originCache || m.sessionCache.HistoryCountIdentity() != valid.identity { + if m.sessionCache == originCache || m.sessionCache.HistoryCountIdentity() != valid.envelope.source.identity { t.Fatal("Ctrl+U did not install a distinct cache for the same count horizon") } @@ -279,7 +324,7 @@ func TestOlderPageNewHorizonSupersedesAsyncCountOnce(t *testing.T) { m = installInitialWindow(t, m) oldCache := m.historyCountCache oldIdentity := m.historyCountIdentity - stale := m.historyCountCmd(oldCache, m.generation)().(mailHistoryCountMsg) + stale := m.historyCountCmd(oldCache)().(mailHistoryCountMsg) appendWindowedIndexedEvent(t, orchDir, "new horizon before Ctrl+U") m.viewport.GotoTop() diff --git a/tui/internal/tui/nirvana.go b/tui/internal/tui/nirvana.go index cb5a1b8c..761e16ad 100644 --- a/tui/internal/tui/nirvana.go +++ b/tui/internal/tui/nirvana.go @@ -39,18 +39,24 @@ const bodhiLeaf = "" + // The app should transition to first-run. type NirvanaDoneMsg struct{} +// nirvanaCleanStartMsg is internal — asks the root App to invalidate every +// project-mail lifetime synchronously before destructive filesystem cleanup can +// begin. The root then returns doClean as the next command. +type nirvanaCleanStartMsg struct{} + // nirvanaCleanDoneMsg is internal — signals that cleanup finished. type nirvanaCleanDoneMsg struct{} // NirvanaModel is a full-screen confirmation view for /nirvana (clean & start fresh). // Cursor defaults to Cancel (index 1) so the user must deliberately move up. type NirvanaModel struct { - lingtaiDir string // .lingtai/ path - cursor int // 0 = Confirm, 1 = Cancel - cleaning bool // true while cleanup runs - done bool // true when cleanup complete, waiting for Enter - width int - height int + lingtaiDir string // .lingtai/ path + cursor int // 0 = Confirm, 1 = Cancel + cleaning bool // true while cleanup runs + cleanupStarted bool // true after the root consumes the one cleanup-start handoff + done bool // true when cleanup complete, waiting for Enter + width int + height int } func NewNirvanaModel(lingtaiDir string) NirvanaModel { @@ -103,7 +109,9 @@ func (m NirvanaModel) Update(msg tea.Msg) (NirvanaModel, tea.Cmd) { switch m.cursor { case 0: // Confirm m.cleaning = true - return m, m.doClean() + // Defer destructive work until the root App has synchronously + // invalidated every async owner from the lifetime being deleted. + return m, func() tea.Msg { return nirvanaCleanStartMsg{} } case 1: // Cancel return m, func() tea.Msg { return ViewChangeMsg{View: "mail"} } } diff --git a/tui/internal/tui/project_mail_store.go b/tui/internal/tui/project_mail_store.go index 4764cd79..9eadffcc 100644 --- a/tui/internal/tui/project_mail_store.go +++ b/tui/internal/tui/project_mail_store.go @@ -9,12 +9,13 @@ import ( tea "charm.land/bubbletea/v2" "github.com/anthropics/lingtai-tui/internal/fs" + "github.com/anthropics/lingtai-tui/internal/inventory" ) // ProjectMailSnapshot is an immutable, accepted view of one project's human -// mailbox. A refresh result becomes visible only after ProjectMailStore accepts -// its store identity, activation, and source version. The cache remains private -// so callers cannot turn a snapshot into a second refresh owner. +// mailbox. A refresh result becomes visible only after its async envelope is +// accepted. The cache remains private so callers cannot turn a snapshot into a +// second refresh owner. type ProjectMailSnapshot struct { version uint64 cache fs.MailCache @@ -40,24 +41,19 @@ func (filesystemProjectMailScanner) Refresh(cache fs.MailCache) fs.MailCache { type projectMailLocationUpdater func(string) type projectMailRefreshMsg struct { - storeID uint64 - projectID string - activation uint64 - sourceVersion uint64 - cache fs.MailCache - mail mailRefreshMsg + envelope asyncEnvelope + cache fs.MailCache + mail mailRefreshPayload } type projectMailTickMsg struct { - storeID uint64 - activation uint64 - chain uint64 - at time.Time + envelope asyncEnvelope + at time.Time } type projectMailRefreshRequestMsg struct { - generation uint64 - initial bool + envelope asyncEnvelope + initial bool } var projectMailStoreSequence atomic.Uint64 @@ -68,40 +64,60 @@ var projectMailStoreSequence atomic.Uint64 // parallel. The gate owns no project data, accepted state, or tick lifecycle. var projectMailScanSingleflight sync.Mutex -// projectMailRuntimeGate is shared by value copies of one store. It lets a -// delayed side-effect command re-check the live activation/version at execution -// time even though Bubble Tea returns App values by copy. -type projectMailRuntimeGate struct { - active atomic.Bool - activation atomic.Uint64 - version atomic.Uint64 +// projectMailAsyncState is the atomic/current binding seam used only by delayed +// side effects. Permission still comes from acceptAsync; this holder merely lets +// a command load the current coordinates after App value copies have moved on. +type projectMailAsyncState struct { + mu sync.RWMutex + current asyncCurrent +} + +func (s *projectMailAsyncState) load() asyncCurrent { + if s == nil { + return asyncCurrent{} + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.current +} + +func (s *projectMailAsyncState) store(current asyncCurrent) { + if s == nil { + return + } + s.mu.Lock() + s.current = current + s.mu.Unlock() } // ProjectMailStore is the root-owned project-lifetime mailbox owner. It owns // exactly one MailCache, one accepted-snapshot sequence, one refresh pipeline, // and one invalidatable polling chain. Bubble Tea serializes its mutations on -// App.Update; background commands receive detached cache values and can only -// publish through the identity/version acceptance gate below. +// App.Update; background commands receive detached values and can publish only +// after the root's shared async-envelope acceptance. type ProjectMailStore struct { - id uint64 - projectID string - projectDir string - humanDir string - cache fs.MailCache - snapshot *ProjectMailSnapshot - version uint64 - activation uint64 - tickChain uint64 - active bool - tickRunning bool - refreshInFlight bool - refreshInitial bool - refreshGeneration uint64 - initialRefreshPending bool - pollRate time.Duration - scanner projectMailScanner - updateLocation projectMailLocationUpdater - runtime *projectMailRuntimeGate + id uint64 + projectID string + projectDir string + humanDir string + cache fs.MailCache + snapshot *ProjectMailSnapshot + version uint64 + activation uint64 + tickChain uint64 + active bool + tickRunning bool + refreshInFlight bool + refreshInitial bool + refreshInFlightEnvelope asyncEnvelope + initialRefreshPending bool + pollRate time.Duration + scanner projectMailScanner + updateLocation projectMailLocationUpdater + binding asyncBinding + revalidateTarget func(asyncOwner, asyncTarget) bool + asyncState *projectMailAsyncState + locationSourceVersion uint64 } func canonicalProjectMailIdentity(projectDir string) string { @@ -127,32 +143,99 @@ func newProjectMailStoreWithDeps(projectDir, humanDir string, scanner projectMai updateLocation = func(string) {} } store := ProjectMailStore{ - id: projectMailStoreSequence.Add(1), - projectID: canonicalProjectMailIdentity(projectDir), - projectDir: projectDir, - humanDir: humanDir, - cache: fs.NewMailCache(humanDir), - activation: 1, - active: true, - pollRate: time.Second, - scanner: scanner, - updateLocation: updateLocation, - runtime: &projectMailRuntimeGate{}, - } - store.syncRuntime() + id: projectMailStoreSequence.Add(1), + projectID: canonicalProjectMailIdentity(projectDir), + projectDir: projectDir, + humanDir: humanDir, + cache: fs.NewMailCache(humanDir), + activation: 1, + active: true, + pollRate: time.Second, + scanner: scanner, + updateLocation: updateLocation, + revalidateTarget: revalidateInventoryTarget, + asyncState: &projectMailAsyncState{}, + locationSourceVersion: 0, + } + store.syncAsyncState() return store } -func (s *ProjectMailStore) syncRuntime() { - if s == nil || s.id == 0 { +// revalidateInventoryTarget performs one fresh inventory scan for substantive +// work on a target that was activated from inventory. Display-only nickname +// changes are intentionally irrelevant to target identity. +func revalidateInventoryTarget(owner asyncOwner, target asyncTarget) bool { + snapshot, err := inventory.Scan(inventory.Options{FilterDir: filepath.Dir(owner.projectID)}) + if err != nil { + return false + } + for _, record := range snapshot.Records { + if inventory.NormalizePath(record.AgentDir) != target.directory { + continue + } + return record.Enterable && + canonicalProjectMailIdentity(filepath.Join(record.Project, ".lingtai")) == owner.projectID && + fs.AddressFingerprint(record.Address) == target.addressFingerprint + } + return false +} + +func (s *ProjectMailStore) setAsyncTargetRevalidator(revalidate func(asyncOwner, asyncTarget) bool) { + if s == nil { + return + } + if revalidate == nil { + revalidate = revalidateInventoryTarget + } + s.revalidateTarget = revalidate + s.syncAsyncState() +} + +func (s *ProjectMailStore) bindMailModel(mail *MailModel, inventoryBound bool) { + if s == nil || mail == nil || s.id == 0 { return } - if s.runtime == nil { - s.runtime = &projectMailRuntimeGate{} + binding := asyncBinding{ + owner: asyncOwner{ + projectID: s.projectID, + storeID: s.id, + activation: s.activation, + }, + target: asyncTarget{ + directory: inventory.NormalizePath(mail.orchestrator), + addressFingerprint: fs.AddressFingerprint(mail.orchAddr), + inventoryBound: inventoryBound, + }, + generation: mail.generation, + } + s.binding = binding + s.locationSourceVersion = s.version + mail.asyncBinding = binding + mail.asyncStoreVersion = s.version + mail.asyncTickEpoch = s.tickChain + mail.revalidateTarget = s.revalidateTarget + if mail.pulseEpoch == 0 { + mail.pulseEpoch = 1 + } + s.syncAsyncState() +} + +func (s ProjectMailStore) asyncCurrent() asyncCurrent { + return asyncCurrent{ + binding: s.binding, + storeVersion: s.version, + tickEpoch: s.tickChain, + revalidateTarget: s.revalidateTarget, } - s.runtime.active.Store(s.active) - s.runtime.activation.Store(s.activation) - s.runtime.version.Store(s.version) +} + +func (s *ProjectMailStore) syncAsyncState() { + if s == nil || s.asyncState == nil { + return + } + current := s.asyncCurrent() + current.storeVersion = s.locationSourceVersion + s.asyncState.store(current) } func (s ProjectMailStore) matches(projectDir, humanDir string) bool { @@ -168,11 +251,12 @@ func (s *ProjectMailStore) suspend() { s.pauseTick() s.active = false s.activation++ + s.binding.owner.activation = s.activation s.refreshInFlight = false s.refreshInitial = false - s.refreshGeneration = 0 + s.refreshInFlightEnvelope = asyncEnvelope{} s.initialRefreshPending = false - s.syncRuntime() + s.syncAsyncState() } func (s *ProjectMailStore) activate() { @@ -181,22 +265,25 @@ func (s *ProjectMailStore) activate() { } s.active = true s.activation++ + s.binding.owner.activation = s.activation s.refreshInFlight = false s.refreshInitial = false - s.refreshGeneration = 0 + s.refreshInFlightEnvelope = asyncEnvelope{} s.initialRefreshPending = false s.tickRunning = false - s.syncRuntime() + s.locationSourceVersion = s.version + s.syncAsyncState() } -// pauseTick invalidates the outstanding chain even if its tea.Every command -// has already fired. A late message therefore cannot pass acceptTick and re-arm. +// pauseTick invalidates the outstanding chain even if its tea.Every command has +// already fired. A late message therefore cannot pass shared acceptance and re-arm. func (s *ProjectMailStore) pauseTick() { if s == nil || s.id == 0 { return } s.tickChain++ s.tickRunning = false + s.syncAsyncState() } // resumeTick creates at most one chain for the current activation. @@ -206,30 +293,34 @@ func (s *ProjectMailStore) resumeTick() tea.Cmd { } s.tickChain++ s.tickRunning = true - return projectMailTickEvery(s.pollRate, s.id, s.activation, s.tickChain) + s.syncAsyncState() + return projectMailTickEvery(s.pollRate, s.asyncCurrent()) } -func projectMailTickEvery(d time.Duration, storeID, activation, chain uint64) tea.Cmd { +func projectMailTickEvery(d time.Duration, current asyncCurrent) tea.Cmd { + envelope := captureAsync(asyncRefreshTick, current) return tea.Every(d, func(t time.Time) tea.Msg { - return projectMailTickMsg{storeID: storeID, activation: activation, chain: chain, at: t} + return projectMailTickMsg{envelope: envelope, at: t} }) } -func (s ProjectMailStore) acceptsTick(msg projectMailTickMsg) bool { - return s.id != 0 && s.active && s.tickRunning && - msg.storeID == s.id && msg.activation == s.activation && msg.chain == s.tickChain -} - func (s ProjectMailStore) nextTick() tea.Cmd { if !s.active || !s.tickRunning { return nil } - return projectMailTickEvery(s.pollRate, s.id, s.activation, s.tickChain) + return projectMailTickEvery(s.pollRate, s.asyncCurrent()) +} + +func refreshAsyncKind(initial bool) asyncKind { + if initial { + return asyncInitialRebuild + } + return asyncSteadyRefresh } // beginRefresh coalesces every project-mail refresh path onto the one active // store pipeline. The command works on detached cache/session values; only -// acceptRefresh can install its result. +// acceptAsync in App.Update can authorize publication. func (s *ProjectMailStore) beginRefresh(mail MailModel, initial bool) tea.Cmd { if s == nil || s.id == 0 || !s.active { return nil @@ -238,18 +329,17 @@ func (s *ProjectMailStore) beginRefresh(mail MailModel, initial bool) tea.Cmd { // A steady scan may be reused only as a cache warm-up. An initial scan // satisfies only the MailModel generation that launched it; a replacement // generation still needs its own authoritative session rebuild. - if initial && (!s.refreshInitial || s.refreshGeneration != mail.generation) { + if initial && (!s.refreshInitial || s.refreshInFlightEnvelope.generation.thread != mail.generation) { s.initialRefreshPending = true } return nil } + current := s.asyncCurrent() + current.storeVersion = s.version + envelope := captureAsync(refreshAsyncKind(initial), current) s.refreshInFlight = true s.refreshInitial = initial - s.refreshGeneration = mail.generation - storeID := s.id - projectID := s.projectID - activation := s.activation - sourceVersion := s.version + s.refreshInFlightEnvelope = envelope cache := s.cache scanner := s.scanner return func() tea.Msg { @@ -263,73 +353,75 @@ func (s *ProjectMailStore) beginRefresh(mail MailModel, initial bool) tea.Cmd { } refreshed := scanner.Refresh(cache) refresh := mail.collectRefreshState() - refresh.generation = mail.generation refresh.initial = initial if initial { refresh.sessionCache = mail.rebuildSession(refreshed) } return projectMailRefreshMsg{ - storeID: storeID, - projectID: projectID, - activation: activation, - sourceVersion: sourceVersion, - cache: refreshed, - mail: refresh, + envelope: envelope, + cache: refreshed, + mail: refresh, } } } -// acceptRefresh distinguishes physical completion from publication. A result -// for the current store/source releases the one in-flight slot even when its -// MailModel generation was superseded, but only the current generation may -// replace root cache/version/snapshot state. -func (s *ProjectMailStore) acceptRefresh(msg projectMailRefreshMsg, generation uint64) (*ProjectMailSnapshot, bool, bool) { - if s == nil || s.id == 0 || !s.active || - msg.storeID != s.id || msg.projectID != s.projectID || - msg.activation != s.activation || msg.sourceVersion != s.version { - return nil, false, false +// settleRefreshWork performs non-publishing execution bookkeeping. Only the +// exact captured physical work token may release the slot; it never installs a +// cache, snapshot, model field, or location update. +func (s *ProjectMailStore) settleRefreshWork(envelope asyncEnvelope) bool { + if s == nil || !s.refreshInFlight || envelope != s.refreshInFlightEnvelope { + return false } s.refreshInFlight = false s.refreshInitial = false - s.refreshGeneration = 0 - if msg.mail.generation != generation { - return nil, false, true + s.refreshInFlightEnvelope = asyncEnvelope{} + return true +} + +// installRefresh publishes a result only after App.Update has accepted its +// envelope and settled its exact physical work token. +func (s *ProjectMailStore) installRefresh(msg projectMailRefreshMsg) *ProjectMailSnapshot { + if s == nil { + return nil } s.cache = msg.cache s.version++ s.snapshot = &ProjectMailSnapshot{version: s.version, cache: msg.cache} - s.syncRuntime() - return s.snapshot, true, true + // A delayed location command reuses the accepted result's source coordinate. + // A newer accepted refresh replaces this coordinate and rejects the old command. + s.locationSourceVersion = msg.envelope.storeVersion + s.syncAsyncState() + return s.snapshot } // beginPendingInitialRefresh starts the authoritative rebuild deferred behind -// an older steady scan. The completed steady result may itself be rejected for -// a superseded MailModel generation; the pending current initial still starts -// after that exact physical slot is released. +// older work. A completed-but-rejected exact token may release the slot, but the +// queued current initial must still pass fresh shared acceptance before launch; +// it never inherits permission from the old result. func (s *ProjectMailStore) beginPendingInitialRefresh(mail MailModel) tea.Cmd { if s == nil || !s.active || !s.initialRefreshPending || s.refreshInFlight { return nil } s.initialRefreshPending = false + current := s.asyncCurrent() + current.storeVersion = s.version + envelope := captureAsync(asyncInitialRebuild, current) + if !acceptAsync(current, envelope) { + return nil + } return s.beginRefresh(mail, true) } -func (s ProjectMailStore) locationUpdateCmd() tea.Cmd { - if s.id == 0 || !s.active || s.updateLocation == nil || s.runtime == nil { +func (s ProjectMailStore) locationUpdateCmd(envelope asyncEnvelope) tea.Cmd { + if s.id == 0 || !s.active || s.updateLocation == nil || s.asyncState == nil { return nil } humanDir := s.humanDir update := s.updateLocation - runtime := s.runtime - activation := s.activation - version := s.version + state := s.asyncState return func() tea.Msg { - // The command may execute after a visit switch, store suspension, or a - // newer accepted snapshot. Re-check the shared live token immediately - // before the side effect so only the current accepted owner may update. - if !runtime.active.Load() || - runtime.activation.Load() != activation || - runtime.version.Load() != version { + current := state.load() + if !acceptAsync(current, envelope) { return nil } update(humanDir) diff --git a/tui/internal/tui/project_mail_store_contract_test.go b/tui/internal/tui/project_mail_store_contract_test.go index 848b9e5d..9c98b38e 100644 --- a/tui/internal/tui/project_mail_store_contract_test.go +++ b/tui/internal/tui/project_mail_store_contract_test.go @@ -93,13 +93,349 @@ func TestProjectMailStoreOwnsTheOnlyGlobalCache(t *testing.T) { } } +func TestProjectMailTargetRevalidationBlocksDirectAndPendingInitialLaunch(t *testing.T) { + a := visitTestApp(t) + a.visiting = true + a.installMailModel(a.mail) + + calls := 0 + a.setAsyncTargetRevalidator(func(asyncOwner, asyncTarget) bool { + calls++ + return false + }) + if cmd := a.beginProjectMailRefresh(true); cmd != nil { + t.Fatalf("direct initial launch bypassed target revalidation and returned %T", cmd()) + } + if calls != 1 { + t.Fatalf("direct initial launch revalidator calls = %d, want 1", calls) + } + + calls = 0 + a.mailStore.initialRefreshPending = true + if cmd := a.mailStore.beginPendingInitialRefresh(a.mail); cmd != nil { + t.Fatalf("pending initial relaunch bypassed target revalidation and returned %T", cmd()) + } + if calls != 1 { + t.Fatalf("pending initial relaunch revalidator calls = %d, want 1", calls) + } + if a.mailStore.initialRefreshPending { + t.Fatal("rejected pending initial remained queued after its launch opportunity") + } +} + +func newNirvanaAsyncContinuationApp(t *testing.T, eventCount int) App { + t.Helper() + sourceDir := buildWindowedAgentDir(t, eventCount) + projectDir := t.TempDir() + orchDir := filepath.Join(projectDir, "Main") + if err := os.Rename(sourceDir, orchDir); err != nil { + t.Fatal(err) + } + humanDir := filepath.Join(projectDir, "human") + a := App{ + projectDir: projectDir, + globalDir: t.TempDir(), + currentView: appViewMail, + orchDir: orchDir, + orchName: "Main", + } + a.mailStore = newProjectMailStore(projectDir, humanDir) + a.installMailModel(NewMailModel(humanDir, "human", projectDir, orchDir, "Main", 100, a.globalDir, "en", false, 0)) + return a +} + +func runNirvanaCleanupToCompletedScreen(t *testing.T, a App) App { + t.Helper() + a.pauseProjectMail() + a.currentView = appViewNirvana + a.nirvana = NewNirvanaModel(a.projectDir) + a.nirvana.cursor = 0 + + model, cmd := a.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + a = model.(App) + for step := 0; step < 3; step++ { + if cmd == nil { + t.Fatalf("Nirvana cleanup stopped before the completed screen at step %d", step) + } + model, cmd = a.Update(cmd()) + a = model.(App) + if a.nirvana.done { + if cmd != nil { + t.Fatalf("completed Nirvana screen returned unexpected command %T", cmd()) + } + if _, err := os.Stat(a.projectDir); !os.IsNotExist(err) { + t.Fatalf("Nirvana cleanup did not remove project before completed screen: %v", err) + } + return a + } + } + t.Fatal("Nirvana cleanup did not reach the completed screen") + return App{} +} + +func TestNirvanaInvalidatesMailLocalAsyncContinuations(t *testing.T) { + resetApp := func(t *testing.T, a App) App { + t.Helper() + if err := os.RemoveAll(a.projectDir); err != nil { + t.Fatal(err) + } + a.currentView = appViewNirvana + a.nirvana = NewNirvanaModel(a.projectDir) + a.nirvana.done = true + model, _ := a.Update(NirvanaDoneMsg{}) + return model.(App) + } + + t.Run("persist", func(t *testing.T) { + a := newNirvanaAsyncContinuationApp(t, 50) + initial := detachedAppProjectMailRefresh(&a, true) + model, postFrame := a.Update(initial) + a = model.(App) + if a.mail.sessionCache == nil || !a.mail.sessionCache.Complete() { + t.Fatal("precondition: initial rebuild did not produce a complete persistable cache") + } + persist := runMailPersistCmd(t, postFrame) + humanDir := a.mail.humanDir + reset := resetApp(t, a) + sessionPath := filepath.Join(humanDir, "logs", "session.jsonl") + if _, err := os.Stat(sessionPath); !os.IsNotExist(err) { + t.Fatalf("precondition: recreated project already has stale session file: %v", err) + } + + model, cmd := reset.Update(persist) + if cmd != nil { + t.Errorf("late pre-Nirvana persist returned command %T", runCmd(cmd)) + } + if _, err := os.Stat(sessionPath); !os.IsNotExist(err) { + t.Fatalf("late pre-Nirvana persist wrote into recreated project: %v", err) + } + got := model.(App) + if got.mail.sessionCache != reset.mail.sessionCache { + t.Fatal("late pre-Nirvana persist replaced Mail state") + } + }) + + t.Run("older page", func(t *testing.T) { + a := newNirvanaAsyncContinuationApp(t, 250) + initial := detachedAppProjectMailRefresh(&a, true) + model, _ := a.Update(initial) + a = model.(App) + a.mail, _ = a.mail.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + a.mail.viewport.GotoTop() + var olderCmd tea.Cmd + a.mail, olderCmd = a.mail.Update(ctrlU()) + if olderCmd == nil || !a.mail.olderLoadInFlight { + t.Fatal("precondition: partial history did not launch an older-page continuation") + } + older, ok := olderCmd().(mailOlderPageMsg) + if !ok { + t.Fatalf("precondition: older-page command returned %T", olderCmd()) + } + reset := resetApp(t, a) + beforeCache := reset.mail.sessionCache + beforeWindow := reset.mail.ingestWindow + beforeExtra := reset.mail.loadedExtra + beforeMessages := len(reset.mail.messages) + + model, cmd := reset.Update(older) + if cmd != nil { + t.Fatalf("late pre-Nirvana older page scheduled %T", runCmd(cmd)) + } + got := model.(App) + if got.mail.sessionCache != beforeCache || got.mail.ingestWindow != beforeWindow || got.mail.loadedExtra != beforeExtra || len(got.mail.messages) != beforeMessages { + t.Fatalf("late pre-Nirvana older page mutated recreated lifetime: cacheChanged=%v window=%d extra=%d messages=%d", got.mail.sessionCache != beforeCache, got.mail.ingestWindow, got.mail.loadedExtra, len(got.mail.messages)) + } + }) +} + +func TestNirvanaInvalidatesAsyncBeforeDestructiveCleanup(t *testing.T) { + t.Run("refresh", func(t *testing.T) { + a := newNirvanaAsyncContinuationApp(t, 50) + refresh := detachedAppProjectMailRefresh(&a, true) + cleaned := runNirvanaCleanupToCompletedScreen(t, a) + beforeSnapshot := cleaned.mail.acceptedSnapshot + beforeCache := cleaned.mail.sessionCache + + model, cmd := cleaned.Update(refresh) + if cmd != nil { + t.Fatalf("late pre-cleanup refresh returned command %T before final Enter", runCmd(cmd)) + } + got := model.(App) + if got.mail.acceptedSnapshot != beforeSnapshot || got.mail.sessionCache != beforeCache { + t.Fatal("late pre-cleanup refresh published into the destroyed lifetime before final Enter") + } + if _, err := os.Stat(cleaned.projectDir); !os.IsNotExist(err) { + t.Fatalf("late pre-cleanup refresh recreated the deleted project: %v", err) + } + }) + + t.Run("persist", func(t *testing.T) { + a := newNirvanaAsyncContinuationApp(t, 50) + initial := detachedAppProjectMailRefresh(&a, true) + model, postFrame := a.Update(initial) + a = model.(App) + if a.mail.sessionCache == nil || !a.mail.sessionCache.Complete() { + t.Fatal("precondition: initial rebuild did not produce a complete persistable cache") + } + persist := runMailPersistCmd(t, postFrame) + humanDir := a.mail.humanDir + cleaned := runNirvanaCleanupToCompletedScreen(t, a) + sessionPath := filepath.Join(humanDir, "logs", "session.jsonl") + + model, cmd := cleaned.Update(persist) + if cmd != nil { + t.Fatalf("late pre-cleanup persist returned command %T before final Enter", runCmd(cmd)) + } + if _, err := os.Stat(sessionPath); !os.IsNotExist(err) { + t.Fatalf("late pre-cleanup persist recreated deleted session state before final Enter: %v", err) + } + got := model.(App) + if got.mail.sessionCache != cleaned.mail.sessionCache { + t.Fatal("late pre-cleanup persist replaced Mail state before final Enter") + } + }) + + t.Run("older page", func(t *testing.T) { + a := newNirvanaAsyncContinuationApp(t, 250) + initial := detachedAppProjectMailRefresh(&a, true) + model, _ := a.Update(initial) + a = model.(App) + a.mail, _ = a.mail.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + a.mail.viewport.GotoTop() + var olderCmd tea.Cmd + a.mail, olderCmd = a.mail.Update(ctrlU()) + if olderCmd == nil || !a.mail.olderLoadInFlight { + t.Fatal("precondition: partial history did not launch an older-page continuation") + } + older, ok := olderCmd().(mailOlderPageMsg) + if !ok { + t.Fatalf("precondition: older-page command returned %T", olderCmd()) + } + cleaned := runNirvanaCleanupToCompletedScreen(t, a) + beforeCache := cleaned.mail.sessionCache + beforeWindow := cleaned.mail.ingestWindow + beforeExtra := cleaned.mail.loadedExtra + beforeMessages := len(cleaned.mail.messages) + + model, cmd := cleaned.Update(older) + if cmd != nil { + t.Fatalf("late pre-cleanup older page scheduled %T before final Enter", runCmd(cmd)) + } + got := model.(App) + if got.mail.sessionCache != beforeCache || got.mail.ingestWindow != beforeWindow || got.mail.loadedExtra != beforeExtra || len(got.mail.messages) != beforeMessages { + t.Fatalf("late pre-cleanup older page mutated destroyed lifetime before final Enter: cacheChanged=%v window=%d extra=%d messages=%d", got.mail.sessionCache != beforeCache, got.mail.ingestWindow, got.mail.loadedExtra, len(got.mail.messages)) + } + if _, err := os.Stat(cleaned.projectDir); !os.IsNotExist(err) { + t.Fatalf("late pre-cleanup older page recreated the deleted project: %v", err) + } + }) +} + +func TestNirvanaCancelPreservesAsyncLifetime(t *testing.T) { + a := newNirvanaAsyncContinuationApp(t, 50) + beforeGeneration := a.mail.generation + beforeBinding := a.mail.asyncBinding + a.pauseProjectMail() + a.currentView = appViewNirvana + a.nirvana = NewNirvanaModel(a.projectDir) + + model, cmd := a.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + a = model.(App) + if cmd == nil { + t.Fatal("default Nirvana cancel did not return to mail") + } + if a.mail.generation != beforeGeneration || a.mail.asyncBinding != beforeBinding { + t.Fatal("pre-confirmation Nirvana cancel invalidated the Mail lifetime") + } + model, _ = a.Update(cmd()) + a = model.(App) + if a.currentView != appViewMail { + t.Fatalf("Nirvana cancel returned to view %d, want mail", a.currentView) + } + if a.mail.generation == 0 || a.mail.asyncBinding.owner.storeID == 0 { + t.Fatal("returning from Nirvana cancel left Mail without a live async lifetime") + } + if _, err := os.Stat(a.projectDir); err != nil { + t.Fatalf("Nirvana cancel changed the project filesystem: %v", err) + } +} + +func TestNirvanaCleanupStartIsSingleUseBeforeDestructiveCommand(t *testing.T) { + a := newNirvanaAsyncContinuationApp(t, 50) + a.pauseProjectMail() + a.currentView = appViewNirvana + a.nirvana = NewNirvanaModel(a.projectDir) + a.nirvana.cursor = 0 // Confirm. + + model, startCmd := a.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + a = model.(App) + if startCmd == nil { + t.Fatal("confirmed Nirvana did not emit cleanup-start command") + } + startMsg := startCmd() + if _, ok := startMsg.(nirvanaCleanStartMsg); !ok { + t.Fatalf("confirmed Nirvana emitted %T, want nirvanaCleanStartMsg", startMsg) + } + beforeGeneration := a.mail.generation + + model, cleanupCmd := a.Update(startMsg) + started := model.(App) + if cleanupCmd == nil { + t.Fatal("first cleanup-start did not return destructive cleanup command") + } + if _, err := os.Stat(started.projectDir); err != nil { + t.Fatalf("destructive cleanup ran before the root invalidation boundary returned: %v", err) + } + if started.mailStore.id != 0 || started.mailStore.active || started.suspendedHomeMailStore != nil || started.visiting || started.visitReturn != nil { + t.Fatalf("cleanup-start retained project Mail ownership: store=%+v suspended=%v visiting=%v return=%v", started.mailStore, started.suspendedHomeMailStore != nil, started.visiting, started.visitReturn != nil) + } + if started.mail.generation == beforeGeneration || started.mail.asyncBinding.owner.storeID != 0 { + t.Fatalf("cleanup-start did not invalidate Mail-local async identity: generation=%d binding=%+v", started.mail.generation, started.mail.asyncBinding) + } + + model, duplicateCmd := started.Update(startMsg) + duplicate := model.(App) + if duplicateCmd != nil { + t.Fatal("duplicate cleanup-start returned a second destructive cleanup command") + } + if duplicate.mail.generation != started.mail.generation || duplicate.mail.asyncBinding != started.mail.asyncBinding { + t.Fatal("duplicate cleanup-start mutated the already-retired Mail lifetime") + } +} + +func TestNirvanaDoneRequiresCompletedActiveScreen(t *testing.T) { + a := newNirvanaAsyncContinuationApp(t, 50) + beforeView := a.currentView + beforeGeneration := a.mail.generation + beforeBinding := a.mail.asyncBinding + beforeStore := a.mailStore + + model, cmd := a.Update(NirvanaDoneMsg{}) + got := model.(App) + if cmd != nil { + t.Fatal("premature NirvanaDoneMsg scheduled project recreation") + } + if got.currentView != beforeView { + t.Fatalf("premature NirvanaDoneMsg changed view to %d, want %d", got.currentView, beforeView) + } + if got.mail.generation != beforeGeneration || got.mail.asyncBinding != beforeBinding || + got.mailStore.id != beforeStore.id || got.mailStore.active != beforeStore.active || + got.mailStore.activation != beforeStore.activation || got.mailStore.tickChain != beforeStore.tickChain || + got.mailStore.binding != beforeStore.binding { + t.Fatal("premature NirvanaDoneMsg invalidated the live project Mail lifetime") + } + if _, err := os.Stat(got.projectDir); err != nil { + t.Fatalf("premature NirvanaDoneMsg changed the project filesystem: %v", err) + } +} + // TestShortMailOtherMailCycleRejectsLateTick exercises a cycle shorter than // the polling interval. The old chain has not fired yet when mail resumes, so // its eventual tick must be rejected and must not re-arm itself. func TestShortMailOtherMailCycleRejectsLateTick(t *testing.T) { a := visitTestApp(t) _ = a.mailStore.resumeTick() - old := projectMailTickMsg{storeID: a.mailStore.id, activation: a.mailStore.activation, chain: a.mailStore.tickChain, at: time.Now()} + old := projectMailTickMsg{envelope: captureAsync(asyncRefreshTick, a.asyncCurrent()), at: time.Now()} model, _ := a.switchToView("help") a = model.(App) @@ -153,7 +489,7 @@ func TestVisitStoreIdentityRejectsWrongProjectRefresh(t *testing.T) { func TestHomeVisitedBackHasOneActiveStore(t *testing.T) { a := visitTestApp(t) homeRefresh := detachedAppProjectMailRefresh(&a, false) - homeSnapshot, accepted, _ := a.mailStore.acceptRefresh(homeRefresh, a.mail.generation) + homeSnapshot, accepted, _ := acceptStoreRefreshForTest(&a.mailStore, homeRefresh) if !accepted { t.Fatal("failed to seed the accepted home snapshot") } @@ -255,18 +591,20 @@ func TestProjectMailStoreRejectsStaleRefreshAndUpdatesLocationOnce(t *testing.T) store := newProjectMailStoreWithDeps(mail.baseDir, mail.humanDir, filesystemProjectMailScanner{}, func(string) { locations.Add(1) }) + bindExistingStoreMailForAsyncTest(t, &store, &mail, 1) old := store.beginRefresh(mail, false)().(projectMailRefreshMsg) store.suspend() store.activate() + bindExistingStoreMailForAsyncTest(t, &store, &mail, mail.generation) current := store.beginRefresh(mail, false)().(projectMailRefreshMsg) - if _, ok, _ := store.acceptRefresh(old, mail.generation); ok { + if _, ok, _ := acceptStoreRefreshForTest(&store, old); ok { t.Fatal("stale pre-suspend refresh was accepted") } - if _, ok, _ := store.acceptRefresh(current, mail.generation); !ok { + if _, ok, _ := acceptStoreRefreshForTest(&store, current); !ok { t.Fatal("current refresh was rejected") } - if cmd := store.locationUpdateCmd(); cmd == nil { + if cmd := store.locationUpdateCmd(current.envelope); cmd == nil { t.Fatal("accepted active store did not own location update") } else { _ = cmd() @@ -339,6 +677,7 @@ func TestSupersededMailGenerationCannotInstallRootRefresh(t *testing.T) { a.mailStore = newProjectMailStoreWithDeps(a.projectDir, a.mail.humanDir, filesystemProjectMailScanner{}, func(string) { locations.Add(1) }) + a.mailStore.bindMailModel(&a.mail, a.visiting) a.mail.acceptedSnapshot = nil steadyCmd := a.beginProjectMailRefresh(false) @@ -346,7 +685,7 @@ func TestSupersededMailGenerationCannotInstallRootRefresh(t *testing.T) { t.Fatal("pre-replacement steady refresh was not scheduled") } steady := steadyCmd().(projectMailRefreshMsg) - oldGeneration := steady.mail.generation + oldGeneration := steady.envelope.generation.thread beforeVersion := a.mailStore.version a.installMailModel(NewMailModel(a.mail.humanDir, "human", a.projectDir, a.orchDir, a.orchName, 500, a.globalDir, "en", false, 0)) @@ -366,8 +705,8 @@ func TestSupersededMailGenerationCannotInstallRootRefresh(t *testing.T) { if !ok { t.Fatal("old generation did not release the pipeline for the queued authoritative initial") } - if !initial.mail.initial || initial.mail.generation != got.mail.generation || initial.mail.sessionCache == nil { - t.Fatalf("follow-up refresh = initial %v generation %d sessionCache %v; want authoritative generation %d", initial.mail.initial, initial.mail.generation, initial.mail.sessionCache != nil, got.mail.generation) + if !initial.mail.initial || initial.envelope.generation.thread != got.mail.generation || initial.mail.sessionCache == nil { + t.Fatalf("follow-up refresh = initial %v generation %d sessionCache %v; want authoritative generation %d", initial.mail.initial, initial.envelope.generation.thread, initial.mail.sessionCache != nil, got.mail.generation) } if locations.Load() != 0 { t.Fatal("rejected old-generation refresh ran a human-location update") @@ -387,7 +726,7 @@ func TestSupersededInitialRefreshQueuesReplacementInitial(t *testing.T) { t.Fatal("pre-replacement initial refresh was not scheduled") } oldInitial := oldInitialCmd().(projectMailRefreshMsg) - oldGeneration := oldInitial.mail.generation + oldGeneration := oldInitial.envelope.generation.thread a.installMailModel(NewMailModel(a.mail.humanDir, "human", a.projectDir, a.orchDir, a.orchName, 500, a.globalDir, "en", false, 0)) if a.mail.generation == oldGeneration { @@ -406,8 +745,8 @@ func TestSupersededInitialRefreshQueuesReplacementInitial(t *testing.T) { if !ok { t.Fatal("replacement generation lost its authoritative initial behind the old-generation initial") } - if !initial.mail.initial || initial.mail.generation != got.mail.generation || initial.mail.sessionCache == nil { - t.Fatalf("follow-up refresh = initial %v generation %d sessionCache %v; want authoritative generation %d", initial.mail.initial, initial.mail.generation, initial.mail.sessionCache != nil, got.mail.generation) + if !initial.mail.initial || initial.envelope.generation.thread != got.mail.generation || initial.mail.sessionCache == nil { + t.Fatalf("follow-up refresh = initial %v generation %d sessionCache %v; want authoritative generation %d", initial.mail.initial, initial.envelope.generation.thread, initial.mail.sessionCache != nil, got.mail.generation) } model, _ = got.Update(initial) @@ -478,16 +817,17 @@ func TestHumanLocationUpdateRevalidatesAtExecution(t *testing.T) { store := newProjectMailStoreWithDeps(projectDir, mail.humanDir, filesystemProjectMailScanner{}, func(string) { updates.Add(1) }) + bindExistingStoreMailForAsyncTest(t, &store, &mail, 1) return &store, mail, updates } t.Run("suspended activation", func(t *testing.T) { store, mail, updates := newStore(t) msg := store.beginRefresh(mail, false)().(projectMailRefreshMsg) - if _, accepted, _ := store.acceptRefresh(msg, mail.generation); !accepted { + if _, accepted, _ := acceptStoreRefreshForTest(store, msg); !accepted { t.Fatal("precondition: current refresh was rejected") } - cmd := store.locationUpdateCmd() + cmd := store.locationUpdateCmd(msg.envelope) store.suspend() _ = cmd() if updates.Load() != 0 { @@ -498,15 +838,15 @@ func TestHumanLocationUpdateRevalidatesAtExecution(t *testing.T) { t.Run("superseded version", func(t *testing.T) { store, mail, updates := newStore(t) first := store.beginRefresh(mail, false)().(projectMailRefreshMsg) - if _, accepted, _ := store.acceptRefresh(first, mail.generation); !accepted { + if _, accepted, _ := acceptStoreRefreshForTest(store, first); !accepted { t.Fatal("precondition: first refresh was rejected") } - staleCmd := store.locationUpdateCmd() + staleCmd := store.locationUpdateCmd(first.envelope) second := store.beginRefresh(mail, false)().(projectMailRefreshMsg) - if _, accepted, _ := store.acceptRefresh(second, mail.generation); !accepted { + if _, accepted, _ := acceptStoreRefreshForTest(store, second); !accepted { t.Fatal("precondition: second refresh was rejected") } - currentCmd := store.locationUpdateCmd() + currentCmd := store.locationUpdateCmd(second.envelope) _ = staleCmd() if updates.Load() != 0 { t.Fatal("superseded store version executed a stale human-location update") diff --git a/tui/internal/tui/project_mail_store_test_helpers_test.go b/tui/internal/tui/project_mail_store_test_helpers_test.go index da70cbcf..7bc72a72 100644 --- a/tui/internal/tui/project_mail_store_test_helpers_test.go +++ b/tui/internal/tui/project_mail_store_test_helpers_test.go @@ -1,26 +1,104 @@ package tui -import tea "charm.land/bubbletea/v2" - -// acceptedInitialMailRefresh runs the root store pipeline synchronously for -// MailModel-focused tests, accepts its snapshot, and returns the target payload -// that production App.Update would route to MailModel.Update. -func acceptedInitialMailRefresh(m MailModel) tea.Msg { - store := newProjectMailStore(m.baseDir, m.humanDir) - cmd := store.beginRefresh(m, true) - msg := cmd().(projectMailRefreshMsg) - snapshot, _, _ := store.acceptRefresh(msg, m.generation) - msg.mail.snapshot = snapshot - return msg.mail +import ( + "path/filepath" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/anthropics/lingtai-tui/internal/fs" + "github.com/anthropics/lingtai-tui/internal/inventory" +) + +// bindMailModelForAsyncTest gives MailModel-focused fixtures a complete current +// binding without weakening production acceptance. Tests whose historical base +// and target directories were independent use the target's parent as the +// synthetic canonical project owner. +func bindMailModelForAsyncTest(t *testing.T, m *MailModel, generation uint64) ProjectMailStore { + t.Helper() + if generation == 0 { + generation = 1 + } + m.generation = generation + if m.orchestrator == "" { + m.orchestrator = filepath.Join(t.TempDir(), "Main") + } + if m.orchAddr == "" { + m.orchAddr = "mail-test-address" + } + projectDir := m.baseDir + probe := asyncOwner{projectID: canonicalProjectMailIdentity(projectDir), storeID: 1, activation: 1} + target := asyncTarget{directory: inventory.NormalizePath(m.orchestrator), addressFingerprint: "invalid"} + if projectDir == "" || !validAsyncTarget(probe, asyncTarget{ + directory: target.directory, + addressFingerprint: fs.AddressFingerprint(m.orchAddr), + }) { + projectDir = filepath.Dir(m.orchestrator) + } + store := newProjectMailStore(projectDir, m.humanDir) + store.bindMailModel(m, false) + return store +} + +func bindExistingStoreMailForAsyncTest(t *testing.T, store *ProjectMailStore, m *MailModel, generation uint64) { + t.Helper() + if generation == 0 { + generation = 1 + } + m.generation = generation + if m.orchestrator == "" || inventory.NormalizePath(m.orchestrator) == store.projectID { + m.orchestrator = filepath.Join(store.projectID, "Main") + } + if m.orchAddr == "" { + m.orchAddr = "mail-test-address" + } + store.bindMailModel(m, false) +} + +// acceptedInitialMailRefresh drives the real outer App.Update acceptance and +// then returns the already-accepted payload for MailModel-focused tests. The +// helper restores the pre-payload model with its accepted store version so the +// test can exercise the payload transition itself exactly once. +func acceptedInitialMailRefresh(t *testing.T, m *MailModel) tea.Msg { + return acceptedMailRefresh(t, m, true) } -func acceptedSteadyMailRefresh(m MailModel) tea.Msg { - store := newProjectMailStore(m.baseDir, m.humanDir) - cmd := store.beginRefresh(m, false) +func acceptedSteadyMailRefresh(t *testing.T, m *MailModel) tea.Msg { + return acceptedMailRefresh(t, m, false) +} + +func acceptedMailRefresh(t *testing.T, m *MailModel, initial bool) tea.Msg { + t.Helper() + generation := m.generation + store := bindMailModelForAsyncTest(t, m, generation) + bound := *m + cmd := store.beginRefresh(bound, initial) + if cmd == nil { + t.Fatal("test fixture could not launch project-mail refresh") + } msg := cmd().(projectMailRefreshMsg) - snapshot, _, _ := store.acceptRefresh(msg, m.generation) - msg.mail.snapshot = snapshot - return msg.mail + app := App{currentView: appViewMail, mail: bound, mailStore: store} + model, _ := app.Update(msg) + updated := model.(App) + if updated.mail.acceptedSnapshot == nil { + t.Fatal("test fixture refresh did not pass real App.Update acceptance") + } + payload := msg.mail + payload.snapshot = updated.mail.acceptedSnapshot + bound.asyncStoreVersion = updated.mailStore.version + *m = bound + return payload +} + +func acceptStoreRefreshForTest(store *ProjectMailStore, msg projectMailRefreshMsg) (*ProjectMailSnapshot, bool, bool) { + settled := store.settleRefreshWork(msg.envelope) + if !acceptAsync(store.asyncCurrent(), msg.envelope) { + return nil, false, settled + } + if !settled { + return nil, false, false + } + return store.installRefresh(msg), true, true } func detachedAppProjectMailRefresh(a *App, initial bool) projectMailRefreshMsg { diff --git a/tui/internal/tui/settings_page_size_test.go b/tui/internal/tui/settings_page_size_test.go index 660d3290..0984743d 100644 --- a/tui/internal/tui/settings_page_size_test.go +++ b/tui/internal/tui/settings_page_size_test.go @@ -1,6 +1,7 @@ package tui import ( + "path/filepath" "reflect" "testing" @@ -48,8 +49,11 @@ func TestSettingsMailPageSizeHundredOption(t *testing.T) { func TestReturningToMailAfterPageSizeChangeRebuildsExactWindow(t *testing.T) { globalDir := t.TempDir() - projectDir := t.TempDir() orchDir := buildWindowedAgentDir(t, 250) + // Keep the synthetic agent under its canonical project owner so the shared + // launch predicate exercises the page-size behavior rather than rejecting an + // impossible cross-project target fixture. + projectDir := filepath.Dir(orchDir) cfg := config.DefaultTUIConfig() cfg.MailPageSize = 100 @@ -65,7 +69,7 @@ func TestReturningToMailAfterPageSizeChangeRebuildsExactWindow(t *testing.T) { orchName: "agent", } a.installMailModel(NewMailModel(t.TempDir(), "human", projectDir, orchDir, "agent", 200, globalDir, "en", false, 0)) - initial := acceptedInitialMailRefresh(a.mail).(mailRefreshMsg) + initial := acceptedInitialMailRefresh(t, &a.mail).(mailRefreshPayload) a.mail, _ = a.mail.Update(initial) if a.mail.sessionCache.Len() != 200 { t.Fatalf("precondition cache = %d, want 200", a.mail.sessionCache.Len()) diff --git a/tui/internal/tui/status_hint_copy_test.go b/tui/internal/tui/status_hint_copy_test.go index cb4fc6b1..7d7886a8 100644 --- a/tui/internal/tui/status_hint_copy_test.go +++ b/tui/internal/tui/status_hint_copy_test.go @@ -36,7 +36,7 @@ func newEnglishHomeModel(t *testing.T, w, h int) MailModel { } m := NewMailModel(humanDir, "human@local", "~", orchDir, "TestOrch", 50, dir, "en", false, 0) m, _ = m.Update(tea.WindowSizeMsg{Width: w, Height: h}) - m, _ = m.Update(acceptedInitialMailRefresh(m)) + m, _ = m.Update(acceptedInitialMailRefresh(t, &m)) return m }