Skip to main content

agentmux_srv/server/agent_handlers/
session.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4
5use std::sync::Arc;
6
7
8use crate::backend::rpc::engine::WshRpcEngine;
9use crate::backend::rpc_types::{
10    COMMAND_LIST_RECENT_SESSIONS, CommandListRecentSessionsData,
11    ListRecentSessionsResult, RecentSessionRow,
12    // Option E (PR 1 of 2) — agent-anchored session zones.
13    COMMAND_AGENT_SESSION_READ, COMMAND_AGENT_SESSION_WRITE_STATE,
14    COMMAND_AGENT_SESSION_APPEND_OUTPUT, COMMAND_AGENT_SESSION_ARCHIVE,
15    COMMAND_AGENT_SESSION_LIST_ARCHIVES,
16    CommandAgentSessionReadData, AgentSessionReadResult,
17    CommandAgentSessionWriteStateData, AgentSessionWriteStateResult,
18    CommandAgentSessionAppendOutputData, AgentSessionAppendOutputResult,
19    CommandAgentSessionArchiveData, AgentSessionArchiveResult,
20    CommandAgentSessionListArchivesData, AgentArchiveRow,
21};
22use crate::backend::storage::store::{AgentIdentityLink, AgentInstance, IdentityAccount};
23
24use super::super::AppState;
25use super::read_session_preview;
26
27/// At-most-once-per-process-lifetime claim set for definition-summary
28/// generation attempts triggered from `listrecentsessions` — see the call
29/// site below and `db_agent_activity_summaries` (OBJECT_SCHEMA_VERSION
30/// v28). `HashSet::insert` returns `true` only the first time a given
31/// `definition_id` is claimed; every later call for the same id (a repeat
32/// poll of "My Agents") sees `false` and does not re-spawn.
33fn definition_summary_attempted() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
34    static SET: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
35        std::sync::OnceLock::new();
36    SET.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
37}
38
39pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
40    // ---- Recent sessions (cascade follow-up 2026-05-23) ----
41    //
42    // listrecentsessions — joins `db_agent_instances` with the
43    // filestore `output.state.json` snapshot for each instance's
44    // block_id_hint, producing a preview + node count so the
45    // AgentPicker can show actual conversation context instead of just
46    // metadata. Sort key is the snapshot modts (last activity)
47    // descending; rows without a snapshot fall back to the instance
48    // started_at and are de-prioritized. Cap at 20 rows.
49    //
50    // The reattach mechanism is the existing continuation flow:
51    // continueOfInstanceId + workDirOverride (see PR #977). This RPC
52    // is a more discoverable surface for finding sessions to continue
53    // — particularly orphaned ones whose pane crashed.
54    let mstore = state.mstore.clone();
55    let id_store_lrs = state.id_store.clone();
56    let identity_store_lrs = state.identity_store.clone();
57    let filestore = state.filestore.clone();
58    let broker_lrs = state.broker.clone();
59    // `Value` request: a malformed one means the defaults (unwrap_or_default).
60    engine.register_typed(COMMAND_LIST_RECENT_SESSIONS, move |data: serde_json::Value, _ctx| {
61        let mstore = mstore.clone();
62        let id_store = id_store_lrs.clone();
63        let identity_store = identity_store_lrs.clone();
64        let filestore = filestore.clone();
65        let broker = broker_lrs.clone();
66        async move {
67                let t0 = std::time::Instant::now();
68                let cmd: CommandListRecentSessionsData =
69                    serde_json::from_value(data).unwrap_or_default();
70                let limit = if cmd.limit == 0 {
71                    20
72                } else {
73                    cmd.limit.min(100)
74                };
75                // Tracks which of this handler's data sources degraded to
76                // empty this call. Every source below used to `.map_err(..)?`
77                // — one bad row anywhere aborted the ENTIRE "My Agents" list,
78                // and that exact failure mode has already broken it to an
79                // empty list at least twice in production (PR #2296's oauth
80                // serde-tag mismatch; see
81                // retro-my-agents-fresh-channel-regression-2026_07_27 (never committed to this repo)
82                // §4/§9 rec 1). A single malformed row must degrade THAT
83                // source only, not the whole response — logged here so the
84                // next incident's log can actually show what happened
85                // (§9 rec 3), unlike this retro's investigation, which had
86                // no way to tell whether this handler ran at all.
87                let mut degraded: Vec<&'static str> = Vec::new();
88                // Pull up to ~10x the requested cap so we can post-
89                // filter by snapshot presence + identity_id without
90                // running out of candidates. 10x is a safety margin
91                // and stays well inside the 200 default of
92                // instance_list_named.
93                let raw_limit = (limit * 10).max(50).min(500);
94
95                // Identity filter is pushed INTO `instance_list_named`
96                // (codex P2 #3 on PR #1096): when a chain has
97                // continuations under different accounts, the
98                // ranking must run on identity-matching rows so the
99                // newest match wins. Post-query filtering would drop
100                // the chain entirely if the newest row used a
101                // different identity, even when older rows match.
102                let identity_filter = cmd
103                    .identity_id
104                    .as_deref()
105                    .map(|s| s.trim())
106                    .filter(|s| !s.is_empty());
107
108                // "My Agents" sources from the cross-version REGISTRY when it's
109                // available, so agents created in ANY build / channel / version
110                // appear here — not just this instance's local SQLite sessions
111                // (the live mirror, registry_mirror.rs, keeps the registry current
112                // for global workspaces). Falls back to local SQLite when the
113                // registry couldn't be resolved (CI / odd envs). Cross-channel rows
114                // arrive as synthetic instances (no live block); the per-instance
115                // snapshot enrichment below lights up the ones that ALSO ran here.
116                let instances: Vec<AgentInstance> = match mstore.shared_agent_registry() {
117                    Some(reg) => {
118                        let agents_root = mstore.registry_agents_base();
119                        let mut records = match reg.list_active() {
120                            Ok(v) => v,
121                            Err(e) => {
122                                tracing::warn!(
123                                    error = %e,
124                                    "listrecentsessions: registry list_active failed — \
125                                     degrading to local-only instances for this call"
126                                );
127                                degraded.push("registry");
128                                Vec::new()
129                            }
130                        };
131                        if let Some(idf) = identity_filter {
132                            records.retain(|r| r.data.identity_id.as_deref() == Some(idf));
133                        }
134                        // Dedup by (definition_id, instance_name) keeping the newest
135                        // launch — the registry read path lacks SQLite's chain-root
136                        // collapse, so two fresh heads of one logical agent would
137                        // otherwise double up. Sort newest-first within each group,
138                        // collapse, then re-sort by recency.
139                        records.sort_by(|a, b| {
140                            a.data
141                                .definition_id
142                                .cmp(&b.data.definition_id)
143                                .then_with(|| a.data.instance_name.cmp(&b.data.instance_name))
144                                .then_with(|| {
145                                    b.data.last_launched_at_ms.cmp(&a.data.last_launched_at_ms)
146                                })
147                        });
148                        records.dedup_by(|a, b| {
149                            a.data.definition_id == b.data.definition_id
150                                && a.data.instance_name == b.data.instance_name
151                        });
152                        records.sort_by(|a, b| {
153                            b.data.last_launched_at_ms.cmp(&a.data.last_launched_at_ms)
154                        });
155                        records.truncate(raw_limit);
156                        // Local agents in PICKER mode (include_continuations=true):
157                        // collapses each chain to one row AND surfaces orphan
158                        // continuations (head hard-deleted) as their own root, so
159                        // they don't vanish from "My Agents" (reagent P2). Indexed by
160                        // (definition_id, instance_name) — the SAME key the registry
161                        // dedup uses — keeping the newest, so overlay AND the
162                        // local-only append agree on identity (reagent P1).
163                        let local = mstore
164                            .instance_list_named(raw_limit, None, identity_filter, true)
165                            .unwrap_or_else(|e| {
166                                tracing::warn!(
167                                    error = %e,
168                                    "listrecentsessions: local instance_list_named failed — \
169                                     degrading to registry-only instances for this call"
170                                );
171                                degraded.push("local_instances");
172                                Vec::new()
173                            });
174                        let mut local_by_key: std::collections::HashMap<
175                            (String, String),
176                            AgentInstance,
177                        > = std::collections::HashMap::new();
178                        for li in local {
179                            let key = (li.definition_id.clone(), li.instance_name.clone());
180                            match local_by_key.get(&key) {
181                                Some(e) if e.started_at >= li.started_at => {}
182                                _ => {
183                                    local_by_key.insert(key, li);
184                                }
185                            }
186                        }
187                        let mut out: Vec<AgentInstance> = records
188                            .into_iter()
189                            .map(|rec| {
190                                let d = rec.data;
191                                let key = (d.definition_id.clone(), d.instance_name.clone());
192                                if let Some(li) = local_by_key.get(&key) {
193                                    // Defense-in-depth (SPEC_PANE_CLOSE_REOPEN_
194                                    // CONTINUITY_GUARANTEE_2026_07_27.md §4.1):
195                                    // the local row is normally the fresher
196                                    // source (persist_session_id keeps it live
197                                    // as of that spec), but fall back to the
198                                    // registry's session_id when the local one
199                                    // is empty rather than surfacing an empty
200                                    // session id the picker's reattach flow
201                                    // would silently treat as "start fresh."
202                                    // Correct regardless of whether the
203                                    // primary live-write fix is in place for
204                                    // this particular row (e.g. it predates
205                                    // that change, or the write raced/failed).
206                                    if li.session_id.is_empty() {
207                                        if let Some(ref registry_sid) = d.session_id {
208                                            if !registry_sid.is_empty() {
209                                                let mut merged = li.clone();
210                                                merged.session_id = registry_sid.clone();
211                                                return merged;
212                                            }
213                                        }
214                                    }
215                                    return li.clone();
216                                }
217                                // Reconstruct the absolute workdir from the record's
218                                // source base (v3) or the current channel (legacy).
219                                let working_directory = match d.source_agents_base.as_deref() {
220                                    Some(src) => std::path::Path::new(src)
221                                        .join(&d.working_dir)
222                                        .to_string_lossy()
223                                        .to_string(),
224                                    None => match agents_root.as_ref() {
225                                        Some(root) => root
226                                            .join(&d.working_dir)
227                                            .to_string_lossy()
228                                            .to_string(),
229                                        None => d.working_dir.clone(),
230                                    },
231                                };
232                                AgentInstance {
233                                    id: d.instance_id,
234                                    definition_id: d.definition_id,
235                                    parent_instance_id: String::new(),
236                                    block_id: String::new(),
237                                    session_id: d.session_id.unwrap_or_default(),
238                                    status: "available".to_string(),
239                                    github_context: String::new(),
240                                    started_at: d.last_launched_at_ms,
241                                    ended_at: 0,
242                                    created_at: d.created_at_ms,
243                                    identity_id: d.identity_id.unwrap_or_default(),
244                                    memory_id: d.memory_id.unwrap_or_default(),
245                                    instance_name: d.instance_name,
246                                    working_directory,
247                                    display_hidden: false,
248                                }
249                            })
250                            .collect();
251                        // APPEND local-only agents — those whose (definition_id,
252                        // instance_name) no registry record represents (created
253                        // before the live mirror could register them, or orphan
254                        // continuations). Keyed identically to the dedup, so a deduped
255                        // agent's local head never re-appears as a duplicate row.
256                        let have_keys: std::collections::HashSet<(String, String)> = out
257                            .iter()
258                            .map(|i| (i.definition_id.clone(), i.instance_name.clone()))
259                            .collect();
260                        for (key, li) in local_by_key {
261                            if !have_keys.contains(&key) {
262                                out.push(li);
263                            }
264                        }
265                        out
266                    }
267                    None => mstore
268                        .instance_list_named(raw_limit, None, identity_filter, true)
269                        .unwrap_or_else(|e| {
270                            tracing::warn!(
271                                error = %e,
272                                "listrecentsessions: local instance_list_named failed \
273                                 (no shared registry attached) — degrading to empty list"
274                            );
275                            degraded.push("local_instances");
276                            Vec::new()
277                        }),
278                };
279
280                let defs = mstore.agent_def_list().unwrap_or_else(|e| {
281                    tracing::warn!(
282                        error = %e,
283                        "listrecentsessions: agent_def_list failed — degrading to empty \
284                         (rows will show \"(missing definition)\")"
285                    );
286                    degraded.push("agent_defs");
287                    Vec::new()
288                });
289                // Identity display names resolve off the direct
290                // agent<->account links now (db_agent_identity_links /
291                // db_accounts), not the retired bundle tables — see
292                // SPEC_ARMORY_PHASE4_STORAGE_RENAME_COMPLETION_2026_07_12.md
293                // §4 item 1. Bulk-fetched once and grouped by definition_id
294                // rather than queried per-row.
295                let agent_identity_links = identity_store.agent_identity_list_all().unwrap_or_else(|e| {
296                    tracing::warn!(
297                        error = %e,
298                        // Fires alongside degraded.push("identity_links")
299                        // below; the row loop then leaves the account line
300                        // empty (a failed lookup is not "No auth").
301                        "listrecentsessions: agent_identity_list_all failed — degrading to \
302                         empty (rows will show no account line)"
303                    );
304                    degraded.push("identity_links");
305                    Vec::new()
306                });
307                let accounts = id_store.identity_list(None).unwrap_or_else(|e| {
308                    // The exact call PR #2296 broke (an oauth secret_ref serde-tag
309                    // mismatch aborted this and, before this hardening, the whole
310                    // handler with it) — now degrades in isolation instead.
311                    tracing::warn!(
312                        error = %e,
313                        "listrecentsessions: identity_list failed — degrading to empty \
314                         (the global mirror fills in what it can; a link it cannot resolve shows no account line)"
315                    );
316                    degraded.push("accounts");
317                    Vec::new()
318                });
319                // The global mirror, read only when a link points at an account
320                // this channel's store does not hold (an isolated channel).
321                let own_ids: std::collections::HashSet<&str> = accounts.iter().map(|a| a.id.as_str()).collect();
322                let mut mirror_failed = false;
323                let mirror_accounts: Vec<IdentityAccount> =
324                    if agent_identity_links.iter().any(|l| !own_ids.contains(l.account_id.as_str())) {
325                        identity_store.identity_list(None).unwrap_or_else(|e| {
326                            tracing::warn!(error = %e, "listrecentsessions: the global account mirror could not be read");
327                            mirror_failed = true;
328                            Vec::new()
329                        })
330                    } else {
331                        Vec::new()
332                    };
333                let account_names = super::account_label::names_by_id(&accounts, &mirror_accounts);
334                let mut unresolved_links = 0usize;
335                let mut links_by_agent: std::collections::HashMap<&str, Vec<&AgentIdentityLink>> =
336                    std::collections::HashMap::new();
337                for link in &agent_identity_links {
338                    links_by_agent
339                        .entry(link.agent_id.as_str())
340                        .or_default()
341                        .push(link);
342                }
343                let memories = id_store.bundle_list().unwrap_or_else(|e| {
344                    tracing::warn!(
345                        error = %e,
346                        "listrecentsessions: bundle_list failed — degrading to empty \
347                         (rows will show \"(missing memory)\")"
348                    );
349                    degraded.push("memories");
350                    Vec::new()
351                });
352
353                // docs/reports/REPORT_AGENT_PICKER_FIELD_ORDER_SORT_AND_DATA_GAPS_AUDIT_2026_08_24.md
354                // §4: when `agent_identity_list_all()` itself failed above,
355                // `links_by_agent` is empty for EVERY definition_id — not
356                // just the ones that genuinely have no bound identity —
357                // so every row would otherwise read "No auth", making a
358                // source failure indistinguishable from a legitimate state.
359                // The same goes for a failed accounts lookup that the global
360                // mirror could not make up for: a link resolving to nothing
361                // then proves nothing. Computed once, outside the loop: these
362                // sources only ever degrade before the loop starts, not per-row.
363                let degraded_lookups = super::account_label::Degraded {
364                    links: degraded.contains(&"identity_links"),
365                    accounts: degraded.contains(&"accounts") && (mirror_failed || mirror_accounts.is_empty()),
366                };
367
368                // Build rows. Hits filestore once per instance; with
369                // raw_limit ≤ 500 and stat() being a single indexed
370                // SQLite query, the per-call cost is dominated by
371                // the eventual snapshot read for the top-20.
372                let mut rows: Vec<RecentSessionRow> = Vec::with_capacity(instances.len());
373                // What each agent was last asked comes from its global transcript
374                // (keyed by agent id), not from this channel's per-block
375                // snapshot, so a closed pane, an agent never launched here and a
376                // registry-only row all get a real preview.
377                let histories = crate::backend::agent_session::history_summary::read_histories(
378                    instances.iter().map(|i| i.definition_id.clone()).collect(),
379                )
380                .await;
381                if histories
382                    .values()
383                    .any(|h| matches!(h, crate::backend::agent_session::history_summary::HistoryRead::Failed))
384                {
385                    degraded.push("transcript");
386                }
387                for inst in instances {
388                    // Consolidation Phase 3b: `definition_id` is the agent's
389                    // own id, so its own row answers every display question
390                    // (name, provider, model, type, created_at) — those are
391                    // the agent's current values, which may well have drifted
392                    // from the template it was launched off.
393                    let def = defs.iter().find(|d| d.id == inst.definition_id);
394                    // The template it came from, used ONLY for the identity-
395                    // link fallback below (empty `parent_id` for an agent that
396                    // was never cloned from one).
397                    let template = def
398                        .filter(|d| !d.parent_id.is_empty())
399                        .and_then(|d| defs.iter().find(|t| t.id == d.parent_id));
400                    // The agent's own account links, falling back to the
401                    // template's. `db_agent_identity_links`' FK now targets
402                    // `db_agents` (Phase 3c, #3088) — a launch-only agent
403                    // CAN hold a link of its own — but nothing writes one
404                    // there yet: the Agent Setup modal, the only identity
405                    // binding UI, only ever targets a template or user
406                    // agent's own definition id. Until a launch-scoped
407                    // binding path exists, this fallback stays exactly as
408                    // necessary as it was before the FK moved; removing it
409                    // now would just make a launched agent's identity
410                    // silently disappear. Retire alongside that writer path
411                    // (Phase 3d).
412                    let links_key = match links_by_agent.get(inst.definition_id.as_str()) {
413                        Some(_) => inst.definition_id.as_str(),
414                        None => template
415                            .map(|t| t.id.as_str())
416                            .unwrap_or(inst.definition_id.as_str()),
417                    };
418                    let line = super::account_label::account_line(
419                        links_by_agent.get(links_key).map(|v| v.as_slice()),
420                        &account_names,
421                        degraded_lookups,
422                    );
423                    unresolved_links += line.unresolved;
424                    let identity_name = line.text;
425                    let memory_name = if inst.memory_id.is_empty() {
426                        "(vanilla CLI)".to_string()
427                    } else {
428                        memories
429                            .iter()
430                            .find(|m| m.id == inst.memory_id)
431                            .map(|m| m.name.clone())
432                            .unwrap_or_else(|| "(missing memory)".to_string())
433                    };
434
435                    // Stat first (cheap) — gives us the modts for
436                    // sorting. Only fetch the full content if the
437                    // snapshot exists.
438                    //
439                    // docs/reports/REPORT_AGENT_PICKER_FIELD_ORDER_SORT_AND_DATA_GAPS_AUDIT_2026_08_24.md
440                    // §5: `Ok(None)` (the block genuinely never wrote a
441                    // snapshot) and `Err(_)` (a transient filestore I/O
442                    // error, lock contention, DB read failure) used to
443                    // collapse into the same `_ =>` arm — a real error on a
444                    // row that DOES have history rendered identically to
445                    // "never had one," silently. Split so the row can carry
446                    // that distinction downstream instead of losing it here.
447                    use crate::backend::agent_session::history_summary::HistoryRead;
448                    // `node_count` is 0 here: counting messages would mean reading
449                    // the whole transcript, so the tile shows none rather than a
450                    // guess.
451                    let from_transcript = match histories.get(&inst.definition_id) {
452                        Some(HistoryRead::Transcript(h)) => Some((
453                            true,
454                            if h.last_activity_ms > 0 { h.last_activity_ms } else { inst.started_at },
455                            h.last_user_message.clone(),
456                            0usize,
457                            false,
458                        )),
459                        Some(HistoryRead::Failed) => Some((false, inst.started_at, String::new(), 0usize, true)),
460                        // No transcript: fall back to this channel's per-block
461                        // snapshot, which old builds wrote.
462                        Some(HistoryRead::NoTranscript) | None => None,
463                    };
464                    let (has_snapshot, last_active_at, mut preview, node_count, snapshot_check_failed) =
465                        if let Some(t) = from_transcript {
466                            t
467                        } else if inst.block_id.is_empty() {
468                            (false, inst.started_at, String::new(), 0usize, false)
469                        } else {
470                            match filestore.stat(&inst.block_id, "output.state.json") {
471                                Ok(Some(file)) => {
472                                    let modts = if file.modts > 0 {
473                                        file.modts
474                                    } else {
475                                        inst.started_at
476                                    };
477                                    let (preview, node_count) = read_session_preview(
478                                        &filestore,
479                                        &inst.block_id,
480                                    );
481                                    (true, modts, preview, node_count, false)
482                                }
483                                Ok(None) => (false, inst.started_at, String::new(), 0usize, false),
484                                Err(e) => {
485                                    tracing::warn!(
486                                        error = %e,
487                                        definition_id = %inst.definition_id,
488                                        block_id = %inst.block_id,
489                                        "listrecentsessions: filestore.stat(output.state.json) failed — \
490                                         row will report snapshot_check_failed instead of silently \
491                                         looking like it never had a snapshot"
492                                    );
493                                    if !degraded.contains(&"snapshot_stat") {
494                                        degraded.push("snapshot_stat");
495                                    }
496                                    (false, inst.started_at, String::new(), 0usize, true)
497                                }
498                            }
499                        };
500
501                    // No structured snapshot to preview — fall back to a
502                    // once-per-definition, on-demand Haiku summary of the
503                    // instance's raw terminal output (see
504                    // docs/reports/REPORT_AGENT_PICKER_FIELD_ORDER_SORT_AND_DATA_GAPS_AUDIT_2026_08_24.md
505                    // §5a). Only meaningful for a LOCAL row with a real
506                    // block_id — a cross-channel row's synthetic empty
507                    // block_id has no local raw output to summarize either,
508                    // so it's left alone (still shows the plain fallback
509                    // text, which is accurate for that case: this channel
510                    // genuinely has nothing).
511                    if !has_snapshot && !inst.block_id.is_empty() {
512                        match mstore.agent_activity_summary_get(&inst.definition_id) {
513                            Ok(Some(existing)) if !existing.summary.is_empty() => {
514                                preview = existing.summary;
515                            }
516                            // `insert` returns `true` only the FIRST time this
517                            // definition_id is claimed for this process's
518                            // lifetime — mirrors `SubagentWatcher.naming_triggered`'s
519                            // "claim once regardless of success/failure"
520                            // pattern (reagent P1, PR #2786). Without this,
521                            // `ambient::gateway().admit()` alone only
522                            // coalesces CONCURRENT triggers, not sequential
523                            // ones — a definition that can never be
524                            // summarized (no cmd meta, no raw output, CLI
525                            // failure) would otherwise be re-spawned on
526                            // EVERY subsequent listrecentsessions call (tab
527                            // visibility regain, agents:changed refetch, …)
528                            // forever. A successful generation is unaffected
529                            // by this gate: it persists to
530                            // db_agent_activity_summaries, which the branch
531                            // above already checks first.
532                            // `Ok(Some(existing))` with an empty `summary`
533                            // shouldn't occur in normal operation (a row is
534                            // only ever inserted once generation succeeds —
535                            // see the table's own doc comment) but is
536                            // handled the same as `Ok(None)` regardless,
537                            // rather than relying on that invariant to make
538                            // the match exhaustive.
539                            Ok(None) | Ok(Some(_))
540                                if definition_summary_attempted().lock().unwrap().insert(inst.definition_id.clone()) =>
541                            {
542                                // Fire-and-forget: generation (a real Haiku
543                                // CLI round-trip) must not block this
544                                // response.
545                                let mstore_bg = mstore.clone();
546                                let filestore_bg = filestore.clone();
547                                let broker_bg = broker.clone();
548                                let definition_id_bg = inst.definition_id.clone();
549                                let block_id_bg = inst.block_id.clone();
550                                let provider_id_bg = def.map(|d| d.provider.clone()).unwrap_or_default();
551                                tokio::spawn(async move {
552                                    let result = crate::ambient::tasks::generate_definition_activity_summary(
553                                        &mstore_bg,
554                                        &filestore_bg,
555                                        &broker_bg,
556                                        &definition_id_bg,
557                                        &block_id_bg,
558                                        &provider_id_bg,
559                                    )
560                                    .await;
561                                    // reagent P2, PR #2786: at minimum, log the
562                                    // spend so it's observable — no UI-facing
563                                    // total exists for ANY background-triggered
564                                    // ambient call today (dispatch_name/
565                                    // subagent_name via trigger_eager_naming,
566                                    // generate_recovered_title all
567                                    // discard their own returned tokens the
568                                    // same way; only the live, RPC-response
569                                    // pull paths — session:activity_summary,
570                                    // session:next_prompt_suggestion — thread
571                                    // usage to the frontend, since only they
572                                    // have a response channel to thread it
573                                    // through). A real aggregate sink for
574                                    // background ambient spend is a separate,
575                                    // cross-cutting follow-up, not scoped to
576                                    // this one call site.
577                                    if let Some(generated) = result {
578                                        if let Some(tokens) = generated.tokens {
579                                            tracing::debug!(
580                                                definition_id = %definition_id_bg,
581                                                ?tokens,
582                                                "listrecentsessions: definition activity summary generated"
583                                            );
584                                        }
585                                    }
586                                });
587                            }
588                            Ok(None) | Ok(Some(_)) => {
589                                // Already attempted this process lifetime —
590                                // no-op, same as any other row without a
591                                // persisted summary.
592                            }
593                            Err(e) => {
594                                tracing::warn!(
595                                    error = %e,
596                                    definition_id = %inst.definition_id,
597                                    "listrecentsessions: agent_activity_summary_get failed — \
598                                     row keeps its plain fallback preview text"
599                                );
600                                if !degraded.contains(&"activity_summary") {
601                                    degraded.push("activity_summary");
602                                }
603                            }
604                        }
605                    }
606
607                    rows.push(RecentSessionRow {
608                        instance_id: inst.id,
609                        instance_name: inst.instance_name,
610                        definition_id: inst.definition_id.clone(),
611                        definition_name: def
612                            .map(|d| d.name.clone())
613                            .unwrap_or_else(|| "(missing definition)".to_string()),
614                        provider: def.map(|d| d.provider.clone()).unwrap_or_default(),
615                        model_vendor_base_url: def.map(|d| d.model_vendor_base_url.clone()).unwrap_or_default(),
616                        working_directory: inst.working_directory,
617                        identity_id: inst.identity_id,
618                        identity_name,
619                        memory_id: inst.memory_id,
620                        memory_name,
621                        block_id_hint: inst.block_id,
622                        // Surface the CLI-captured session id so the
623                        // picker reattach can `--resume <sid>` on the
624                        // FIRST turn of the new block. Without this
625                        // the new subprocess starts a fresh session
626                        // and the CLI re-injects the startup context.
627                        session_id: inst.session_id,
628                        preview,
629                        node_count,
630                        last_active_at,
631                        has_snapshot,
632                        snapshot_check_failed,
633                        agent_created_at: def.map(|d| d.created_at).unwrap_or(0),
634                        started_at: inst.started_at,
635                        agent_type: def.map(|d| d.agent_type.clone()).unwrap_or_default(),
636                    });
637                }
638
639                // Sort: rows with a snapshot first (descending by
640                // modts), then no-snapshot rows by started_at desc.
641                // This keeps live conversations at the top while
642                // still surfacing legacy rows.
643                rows.sort_by(|a, b| match (a.has_snapshot, b.has_snapshot) {
644                    (true, true) | (false, false) => {
645                        b.last_active_at.cmp(&a.last_active_at)
646                    }
647                    (true, false) => std::cmp::Ordering::Less,
648                    (false, true) => std::cmp::Ordering::Greater,
649                });
650                rows.truncate(limit);
651
652                // Command-level completion trace (retro §9 rec 3) — the
653                // prior incident's own log had no way to show whether this
654                // RPC ran, succeeded, or returned degraded data; this line
655                // answers all three going forward. `degraded` is non-empty
656                // only when at least one source above fell back to empty —
657                // an all-clear call logs an empty list here.
658                tracing::info!(
659                    rows = rows.len(),
660                    degraded = ?degraded,
661                    // Links to an account this store cannot find (deleted, or an
662                    // unparseable secret_ref skipped by identity_list): those
663                    // agents show "No auth", and this says how many.
664                    unresolved_account_links = unresolved_links,
665                    elapsed_ms = t0.elapsed().as_millis() as u64,
666                    "listrecentsessions: completed"
667                );
668
669                // `degraded` must also reach the CALLER, not just the log
670                // (reagent P1 on PR #2327): once every source degrades to
671                // empty instead of erroring the whole RPC, a transport-level
672                // success/failure check can no longer tell "genuinely zero
673                // agents" apart from "a source failed and we got nothing" —
674                // the exact ambiguity this hardening exists to close, just
675                // pushed from the RPC layer down into the response body.
676                // See ListRecentSessionsResult's own doc comment.
677                let result = ListRecentSessionsResult {
678                    rows,
679                    degraded: degraded.iter().map(|s| s.to_string()).collect(),
680                };
681                Ok(result)
682        }
683    });
684
685    // ---- agent:session:read ----
686    let filestore = state.filestore.clone();
687    engine.register_typed(
688        COMMAND_AGENT_SESSION_READ,
689        move |cmd: CommandAgentSessionReadData, _ctx| {
690            let filestore = filestore.clone();
691            async move {
692                let (content, modts) =
693                    crate::backend::agent_session::read_session_state(&filestore, &cmd.definition_id)
694                        .map_err(|e| format!("agent:session:read: {e}"))?;
695                Ok(AgentSessionReadResult { content, modts })
696            }
697        },
698    );
699
700    // ---- agent:session:write_state ----
701    let filestore = state.filestore.clone();
702    engine.register_typed(
703        COMMAND_AGENT_SESSION_WRITE_STATE,
704        move |cmd: CommandAgentSessionWriteStateData, _ctx| {
705            let filestore = filestore.clone();
706            async move {
707                let bytes = cmd.content.as_bytes();
708                let bytes_written = bytes.len() as u64;
709                crate::backend::agent_session::write_session_state(
710                    &filestore,
711                    &cmd.definition_id,
712                    bytes,
713                )
714                .map_err(|e| format!("agent:session:write_state: {e}"))?;
715                Ok(AgentSessionWriteStateResult { bytes_written })
716            }
717        },
718    );
719
720    // ---- agent:session:append_output ----
721    let filestore = state.filestore.clone();
722    engine.register_typed(
723        COMMAND_AGENT_SESSION_APPEND_OUTPUT,
724        move |cmd: CommandAgentSessionAppendOutputData, _ctx| {
725            let filestore = filestore.clone();
726            async move {
727                let bytes_written = crate::backend::agent_session::append_session_output(
728                    &filestore,
729                    &cmd.definition_id,
730                    &cmd.line,
731                )
732                .map_err(|e| format!("agent:session:append_output: {e}"))?;
733                Ok(AgentSessionAppendOutputResult { bytes_written })
734            }
735        },
736    );
737
738    // ---- agent:session:archive ----
739    let filestore = state.filestore.clone();
740    engine.register_typed(
741        COMMAND_AGENT_SESSION_ARCHIVE,
742        move |cmd: CommandAgentSessionArchiveData, _ctx| {
743            let filestore = filestore.clone();
744            async move {
745                let result =
746                    crate::backend::agent_session::archive_session(&filestore, &cmd.definition_id)
747                        .map_err(|e| format!("agent:session:archive: {e}"))?;
748                let (archive_zoneid, archived_at_ms) = match result {
749                    Some((z, ts)) => (z, ts),
750                    None => (String::new(), 0),
751                };
752                Ok(AgentSessionArchiveResult {
753                    archive_zoneid,
754                    archived_at_ms,
755                })
756            }
757        },
758    );
759
760    // ---- agent:session:list_archives ----
761    let filestore = state.filestore.clone();
762    engine.register_typed(
763        COMMAND_AGENT_SESSION_LIST_ARCHIVES,
764        move |cmd: CommandAgentSessionListArchivesData, _ctx| {
765            let filestore = filestore.clone();
766            async move {
767                let summaries = crate::backend::agent_session::list_archives(
768                    &filestore,
769                    &cmd.definition_id,
770                    cmd.limit,
771                )
772                .map_err(|e| format!("agent:session:list_archives: {e}"))?;
773                let rows: Vec<AgentArchiveRow> = summaries
774                    .into_iter()
775                    .map(|s| AgentArchiveRow {
776                        archive_zoneid: s.archive_zoneid,
777                        archived_at_ms: s.archived_at_ms,
778                        preview: s.preview,
779                        node_count: s.node_count,
780                    })
781                    .collect();
782                Ok(rows)
783            }
784        },
785    );
786}
787
788#[cfg(test)]
789mod tests {
790    use super::*;
791    use crate::backend::storage::store::AgentDefinition;
792    use crate::backend::rpc_types::RpcMessage;
793    use crate::registry::{
794        DefinitionRecord, DefinitionRecordV1, DefinitionStore, NamedAgentRecord,
795        NamedAgentRecordV1, Registry,
796    };
797    use crate::server::tests::test_state;
798
799    fn named_record(instance_id: &str, definition_id: &str) -> NamedAgentRecord {
800        NamedAgentRecord {
801            schema_version: 1,
802            data: NamedAgentRecordV1 {
803                instance_id: instance_id.to_string(),
804                instance_name: format!("session-{instance_id}"),
805                definition_id: definition_id.to_string(),
806                identity_id: None,
807                memory_id: None,
808                session_id: None,
809                working_dir: format!("{definition_id}-workdir"),
810                source_agents_base: None,
811                created_at_ms: 1,
812                last_launched_at_ms: 1,
813                created_by_version: "test".to_string(),
814                last_launched_by_version: "test".to_string(),
815            },
816        }
817    }
818
819    fn definition_record(id: &str) -> DefinitionRecord {
820        DefinitionRecord {
821            schema_version: 1,
822            data: DefinitionRecordV1 {
823                id: id.to_string(),
824                name: format!("Agent {id}"),
825                provider: "claude".to_string(),
826                is_seeded: 0,
827                ..Default::default()
828            },
829        }
830    }
831
832    /// Seeds `state.mstore` with N cross-channel registry + definition
833    /// records (as they'd exist on a REAL machine before a fresh channel
834    /// ever boots) and returns the dispatch machinery ready to call
835    /// `listrecentsessions`.
836    fn setup_with_n_cross_channel_agents(
837        n: usize,
838    ) -> (
839        AppState,
840        std::sync::Arc<WshRpcEngine>,
841        tokio::sync::mpsc::UnboundedReceiver<RpcMessage>,
842        tempfile::TempDir,
843        tempfile::TempDir,
844    ) {
845        let state = test_state();
846        let registry_dir = tempfile::tempdir().unwrap();
847        let def_dir = tempfile::tempdir().unwrap();
848        let registry = Registry::open(registry_dir.path().to_path_buf()).unwrap();
849        let def_store = DefinitionStore::open(def_dir.path().to_path_buf()).unwrap();
850        for i in 0..n {
851            let id = format!("inst-{i}");
852            let def_id = format!("def-{i}");
853            registry.upsert(&named_record(&id, &def_id)).unwrap();
854            def_store.upsert(&definition_record(&def_id)).unwrap();
855        }
856        state.mstore.set_registry(std::sync::Arc::new(registry));
857        state.mstore.set_def_registry(std::sync::Arc::new(def_store));
858
859        let (engine, output_rx) = WshRpcEngine::new();
860        register(&engine, &state);
861        (state, engine, output_rx, registry_dir, def_dir)
862    }
863
864    async fn dispatch_list_recent_sessions(
865        engine: &std::sync::Arc<WshRpcEngine>,
866        output_rx: &mut tokio::sync::mpsc::UnboundedReceiver<RpcMessage>,
867    ) -> RpcMessage {
868        engine.handle_message(RpcMessage {
869            command: COMMAND_LIST_RECENT_SESSIONS.to_string(),
870            reqid: "req-1".to_string(),
871            data: Some(serde_json::json!({ "limit": 20 })),
872            ..Default::default()
873        });
874        tokio::time::timeout(std::time::Duration::from_secs(2), output_rx.recv())
875            .await
876            .unwrap()
877            .unwrap()
878    }
879
880    /// Retro
881    /// `docs/retro/retro-my-agents-fresh-channel-regression-2026-07-27.md`
882    /// §9 rec 5: this exact path (fresh channel + pre-populated global
883    /// registry) had broken three separate times with no regression test
884    /// covering it. Directly exercises the RPC end-to-end against a real
885    /// `Registry`/`DefinitionStore` on disk — the same code path a fresh
886    /// `task package` channel hits on its very first "My Agents" fetch.
887    #[tokio::test]
888    async fn cross_channel_registry_agents_populate_a_fresh_channels_my_agents_list() {
889        let (_state, engine, mut output_rx, _reg_dir, _def_dir) =
890            setup_with_n_cross_channel_agents(3);
891
892        let resp = dispatch_list_recent_sessions(&engine, &mut output_rx).await;
893
894        assert!(resp.error.is_empty(), "unexpected error: {}", resp.error);
895        let result: ListRecentSessionsResult =
896            serde_json::from_value(resp.data.expect("expected result data")).unwrap();
897        assert_eq!(
898            result.rows.len(),
899            3,
900            "all 3 cross-channel registry agents must appear on a channel that never created any of them locally"
901        );
902        assert!(
903            result.degraded.is_empty(),
904            "a fully healthy call must report no degraded sources, got: {:?}",
905            result.degraded
906        );
907    }
908
909    /// Retro §4/§9 rec 1 — the core hardening this test locks in. Before
910    /// this fix, `identity_list()` failing on one unparseable `db_accounts`
911    /// row (PR #2296's exact incident: an oauth secret_ref serde-tag
912    /// mismatch) `?`-aborted the ENTIRE `listrecentsessions` response,
913    /// zeroing "My Agents" even though the registry/definitions themselves
914    /// were completely healthy. Reproduces that exact malformed-row shape
915    /// directly via raw SQL (bypassing `identity_upsert`, which would
916    /// never produce an invalid `secret_ref`) and asserts the response
917    /// still contains every agent — only the identity-name enrichment
918    /// degrades, not the whole list.
919    #[tokio::test]
920    async fn a_malformed_identity_account_row_is_tolerated_without_degrading_the_accounts_source() {
921        let (state, engine, mut output_rx, _reg_dir, _def_dir) =
922            setup_with_n_cross_channel_agents(2);
923
924        // Same malformed shape as the real PR #2296 incident: a
925        // `secret_ref.backend` tag no `SecretRef` variant matches.
926        //
927        // Updated for ANALYSIS_ARMORY_STASH_CREDENTIAL_VISIBILITY_GAP_2026_08_04:
928        // `identity_list` itself now skips a malformed row (with a warning)
929        // instead of erroring the whole call (backend/storage/identities.rs),
930        // so this scenario no longer reaches the `unwrap_or_else` degrade
931        // path below at all — the "accounts" source is no longer degraded
932        // by a single bad row, it's just quietly correct. The `degraded`
933        // push at that call site still exists and is still meaningful for a
934        // genuine `identity_list` error (e.g. a real DB failure); it's
935        // simply no longer reachable via this specific fixture.
936        {
937            let conn = state.mstore.conn().lock().unwrap();
938            conn.execute(
939                "INSERT INTO db_accounts
940                    (id, name, provider, kind, display_name, secret_ref, context, status, created_at, updated_at)
941                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
942                rusqlite::params![
943                    "acct-broken",
944                    "broken-account",
945                    "claude",
946                    "oauth",
947                    "",
948                    r#"{"backend":"totally_bogus_backend"}"#,
949                    "{}",
950                    "unknown",
951                    0i64,
952                    0i64,
953                ],
954            )
955            .unwrap();
956        }
957
958        let resp = dispatch_list_recent_sessions(&engine, &mut output_rx).await;
959
960        assert!(resp.error.is_empty(), "unexpected error: {}", resp.error);
961        let result: ListRecentSessionsResult =
962            serde_json::from_value(resp.data.expect("expected result data")).unwrap();
963        assert_eq!(
964            result.rows.len(),
965            2,
966            "a single malformed db_accounts row must not zero out the whole \
967             My Agents list"
968        );
969        // The improved behavior: identity_list's own per-row tolerance means
970        // this is no longer a "source failure" at all — nothing degraded,
971        // the malformed row was just silently excluded. Contrast with the
972        // OLD assertion this test used to make (`degraded` contains
973        // "accounts") — that was evidence of a real, if survivable, failure;
974        // this is evidence there wasn't one. The `degraded.push("accounts")`
975        // call site (above, in the handler) is still meaningful for a
976        // genuine `identity_list` error — just no longer reachable via this
977        // fixture.
978        assert!(
979            !result.degraded.contains(&"accounts".to_string()),
980            "a single malformed row must not degrade the accounts source at all \
981             (identity_list now tolerates it internally), got: {:?}",
982            result.degraded
983        );
984    }
985
986    /// docs/reports/REPORT_AGENT_PICKER_FIELD_ORDER_SORT_AND_DATA_GAPS_AUDIT_2026_08_24.md
987    /// §4: when `agent_identity_list_all()` itself fails, no row can say whether
988    /// its agent is bound. A failure must not read like a healthy "No auth", so
989    /// the account line is left empty (SPEC_MY_AGENTS_TILES_AUTH_AND_HISTORY_2026_10_03.md
990    /// §5.2 R5). Forces the failure by dropping `db_agent_identity_links` outright
991    /// and asserts every row has an empty account line, with `degraded`
992    /// reporting "identity_links".
993    #[tokio::test]
994    async fn identity_links_source_failure_leaves_the_account_line_empty_not_no_auth() {
995        let (state, engine, mut output_rx, _reg_dir, _def_dir) =
996            setup_with_n_cross_channel_agents(2);
997
998        {
999            let conn = state.mstore.conn().lock().unwrap();
1000            conn.execute("DROP TABLE db_agent_identity_links", []).unwrap();
1001        }
1002
1003        let resp = dispatch_list_recent_sessions(&engine, &mut output_rx).await;
1004
1005        assert!(resp.error.is_empty(), "unexpected error: {}", resp.error);
1006        let result: ListRecentSessionsResult =
1007            serde_json::from_value(resp.data.expect("expected result data")).unwrap();
1008        assert_eq!(result.rows.len(), 2, "the identity_links failure must not zero out the whole list");
1009        assert!(
1010            result.degraded.contains(&"identity_links".to_string()),
1011            "got: {:?}",
1012            result.degraded
1013        );
1014        for row in &result.rows {
1015            assert_eq!(
1016                row.identity_name, "",
1017                "a source FAILURE must claim nothing: an unbound agent reads \"No auth\" and a \
1018                 backend outage must not look identical to that"
1019            );
1020        }
1021    }
1022
1023    /// A failed ACCOUNTS lookup (the global mirror unreadable too) must not turn a
1024    /// bound agent into "No auth": its account may be perfectly good. The linked
1025    /// agent's line is left empty; an agent with no link is still plainly unbound.
1026    #[tokio::test]
1027    async fn a_failed_accounts_lookup_leaves_a_linked_agents_line_empty_not_no_auth() {
1028        let (state, engine, mut output_rx, _reg_dir, _def_dir) = setup_with_n_cross_channel_agents(2);
1029        {
1030            let conn = state.identity_store.conn().lock().unwrap();
1031            conn.execute_batch(
1032                "PRAGMA foreign_keys=OFF; INSERT INTO db_agent_identity_links (agent_id, account_id, provider)                  VALUES ('def-0', 'acct-that-is-fine', 'claude');",
1033            )
1034            .unwrap();
1035        }
1036        {
1037            let conn = state.id_store.conn().lock().unwrap();
1038            conn.execute_batch("PRAGMA foreign_keys=OFF; DROP TABLE db_accounts;").unwrap();
1039        }
1040        let resp = dispatch_list_recent_sessions(&engine, &mut output_rx).await;
1041        assert!(resp.error.is_empty(), "unexpected error: {}", resp.error);
1042        let result: ListRecentSessionsResult = serde_json::from_value(resp.data.expect("expected result data")).unwrap();
1043        assert!(result.degraded.contains(&"accounts".to_string()), "got: {:?}", result.degraded);
1044        let line = |def: &str| result.rows.iter().find(|r| r.definition_id == def).unwrap().identity_name.clone();
1045        assert_eq!(line("def-0"), "", "a link we cannot resolve because the lookup failed is unknown, not unbound");
1046        assert_eq!(line("def-1"), "No auth", "an agent with no link is unbound whatever the accounts lookup did");
1047    }
1048
1049    /// A healthy list: agents with no account link read "No auth", and none of the
1050    /// retired labels appears anywhere.
1051    #[tokio::test]
1052    async fn an_agent_with_no_account_link_says_no_auth_and_the_old_labels_are_gone() {
1053        let (_state, engine, mut output_rx, _reg_dir, _def_dir) = setup_with_n_cross_channel_agents(2);
1054        let resp = dispatch_list_recent_sessions(&engine, &mut output_rx).await;
1055        assert!(resp.error.is_empty(), "unexpected error: {}", resp.error);
1056        let result: ListRecentSessionsResult = serde_json::from_value(resp.data.expect("expected result data")).unwrap();
1057        assert_eq!(result.rows.len(), 2);
1058        for row in &result.rows {
1059            assert_eq!(row.identity_name, "No auth");
1060            for old in ["(missing account)", "(ambient creds)", "(unknown account)"] {
1061                assert_ne!(row.identity_name, old);
1062            }
1063        }
1064    }
1065
1066    /// Minimal local (not cross-channel registry) `AgentDefinition` —
1067    /// mirrors `storage/agents/mod.rs`'s own private `test_agent_def` test fixture
1068    /// shape, duplicated here rather than widening that helper's
1069    /// visibility for one caller.
1070    fn local_agent_def(id: &str) -> AgentDefinition {
1071        AgentDefinition {
1072            id: id.to_string(),
1073            slug: id.to_string(),
1074            name: format!("Agent {id}"),
1075            icon: String::new(),
1076            provider: "claude".to_string(),
1077            description: String::new(),
1078            working_directory: String::new(),
1079            shell: String::new(),
1080            provider_flags: String::new(),
1081            auto_start: 0,
1082            restart_on_crash: 0,
1083            idle_timeout_minutes: 0,
1084            created_at: 1,
1085            agent_type: "standalone".to_string(),
1086            environment: String::new(),
1087            agent_bus_id: String::new(),
1088            is_seeded: 0,
1089            accounts: String::new(),
1090            parent_id: String::new(),
1091            branch_label: String::new(),
1092            updated_at: 1,
1093            user_hidden: 0,
1094            container_image: String::new(),
1095            container_volumes: "[]".to_string(),
1096            container_name: String::new(),
1097            use_ambient_login: 0,
1098            model_vendor_base_url: String::new(),
1099            auto_continue_enabled: 0,
1100            memory_id: String::new(),
1101            conversation_visibility: "private".to_string(),
1102        }
1103    }
1104
1105    fn local_agent_instance(id: &str, definition_id: &str, block_id: &str) -> AgentInstance {
1106        AgentInstance {
1107            id: id.to_string(),
1108            definition_id: definition_id.to_string(),
1109            parent_instance_id: String::new(),
1110            block_id: block_id.to_string(),
1111            session_id: String::new(),
1112            status: "available".to_string(),
1113            github_context: String::new(),
1114            started_at: 1,
1115            ended_at: 0,
1116            created_at: 1,
1117            identity_id: String::new(),
1118            memory_id: String::new(),
1119            instance_name: id.to_string(),
1120            working_directory: String::new(),
1121            display_hidden: false,
1122        }
1123    }
1124
1125    /// docs/reports/REPORT_AGENT_PICKER_FIELD_ORDER_SORT_AND_DATA_GAPS_AUDIT_2026_08_24.md
1126    /// §5a: a row with no structured `output.state.json` snapshot but a
1127    /// PERSISTED `db_agent_activity_summaries` row for its definition must
1128    /// surface that summary as `preview`, still reporting `has_snapshot:
1129    /// false` (accurate — there genuinely is no structured snapshot; the
1130    /// summary is a substitute, not a claim that one exists).
1131    #[tokio::test]
1132    async fn snapshot_less_row_uses_a_persisted_definition_summary_as_preview() {
1133        let state = test_state();
1134        let mut def = local_agent_def("def-local-1");
1135        state.mstore.agent_def_insert(&mut def).unwrap();
1136        state
1137            .mstore
1138            .instance_create(&local_agent_instance("inst-local-1", "def-local-1", "block-local-1"))
1139            .unwrap();
1140        state
1141            .mstore
1142            .agent_activity_summary_set("def-local-1", "Fixed the login race condition", 1000)
1143            .unwrap();
1144
1145        let (engine, mut output_rx) = WshRpcEngine::new();
1146        register(&engine, &state);
1147        let resp = dispatch_list_recent_sessions(&engine, &mut output_rx).await;
1148
1149        assert!(resp.error.is_empty(), "unexpected error: {}", resp.error);
1150        let result: ListRecentSessionsResult =
1151            serde_json::from_value(resp.data.expect("expected result data")).unwrap();
1152        assert_eq!(result.rows.len(), 1);
1153        let row = &result.rows[0];
1154        assert!(!row.has_snapshot, "no output.state.json was ever written for this instance");
1155        assert_eq!(
1156            row.preview, "Fixed the login race condition",
1157            "persisted definition summary must surface as the row's preview \
1158             when there's no real snapshot to preview instead"
1159        );
1160    }
1161
1162    /// Counterpart to the above: when NOTHING is persisted yet for the
1163    /// definition, the row's `preview` stays empty and the frontend shows its
1164    /// own empty-state text ("No conversations yet").
1165    /// Generation itself (a real Haiku CLI round-trip) is fire-and-forget
1166    /// and not asserted here — this only locks in that a missing summary
1167    /// never surfaces as a fabricated non-empty preview.
1168    #[tokio::test]
1169    async fn snapshot_less_row_with_no_persisted_summary_leaves_preview_empty() {
1170        let state = test_state();
1171        let mut def = local_agent_def("def-local-2");
1172        state.mstore.agent_def_insert(&mut def).unwrap();
1173        state
1174            .mstore
1175            .instance_create(&local_agent_instance("inst-local-2", "def-local-2", "block-local-2"))
1176            .unwrap();
1177
1178        let (engine, mut output_rx) = WshRpcEngine::new();
1179        register(&engine, &state);
1180        let resp = dispatch_list_recent_sessions(&engine, &mut output_rx).await;
1181
1182        assert!(resp.error.is_empty(), "unexpected error: {}", resp.error);
1183        let result: ListRecentSessionsResult =
1184            serde_json::from_value(resp.data.expect("expected result data")).unwrap();
1185        assert_eq!(result.rows.len(), 1);
1186        let row = &result.rows[0];
1187        assert!(!row.has_snapshot);
1188        assert_eq!(row.preview, "", "no summary persisted yet — preview must stay empty, not fabricated");
1189    }
1190
1191    /// Put an `output` file in the (test) global transcript store for `def_id`.
1192    fn install_transcript(store: &std::sync::Arc<crate::backend::storage::filestore::FileStore>, def_id: &str, ndjson: &str) {
1193        let zone = format!("agent:{def_id}:current");
1194        store
1195            .make_file(&zone, "output", Default::default(), crate::backend::storage::filestore::FileOpts::default())
1196            .unwrap();
1197        store.write_file(&zone, "output", ndjson.as_bytes()).unwrap();
1198    }
1199
1200    fn user_line(text: &str) -> String {
1201        serde_json::json!({"type":"user","message":{"role":"user","content":text}}).to_string()
1202    }
1203
1204    /// Clears the thread's test transcript store when a test ends, pass or fail.
1205    struct TranscriptStoreGuard;
1206    impl Drop for TranscriptStoreGuard {
1207        fn drop(&mut self) {
1208            crate::backend::agent_session::history_summary::set_test_store(None);
1209        }
1210    }
1211
1212    /// The preview, last-active time and "has history" flag come from the agent's
1213    /// global transcript, so a registry-only row (no local instance, no block id)
1214    /// with a transcript shows what it was last asked.
1215    #[tokio::test]
1216    async fn a_row_with_no_block_takes_its_preview_from_the_global_transcript() {
1217        let (_state, engine, mut output_rx, _reg_dir, _def_dir) = setup_with_n_cross_channel_agents(2);
1218        let store = std::sync::Arc::new(crate::backend::storage::filestore::FileStore::open_in_memory().unwrap());
1219        install_transcript(&store, "def-0", &format!("{}\n{}", user_line("# Session Context\nboot"), user_line("ship the tiles")));
1220        crate::backend::agent_session::history_summary::set_test_store(Some(store));
1221        let _guard = TranscriptStoreGuard;
1222
1223        let resp = dispatch_list_recent_sessions(&engine, &mut output_rx).await;
1224        assert!(resp.error.is_empty(), "unexpected error: {}", resp.error);
1225        let result: ListRecentSessionsResult = serde_json::from_value(resp.data.expect("expected result data")).unwrap();
1226        let with = result.rows.iter().find(|r| r.definition_id == "def-0").unwrap();
1227        assert!(with.block_id_hint.is_empty(), "the row has no local block");
1228        assert!(with.has_snapshot);
1229        assert!(!with.snapshot_check_failed);
1230        assert_eq!(with.preview, "ship the tiles");
1231        assert!(with.last_active_at > 1, "last-active comes from the transcript, got {}", with.last_active_at);
1232        let without = result.rows.iter().find(|r| r.definition_id == "def-1").unwrap();
1233        assert!(!without.has_snapshot, "an agent with no transcript has no history");
1234        assert!(!without.snapshot_check_failed, "no transcript is a state, not a failed read");
1235        assert_eq!(without.preview, "");
1236    }
1237
1238    /// A transcript that holds no user prompt is still history: the row says so
1239    /// (has_snapshot) rather than claiming the agent never ran.
1240    #[tokio::test]
1241    async fn a_transcript_without_a_prompt_still_counts_as_history() {
1242        let (_state, engine, mut output_rx, _reg_dir, _def_dir) = setup_with_n_cross_channel_agents(1);
1243        let store = std::sync::Arc::new(crate::backend::storage::filestore::FileStore::open_in_memory().unwrap());
1244        install_transcript(&store, "def-0", r#"{"type":"stream_event","event":{}}"#);
1245        crate::backend::agent_session::history_summary::set_test_store(Some(store));
1246        let _guard = TranscriptStoreGuard;
1247
1248        let resp = dispatch_list_recent_sessions(&engine, &mut output_rx).await;
1249        let result: ListRecentSessionsResult = serde_json::from_value(resp.data.expect("expected result data")).unwrap();
1250        assert_eq!(result.rows.len(), 1);
1251        assert!(result.rows[0].has_snapshot);
1252        assert_eq!(result.rows[0].preview, "");
1253    }
1254
1255    /// Consolidation Phase 3b (reagent P1 on PR #3080): the row's display
1256    /// fields must come from the AGENT's own row, never from the template it
1257    /// was launched off. The template is consulted for exactly one thing —
1258    /// the legacy-shaped identity-link fallback — and an agent whose
1259    /// provider/name/type has since drifted from its template must report its
1260    /// own current values, as it did before consolidation.
1261    #[tokio::test]
1262    async fn a_launched_agents_row_reports_its_own_config_not_its_templates() {
1263        let state = test_state();
1264        let mut tmpl = local_agent_def("tmpl-drift");
1265        tmpl.is_seeded = 1;
1266        tmpl.name = "Template".to_string();
1267        tmpl.provider = "claude".to_string();
1268        tmpl.agent_type = "standalone".to_string();
1269        state.mstore.agent_def_insert(&mut tmpl).unwrap();
1270
1271        let mut own = local_agent_def("agent-drift");
1272        own.parent_id = "tmpl-drift".to_string();
1273        state.mstore.agent_def_insert(&mut own).unwrap();
1274        state
1275            .mstore
1276            .instance_create(&local_agent_instance("launch-1", "agent-drift", "block-drift"))
1277            .unwrap();
1278
1279        // The agent drifts from the template it was cloned from.
1280        own.name = "Renamed by the user".to_string();
1281        own.provider = "codex".to_string();
1282        own.agent_type = "orchestrator".to_string();
1283        state.mstore.agent_def_update(&mut own).unwrap();
1284
1285        let (engine, mut output_rx) = WshRpcEngine::new();
1286        register(&engine, &state);
1287        let resp = dispatch_list_recent_sessions(&engine, &mut output_rx).await;
1288
1289        assert!(resp.error.is_empty(), "unexpected error: {}", resp.error);
1290        let result: ListRecentSessionsResult =
1291            serde_json::from_value(resp.data.expect("expected result data")).unwrap();
1292        assert_eq!(result.rows.len(), 1);
1293        let row = &result.rows[0];
1294        assert_eq!(row.definition_name, "Renamed by the user");
1295        assert_eq!(row.provider, "codex", "the template's 'claude' must not shadow the agent's own provider");
1296        assert_eq!(row.agent_type, "orchestrator");
1297    }
1298
1299    /// docs/reports/REPORT_AGENT_PICKER_FIELD_ORDER_SORT_AND_DATA_GAPS_AUDIT_2026_08_24.md
1300    /// §5, bullet 1: `filestore.stat()`'s `Err` (I/O error, lock
1301    /// contention, DB read failure) used to collapse into the exact same
1302    /// branch as `Ok(None)` (genuinely no snapshot) — a transient storage
1303    /// error on a row that DOES have real history rendered identically to
1304    /// "never had one," with no log line. Forces the failure
1305    /// deterministically the same way the identity_links test above does
1306    /// (`DROP TABLE`), rather than relying on real I/O flakiness.
1307    #[tokio::test]
1308    async fn filestore_stat_error_is_distinguished_from_genuinely_no_snapshot() {
1309        let state = test_state();
1310        let mut def = local_agent_def("def-local-3");
1311        state.mstore.agent_def_insert(&mut def).unwrap();
1312        state
1313            .mstore
1314            .instance_create(&local_agent_instance("inst-local-3", "def-local-3", "block-local-3"))
1315            .unwrap();
1316
1317        {
1318            let conn = state.filestore.conn().lock().unwrap();
1319            conn.execute("DROP TABLE db_wave_file", []).unwrap();
1320        }
1321
1322        let (engine, mut output_rx) = WshRpcEngine::new();
1323        register(&engine, &state);
1324        let resp = dispatch_list_recent_sessions(&engine, &mut output_rx).await;
1325
1326        assert!(resp.error.is_empty(), "unexpected error: {}", resp.error);
1327        let result: ListRecentSessionsResult =
1328            serde_json::from_value(resp.data.expect("expected result data")).unwrap();
1329        assert_eq!(result.rows.len(), 1, "a stat() failure on one row must not zero out the whole list");
1330        let row = &result.rows[0];
1331        assert!(!row.has_snapshot, "a failed stat() is not evidence a snapshot exists");
1332        assert!(
1333            row.snapshot_check_failed,
1334            "a stat() I/O error must be distinguishable from a genuine Ok(None) — \
1335             otherwise a transient failure looks identical to \"never had history\""
1336        );
1337        assert!(
1338            result.degraded.contains(&"snapshot_stat".to_string()),
1339            "got: {:?}",
1340            result.degraded
1341        );
1342    }
1343
1344    /// Counterpart: a genuine `Ok(None)` (the block really never wrote a
1345    /// snapshot) must NOT set `snapshot_check_failed` — only an actual
1346    /// `Err` does. Without this, the fix above could be made to pass by
1347    /// setting the flag unconditionally whenever `has_snapshot` is false,
1348    /// which would just move the ambiguity rather than remove it.
1349    #[tokio::test]
1350    async fn genuinely_missing_snapshot_does_not_set_the_check_failed_flag() {
1351        let state = test_state();
1352        let mut def = local_agent_def("def-local-4");
1353        state.mstore.agent_def_insert(&mut def).unwrap();
1354        state
1355            .mstore
1356            .instance_create(&local_agent_instance("inst-local-4", "def-local-4", "block-local-4"))
1357            .unwrap();
1358
1359        let (engine, mut output_rx) = WshRpcEngine::new();
1360        register(&engine, &state);
1361        let resp = dispatch_list_recent_sessions(&engine, &mut output_rx).await;
1362
1363        assert!(resp.error.is_empty(), "unexpected error: {}", resp.error);
1364        let result: ListRecentSessionsResult =
1365            serde_json::from_value(resp.data.expect("expected result data")).unwrap();
1366        let row = &result.rows[0];
1367        assert!(!row.has_snapshot);
1368        assert!(
1369            !row.snapshot_check_failed,
1370            "a genuine Ok(None) — the block simply never wrote a snapshot — is not a check failure"
1371        );
1372    }
1373
1374    /// reagent P1, PR #2786: without a dedup gate, a definition that can
1375    /// never be summarized (no cmd meta, no raw output, CLI failure) gets
1376    /// re-spawned on every `listrecentsessions` poll forever —
1377    /// `ambient::gateway().admit()` alone only coalesces CONCURRENT
1378    /// triggers, not sequential ones, since its guard drops (freeing the
1379    /// slot) the moment one call finishes. This tests
1380    /// `definition_summary_attempted()`'s claim set directly rather than
1381    /// the RPC dispatch (which can't observe whether a background
1382    /// `tokio::spawn` happened without mocking the Haiku CLI) — a unique
1383    /// id avoids cross-test interference with the process-wide static set.
1384    #[test]
1385    fn definition_summary_attempted_claims_a_definition_id_at_most_once() {
1386        let id = "def-dedup-test-unique-9f3a1b".to_string();
1387        assert!(
1388            definition_summary_attempted().lock().unwrap().insert(id.clone()),
1389            "first claim for a fresh id must succeed"
1390        );
1391        assert!(
1392            !definition_summary_attempted().lock().unwrap().insert(id.clone()),
1393            "second claim for the same id must be rejected — this is the fix for \
1394             reagent's unbounded-retry finding on PR #2786"
1395        );
1396    }
1397}