From 8bdb84d94b4c385810eab035d4dc3da7bb549048 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 7 Nov 2026 03:20:23 +0100 Subject: [PATCH] self: a briefless turn is open from a request with nothing echoed until its result; the start message states the routing rules A request with no line of ours echoed is a turn that took in no brief, notification or not; until its result only the conversation it continues, or the notification's task's, is written. Nothing told yet means no other conversation exists. Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/self_run.rs | 30 ++++++++++++--- .../src/self_run/tests/conversation.rs | 38 +++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/crates/mind-body/src/self_run.rs b/crates/mind-body/src/self_run.rs index d607ae6..b81a8a0 100644 --- a/crates/mind-body/src/self_run.rs +++ b/crates/mind-body/src/self_run.rs @@ -605,6 +605,9 @@ struct SelfInbox<'a> { /// The `tool_use_id`s of task notifications whose turn has not yet ended in a result that took in no brief. While any is pending, a /// brief is written only when it is of the conversation the notification is attributed to. notified: Vec, + /// A turn that took in no brief is open: a request came with nothing echoed, and no line of ours has joined it. Until its result, a brief + /// is written only when it is of the conversation that turn is attributed to (the notification'"'"'s task, else the turn before). + briefless_open: bool, /// The flush was written and the CLI has not yet taken it. flush_written: bool, /// The CLI took the flush and its result has not come: that result is the session's note to its successor, never a reply to a brief. @@ -625,7 +628,7 @@ impl<'a> SelfInbox<'a> { carried.push(brief.id.clone()); } } - Self { mind, start, plan: render_plan(plan_quota, now_ms), told, waiting: Vec::new(), heard, run: run.clone(), carried, echoed: Vec::new(), conversation, last_answers: Vec::new(), tasks: HashMap::new(), notified: Vec::new(), flush_written: false, flushing: false, replies: 0, unadmitted: Vec::new() } + Self { mind, start, plan: render_plan(plan_quota, now_ms), told, waiting: Vec::new(), heard, run: run.clone(), carried, echoed: Vec::new(), conversation, last_answers: Vec::new(), tasks: HashMap::new(), notified: Vec::new(), briefless_open: false, flush_written: false, flushing: false, replies: 0, unadmitted: Vec::new() } } /// Admits each reply the mind has not taken, in order; one try each. A refusal or an unreachable mind keeps the reply and never ends the session. @@ -647,6 +650,7 @@ impl<'a> SelfInbox<'a> { let Ok(Value::Object(event)) = serde_json::from_str::(line) else { return }; let text = result_text(&event); let flush = std::mem::take(&mut self.flushing); + let took = self.echoed.first().and_then(|id| self.conversation_of(id)); // The flush result is the session's note to its successor, in the journal: it is no reply, so nothing ever points at it. if ok && !flush && !text.trim().is_empty() { self.replies += 1; @@ -665,10 +669,12 @@ impl<'a> SelfInbox<'a> { text: cut_text(text, REPLY_TEXT_LIMIT).as_str().into(), }); } - // A turn that took in no brief has ended: it was the turn of the notifications pending, which are now answered. - if !flush && self.echoed.is_empty() { + // The turn has ended. A turn that took in no brief answered the notifications pending; one that took in the notification's own conversation's briefs + // took the notification in with them. A notification of another conversation stays pending: its turn is still to come. + if !flush && (took.is_none() || (ok && !text.trim().is_empty() && self.turn_conversation() == took)) { self.notified.clear(); } + self.briefless_open = false; self.admit_unadmitted(); self.conversation = self.carried.first().or(self.echoed.first()).and_then(|id| self.conversation_of(id)); } @@ -695,8 +701,8 @@ impl<'a> SelfInbox<'a> { answers } - /// The one conversation the pending notifications are attributed to; none when they name two, or one could not be attributed. - fn notified_conversation(&self) -> Option { + /// The one conversation the open turn is attributed to: its notifications' tasks', else the turn before. None when they name two, or one could not be attributed. + fn turn_conversation(&self) -> Option { let answers = self.briefless_answers(); let mut conversations = answers.iter().map(|id| self.conversation_of(id)); let first = conversations.next()??; @@ -735,10 +741,16 @@ impl<'a> SelfInbox<'a> { self.told.iter().find(|brief| brief.id == id).map(|brief| brief.conversation.clone()) } + /// Whether the open turn lets this brief in: while a task notification or another turn that took in no brief is open, only the conversation + /// it is attributed to (none when it cannot be placed), unless nothing has been told yet and no other conversation exists. + fn turn_allows(&self, brief: &Brief) -> bool { + (self.notified.is_empty() && (!self.briefless_open || self.told.is_empty())) || self.turn_conversation().as_ref() == Some(&brief.conversation) + } + /// Whether a waiting brief may be told now: any when every brief told has been answered, else only the unanswered ones' conversation. /// While a task notification's turn is open, only the conversation the notification is attributed to may be told (none when it cannot be placed). fn may_tell(&self, brief: &Brief) -> bool { - (self.notified.is_empty() || self.notified_conversation().as_ref() == Some(&brief.conversation)) && self.conversation.as_ref().is_none_or(|conversation| *conversation == brief.conversation) + self.turn_allows(brief) && self.conversation.as_ref().is_none_or(|conversation| *conversation == brief.conversation) } } @@ -777,9 +789,15 @@ impl SessionInbox for SelfInbox<'_> { fn line(&mut self, line: &str) { self.heard.hear(line); self.note_task(line); + if matches!(read_usage(line), Usage::Request { .. }) && self.echoed.is_empty() { + self.briefless_open = true; + } } fn echoed(&mut self) { + if !self.carried.is_empty() { + self.briefless_open = false; + } self.echoed.append(&mut self.carried); self.flushing |= std::mem::take(&mut self.flush_written); } diff --git a/crates/mind-body/src/self_run/tests/conversation.rs b/crates/mind-body/src/self_run/tests/conversation.rs index 06e673b..441f4a6 100644 --- a/crates/mind-body/src/self_run/tests/conversation.rs +++ b/crates/mind-body/src/self_run/tests/conversation.rs @@ -338,3 +338,41 @@ fn a_restart_with_a_resolved_claim_and_no_open_brief_holds_the_gate_for_the_clai assert!(ran.outcome.is_ok()); assert_eq!((answered(&mind, 1), answered(&mind, 2)), (vec![id_of("a1")], vec![id_of("l1")])); } + +/// A turn that took in no brief is running (a request with nothing echoed) and the stream gave no notification event: it continues the turn +/// before, so another conversation's brief is not written into it. +#[test] +fn another_conversations_brief_waits_for_any_turn_that_took_in_no_brief() { + let before = |mind: &Rc| { + vec![ + Out(echo()), + Out(saying("nothing yet")), + briefed(mind, 31, vec![aqua("a1")]), + Out(echo()), + Out(saying("aqua turn: started a background task")), + Out(at("n1", 10)), + briefed(mind, 31, vec![libby("l1")]), + Idle(31), + ] + }; + let mind = live_mind(); + let ran = mind_run(&mind, before(&mind)); + assert!(ran.outcome.is_ok()); + assert!(!written_with(&ran, &id_of("l1")), "{:?}", ran.written()); + + let mind = live_mind(); + let ran = mind_run(&mind, [before(&mind), vec![Out(saying("SYNTHETIC aqua task outcome")), Idle(31), Out(echo()), Out(saying("libby done"))]].concat()); + assert!(ran.outcome.is_ok()); + assert_eq!(answered(&mind, 3), vec![id_of("a1")], "the outcome continues aqua's turn, not libby's"); + assert_eq!(answered(&mind, 4), vec![id_of("l1")]); +} + +/// The start message states the routing rules her Self works under, in place of the old continuation sentence. +#[test] +fn the_start_message_states_the_routing_rules() { + let text = start_message(&Slug("yggdrasil".into()), &run_ref("selfrun-a"), &[], 0); + for rule in ["one conversation at a time", "the conversation of the turn that started it", "no other conversation's brief is told into that turn", "pointer to it by id, without its text"] { + assert!(text.contains(rule), "{rule}: {text}"); + } + assert!(!text.contains("continues the one before")); +}