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}