chore(wip): checkpoint P8/C avant chantier Codex inter-agents

Sauvegarde de l'arbre de travail en cours (persistance P8, conversations
C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le
support de la délégation inter-agents pour les profils Codex.

Le round-trip inter-agent question/réponse est couvert sans tokens par
les tests loopback existants (state::mcp_e2e_loopback_tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 21:42:53 +02:00
parent 4509f0db9d
commit fdcf16c387
76 changed files with 3783 additions and 1404 deletions

View File

@ -343,10 +343,10 @@ impl PtyPort for FakePty {
})
}
fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> {
self.writes
.lock()
.unwrap()
.push((handle.session_id, String::from_utf8_lossy(data).into_owned()));
self.writes.lock().unwrap().push((
handle.session_id,
String::from_utf8_lossy(data).into_owned(),
));
Ok(())
}
fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> {
@ -380,7 +380,6 @@ impl EventBus for SpyBus {
}
}
struct SeqIds(Mutex<u128>);
impl SeqIds {
fn new() -> Self {
@ -655,13 +654,19 @@ async fn agent_run_visible_other_cell_refuses_when_live_elsewhere() {
assert_eq!(err.code(), "AGENT_ALREADY_RUNNING", "got {err:?}");
match err {
application::AppError::AgentAlreadyRunning { node_id, .. } => {
assert_eq!(node_id, first_cell, "reports the live host cell, not target");
assert_eq!(
node_id, first_cell,
"reports the live host cell, not target"
);
}
other => panic!("expected AgentAlreadyRunning, got {other:?}"),
}
let session = fx.sessions.session(&sid(777)).expect("live session");
assert_eq!(session.node_id, first_cell, "session stays on its host cell");
assert_eq!(
session.node_id, first_cell,
"session stays on its host cell"
);
assert_eq!(fx.pty.spawns().len(), 1, "refused launch must not respawn");
}
@ -784,7 +789,6 @@ async fn create_skill_honours_global_scope() {
assert_eq!(saved[0].scope, SkillScope::Global);
}
// ---------------------------------------------------------------------------
// Option 1 « Terminal + MCP » — idea_ask_agent / idea_reply (B-3 / B-4)
//
@ -826,7 +830,11 @@ impl TestMailbox {
}
}
fn pending(&self, agent: &AgentId) -> usize {
self.queues.lock().unwrap().get(agent).map_or(0, VecDeque::len)
self.queues
.lock()
.unwrap()
.get(agent)
.map_or(0, VecDeque::len)
}
/// Ids of the tickets queued for `agent`, in FIFO order (test inspection).
fn ticket_ids(&self, agent: &AgentId) -> Vec<TicketId> {
@ -953,7 +961,10 @@ impl InputMediator for TestMediator {
self.preempts.lock().unwrap().push(agent);
}
fn mark_idle(&self, agent: AgentId) {
self.busy.lock().unwrap().insert(agent, AgentBusyState::Idle);
self.busy
.lock()
.unwrap()
.insert(agent, AgentBusyState::Idle);
}
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
self.busy
@ -1004,7 +1015,9 @@ impl ConversationRegistry for TestConversations {
}
fn bind_session(&self, id: ConversationId, session: SessionRef) {
if let Some(c) = self.by_id.lock().unwrap().get_mut(&id) {
c.session = ConversationSession::Live { handle_ref: session };
c.session = ConversationSession::Live {
handle_ref: session,
};
}
}
fn suspend(&self, id: ConversationId, resumable_id: Option<String>) {
@ -1131,8 +1144,7 @@ async fn await_until<F: Fn() -> bool>(cond: F) {
.expect("condition never reached within guard (possible hang/deadlock)");
}
const ASK_JSON: &str =
r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"Analyse §17" }"#;
const ASK_JSON: &str = r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"Analyse §17" }"#;
/// Builds an `agent.reply` command for `from` with `result` (the handshake-injected
/// path, exactly what the MCP server produces from a peer's `idea_reply`).
@ -1171,12 +1183,25 @@ async fn ask_live_pty_target_writes_task_and_returns_reply() {
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
// The task was written into the target's terminal, prefixed for idea_reply.
let writes = fx.pty.writes_for(sid(800));
assert_eq!(writes.len(), 1, "exactly one task write to the live terminal");
assert_eq!(
writes.len(),
1,
"exactly one task write to the live terminal"
);
assert!(writes[0].contains("Analyse §17"), "task body written");
assert!(writes[0].contains("[IdeA · tâche"), "delegated-task prefix present");
assert!(writes[0].contains("idea_reply") || writes[0].contains("ticket"), "carries ticket/idea_reply cue");
assert!(
writes[0].contains("[IdeA · tâche"),
"delegated-task prefix present"
);
assert!(
writes[0].contains("idea_reply") || writes[0].contains("ticket"),
"carries ticket/idea_reply cue"
);
// No PTY spawned: the live terminal was reused.
assert!(fx.pty.spawns().is_empty(), "live target reused, not respawned");
assert!(
fx.pty.spawns().is_empty(),
"live target reused, not respawned"
);
// The target renders its result via idea_reply ⇒ resolves the head ticket.
fx.service
@ -1242,7 +1267,11 @@ async fn ask_dead_target_launches_pty_then_writes_and_replies() {
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
// The dead target was launched as a PTY (spawn recorded, session registered).
assert_eq!(fx.pty.spawns(), vec![sid(777)], "dead target launched as PTY");
assert_eq!(
fx.pty.spawns(),
vec![sid(777)],
"dead target launched as PTY"
);
assert_eq!(fx.sessions.session_for_agent(&aid(1)), Some(sid(777)));
// The delegated task was written into the freshly-launched terminal (the Stdin
// context injection may also write the persona, so we look for the task prefix).
@ -1289,7 +1318,10 @@ async fn ask_success_publishes_agent_replied_event() {
.events()
.into_iter()
.find_map(|e| match e {
DomainEvent::AgentReplied { agent_id, reply_len } => Some((agent_id, reply_len)),
DomainEvent::AgentReplied {
agent_id,
reply_len,
} => Some((agent_id, reply_len)),
_ => None,
})
.expect("AgentReplied must be published");
@ -1358,7 +1390,11 @@ async fn reply_without_pending_ask_is_invalid() {
.dispatch(&project(), reply_cmd(aid(7), "orphan result"))
.await
.unwrap_err();
assert_eq!(err.code(), "INVALID", "orphan reply is typed INVALID: {err:?}");
assert_eq!(
err.code(),
"INVALID",
"orphan reply is typed INVALID: {err:?}"
);
}
/// `Reply` resolves the HEAD ticket of the emitting agent's queue (positional
@ -1417,8 +1453,12 @@ async fn ask_different_targets_run_in_parallel() {
let mut inner = contexts.0.lock().unwrap();
inner.manifest.entries.push(ManifestEntry::from_agent(&a));
inner.manifest.entries.push(ManifestEntry::from_agent(&b));
inner.contents.insert(a.context_path.clone(), "# a".to_owned());
inner.contents.insert(b.context_path.clone(), "# b".to_owned());
inner
.contents
.insert(a.context_path.clone(), "# a".to_owned());
inner
.contents
.insert(b.context_path.clone(), "# b".to_owned());
}
let fx = ask_fixture(contexts);
seed_live_pty(&fx.sessions, aid(1), sid(801));
@ -1498,10 +1538,10 @@ impl FileSystem for CapturingFs {
Err(FsError::NotFound(path.as_str().to_owned()))
}
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
self.0
.lock()
.unwrap()
.push((path.as_str().to_owned(), String::from_utf8_lossy(data).into_owned()));
self.0.lock().unwrap().push((
path.as_str().to_owned(),
String::from_utf8_lossy(data).into_owned(),
));
Ok(())
}
async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> {
@ -1640,8 +1680,7 @@ fn ask_fixture_ex(
.with_events(Arc::new(bus.clone()));
if let Some(p) = provider {
service =
service.with_mcp_runtime_provider(p as Arc<dyn application::McpRuntimeProvider>);
service = service.with_mcp_runtime_provider(p as Arc<dyn application::McpRuntimeProvider>);
}
AskFixtureEx {
@ -1677,7 +1716,11 @@ async fn f1_ask_dead_target_injects_provider_runtime_into_mcp_json() {
let calls = provider.calls();
assert_eq!(calls.len(), 1, "provider interrogé exactement une fois");
assert_eq!(calls[0].0, project().id, "interrogé pour le bon projet");
assert_eq!(calls[0].1, aid(1), "interrogé pour la cible relancée (= --requester)");
assert_eq!(
calls[0].1,
aid(1),
"interrogé pour la cible relancée (= --requester)"
);
// La déclaration `.mcp.json` écrite porte les valeurs sentinelles du runtime.
let decl = fx
@ -1780,13 +1823,20 @@ async fn f2_ask_codex_target_is_invalid_no_launch() {
.await
.unwrap_err();
assert_eq!(err.code(), "INVALID", "garde F2 = erreur typée Invalid: {err:?}");
assert_eq!(
err.code(),
"INVALID",
"garde F2 = erreur typée Invalid: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("idea_*") || msg.contains("pont") || msg.contains(".mcp.json"),
"message explicite sur le pont non supporté: {msg}"
);
assert!(fx.pty.spawns().is_empty(), "aucun lancement sur cible refusée");
assert!(
fx.pty.spawns().is_empty(),
"aucun lancement sur cible refusée"
);
assert_eq!(fx.mailbox.pending(&aid(1)), 0, "aucun ticket enfilé");
}
@ -1856,7 +1906,10 @@ fn ask_fixture_c3(contexts: FakeContexts) -> AskFixtureC3 {
None,
));
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions)));
let close = Arc::new(CloseTerminal::new(
Arc::new(pty.clone()),
Arc::clone(&sessions),
));
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone())));
let create_skill = Arc::new(CreateSkill::new(
Arc::new(RecordingSkills::default()) as Arc<dyn SkillStore>,
@ -1898,8 +1951,12 @@ fn seed_two_agents(contexts: &FakeContexts) {
let mut inner = contexts.0.lock().unwrap();
inner.manifest.entries.push(ManifestEntry::from_agent(&a));
inner.manifest.entries.push(ManifestEntry::from_agent(&b));
inner.contents.insert(a.context_path.clone(), "# a".to_owned());
inner.contents.insert(b.context_path.clone(), "# b".to_owned());
inner
.contents
.insert(a.context_path.clone(), "# a".to_owned());
inner
.contents
.insert(b.context_path.clone(), "# b".to_owned());
}
/// An `agent.message` from agent A (uuid requester) to a named target.
@ -1920,15 +1977,19 @@ async fn ask_agent_routes_into_a_to_b_conversation_not_user_b() {
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move {
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "do B")))
.await
svc.dispatch(
&project(),
cmd(&ask_from_agent_json(aid(1), "beta", "do B")),
)
.await
});
await_until(|| fx.mailbox.pending(&aid(2)) == 1).await;
// The registry now holds the A↔B thread (agent 1 ↔ agent 2), not User↔B.
let a_to_b = fx
.conversations
.resolve(ConversationParty::agent(aid(1)), ConversationParty::agent(aid(2)));
let a_to_b = fx.conversations.resolve(
ConversationParty::agent(aid(1)),
ConversationParty::agent(aid(2)),
);
let user_b = fx
.conversations
.resolve(ConversationParty::User, ConversationParty::agent(aid(2)));
@ -1941,7 +2002,10 @@ async fn ask_agent_routes_into_a_to_b_conversation_not_user_b() {
"ask A→B resolved the A↔B pair"
);
// The live B session is bound to the A↔B thread (session keyed by conversation).
assert!(a_to_b.session.is_live(), "A↔B thread bound to a live session");
assert!(
a_to_b.session.is_live(),
"A↔B thread bound to a live session"
);
fx.service
.dispatch(&project(), reply_cmd(aid(2), "B done"))
@ -1964,21 +2028,30 @@ async fn cycle_a_to_b_to_a_is_refused_before_deadlock() {
// A→B in flight (held — B never replies during the test): edge A→B is posted.
let svc = Arc::clone(&fx.service);
let _ask_ab = tokio::spawn(async move {
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "to B")))
.await
svc.dispatch(
&project(),
cmd(&ask_from_agent_json(aid(1), "beta", "to B")),
)
.await
});
await_until(|| fx.mailbox.pending(&aid(2)) == 1).await;
// Now B→A would close the cycle ⇒ typed INVALID, no enqueue on A, returns fast.
let err = timeout(
TEST_GUARD,
fx.service
.dispatch(&project(), cmd(&ask_from_agent_json(aid(2), "alpha", "back to A"))),
fx.service.dispatch(
&project(),
cmd(&ask_from_agent_json(aid(2), "alpha", "back to A")),
),
)
.await
.expect("cycle ask must return fast, never deadlock")
.unwrap_err();
assert_eq!(err.code(), "INVALID", "re-entrant cycle is typed INVALID: {err:?}");
assert_eq!(
err.code(),
"INVALID",
"re-entrant cycle is typed INVALID: {err:?}"
);
assert!(
err.to_string().contains("cycle") || err.to_string().contains("ré-entrante"),
"explicit cycle message: {err}"
@ -1999,8 +2072,11 @@ async fn edge_cleared_after_turn_allows_later_reverse_delegation() {
// A→B completes.
let svc = Arc::clone(&fx.service);
let ask_ab = tokio::spawn(async move {
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(1), "beta", "to B")))
.await
svc.dispatch(
&project(),
cmd(&ask_from_agent_json(aid(1), "beta", "to B")),
)
.await
});
await_until(|| fx.mailbox.pending(&aid(2)) == 1).await;
fx.service
@ -2012,8 +2088,11 @@ async fn edge_cleared_after_turn_allows_later_reverse_delegation() {
// Edge A→B is now gone ⇒ B→A is allowed (would only cycle if A→B still pending).
let svc2 = Arc::clone(&fx.service);
let ask_ba = tokio::spawn(async move {
svc2.dispatch(&project(), cmd(&ask_from_agent_json(aid(2), "alpha", "now to A")))
.await
svc2.dispatch(
&project(),
cmd(&ask_from_agent_json(aid(2), "alpha", "now to A")),
)
.await
});
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
fx.service
@ -2021,7 +2100,11 @@ async fn edge_cleared_after_turn_allows_later_reverse_delegation() {
.await
.expect("reply ok");
let out = timeout(TEST_GUARD, ask_ba).await.unwrap().unwrap().unwrap();
assert_eq!(out.reply.as_deref(), Some("A ok"), "reverse delegation allowed");
assert_eq!(
out.reply.as_deref(),
Some("A ok"),
"reverse delegation allowed"
);
}
/// C3 — reply correlates **by ticket** (deterministic id-keyed resolution). The agent
@ -2064,7 +2147,11 @@ async fn reply_correlates_by_ticket() {
.dispatch(&project(), reply_cmd_ticket(aid(2), bogus, "wrong"))
.await
.unwrap_err();
assert_eq!(err.code(), "INVALID", "unknown ticket id ⇒ typed INVALID: {err:?}");
assert_eq!(
err.code(),
"INVALID",
"unknown ticket id ⇒ typed INVALID: {err:?}"
);
let t2 = fx.mailbox.ticket_ids(&aid(2))[0];
fx.service
.dispatch(&project(), reply_cmd_ticket(aid(2), t2, "R2"))
@ -2129,7 +2216,11 @@ async fn timeout_path_frees_queue_and_keeps_target_alive() {
"closed-channel ask is a typed error: {err:?}"
);
// Queue freed, and the target B is still live (never killed by the failed ask).
assert_eq!(fx.mailbox.pending(&aid(2)), 0, "queue freed after retirement");
assert_eq!(
fx.mailbox.pending(&aid(2)),
0,
"queue freed after retirement"
);
assert_eq!(
fx.sessions.session_for_agent(&aid(2)),
Some(sid(802)),
@ -2232,8 +2323,11 @@ async fn submit_and_ask_share_one_fifo_per_agent() {
// A delegation A→B blocks in B's FIFO (it awaits a reply).
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move {
svc.dispatch(&project(), cmd(&ask_from_agent_json(aid(10), "architect", "deleg")))
.await
svc.dispatch(
&project(),
cmd(&ask_from_agent_json(aid(10), "architect", "deleg")),
)
.await
});
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
@ -2256,7 +2350,8 @@ async fn submit_and_ask_share_one_fifo_per_agent() {
);
// Unblock the delegation so the spawned task ends cleanly.
fx.mailbox.cancel_head(aid(1), fx.mailbox.ticket_ids(&aid(1))[0]);
fx.mailbox
.cancel_head(aid(1), fx.mailbox.ticket_ids(&aid(1))[0]);
let _ = timeout(TEST_GUARD, ask).await;
}
@ -2275,11 +2370,7 @@ async fn interrupt_preempts_without_enqueue_or_resolve() {
.expect("interrupt succeeds");
assert_eq!(fx.mediator.preempts(), vec![aid(1)], "preempt called once");
assert_eq!(
fx.mailbox.pending(&aid(1)),
0,
"interrupt enqueues nothing"
);
assert_eq!(fx.mailbox.pending(&aid(1)), 0, "interrupt enqueues nothing");
}
/// A human submit to an unknown agent id is a typed NotFound (never a panic).
@ -2304,7 +2395,10 @@ async fn interrupt_unknown_agent_is_not_found() {
.await
.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND", "unknown agent interrupt: {err:?}");
assert!(fx.mediator.preempts().is_empty(), "no preempt on unknown agent");
assert!(
fx.mediator.preempts().is_empty(),
"no preempt on unknown agent"
);
}
// ---------------------------------------------------------------------------
@ -2323,13 +2417,13 @@ async fn interrupt_unknown_agent_is_not_found() {
// in `conversation_record.rs`.
// ---------------------------------------------------------------------------
use application::{OrchestratorOutcome, RecordTurn, RecordTurnProvider};
use domain::ports::Clock;
use domain::InputSource;
use domain::{
ConversationLog, ConversationTurn as DomainTurn, Handoff, HandoffStore, HandoffSummarizer,
TurnId, TurnRole,
};
use application::{OrchestratorOutcome, RecordTurn, RecordTurnProvider};
use domain::ports::Clock;
use domain::InputSource;
/// A deterministic [`Clock`]: every persisted turn is stamped with this constant.
struct FixedClock(i64);
@ -2367,7 +2461,9 @@ impl ConversationLog for RecordingLog {
turn: DomainTurn,
) -> Result<(), StoreError> {
if self.fail_append {
return Err(StoreError::Io("recording log: forced append failure".to_owned()));
return Err(StoreError::Io(
"recording log: forced append failure".to_owned(),
));
}
self.appends.lock().unwrap().push(turn);
Ok(())
@ -2399,11 +2495,7 @@ impl HandoffStore for NoopHandoffStore {
async fn load(&self, conversation: ConversationId) -> Result<Option<Handoff>, StoreError> {
Ok(self.0.lock().unwrap().get(&conversation).cloned())
}
async fn save(
&self,
conversation: ConversationId,
handoff: Handoff,
) -> Result<(), StoreError> {
async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError> {
self.0.lock().unwrap().insert(conversation, handoff);
Ok(())
}
@ -2419,7 +2511,10 @@ impl HandoffSummarizer for ConcatSummarizer {
for t in new_turns {
summary.push_str(&t.text);
}
let up_to = new_turns.last().map(|t| t.id).expect("at least one new turn");
let up_to = new_turns
.last()
.map(|t| t.id)
.expect("at least one new turn");
Handoff::new(summary, up_to, None)
}
}
@ -2564,7 +2659,11 @@ async fn p6b_user_ask_records_prompt_then_response_pair() {
assert_eq!(out.reply.as_deref(), Some("the §17 answer"));
let appends = log.appends();
assert_eq!(appends.len(), 2, "exactly two turns persisted (Prompt + Response)");
assert_eq!(
appends.len(),
2,
"exactly two turns persisted (Prompt + Response)"
);
let prompt = &appends[0];
let response = &appends[1];
@ -2575,12 +2674,26 @@ async fn p6b_user_ask_records_prompt_then_response_pair() {
);
// Order + role.
assert_eq!(prompt.role, TurnRole::Prompt, "first turn is the Prompt");
assert_eq!(response.role, TurnRole::Response, "second turn is the Response");
assert_eq!(
response.role,
TurnRole::Response,
"second turn is the Response"
);
// Text: task then result.
assert_eq!(prompt.text, "Analyse §17", "prompt text is the delegated task");
assert_eq!(response.text, "the §17 answer", "response text is the reply result");
assert_eq!(
prompt.text, "Analyse §17",
"prompt text is the delegated task"
);
assert_eq!(
response.text, "the §17 answer",
"response text is the reply result"
);
// Source: Human prompt (no agent requester), target-sourced response.
assert_eq!(prompt.source, InputSource::Human, "User ask ⇒ Human prompt source");
assert_eq!(
prompt.source,
InputSource::Human,
"User ask ⇒ Human prompt source"
);
assert_eq!(
response.source,
InputSource::agent(aid(1)),
@ -2603,8 +2716,11 @@ async fn p6b_agent_requester_records_pair_on_a_b_thread() {
Arc::new(ConcatSummarizer) as Arc<dyn HandoffSummarizer>,
));
let provider = Arc::new(SharedRecordProvider(record)) as Arc<dyn RecordTurnProvider>;
let fx =
ask_fixture_with_record(FakeContexts::with_agent(&architect, "# persona"), provider, 0);
let fx = ask_fixture_with_record(
FakeContexts::with_agent(&architect, "# persona"),
provider,
0,
);
seed_live_pty(&fx.sessions, aid(1), sid(800));
// Requester is agent A (id 7). The orchestrator threads `requester` from the
@ -2625,7 +2741,10 @@ async fn p6b_agent_requester_records_pair_on_a_b_thread() {
// assert both turns landed on it.
let convs = TestConversations::new();
let expected = convs
.resolve(ConversationParty::agent(a), ConversationParty::agent(aid(1)))
.resolve(
ConversationParty::agent(a),
ConversationParty::agent(aid(1)),
)
.id;
// (Resolution is deterministic per pair within one registry; we instead assert the
// turns share a thread and the prompt source carries A — the load-bearing facts.)
@ -2655,7 +2774,11 @@ async fn p6b_no_provider_is_silent_no_op() {
seed_live_pty(&fx.sessions, aid(1), sid(800));
let out = run_ask_roundtrip(&fx, ASK_JSON, aid(1), "ok").await;
assert_eq!(out.reply.as_deref(), Some("ok"), "ask succeeds without persistence");
assert_eq!(
out.reply.as_deref(),
Some("ok"),
"ask succeeds without persistence"
);
}
/// Provider wired but returns `None` for the root ⇒ ask still succeeds (best-effort
@ -2668,7 +2791,11 @@ async fn p6b_provider_returns_none_is_silent_no_op() {
seed_live_pty(&fx.sessions, aid(1), sid(800));
let out = run_ask_roundtrip(&fx, ASK_JSON, aid(1), "ok").await;
assert_eq!(out.reply.as_deref(), Some("ok"), "ask succeeds when provider declines");
assert_eq!(
out.reply.as_deref(),
Some("ok"),
"ask succeeds when provider declines"
);
}
/// `RecordTurn` built on a log whose `append` always fails ⇒ persistence errors are
@ -2693,5 +2820,8 @@ async fn p6b_failing_record_does_not_degrade_ask() {
"a failing append must not turn a successful delegation into an error"
);
// The failing log recorded nothing (every append errored).
assert!(failing_log.appends().is_empty(), "no turns stored when append fails");
assert!(
failing_log.appends().is_empty(),
"no turns stored when append fails"
);
}