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

@ -366,11 +366,7 @@ mod tests {
#[test]
fn rejects_two_users() {
assert_eq!(
Conversation::try_new(
conv_id(1),
ConversationParty::User,
ConversationParty::User
),
Conversation::try_new(conv_id(1), ConversationParty::User, ConversationParty::User),
Err(ConversationError::TwoUsers)
);
}
@ -428,10 +424,7 @@ mod tests {
// A→B exists; B→C is fine (no path C→…→B).
let mut g = WaitForGraph::new();
g.add_edge(agent(1), agent(2)); // A waits B
assert!(
!g.would_cycle(agent(2), agent(3)),
"A→B→C is acyclic"
);
assert!(!g.would_cycle(agent(2), agent(3)), "A→B→C is acyclic");
}
#[test]

View File

@ -31,9 +31,7 @@ use crate::ports::StoreError;
/// Newtype autour d'[`uuid::Uuid`], calqué sur [`crate::conversation::ConversationId`]
/// / [`crate::mailbox::TicketId`]. Sa monotonie (un id frappé à l'`append`) sert de
/// **curseur** à la relecture incrémentale ([`ConversationLog::read`] avec `since`).
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TurnId(pub uuid::Uuid);
@ -199,11 +197,7 @@ pub struct Handoff {
impl Handoff {
/// Construit un handoff à partir de ses composants.
#[must_use]
pub fn new(
summary_md: impl Into<String>,
up_to: TurnId,
objective: Option<String>,
) -> Self {
pub fn new(summary_md: impl Into<String>, up_to: TurnId, objective: Option<String>) -> Self {
Self {
summary_md: summary_md.into(),
up_to,
@ -235,11 +229,7 @@ pub trait HandoffStore: Send + Sync {
///
/// # Errors
/// [`StoreError`] en cas d'échec d'écriture ou de sérialisation.
async fn save(
&self,
conversation: ConversationId,
handoff: Handoff,
) -> Result<(), StoreError>;
async fn save(&self, conversation: ConversationId, handoff: Handoff) -> Result<(), StoreError>;
}
/// Le résumeur incrémental de handoff (port driving, lot P4).
@ -435,7 +425,10 @@ mod tests {
#[test]
fn turn_role_serializes_camel_case() {
assert_eq!(serde_json::to_string(&TurnRole::Prompt).unwrap(), "\"prompt\"");
assert_eq!(
serde_json::to_string(&TurnRole::Prompt).unwrap(),
"\"prompt\""
);
assert_eq!(
serde_json::to_string(&TurnRole::Response).unwrap(),
"\"response\""
@ -581,7 +574,11 @@ mod tests {
.unwrap();
}
let last2 = log.last(c, 2).await.unwrap();
assert_eq!(texts(&last2), vec!["c", "d"], "the 2 last, in insertion order");
assert_eq!(
texts(&last2),
vec!["c", "d"],
"the 2 last, in insertion order"
);
}
#[tokio::test]
@ -599,7 +596,10 @@ mod tests {
.await
.unwrap();
assert_eq!(texts(&log.read(c1, None).await.unwrap()), vec!["c1-a", "c1-b"]);
assert_eq!(
texts(&log.read(c1, None).await.unwrap()),
vec!["c1-a", "c1-b"]
);
assert_eq!(texts(&log.read(c2, None).await.unwrap()), vec!["c2-a"]);
// A cursor from one thread never leaks tours from another.
assert_eq!(texts(&log.last(c2, 10).await.unwrap()), vec!["c2-a"]);

View File

@ -2,6 +2,7 @@
//! presentation layer (ARCHITECTURE §3.2).
use crate::ids::{AgentId, ProfileId, ProjectId, SessionId, SkillId, TemplateId};
use crate::mailbox::TicketId;
use crate::memory::MemorySlug;
use crate::template::TemplateVersion;
@ -173,6 +174,28 @@ pub enum DomainEvent {
/// Whether the in-process ONNX capability (`localOnnx`) is compiled in.
vector_onnx_enabled: bool,
},
/// A delegation is ready to be injected into the agent's **native terminal**
/// (ARCHITECTURE §20.2/§20.3). Published when a turn *starts* (Idle→Busy); the
/// backend stays the authority of the FIFO/busy state and **no longer writes the
/// turn into the PTY** — the frontend cell runs the write-portal handshake and
/// writes `text` + `submit_sequence` through the single PTY writer (the front).
/// Discrete, low-frequency (one per delegation). Relayed to the front as
/// `delegationReady` like every other [`DomainEvent`].
DelegationReady {
/// The target agent whose terminal will receive the delegation.
agent_id: AgentId,
/// The mailbox ticket correlating this turn (acked back via
/// `delegation_delivered`); the requester's `ask` is woken by `idea_reply`.
ticket: TicketId,
/// The task text to inject (written without a trailing `\n` by the portal).
text: String,
/// Target profile's submit sequence (the cell applies it after the text).
/// `None` ⇒ the front applies its default (`"\r"`); never hard-coded in the
/// domain.
submit_sequence: Option<String>,
/// Target profile's submit delay in ms. `None` ⇒ front default (~60 ms).
submit_delay_ms: Option<u32>,
},
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
PtyOutput {
/// The session.

View File

@ -97,6 +97,29 @@ impl AgentBusyState {
}
}
/// Per-agent submit configuration (ARCHITECTURE §20.3), resolved from the target
/// agent's profile and carried to the [`InputMediator`] at bind time so it can be
/// echoed on a [`crate::events::DomainEvent::DelegationReady`].
///
/// Both fields stay `Option`: the **default** (`"\r"`, ~60 ms) is applied at the
/// point of use (the frontend write-portal), never hard-coded in the domain — the
/// domain only transports the declared values.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SubmitConfig {
/// The profile's `submit_sequence` (e.g. `"\r"`). `None` ⇒ front default.
pub sequence: Option<String>,
/// The profile's `submit_delay_ms`. `None` ⇒ front default (~60 ms).
pub delay_ms: Option<u32>,
}
impl SubmitConfig {
/// Builds a submit config from the two optional profile fields.
#[must_use]
pub const fn new(sequence: Option<String>, delay_ms: Option<u32>) -> Self {
Self { sequence, delay_ms }
}
}
/// The single convergence point of **all** of an agent's input (one FIFO/agent),
/// with `enqueue` (Envoyer) and `preempt` (Interrompre) kept **distinct**, plus the
/// busy state.
@ -108,12 +131,14 @@ pub trait InputMediator: Send + Sync {
/// Envoyer = enqueue: appends `ticket` to `agent`'s FIFO and returns the
/// [`PendingReply`] to await (the mailbox reply type, reused).
///
/// **Delivery** (cadrage C3 §5.2): the implementation also **writes** the turn
/// into the agent's input stream — *this* is the single, serialized write path
/// that replaces the orchestrator's former ad-hoc PTY write. The write target is
/// the [`PtyHandle`] previously registered with [`InputMediator::bind_handle`];
/// without one, the enqueue still queues the ticket (forward, never reject) and
/// the orchestrator falls back to its own delivery.
/// **Delivery** (ARCHITECTURE §20.2/§20.3): the mediator **no longer writes the
/// turn into the PTY** (the `\n` band-aid is gone — it never submitted in raw mode
/// and produced a "double chat"). Instead, on the enqueue that **starts a turn**
/// (Idle→Busy), the adapter publishes a [`crate::events::DomainEvent::DelegationReady`]
/// carrying the task text + ticket + the target's [`SubmitConfig`]; the frontend
/// cell (sole owner of the terminal) runs the write-portal handshake and writes the
/// text + submit sequence through the single PTY writer. The mediator stays the
/// authority of the FIFO/busy state and correlation only.
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply;
/// Registers (or refreshes) the live input [`PtyHandle`] of `agent` so a later
@ -134,21 +159,30 @@ pub trait InputMediator: Send + Sync {
/// only returns `Idle` on the explicit signal or the per-turn timeout (fallback
/// «en cas de doute → reste Busy mais la file accepte»; never a false `Idle`).
///
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern (a
/// mediator that does not observe the output stream). The infra adapter overrides
/// it to arm the watcher.
/// It also records the target's [`SubmitConfig`] (ARCHITECTURE §20.3) so the
/// adapter can echo `submit_sequence`/`submit_delay_ms` on the
/// [`crate::events::DomainEvent::DelegationReady`] published when a turn starts.
/// The bind is the natural carrier: both the prompt pattern and the submit config
/// are per-agent profile data the orchestrator resolves at the same time, so they
/// travel together (no extra ticket field, no second resolve).
///
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern and
/// submit config (a mediator that does not observe the output stream). The infra
/// adapter overrides it to arm the watcher and stash the submit config.
fn bind_handle_with_prompt(
&self,
agent: AgentId,
handle: PtyHandle,
_prompt_ready_pattern: Option<String>,
_submit: SubmitConfig,
) {
self.bind_handle(agent, handle);
}
/// Whether this mediator owns the turn-delivery write for `agent` (i.e. it has a
/// bound handle **and** a PTY port). When `false`, the orchestrator must deliver
/// the turn itself. Default: `false`.
/// **Deprecated since ARCHITECTURE §20**: the mediator never writes the turn into
/// the PTY anymore — the **frontend** always delivers (via the write-portal). Kept
/// for source compatibility; it now always returns `false`. Callers should stop
/// branching on it (the orchestrator no longer falls back to its own PTY write).
#[must_use]
fn delivers_turn(&self, _agent: AgentId) -> bool {
false

View File

@ -40,16 +40,7 @@ use crate::input::InputSource;
/// correlation is positional (head of the FIFO), the id only lets the caller name
/// the exact ticket it wants retired on timeout ([`AgentMailbox::cancel_head`]).
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
PartialOrd,
Ord,
serde::Serialize,
serde::Deserialize,
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
#[serde(transparent)]
pub struct TicketId(pub uuid::Uuid);
@ -189,9 +180,7 @@ pub struct PendingReply {
impl PendingReply {
/// Wraps a reply future built by the adapter (e.g. over a one-shot receiver).
#[must_use]
pub fn new(
inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>,
) -> Self {
pub fn new(inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>) -> Self {
Self { inner }
}
}
@ -286,10 +275,7 @@ mod tests {
assert_eq!(t.task, "do X");
// Back-compat default: human source, nil conversation.
assert_eq!(t.source, InputSource::Human);
assert_eq!(
t.conversation,
ConversationId::from_uuid(uuid::Uuid::nil())
);
assert_eq!(t.conversation, ConversationId::from_uuid(uuid::Uuid::nil()));
}
#[test]
@ -322,9 +308,6 @@ mod tests {
#[test]
fn mailbox_errors_are_distinct_and_typed() {
let a = AgentId::from_uuid(uuid::Uuid::from_u128(1));
assert_ne!(
MailboxError::NoPendingRequest(a),
MailboxError::Cancelled
);
assert_ne!(MailboxError::NoPendingRequest(a), MailboxError::Cancelled);
}
}

View File

@ -325,6 +325,28 @@ pub struct AgentProfile {
/// un profil sans motif sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_ready_pattern: Option<String>,
/// Séquence de soumission écrite **après** le texte d'une délégation pour la
/// faire valider par la CLI (§20.3, fix Bug 1). Le portail d'écriture (front)
/// écrit d'abord le texte (sans `\n`, pour esquiver la détection de paste de
/// la TUI), puis **cette séquence seule** après un court délai.
///
/// `None` ⇒ défaut `"\r"` appliqué **au point d'usage** (front), jamais codé
/// en dur dans le domaine (model-agnostic, déclaratif). Une autre TUI peut
/// exiger une séquence différente → c'est précisément pourquoi c'est donnée.
///
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** : un profil
/// sans cette clé sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submit_sequence: Option<String>,
/// Délai (ms) entre l'écriture du texte et celle de [`Self::submit_sequence`]
/// (§20.3, fix Bug 1). Évite que la TUI absorbe la soumission comme un paste.
///
/// `None` ⇒ défaut (~60 ms) appliqué **au point d'usage** (front), jamais codé
/// en dur dans le domaine.
///
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submit_delay_ms: Option<u32>,
}
/// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel).
@ -461,6 +483,8 @@ impl AgentProfile {
structured_adapter: None,
mcp: None,
prompt_ready_pattern: None,
submit_sequence: None,
submit_delay_ms: None,
})
}
@ -491,6 +515,23 @@ impl AgentProfile {
self
}
/// Builder : fixe la [`Self::submit_sequence`] (§20.3, fix Bug 1) et renvoie le
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils qui s'en remettent au défaut `"\r"` ne l'appellent simplement pas.
#[must_use]
pub fn with_submit_sequence(mut self, sequence: impl Into<String>) -> Self {
self.submit_sequence = Some(sequence.into());
self
}
/// Builder : fixe le [`Self::submit_delay_ms`] (§20.3, fix Bug 1) et renvoie le
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel).
#[must_use]
pub const fn with_submit_delay_ms(mut self, delay_ms: u32) -> Self {
self.submit_delay_ms = Some(delay_ms);
self
}
/// Indique si ce profil peut être **proposé à la sélection/création** d'un
/// agent (§17.3, lot D7). Source **unique** de vérité : un profil n'est
/// sélectionnable que s'il porte un [`StructuredAdapter`], c'est-à-dire s'il
@ -611,10 +652,9 @@ mod mcp_tests {
#[test]
fn mcp_config_strategy_uses_tagged_camel_case() {
// The `strategy` tag and camelCase rename are part of the wire contract.
let json = serde_json::to_string(
&McpConfigStrategy::config_file(".mcp.json").expect("valid"),
)
.expect("serialise");
let json =
serde_json::to_string(&McpConfigStrategy::config_file(".mcp.json").expect("valid"))
.expect("serialise");
assert!(json.contains("\"strategy\":\"configFile\""), "got: {json}");
}
@ -651,7 +691,12 @@ mod mcp_tests {
#[test]
fn config_file_accepts_safe_relative_target() {
let s = McpConfigStrategy::config_file(".mcp.json").expect("safe relative");
assert_eq!(s, McpConfigStrategy::ConfigFile { target: ".mcp.json".to_owned() });
assert_eq!(
s,
McpConfigStrategy::ConfigFile {
target: ".mcp.json".to_owned()
}
);
}
#[test]
@ -663,7 +708,12 @@ mod mcp_tests {
#[test]
fn flag_accepts_non_empty() {
let s = McpConfigStrategy::flag("--mcp-config {path}").expect("non-empty");
assert_eq!(s, McpConfigStrategy::Flag { flag: "--mcp-config {path}".to_owned() });
assert_eq!(
s,
McpConfigStrategy::Flag {
flag: "--mcp-config {path}".to_owned()
}
);
}
#[test]
@ -675,7 +725,12 @@ mod mcp_tests {
#[test]
fn env_accepts_valid_name() {
let s = McpConfigStrategy::env("IDEA_MCP_SERVER").expect("valid");
assert_eq!(s, McpConfigStrategy::Env { var: "IDEA_MCP_SERVER".to_owned() });
assert_eq!(
s,
McpConfigStrategy::Env {
var: "IDEA_MCP_SERVER".to_owned()
}
);
}
// -- Lot C5 : prompt_ready_pattern (détection retour-de-prompt) --------------
@ -721,4 +776,60 @@ mod mcp_tests {
assert_eq!(profile, back);
assert_eq!(back.prompt_ready_pattern.as_deref(), Some("\n> "));
}
// -- §20 : submit_sequence / submit_delay_ms (portail d'écriture) ------------
#[test]
fn profile_default_has_no_submit_fields() {
let p = profile_without_mcp();
assert!(p.submit_sequence.is_none());
assert!(p.submit_delay_ms.is_none());
}
#[test]
fn profile_without_submit_fields_omits_keys_in_json() {
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
assert!(
!json.contains("submitSequence"),
"no submitSequence key when None (zero regression); got: {json}"
);
assert!(
!json.contains("submitDelayMs"),
"no submitDelayMs key when None (zero regression); got: {json}"
);
}
#[test]
fn legacy_json_without_submit_fields_deserialises_to_none() {
let legacy = r#"{
"id": "00000000-0000-0000-0000-000000000000",
"name": "Dev",
"command": "claude",
"args": [],
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
"detect": null,
"cwdTemplate": "{agentRunDir}"
}"#;
let p: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
assert!(p.submit_sequence.is_none());
assert!(p.submit_delay_ms.is_none());
}
#[test]
fn with_submit_fields_set_and_round_trip_camel_case() {
let p = profile_without_mcp()
.with_submit_sequence("\r")
.with_submit_delay_ms(60);
assert_eq!(p.submit_sequence.as_deref(), Some("\r"));
assert_eq!(p.submit_delay_ms, Some(60));
let json = serde_json::to_string(&p).expect("serialise");
assert!(json.contains("submitSequence"), "key present: {json}");
assert!(json.contains("submitDelayMs"), "key present: {json}");
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(p, back);
assert_eq!(back.submit_sequence.as_deref(), Some("\r"));
assert_eq!(back.submit_delay_ms, Some(60));
}
}

View File

@ -194,13 +194,19 @@ fn leaf_returns_none_for_grid_node_id() {
// L'id de la grille n'est pas une feuille.
assert!(grid.leaf(node(50)).is_none());
// La feuille imbriquée est bien retrouvée.
assert_eq!(*grid.leaf(node(3)).expect("nested leaf"), leaf_cell(3, None));
assert_eq!(
*grid.leaf(node(3)).expect("nested leaf"),
leaf_cell(3, None)
);
}
#[test]
fn leaf_finds_single_root_leaf() {
let tree = LayoutTree::single(leaf_cell(7, Some(70)));
assert_eq!(*tree.leaf(node(7)).expect("root leaf"), leaf_cell(7, Some(70)));
assert_eq!(
*tree.leaf(node(7)).expect("root leaf"),
leaf_cell(7, Some(70))
);
assert!(tree.leaf(node(8)).is_none());
}

View File

@ -837,12 +837,7 @@ fn move_session_preserves_resume_fields_on_both_leaves() {
// ---------------------------------------------------------------------------
/// Builds an agent-bearing leaf with explicit resume signals.
fn agent_leaf_full(
id: u128,
agent: u128,
conv: Option<&str>,
running: bool,
) -> LeafCell {
fn agent_leaf_full(id: u128, agent: u128, conv: Option<&str>, running: bool) -> LeafCell {
LeafCell {
id: node(id),
session: None,

View File

@ -150,7 +150,9 @@ fn profile_with_adapter_roundtrips_and_uses_camel_case() {
#[test]
fn with_structured_adapter_sets_only_that_field() {
let before = pty_profile();
let after = before.clone().with_structured_adapter(StructuredAdapter::Codex);
let after = before
.clone()
.with_structured_adapter(StructuredAdapter::Codex);
// Le seul champ muté :
assert_eq!(after.structured_adapter, Some(StructuredAdapter::Codex));
@ -275,7 +277,10 @@ fn agent_session_error_is_std_error_and_equates() {
AgentSessionError::Start("a".into()),
AgentSessionError::Start("b".into())
);
assert_ne!(AgentSessionError::Timeout, AgentSessionError::Io("t".into()));
assert_ne!(
AgentSessionError::Timeout,
AgentSessionError::Io("t".into())
);
}
// ---------------------------------------------------------------------------