From fcbfc95a9b188d872008fd31d1940c9875064d6f Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Sun, 23 Aug 2026 09:21:19 +0900 Subject: [PATCH 1/7] feat(room): add the floor ledger that orders two speakers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `crates/room-floor`, a tauri-free crate holding what has been said and how much of it a participant had seen when they tried to speak. `Floor::admit` is one operation: it resolves the speaker's watermark and appends the post under the caller's single lock acquisition, so two participants speaking at once are serialised and the second is refused rather than delivered blind. 判定と刻印を一つの操作に閉じるための土台。tauri 非依存の crate に置いたのは docs/0-requirements.md 「テストの配置」の方針どおりで、src-tauri 側の純粋 ロジックは GNU ターゲットでテストが起動せず検証不能になるため。同時発話の 順序付けはまさに読んでも確かめられない部分なので、走るテストの側へ置く。 Refs #47 Co-Authored-By: Claude Opus 5 --- crates/room-floor/Cargo.lock | 75 +++++++ crates/room-floor/Cargo.toml | 16 ++ crates/room-floor/src/lib.rs | 423 +++++++++++++++++++++++++++++++++++ 3 files changed, 514 insertions(+) create mode 100644 crates/room-floor/Cargo.lock create mode 100644 crates/room-floor/Cargo.toml create mode 100644 crates/room-floor/src/lib.rs diff --git a/crates/room-floor/Cargo.lock b/crates/room-floor/Cargo.lock new file mode 100644 index 0000000..ae2d4e8 --- /dev/null +++ b/crates/room-floor/Cargo.lock @@ -0,0 +1,75 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "room-floor" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/crates/room-floor/Cargo.toml b/crates/room-floor/Cargo.toml new file mode 100644 index 0000000..ecc3584 --- /dev/null +++ b/crates/room-floor/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "room-floor" +version = "0.1.0" +description = "The floor of the liplus-chat room: what has been said, and who had seen it before speaking" +authors = ["Liplus Project Contributors"] +license = "Apache-2.0" +edition = "2021" + +# Deliberately free of tauri, for the reason docs/0-requirements.md gives: a +# test binary that links the tauri dependency tree does not load on the GNU +# target, so pure logic left in `src-tauri/src/` is unverifiable the moment it +# is written. The ordering this crate decides is the part of #47 that a reader +# cannot check by reading, so it has to be the part a test can run. + +[dependencies] +serde = { version = "1", features = ["derive"] } diff --git a/crates/room-floor/src/lib.rs b/crates/room-floor/src/lib.rs new file mode 100644 index 0000000..fb906f5 --- /dev/null +++ b/crates/room-floor/src/lib.rs @@ -0,0 +1,423 @@ +//! The floor of the room. +//! +//! What has been said, in the order the room put it in, and how much of it a +//! participant had seen at the moment they tried to speak. +//! +//! A participant composing a reply cannot see the floor. Posts that arrive +//! while they compose are delivered to their sidecar, but whether they enter +//! the participant's context is decided by where the next tool-result boundary +//! falls — and a reply that needs no tool has no boundary before its own send. +//! Delivery is therefore not reading, and the room cannot tell the two apart +//! from its own side. Only the speaker knows what they actually saw, so the +//! speaker declares it, as `last_seen` (#47). +//! +//! [`Floor::admit`] is one operation: it reads the watermark and appends the +//! post under the caller's single lock acquisition. That is the whole point of +//! the type. Two participants speaking at once are serialised by that lock, so +//! the first one's post is on the floor before the second one's check reads +//! it, and the second is refused rather than delivered blind. A design where +//! the check and the append can interleave gives both of them an empty floor, +//! which is the case this exists to close. +//! +//! The floor holds no opinion about content. Whether a missed post bears on +//! what the speaker was going to say is the speaker's judgment; one missed +//! post refuses the attempt, whoever it was addressed to. + +use std::collections::VecDeque; + +use serde::Serialize; + +/// How many posts the floor keeps. +/// +/// Bounded because a room runs for as long as the app does. The bound is what +/// makes a watermark resolvable or not: an id older than this has been +/// dropped, and [`Floor::admit`] then reads the speaker as having seen nothing +/// rather than guessing (see that method's watermark resolution). +pub const DEFAULT_CAPACITY: usize = 512; + +/// One utterance, as it was said. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Post { + pub message_id: String, + pub speaker: String, + pub content: String, + pub to: Option, + pub ts: String, +} + +/// A post the speaker had not seen, handed back in place of their own. +/// +/// Carries what a participant needs in order to decide again: who said it, +/// what they said, who they said it to, and the `message_id` to declare as +/// `last_seen` on the next attempt. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Missed { + pub message_id: String, + pub speaker: String, + pub content: String, + pub to: Option, + pub ts: String, +} + +/// What the floor did with an attempt to speak. +/// +/// An enum rather than a bool, so the refusal can carry its reason. Widening +/// [`Admission::Admitted`] later — to hand back a position in a queue rather +/// than only the position taken — adds a field here and changes no caller's +/// signature (#47 constraint: leave no step up to turn assignment). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Admission { + /// On the floor, at this position. + Admitted { seq: u64 }, + /// Not on the floor. These are the posts the speaker had not seen, oldest + /// first. Nothing was delivered in their place. + Unseen(Vec), +} + +#[derive(Debug, Clone)] +struct Entry { + seq: u64, + /// The connection the post arrived on. Never a name: two participants may + /// answer to one name, and a name test would hold one of them responsible + /// for the other's post. + origin: String, + post: Post, +} + +/// The room's ordering authority. +#[derive(Debug)] +pub struct Floor { + seq: u64, + log: VecDeque, + capacity: usize, +} + +impl Default for Floor { + fn default() -> Self { + Floor::new() + } +} + +impl Floor { + pub fn new() -> Self { + Floor::with_capacity(DEFAULT_CAPACITY) + } + + pub fn with_capacity(capacity: usize) -> Self { + Floor { + seq: 0, + log: VecDeque::new(), + capacity: capacity.max(1), + } + } + + /// The position of the newest post, or 0 when nothing has been said. + /// + /// Read when a participant takes a seat: it is the floor they start from, + /// since what predates their connection was never delivered to them. + pub fn seq(&self) -> u64 { + self.seq + } + + /// Check the speaker against the floor and, if they are clear, put their + /// post on it. + /// + /// One operation on purpose. The caller holds one lock across both halves, + /// so concurrent speakers get an order instead of both reading the floor + /// as it stood before either of them spoke. + /// + /// `since` is the position at which the speaker's seat was taken, used + /// when they declare no watermark: a participant who joined mid + /// conversation has seen nothing, and is owed nothing for what predates + /// their seat either. + /// + /// Watermark resolution: + /// + /// - `last_seen` naming a post still on the floor: that post's position. + /// - `last_seen` naming anything else — an id from before the retained + /// window, or a value the room never issued: position 0, which is the + /// whole retained floor. A value the room cannot resolve is read as + /// having seen nothing, erring toward refusing (#47 constraint). It + /// costs the speaker one round trip and cannot cost anyone a missed + /// post. + /// - no `last_seen`: `since`. + /// + /// The speaker's own posts are never counted against them. They are not + /// delivered back to their author, so there was nothing there to read. + pub fn admit( + &mut self, + origin: &str, + since: u64, + last_seen: Option<&str>, + post: Post, + ) -> Admission { + let watermark = self.watermark(since, last_seen); + let missed: Vec = self + .log + .iter() + .filter(|entry| entry.seq > watermark && entry.origin != origin) + .map(|entry| Missed { + message_id: entry.post.message_id.clone(), + speaker: entry.post.speaker.clone(), + content: entry.post.content.clone(), + to: entry.post.to.clone(), + ts: entry.post.ts.clone(), + }) + .collect(); + + if !missed.is_empty() { + return Admission::Unseen(missed); + } + + self.seq += 1; + let seq = self.seq; + self.log.push_back(Entry { + seq, + origin: origin.to_string(), + post, + }); + while self.log.len() > self.capacity { + self.log.pop_front(); + } + Admission::Admitted { seq } + } + + fn watermark(&self, since: u64, last_seen: Option<&str>) -> u64 { + match last_seen { + None => since, + Some(id) => self + .log + .iter() + .find(|entry| entry.post.message_id == id) + .map(|entry| entry.seq) + .unwrap_or(0), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Barrier, Mutex}; + use std::thread; + + fn post(message_id: &str, speaker: &str, content: &str) -> Post { + Post { + message_id: message_id.to_string(), + speaker: speaker.to_string(), + content: content.to_string(), + to: None, + ts: "2026-08-23T00:00:00.000Z".to_string(), + } + } + + fn admitted_seq(admission: &Admission) -> u64 { + match admission { + Admission::Admitted { seq } => *seq, + Admission::Unseen(missed) => panic!("expected admission, refused with {missed:?}"), + } + } + + fn refusal(admission: &Admission) -> &[Missed] { + match admission { + Admission::Unseen(missed) => missed, + Admission::Admitted { seq } => panic!("expected refusal, admitted at {seq}"), + } + } + + #[test] + fn an_empty_floor_admits_the_first_speaker() { + let mut floor = Floor::new(); + let admission = floor.admit("master", 0, None, post("m-1", "Master", "ハロー")); + assert_eq!(admitted_seq(&admission), 1); + } + + #[test] + fn a_speaker_who_has_seen_the_floor_is_admitted() { + let mut floor = Floor::new(); + floor.admit("master", 0, None, post("m-1", "Master", "ハロー")); + let admission = floor.admit("lin", 0, Some("m-1"), post("m-2", "Claude Lin", "はい")); + assert_eq!(admitted_seq(&admission), 2); + } + + #[test] + fn an_unseen_post_refuses_the_speaker_and_is_handed_back() { + let mut floor = Floor::new(); + floor.admit("master", 0, None, post("m-1", "Master", "ハロー")); + floor.admit("lay", 0, Some("m-1"), post("m-2", "Claude Lay", "答えます")); + + // Lin is still declaring m-1: it began composing before m-2 landed. + let admission = floor.admit("lin", 0, Some("m-1"), post("m-3", "Claude Lin", "答えます")); + let missed = refusal(&admission); + assert_eq!(missed.len(), 1); + assert_eq!(missed[0].message_id, "m-2"); + assert_eq!(missed[0].speaker, "Claude Lay"); + assert_eq!(missed[0].content, "答えます"); + + // Refused means not delivered: the floor did not take the post. + assert_eq!(floor.seq(), 2); + } + + #[test] + fn the_refusal_carries_every_missed_post_oldest_first() { + let mut floor = Floor::new(); + floor.admit("master", 0, None, post("m-1", "Master", "ハロー")); + floor.admit("lay", 0, Some("m-1"), post("m-2", "Claude Lay", "ひとつめ")); + floor.admit("master", 0, Some("m-2"), post("m-3", "Master", "ふたつめ")); + + let admission = floor.admit("lin", 0, Some("m-1"), post("m-4", "Claude Lin", "答えます")); + let missed = refusal(&admission); + let ids: Vec<&str> = missed.iter().map(|one| one.message_id.as_str()).collect(); + assert_eq!(ids, ["m-2", "m-3"]); + } + + #[test] + fn a_speakers_own_posts_are_not_held_against_them() { + let mut floor = Floor::new(); + floor.admit("lin", 0, None, post("m-1", "Claude Lin", "ひとつめ")); + // Nothing arrived in between, so Lin has no id but its own to declare. + // A reply that needs no tool is exactly this shape. + let admission = floor.admit("lin", 0, None, post("m-2", "Claude Lin", "ふたつめ")); + assert_eq!(admitted_seq(&admission), 2); + } + + #[test] + fn an_unresolvable_watermark_is_read_as_having_seen_nothing() { + let mut floor = Floor::new(); + floor.admit("master", 0, None, post("m-1", "Master", "ハロー")); + + // `since` alone would have cleared this speaker; a declared value the + // room cannot resolve must not be softened into it. + let seated_at = floor.seq(); + let admission = floor.admit( + "lin", + seated_at, + Some("m-nonexistent"), + post("m-2", "Claude Lin", "答えます"), + ); + let missed = refusal(&admission); + assert_eq!(missed.len(), 1); + assert_eq!(missed[0].message_id, "m-1"); + } + + #[test] + fn a_watermark_dropped_from_the_window_falls_back_to_the_whole_floor() { + let mut floor = Floor::with_capacity(2); + floor.admit("master", 0, None, post("m-1", "Master", "ひとつめ")); + floor.admit("master", 0, Some("m-1"), post("m-2", "Master", "ふたつめ")); + floor.admit("master", 0, Some("m-2"), post("m-3", "Master", "みっつめ")); + + // m-1 has been dropped. Declaring it resolves to nothing, so the + // retained floor is handed back rather than assumed read. + let admission = floor.admit("lin", 0, Some("m-1"), post("m-4", "Claude Lin", "答えます")); + let ids: Vec<&str> = refusal(&admission) + .iter() + .map(|one| one.message_id.as_str()) + .collect(); + assert_eq!(ids, ["m-2", "m-3"]); + } + + #[test] + fn a_participant_is_not_shown_what_predates_their_seat() { + let mut floor = Floor::new(); + floor.admit("master", 0, None, post("m-1", "Master", "ハロー")); + + // Lin connects here and declares nothing: it has seen nothing, and + // m-1 was never delivered to it either. + let since = floor.seq(); + let admission = floor.admit("lin", since, None, post("m-2", "Claude Lin", "参加しました")); + assert_eq!(admitted_seq(&admission), 2); + } + + #[test] + fn what_arrives_after_a_seat_is_taken_still_refuses() { + let mut floor = Floor::new(); + let since = floor.seq(); + floor.admit("master", 0, None, post("m-1", "Master", "ハロー")); + + let admission = floor.admit("lin", since, None, post("m-2", "Claude Lin", "答えます")); + let missed = refusal(&admission); + assert_eq!(missed.len(), 1); + assert_eq!(missed[0].message_id, "m-1"); + } + + #[test] + fn an_addressee_does_not_narrow_the_refusal() { + let mut floor = Floor::new(); + floor.admit("master", 0, None, post("m-1", "Master", "ハロー")); + let mut addressed = post("m-2", "Master", "レイだけ答えて"); + addressed.to = Some("Claude Lay".to_string()); + floor.admit("master", 0, Some("m-1"), addressed); + + // Addressed to someone else, and it refuses all the same: the room + // does not judge whether a missed post bears on what Lin would say. + let admission = floor.admit("lin", 0, Some("m-1"), post("m-3", "Claude Lin", "答えます")); + let missed = refusal(&admission); + assert_eq!(missed.len(), 1); + assert_eq!(missed[0].to.as_deref(), Some("Claude Lay")); + } + + #[test] + fn two_speakers_at_once_get_an_order() { + // The case the type exists for. Both threads hold the same watermark — + // they composed from the same floor — and both try to speak. The lock + // serialises them, so the second one's check runs against a floor the + // first has already changed. + let floor = Arc::new(Mutex::new(Floor::new())); + floor + .lock() + .unwrap() + .admit("master", 0, None, post("m-1", "Master", "ハロー")); + + let start = Arc::new(Barrier::new(2)); + let speakers = [ + ("lin", "m-lin", "Claude Lin"), + ("lay", "m-lay", "Claude Lay"), + ]; + let handles: Vec<_> = speakers + .into_iter() + .map(|(origin, id, speaker)| { + let floor = Arc::clone(&floor); + let start = Arc::clone(&start); + thread::spawn(move || { + start.wait(); + let mut floor = floor.lock().unwrap(); + floor.admit(origin, 0, Some("m-1"), post(id, speaker, "答えます")) + }) + }) + .collect(); + + let results: Vec = handles.into_iter().map(|one| one.join().unwrap()).collect(); + + let admitted: Vec<&Admission> = results + .iter() + .filter(|one| matches!(one, Admission::Admitted { .. })) + .collect(); + assert_eq!( + admitted.len(), + 1, + "exactly one of two simultaneous speakers may take the floor, got {results:?}" + ); + assert_eq!(admitted_seq(admitted[0]), 2); + + let refused = results + .iter() + .find(|one| matches!(one, Admission::Unseen(_))) + .expect("the other speaker must be refused"); + let missed = refusal(refused); + assert_eq!( + missed.len(), + 1, + "the refused speaker is handed the post that beat them" + ); + assert!( + missed[0].message_id == "m-lin" || missed[0].message_id == "m-lay", + "the missed post is the one that won the floor, got {:?}", + missed[0] + ); + + // One post went on, not two: the refusal is a refusal, not a notice + // attached to a delivery. + assert_eq!(floor.lock().unwrap().seq(), 2); + } +} From 90edec68141a45384310f44cc5e1ba630d1108cd Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Sun, 23 Aug 2026 13:12:37 +0900 Subject: [PATCH 2/7] feat(room): refuse a post from a speaker who has not seen the floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `deliver` now takes the speaker's `last_seen` watermark and returns a `PostOutcome`. The floor check and the append share one acquisition of the existing `Arc>`, so two participants posting at once are serialised: the first one's post is on the floor before the second one's check reads it, and the second is refused rather than delivered blind. A refused post is not delivered to anyone and comes back carrying what was missed. Both callers go through it. The socket loop passes the frame's `last_seen` and answers on the same connection with a new `post_result` frame; `room_post` returns the outcome to the screen. Protocol 4. 判定と刻印を既存ロックの内側に置いた。参加者クラスで書き分けていないため、 画面からの投稿も同じ経路で同じ判定を受ける。宛先による絞り込みは行わず、 見落としが一件でもあれば弾く。`last_seen` は身元の主張ではないので検証せず、 部屋の知らない値は「見ていない」として扱う。`post_result` を返すことで、 ゼロ tool の返信でも呼び出し自体が境界になる。 Refs #47 Co-Authored-By: Claude Opus 5 --- src-tauri/Cargo.lock | 8 ++ src-tauri/Cargo.toml | 1 + src-tauri/src/room.rs | 227 ++++++++++++++++++++++++++++++++++-------- 3 files changed, 196 insertions(+), 40 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 177cc70..691cc56 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2139,6 +2139,7 @@ dependencies = [ "mcp-config", "parking_lot", "portable-pty", + "room-floor", "serde", "serde_json", "tauri", @@ -3096,6 +3097,13 @@ dependencies = [ "web-sys", ] +[[package]] +name = "room-floor" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "rustc-hash" version = "2.1.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3741199..4da5c63 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,6 +25,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" portable-pty = { path = "../portable-pty-patch" } mcp-config = { path = "../crates/mcp-config" } +room-floor = { path = "../crates/room-floor" } uuid = { version = "1", features = ["v4"] } parking_lot = "0.12" tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros"] } diff --git a/src-tauri/src/room.rs b/src-tauri/src/room.rs index f0db8e8..51ad174 100644 --- a/src-tauri/src/room.rs +++ b/src-tauri/src/room.rs @@ -7,7 +7,9 @@ //! Frames on the wire are the room protocol: //! //! sidecar -> room : { type: "hello", protocol, name, hue? } -//! both ways : { type: "post", message_id, speaker, content, to?, ts } +//! both ways : { type: "post", message_id, speaker, content, to?, ts, +//! last_seen? } +//! room -> sidecar : { type: "post_result", message_id, delivered, missed } //! //! One frame kind carries speech, whoever produced it. A person and a session //! are both participants; what separates them is a name, not a frame. The @@ -34,11 +36,32 @@ //! to one name; they are still two, and one of them leaving must not take the //! other off the roster (#40). //! +//! `last_seen` on a post is the speaker's watermark: the `message_id` of the +//! newest post they had actually seen when they composed. The room checks it +//! against the floor (`room_floor::Floor`) and refuses the post outright when +//! anything is behind it, handing those posts back as `missed` instead of +//! delivering. The room knows what it handed to each connection, but delivery +//! is not reading — whether a post entered a participant's context depends on +//! where their next tool-result boundary fell, which the room cannot see. Only +//! the speaker knows, so the speaker declares (#47). +//! +//! The check and the stamp share one acquisition of the room's lock. That is +//! what gives two participants speaking at once an order: the first one's post +//! is on the floor before the second one's check reads it. Splitting them — +//! checking, then delivering — hands both of them the floor as it stood before +//! either spoke, which is the case the whole mechanism exists to close. +//! +//! `post_result` is the answer, and it goes back only to the connection that +//! posted. It is the reason a reply needing no other tool no longer misses +//! what arrived while it was composed: the call is itself the boundary, and +//! the refusal arrives on it. +//! //! Everything the frontend needs arrives as a `room-message` event. The room //! never reads a CLI's terminal output; that is not a message source. use futures_util::{SinkExt, StreamExt}; use parking_lot::Mutex; +use room_floor::{Admission, Floor, Missed, Post}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::sync::Arc; @@ -54,7 +77,9 @@ use uuid::Uuid; /// /// 2: `say` and `reply` collapsed into one `post` frame; `agent` became `name`. /// 3: `hello` carries the declared `hue` beside the name. -pub const PROTOCOL_VERSION: u32 = 3; +/// 4: `post` carries the speaker's `last_seen` watermark, and the room answers +/// every post with `post_result` (#47). +pub const PROTOCOL_VERSION: u32 = 4; /// One post of the room, as the frontend sees it. /// @@ -95,16 +120,26 @@ pub struct Participant { pub own: bool, } -/// A post as it happened, before any viewer-relative framing. -struct Post { - message_id: String, - speaker: String, - content: String, - to: Option, - ts: String, +/// What the room did with a post, as the participant who submitted it sees it. +/// +/// One shape for both callers, because there is one path. Refused is not an +/// error: the participant asked to speak and was told what they had not seen, +/// and deciding not to speak after reading it is a legitimate answer. +/// +/// Widening this to carry a place in a queue as well as the posts that were +/// missed adds a field here and changes no signature (#47). +#[derive(Debug, Clone, Serialize)] +pub struct PostOutcome { + /// True when the post went into the room. + pub delivered: bool, + /// The id the post is filed under, or `None` when it was refused. A + /// refused post has no id in the room because it is not in the room. + pub message_id: Option, + /// What the speaker had not seen, oldest first. Empty when `delivered`. + pub missed: Vec, } -/// A post on its way out, tagged with the connection that produced it. +/// A frame on its way out, tagged with the connection that produced it. /// /// The tag rides beside the frame, never inside it: it is never serialised, so /// no sender can supply one and no sender can forge one. Suppression is a @@ -113,6 +148,10 @@ struct Post { #[derive(Clone)] struct Fanout { origin: String, + /// Set when this frame is for one connection only — a `post_result` is the + /// room answering the participant who posted, not something the room says. + /// `None` is the fan-out proper: everyone but `origin`. + target: Option, frame: String, } @@ -126,6 +165,7 @@ struct IncomingFrame { content: Option, to: Option, ts: Option, + last_seen: Option, protocol: Option, } @@ -134,6 +174,12 @@ struct IncomingFrame { struct Seat { name: String, hue: Option, + /// Where the floor stood when this connection took its seat. It is the + /// watermark of a participant who declares none: what predates the seat + /// was never delivered to them, so it is not theirs to have missed. + /// Preserved across a rename — that is the same participant, still having + /// seen what they saw. + since: u64, } struct RoomInner { @@ -144,6 +190,10 @@ struct RoomInner { /// `Claude Code` were one entry, and either of them disconnecting removed /// both (#40). participants: BTreeMap, + /// What has been said, and the order the room put it in. Lives here rather + /// than beside the lock so the check and the stamp are one critical + /// section (#47). + floor: Floor, } /// Shared handle to the room socket. Cloneable; all clones share one room. @@ -167,6 +217,7 @@ impl RoomState { inner: Arc::new(Mutex::new(RoomInner { port: None, participants: BTreeMap::new(), + floor: Floor::new(), })), to_participants, token: Uuid::new_v4().to_string(), @@ -221,17 +272,32 @@ impl RoomState { /// nothing new does not emit a roster event. fn seat(&self, origin: &str, name: &str, hue: Option) -> bool { let mut inner = self.inner.lock(); - let seat = Seat { - name: name.to_string(), - hue, - }; - match inner.participants.get(origin) { - Some(current) if current.name == seat.name && current.hue == seat.hue => false, - _ => { - inner.participants.insert(origin.to_string(), seat); - true + let current = inner + .participants + .get(origin) + .map(|seat| (seat.name.clone(), seat.hue, seat.since)); + if let Some((current_name, current_hue, _)) = ¤t { + if current_name == name && *current_hue == hue { + return false; } } + // A seat taken now starts from the floor as it stands: this connection + // was not there for what came before and was never handed it. A seat + // being replaced keeps the position it started from — renaming does + // not make a participant newly arrived. + let since = match ¤t { + Some((_, _, since)) => *since, + None => inner.floor.seq(), + }; + inner.participants.insert( + origin.to_string(), + Seat { + name: name.to_string(), + hue, + since, + }, + ); + true } fn unseat(&self, origin: &str) { @@ -292,14 +358,53 @@ pub fn now_iso() -> String { ) } -/// Put one post into the room. +/// Put one post into the room, if the speaker has seen the floor. /// /// The single path every utterance takes, whoever spoke. `origin` is the /// connection it arrived on: the fan-out skips that connection, and the screen /// reads it to know whether the line is its own. Two callers reach here — the /// socket loop and the screen's own command — and neither has a path of its -/// own past this point. -fn deliver(app: &AppHandle, room: &RoomState, origin: &str, post: Post) { +/// own past this point. The gate is here for that reason, and applies to both: +/// a participant is a participant, and a post from the screen is not a +/// different act (#39). +/// +/// `last_seen` is the speaker's own account of the newest post they had seen. +/// When anything on the floor is behind it, nothing is delivered and those +/// posts come back in the outcome. Accepting the post and mentioning the miss +/// afterwards would be detection without the interruption that makes detection +/// worth anything (#47). +fn deliver( + app: &AppHandle, + room: &RoomState, + origin: &str, + post: Post, + last_seen: Option<&str>, +) -> PostOutcome { + let message_id = post.message_id.clone(); + + // One acquisition, both halves. Concurrent speakers serialise here, so the + // loser's check runs against a floor the winner has already changed. + let (admission, hue) = { + let mut inner = room.inner.lock(); + let (since, hue) = match inner.participants.get(origin) { + Some(seat) => (seat.since, seat.hue), + // Unseated: nothing was ever delivered here, so nothing is + // presumed read. Speaking seats a participant, and the screen's + // command does that before it reaches this point. + None => (0, None), + }; + let admission = inner.floor.admit(origin, since, last_seen, post.clone()); + (admission, hue) + }; + + if let Admission::Unseen(missed) = admission { + return PostOutcome { + delivered: false, + message_id: None, + missed, + }; + } + let mut frame = serde_json::json!({ "type": "post", "message_id": post.message_id, @@ -316,6 +421,7 @@ fn deliver(app: &AppHandle, room: &RoomState, origin: &str, post: Post) { // the room accepts what is said in it; a later joiner simply missed it. let _ = room.to_participants.send(Fanout { origin: origin.to_string(), + target: None, frame: frame.to_string(), }); @@ -326,13 +432,21 @@ fn deliver(app: &AppHandle, room: &RoomState, origin: &str, post: Post) { speaker: post.speaker, // Read off the seat on this connection, so the colour of a line and // the colour of its author's roster entry are the one declaration. - hue: room.hue_of(origin), + // Read inside the critical section above, with the same lock the + // floor was judged under. + hue, content: post.content, to: post.to, ts: post.ts, own: origin == room.local_origin, }, ); + + PostOutcome { + delivered: true, + message_id: Some(message_id), + missed: Vec::new(), + } } /// Bind the room socket and start accepting sidecars. @@ -410,11 +524,17 @@ async fn serve_participant( let own_origin = origin.clone(); let pump = tokio::spawn(async move { while let Ok(fanout) = from_room.recv().await { - // A participant does not receive their own post. Decided on the - // connection, never on the name: while two participants share a - // name, a name test drops the other one's posts too (#40). - if fanout.origin == own_origin { - continue; + match &fanout.target { + // Addressed to one connection: the room answering whoever + // posted. Everyone else's socket is not part of that exchange. + Some(target) if *target != own_origin => continue, + Some(_) => {} + // A participant does not receive their own post. Decided on + // the connection, never on the name: while two participants + // share a name, a name test drops the other one's posts too + // (#40). + None if fanout.origin == own_origin => continue, + None => {} } if sink.send(Message::Text(fanout.frame.into())).await.is_err() { break; @@ -452,20 +572,40 @@ async fn serve_participant( // Attribution comes from the connection, not from the frame. A // sender may name an addressee; it may not name itself. let speaker = joined_as.clone().unwrap_or_else(|| "session".to_string()); - deliver( + let message_id = frame + .message_id + .unwrap_or_else(|| Uuid::new_v4().to_string()); + // `last_seen` is not a claim about identity, so nothing is + // verified here. A participant who declares a false watermark + // spends its own round trips; nobody else's post moves. + let outcome = deliver( &app, &room, &origin, Post { - message_id: frame - .message_id - .unwrap_or_else(|| Uuid::new_v4().to_string()), + message_id: message_id.clone(), speaker, content: frame.content.unwrap_or_default(), to: normalize_to(frame.to), ts: frame.ts.unwrap_or_else(now_iso), }, + frame.last_seen.as_deref(), ); + // Answered on the connection that posted, always — a refusal + // that says nothing is indistinguishable from a delivery, and + // this answer is the boundary at which a reply needing no + // other tool finally gets to read what it missed. + let receipt = serde_json::json!({ + "type": "post_result", + "message_id": message_id, + "delivered": outcome.delivered, + "missed": outcome.missed, + }); + let _ = room.to_participants.send(Fanout { + origin: origin.clone(), + target: Some(origin.clone()), + frame: receipt.to_string(), + }); } _ => {} } @@ -524,8 +664,15 @@ pub fn room_join( /// Post this screen's person's utterance into the room. /// /// Goes through `deliver` like every other post: same frame, same fan-out, -/// same event. The screen does not append locally on send, so the room keeps -/// one ordering authority rather than two. +/// same event, same floor check. The screen does not append locally on send, +/// so the room keeps one ordering authority rather than two. +/// +/// `last_seen` is the newest post the screen has drawn. The person at the +/// keyboard is a participant like any other and is refused on the same terms; +/// exempting them would be the room deciding by participant class, which is +/// the distinction the protocol stopped carrying (#39). What differs is only +/// how easily the watermark is known: the screen renders what it is handed, so +/// it always has one. #[tauri::command] pub fn room_post( app: AppHandle, @@ -533,7 +680,8 @@ pub fn room_post( speaker: String, content: String, to: Option, -) -> Result { + last_seen: Option, +) -> Result { let content = content.trim().to_string(); if content.is_empty() { return Err("content is empty".to_string()); @@ -554,18 +702,17 @@ pub fn room_post( } let message_id = Uuid::new_v4().to_string(); - deliver( + Ok(deliver( &app, &state, &local_origin, Post { - message_id: message_id.clone(), + message_id, speaker, content, to: normalize_to(to), ts: now_iso(), }, - ); - - Ok(message_id) + last_seen.as_deref(), + )) } From d0024963412e11f6a4d1d1f10fd00d05dd210c7b Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Sun, 23 Aug 2026 13:13:46 +0900 Subject: [PATCH 3/7] feat(ui): declare what the screen has drawn when posting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `room_post` now takes the screen's watermark and returns a `PostOutcome`. The screen tracks the newest post it has drawn and passes it; a refusal puts the typed text back and names who spoke while it was being typed. The person at the keyboard is gated on the same terms as a session, through the same command and the same room-side check. What differs is only that the screen always has a watermark to declare: a line is drawn after the room has admitted it, so the ordering at the race point is real for this participant too, rather than nominal. 画面を例外にすると部屋が参加者クラスで判定することになる(#39)。同じ経路で 同じ判定を受ける形にした。弾かれた場合は入力を戻し、届いていた発言の発言者を 出す。発言そのものは同じイベントで既に画面に並んでいるため、読むのは人間側。 Refs #47 Co-Authored-By: Claude Opus 5 --- src/main.ts | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index 62c0d53..096a3cc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -32,6 +32,28 @@ interface RoomMessage { own: boolean; } +/** + * What the room did with a post from this screen. + * + * The room refuses a post whose speaker had not seen everything on the floor, + * and hands back what they missed instead of delivering (#47). Not an error: + * being told what arrived while the message was being typed, and deciding + * again, is the point. + */ +interface PostOutcome { + delivered: boolean; + /** The id the post is filed under, or null when it was refused. */ + message_id: string | null; + /** What this screen had not seen, oldest first. Empty when delivered. */ + missed: { + message_id: string; + speaker: string; + content: string; + to: string | null; + ts: string; + }[]; +} + /** * One participant of the room, as the roster lists them. * @@ -138,6 +160,17 @@ let tabs: TabConfig[] = []; let participants: Participant[] = []; /** The session the terminal is attached to, once one is running. */ let activePtyId: string | null = null; +/** + * The newest post this screen has drawn, declared as `last_seen` when posting. + * + * Drawn, not read — the screen can only speak for what it put on the glass. It + * is an honest watermark all the same: a line is drawn only after the room has + * admitted it, so a post arriving in the same instant as a send is either + * already on screen or genuinely behind this value, and the room orders the + * two. A person who leaves a message unread on screen is a gap the room cannot + * see and does not pretend to (#47). + */ +let lastSeenId: string | null = null; const terminal = new Terminal({ cursorBlink: true, @@ -346,6 +379,10 @@ function appendMessage(message: RoomMessage): void { line.append(head, body); roomEl.appendChild(line); + // On the glass, so it is what this screen can declare having seen. Own posts + // included: the room does not hold a speaker's own posts against them, and + // carrying the newest id either way keeps this one value rather than two. + lastSeenId = message.message_id; if (atBottom) roomEl.scrollTop = roomEl.scrollHeight; } @@ -475,9 +512,29 @@ async function send(): Promise { // Empty means the room as a whole. The app still delivers to everyone; the // addressee is judgment material for the participants, not a delivery filter. const to = toEl.value || null; + // Read before the await: what the screen had drawn when this was sent is the + // watermark, and an arrival during the round trip must not be folded into it. + const lastSeen = lastSeenId; inputEl.value = ""; try { - await invoke("room_post", { speaker, content, to }); + const outcome = await invoke("room_post", { + speaker, + content, + to, + lastSeen, + }); + if (!outcome.delivered) { + // Refused, not failed. The missed posts are already on screen — the room + // drew them through the same event — so the report names who spoke and + // leaves the reading where it belongs. + inputEl.value = content; + const speakers = [...new Set(outcome.missed.map((one) => one.speaker))]; + status( + `送っていません。書いている間に届いた発言が ${outcome.missed.length} 件あります(${speakers.join("、")})。読んでから送るか決めてください。`, + "error", + ); + return; + } status(""); } catch (err) { // Put the text back rather than losing what was typed. From 7d8efb2348ff60d0ee81fb28abad6692c05c96d0 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Sun, 23 Aug 2026 13:19:15 +0900 Subject: [PATCH 4/7] feat(sidecar): declare the watermark and act on the room's answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `say_to_room` gains a `last_seen` argument and waits for the room's `post_result` before reporting. Three outcomes, each said plainly: delivered, refused with the posts the agent had not seen and the id to declare next time, or unconfirmed when the room never answered. Answers are correlated by message_id, so a verdict for another post cannot settle this call. Protocol 4. The manners gain the block that makes the argument usable: what to put in last_seen, that a refusal means nothing was posted, that not sending is a valid outcome, and that being refused is the room supplying an order two participants cannot supply themselves. 往復ハーネスは文頭の部分正規表現をやめ、作法を丸ごとの literal で押さえる。 #49 のテストは両アサーションが最初の読点までしか一致しておらず、その尾部は 消しても CI が緑のままだった。現在は #49 の尾部と本 issue の追加分の両方が 固定されており、いずれか一行を削るとテストが落ちることを確認した。加えて last_seen が wire に載ること、未宣言ならキーごと省くこと、弾かれた戻り値が 見落とした発言を全件(id・発言者・内容・宛先)運ぶこと、無応答が「配った」 とも「弾かれた」とも読めないことを検査する。ツールは 1 本のままである。 Refs #47 Co-Authored-By: Claude Opus 5 --- sidecar/src/index.ts | 181 +++++++++++++++++- sidecar/test/round-trip.test.mjs | 305 ++++++++++++++++++++++++++----- 2 files changed, 436 insertions(+), 50 deletions(-) diff --git a/sidecar/src/index.ts b/sidecar/src/index.ts index b7dbb4f..26259b5 100644 --- a/sidecar/src/index.ts +++ b/sidecar/src/index.ts @@ -54,7 +54,17 @@ function readHue(raw: string | undefined): number | null { return Number.isFinite(hue) ? hue : null; } -const PROTOCOL_VERSION = 3; +const PROTOCOL_VERSION = 4; + +/** + * How long a post waits for the room to answer it. + * + * The room answers every post, so silence past this is the room having gone + * away mid-post rather than a slow decision. Reported as unconfirmed, never as + * delivered: the frame may well have landed, and claiming either way would be + * a guess the agent then acts on. + */ +const POST_RESULT_TIMEOUT = 15_000; function log(line: string): void { process.stderr.write(`[liplus-chat sidecar] ${line}\n`); @@ -64,9 +74,10 @@ function log(line: string): void { // // Sidecar -> room: // { type: "hello", protocol, name, hue? } -// { type: "post", message_id, content, to?, ts } +// { type: "post", message_id, content, to?, ts, last_seen? } // Room -> sidecar: // { type: "post", message_id, speaker, content, to?, ts } +// { type: "post_result", message_id, delivered, missed } // // One frame kind carries speech, whoever produced it. The room stamps // `speaker` from the connection the frame arrived on, so this side does not @@ -88,6 +99,20 @@ function log(line: string): void { // itself — and a name collision cannot make this side swallow someone else's // post (#40). // +// `last_seen` is the agent's own account of the newest post it had actually +// seen. It rides on the post because the room refuses one whose speaker was +// behind the floor, and only the speaker can supply it: this process receives +// every post, but whether one reached the agent's context is decided by where +// the agent's next tool-result boundary fell, which nothing here can observe +// (#47). +// +// `post_result` is the room's answer to a post, correlated by the +// `message_id` the post was sent under. It arrives on this connection only. +// Waiting for it is what makes the tool call a boundary: a reply that needs no +// other tool used to have none before its own send, so anything arriving while +// it was composed was unreadable until too late. Now the send itself is where +// the room hands that back. +// // Frames whose `type` is unknown are ignored rather than rejected, so the room // can add frame kinds without breaking a sidecar built against this revision. @@ -100,6 +125,22 @@ interface PostFrame { ts?: string; } +/** One post the room says this agent had not seen when it tried to speak. */ +interface MissedPost { + message_id?: string; + speaker?: string; + content?: string; + to?: string; + ts?: string; +} + +interface PostResultFrame { + type: "post_result"; + message_id?: string; + delivered?: boolean; + missed?: MissedPost[]; +} + // ── MCP server ─────────────────────────────────────────────────────────────── const INSTRUCTIONS = [ @@ -134,6 +175,20 @@ const INSTRUCTIONS = [ "- 送る直前に、届いている発言をもう一度見てください。組み立てている間にも", " 発言は届きます。言おうとしていたことが既に言われていたら送らず、", " 足りないことがあるときだけ足してください。", + "", + "床を見てから送る:", + "- say_to_room には last_seen を付けてください。値は、あなたが実際に見た", + " いちばん新しい発言の meta.message_id です。まだ何も見ていないときだけ", + " 省いてください。", + "- 組み立てている間に届いた発言があると、部屋はあなたの発言を配りません。", + " 代わりに、あなたが見ていなかった発言を返します。あなたの発言は部屋に", + " 載っていません。", + "- 返ってきた発言を読んでから、もう一度決めてください。言おうとしていた", + " ことが既に言われていたら送らないでください。送らない判断は正当です。", + "- それでも足すことがあるときは、返ってきたうちいちばん新しい message_id を", + " last_seen に入れて、もう一度 say_to_room を呼んでください。", + "- 弾かれるのは、あなたの注意が足りなかったからではありません。二人が同時に", + " 書き始めたとき、順序を付けられるのは部屋だけです。これはその順序です。", ].join("\n"); const mcp = new Server( @@ -166,6 +221,16 @@ const TOOLS = [ "Optional. The participant this message is addressed to. Omit to " + "address the room.", }, + last_seen: { + type: "string", + description: + "The meta.message_id of the newest room post you have actually " + + "seen. Omit only when you have seen none. If anything reached " + + "the room after it, this post is refused and those posts are " + + "returned to you instead of being delivered — read them, decide " + + "again, and call again with the newest message_id if you still " + + "have something to add.", + }, }, required: ["content"], }, @@ -193,19 +258,32 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => { } const to = typeof args?.to === "string" ? args.to : undefined; + // Passed through as given. This process cannot check it and does not try: + // the watermark is a statement about the agent's own context, not a claim + // about who the agent is, and a false one costs only its author a round trip. + const declared = typeof args?.last_seen === "string" ? args.last_seen.trim() : ""; + const lastSeen = declared || undefined; + // No speaker field: the room stamps that from this connection. Sending one // would be a claim about who is speaking, and the room would overwrite it. + const messageId = randomUUID(); + // Registered before the frame goes out, so an answer that comes back inside + // the same tick has somewhere to land. + const answered = awaitPostResult(messageId); const sent = sendToRoom({ type: "post", - message_id: randomUUID(), + message_id: messageId, content, ...(to ? { to } : {}), + ...(lastSeen ? { last_seen: lastSeen } : {}), ts: new Date().toISOString(), }); if (!sent) { // The room is the only audience. Reporting success on a dropped frame // would let the agent believe it had spoken. + abandonPost(messageId); + await answered; return { content: [ { @@ -217,9 +295,59 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + const result = await answered; + + if (result === null) { + // Unconfirmed, and said as such. "Delivered" here would be a guess the + // agent goes on to act on, and so would "not delivered". + return { + content: [ + { + type: "text", + text: + `Not confirmed: the room did not answer this post (${roomStatus()}). ` + + "It may or may not have been delivered. Do not repeat it blind.", + }, + ], + isError: true, + }; + } + + if (result.delivered !== true) { + // Refused. An error rather than a quiet note, because the agent's next + // move depends on it: nothing was posted, and this is the one moment the + // missed posts are in front of it. + return { + content: [{ type: "text", text: describeRefusal(result.missed ?? []) }], + isError: true, + }; + } + return { content: [{ type: "text", text: "Delivered to the room." }] }; }); +/** The room's refusal, written so the next move is unambiguous. */ +function describeRefusal(missed: MissedPost[]): string { + const lines = missed.map((one) => { + const addressee = one.to ? ` -> ${one.to}` : ""; + const id = one.message_id ?? "?"; + return `- [${id}] ${one.speaker ?? "someone"}${addressee}: ${one.content ?? ""}`; + }); + const newest = missed[missed.length - 1]?.message_id; + const again = newest + ? `call say_to_room again with last_seen: "${newest}"` + : "call say_to_room again with last_seen set to the newest message_id above"; + return [ + "Not delivered. These posts reached the room while you were composing, " + + "and you had not seen them:", + ...lines, + "", + "Your message was not posted. Read the above and decide again. Saying " + + "nothing is a valid outcome: if what you were going to say is already " + + `there, do not send it. If you still have something to add, ${again}.`, + ].join("\n"); +} + // ── Room socket ────────────────────────────────────────────────────────────── let ws: WebSocket | null = null; @@ -237,6 +365,48 @@ function roomStatus(): string { return lastError ? `disconnected: ${lastError}` : "disconnected"; } +/** + * Posts waiting for the room's answer, keyed by the id they were sent under. + * + * Keyed rather than a single slot: a host may have more than one tool call in + * flight, and settling the wrong one would report another post's verdict. + */ +const awaitingResult = new Map void>(); + +function awaitPostResult(messageId: string): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + awaitingResult.delete(messageId); + resolve(null); + }, POST_RESULT_TIMEOUT); + // A pending answer must not be the reason this process stays alive. + timer.unref?.(); + awaitingResult.set(messageId, (result) => { + clearTimeout(timer); + awaitingResult.delete(messageId); + resolve(result); + }); + }); +} + +/** Settle the post this answer belongs to, and only that one. */ +function settlePostResult(frame: PostResultFrame): void { + const id = frame.message_id; + if (typeof id !== "string") return; + awaitingResult.get(id)?.(frame); +} + +/** Give up on one post's answer: nothing will come for it. */ +function abandonPost(messageId: string): void { + awaitingResult.get(messageId)?.(null); +} + +/** The room went away. Everything in flight is unanswerable now, and waiting + * out the timeout would leave the agent blocked for no new information. */ +function abandonPendingPosts(): void { + for (const settle of [...awaitingResult.values()]) settle(null); +} + function sendToRoom(frame: Record): boolean { if (!ws || ws.readyState !== WebSocket.OPEN) return false; try { @@ -319,6 +489,10 @@ function connectRoom(): void { if (typeof data !== "object" || data === null) return; const frame = data as { type?: string }; if (frame.type === "post") pushToChannel(frame as PostFrame); + // The answer to a post this agent made. Not pushed to the channel: it is + // the tool call's own result, and putting it in the conversation would + // read as somebody having said it. + else if (frame.type === "post_result") settlePostResult(frame as PostResultFrame); // Unknown frame kinds are ignored on purpose; see the frame comment above. }); @@ -328,6 +502,7 @@ function connectRoom(): void { ws = null; lastError = `closed with code ${code}`; log(`room socket: ${lastError}`); + abandonPendingPosts(); scheduleRetry(); }); diff --git a/sidecar/test/round-trip.test.mjs b/sidecar/test/round-trip.test.mjs index 8e2ae0b..1a730db 100644 --- a/sidecar/test/round-trip.test.mjs +++ b/sidecar/test/round-trip.test.mjs @@ -5,6 +5,10 @@ // isolation harness for the round trip — when the real app stops delivering, // running this says whether the sidecar or the app side moved. // +// The fake room answers posts, because the real one does: since #47 a post is +// a request the room replies to with `post_result`, and a room that never +// answers is a room the sidecar reports as unconfirmed. +// // Run: npm run sidecar:test import { test } from "node:test"; import assert from "node:assert/strict"; @@ -20,6 +24,46 @@ const REPO = join(HERE, "..", ".."); const TIMEOUT = 20_000; +// The manners, whole. +// +// Asserted as complete literals rather than by a regex on the opening clause. +// The head-only form was checked here before and did not hold: both turn-taking +// assertions matched only up to the first comma, so every sentence after it — +// including the one being rewritten — could be deleted with CI still green +// (#47). A test that stops at the first clause is testing that a heading +// exists. What these paragraphs claim is in the tail. +const TURN_TAKING = [ + "- 先に誰かが答えていたら、その発言を読んでから自分の発言を決めてください。", + " 全体宛の問いに、全員が答える必要はありません。", + "- 送る直前に、届いている発言をもう一度見てください。組み立てている間にも", + " 発言は届きます。言おうとしていたことが既に言われていたら送らず、", + " 足りないことがあるときだけ足してください。", +].join("\n"); + +const SEE_THE_FLOOR = [ + "床を見てから送る:", + "- say_to_room には last_seen を付けてください。値は、あなたが実際に見た", + " いちばん新しい発言の meta.message_id です。まだ何も見ていないときだけ", + " 省いてください。", + "- 組み立てている間に届いた発言があると、部屋はあなたの発言を配りません。", + " 代わりに、あなたが見ていなかった発言を返します。あなたの発言は部屋に", + " 載っていません。", + "- 返ってきた発言を読んでから、もう一度決めてください。言おうとしていた", + " ことが既に言われていたら送らないでください。送らない判断は正当です。", + "- それでも足すことがあるときは、返ってきたうちいちばん新しい message_id を", + " last_seen に入れて、もう一度 say_to_room を呼んでください。", + "- 弾かれるのは、あなたの注意が足りなかったからではありません。二人が同時に", + " 書き始めたとき、順序を付けられるのは部屋だけです。これはその順序です。", +].join("\n"); + +/** Whole-literal containment, with both sides shown when it fails. */ +function assertContains(haystack, needle, message) { + assert.ok( + haystack.includes(needle), + `${message}\n--- expected to contain ---\n${needle}\n--- actual ---\n${haystack}`, + ); +} + function deferred() { let resolve; const promise = new Promise((r) => { @@ -48,19 +92,92 @@ test("a room post reaches the channel, and say_to_room reaches the room", async const connected = deferred(); const helloSeen = deferred(); - const postSeen = deferred(); + const postFrames = []; + const postWaiters = []; let roomSocket = null; + // How the fake room answers the next post. "deliver" takes it, "refuse" + // hands back what the speaker had not seen, "silent" answers nothing. + let answer = "deliver"; + + // What a refusal carries. Two posts, one of them addressed elsewhere: the + // room does not narrow the refusal by addressee, so both come back. + const MISSED = [ + { + message_id: "m-9", + speaker: "Claude Lay", + content: "先に答えました", + ts: "2026-08-21T00:00:04.000Z", + }, + { + message_id: "m-10", + speaker: "Master", + content: "レイに任せる", + to: "Claude Lay", + ts: "2026-08-21T00:00:05.000Z", + }, + ]; + wss.on("connection", (socket) => { roomSocket = socket; connected.resolve(socket); socket.on("message", (raw) => { const frame = JSON.parse(raw.toString()); if (frame.type === "hello") helloSeen.resolve(frame); - if (frame.type === "post") postSeen.resolve(frame); + if (frame.type !== "post") return; + + postFrames.push(frame); + for (const waiter of postWaiters.splice(0)) waiter(); + + if (answer === "silent") return; + if (answer === "refuse") { + // A verdict for a post nobody made, sent first and saying delivered. + // The call must not settle on it: answers are correlated by + // message_id, not by arrival order. + socket.send( + JSON.stringify({ + type: "post_result", + message_id: "not-this-post", + delivered: true, + missed: [], + }), + ); + socket.send( + JSON.stringify({ + type: "post_result", + message_id: frame.message_id, + delivered: false, + missed: MISSED, + }), + ); + return; + } + socket.send( + JSON.stringify({ + type: "post_result", + message_id: frame.message_id, + delivered: true, + missed: [], + }), + ); }); }); + /** The `index`-th post frame the room received, awaited if not yet there. */ + function nextPost(index = 0) { + if (postFrames.length > index) return Promise.resolve(postFrames[index]); + return withTimeout( + new Promise((resolve) => { + const waiter = () => { + if (postFrames.length > index) resolve(postFrames[index]); + else postWaiters.push(waiter); + }; + postWaiters.push(waiter); + }), + `post frame #${index}`, + ); + } + // ── sidecar, spawned the way the CLI would ───────────────────────────────── const child = spawn( process.execPath, @@ -83,8 +200,10 @@ test("a room post reaches the channel, and say_to_room reaches the room", async t.after(() => { child.kill(); - wss.close(); - http.close(); + // Callbacks, because the body closes these too: a bare close on a server + // already shut down emits an unhandled error event. + wss.close(() => {}); + http.close(() => {}); }); // ── MCP stdio plumbing: one JSON-RPC message per line ────────────────────── @@ -150,22 +269,19 @@ test("a room post reaches the channel, and say_to_room reaches the room", async init.result.capabilities.experimental?.["claude/channel"], "server must declare the claude/channel experimental capability", ); - assert.match( - init.result.instructions ?? "", - /say_to_room/, - "instructions must name the posting tool", - ); + const instructions = init.result.instructions ?? ""; + assert.match(instructions, /say_to_room/, "instructions must name the posting tool"); // The manners and the material they are judged on ship together. Manners // that say "answer what is addressed to you" without naming where the // addressee is, or without naming what this agent is called, ask for a // judgment the agent has nothing to make. assert.match( - init.result.instructions ?? "", + instructions, /meta\.to/, "instructions must name the addressee as judgment material", ); assert.match( - init.result.instructions ?? "", + instructions, /test-agent/, "instructions must tell the agent the name it answers to", ); @@ -173,7 +289,7 @@ test("a room post reaches the channel, and say_to_room reaches the room", async // answer "the human" would be reading a distinction the protocol no longer // carries (#39). assert.match( - init.result.instructions ?? "", + instructions, /人間と AI を区別しません/, "instructions must state that participants are not split into human and AI", ); @@ -182,20 +298,20 @@ test("a room post reaches the channel, and say_to_room reaches the room", async // the earlier answer before deciding, and look again at what arrived while // the message was being composed, since the composing agent cannot see the // floor and the arrivals are all it has to look at (#49). - assert.match( - init.result.instructions ?? "", - /先に誰かが答えていたら、その発言を読んでから自分の発言を決めて/, - "instructions must tell the agent to read an earlier answer before deciding its own", + assertContains( + instructions, + TURN_TAKING, + "instructions must carry the turn-taking manners in full, tail included", ); - assert.match( - init.result.instructions ?? "", - /送る直前に、届いている発言をもう一度見て/, - "instructions must tell the agent to re-read what arrived just before sending", - ); - assert.match( - init.result.instructions ?? "", - /全員が答える必要はありません/, - "instructions must state that a room-wide question needs no answer from everyone", + // Seeing the floor. These are not advice: `last_seen` is what the room + // judges the post on, and a refusal is a state the agent has to know how to + // leave. An agent that does not know to send the watermark is refused on + // every post after its first; one that does not know a refusal means "not + // posted" repeats itself blind (#47). + assertContains( + instructions, + SEE_THE_FLOOR, + "instructions must carry the floor manners in full, tail included", ); notify("notifications/initialized", {}); @@ -206,6 +322,20 @@ test("a room post reaches the channel, and say_to_room reaches the room", async ["say_to_room"], "exactly one posting tool is exposed", ); + // Seeing the floor is an argument of the one tool, not a second tool. A + // separate read call would put "which one do I speak through" back on the + // agent, which is the reason there is one (docs/0-requirements.md). + const schema = tools.result.tools[0].inputSchema; + assert.deepEqual( + Object.keys(schema.properties).sort(), + ["content", "last_seen", "to"], + "the watermark rides on say_to_room rather than adding a tool", + ); + assert.deepEqual( + schema.required, + ["content"], + "the watermark is optional: a participant that has seen nothing must still be able to speak", + ); // ── room -> agent ────────────────────────────────────────────────────────── await withTimeout(connected.promise, "sidecar to connect to the room"); @@ -216,7 +346,7 @@ test("a room post reaches the channel, and say_to_room reaches the room", async // the same name (#40). assert.equal(hello.name, "test-agent"); assert.equal(hello.hue, 145); - assert.equal(hello.protocol, 3); + assert.equal(hello.protocol, 4); roomSocket.send( JSON.stringify({ @@ -280,15 +410,24 @@ test("a room post reaches the channel, and say_to_room reaches the room", async // ── this participant -> room ─────────────────────────────────────────────── const call = await request("tools/call", { name: "say_to_room", - arguments: { content: "聞こえてるわ", to: "Master" }, + arguments: { content: "聞こえてるわ", to: "Master", last_seen: "m-3" }, }); assert.ok(!call.result.isError, `tool call failed: ${JSON.stringify(call.result)}`); + assert.equal( + call.result.content[0].text, + "Delivered to the room.", + "a post the room admits reads as delivered and says nothing else", + ); - const post = await withTimeout(postSeen.promise, "post frame"); + const post = await nextPost(0); assert.equal(post.type, "post"); assert.equal(post.content, "聞こえてるわ"); // A person is addressed exactly like a session. One vocabulary, one frame. assert.equal(post.to, "Master"); + // The watermark the agent declared, carried through unchanged. This side + // cannot check it and must not invent it: what the agent saw is the one + // thing only the agent knows (#47). + assert.equal(post.last_seen, "m-3"); // Attribution belongs to the room, stamped from the connection. A sender // that could name itself could name somebody else. assert.equal( @@ -297,27 +436,94 @@ test("a room post reaches the channel, and say_to_room reaches the room", async "a posting participant must not name itself; the room stamps the speaker", ); - // ── suppression is the room's, and is not decided by name ───────────────── - // The room drops a post on the connection that produced it, so this side - // never has to recognise itself. It must not second-guess that with a name - // test: while two participants share a name, a name test here would swallow - // the other one's posts too (#40). - roomSocket.send( - JSON.stringify({ - type: "post", - message_id: "m-4", - speaker: "test-agent", - content: "同じ名前の別参加者", - ts: "2026-08-21T00:00:03.000Z", - }), + // ── a participant that has seen nothing omits the key ────────────────────── + // No key rather than an empty one, for the same reason `to` and `hue` omit: + // the room reads a value it cannot resolve as having seen nothing, and "" + // is such a value. Sending it would refuse a first post that should pass. + const first = await request("tools/call", { + name: "say_to_room", + arguments: { content: "はじめまして" }, + }); + assert.ok(!first.result.isError, `tool call failed: ${JSON.stringify(first.result)}`); + const firstPost = await nextPost(1); + assert.equal( + "last_seen" in firstPost, + false, + "an undeclared watermark must carry no key", + ); + + // ── the room refuses, and the refusal carries what was missed ────────────── + answer = "refuse"; + const refused = await request("tools/call", { + name: "say_to_room", + arguments: { content: "私も答えます", last_seen: "m-1" }, + }); + assert.ok( + refused.result.isError, + `a refused post must not read as delivered: ${JSON.stringify(refused.result)}`, + ); + const refusal = refused.result.content[0].text; + // Correlation held: the bogus `delivered: true` for another post arrived + // first and did not settle this call. + assertContains( + refusal, + "Not delivered.", + "a refusal must say the post did not go into the room", + ); + assertContains( + refusal, + "Your message was not posted.", + "the refusal must be unambiguous that nothing was said, not a note attached to a delivery", + ); + // Every field of every missed post, not just the first line. The condition + // is that the return value carries the posts the speaker had not seen — a + // check on the opening sentence passes on a report that dropped all of them. + for (const missed of MISSED) { + assertContains(refusal, missed.message_id, "each missed post must carry its id"); + assertContains(refusal, missed.speaker, "each missed post must name its speaker"); + assertContains(refusal, missed.content, "each missed post must carry what was said"); + } + assertContains( + refusal, + "Claude Lay", + "a missed post addressed elsewhere still comes back; the room does not narrow by addressee", + ); + // The way out of the refusal, named concretely. Being told to try again with + // "the newest id" and left to work out which is which is the shape that goes + // unread. + assertContains( + refusal, + 'last_seen: "m-10"', + "the refusal must name the watermark to declare on the next attempt", ); - const sameName = await nextNotification("notifications/claude/channel", 3); - assert.equal(sameName.params.meta.message_id, "m-4"); - assert.equal(sameName.params.meta.user, "test-agent"); + // ── an unanswered post is unconfirmed, not delivered and not refused ─────── + // The frame may well have landed. Reporting either verdict would be a guess + // the agent then acts on: "delivered" lets it believe it spoke, "refused" + // invites it to say the same thing twice. + answer = "silent"; + const unanswered = request("tools/call", { + name: "say_to_room", + arguments: { content: "届いてる?", last_seen: "m-1" }, + }); + await nextPost(3); + roomSocket.close(); + const unconfirmed = await unanswered; + assert.ok( + unconfirmed.result.isError, + `an unanswered post must not read as delivered: ${JSON.stringify(unconfirmed.result)}`, + ); + assertContains( + unconfirmed.result.content[0].text, + "Not confirmed", + "a post the room never answered must read as unconfirmed, not as either verdict", + ); // ── a dropped frame must not read as delivered ───────────────────────────── - roomSocket.close(); + // The room is taken down so the sidecar's retry cannot reconnect underneath + // this assertion. + wss.close(() => {}); + http.close(() => {}); await new Promise((r) => setTimeout(r, 500)); const offline = await request("tools/call", { @@ -328,6 +534,11 @@ test("a room post reaches the channel, and say_to_room reaches the room", async offline.result.isError, "a send with no room attached must report failure, not silence", ); + assertContains( + offline.result.content[0].text, + "Not delivered: the room socket is not connected", + "a send with no socket must say so rather than wait out the answer it will never get", + ); }); test("a session launched without a declared hue says so by omission", async (t) => { @@ -370,8 +581,8 @@ test("a session launched without a declared hue says so by omission", async (t) t.after(() => { child.kill(); - wss.close(); - http.close(); + wss.close(() => {}); + http.close(() => {}); }); const hello = await withTimeout(helloSeen.promise, "hello frame"); From 660e95fb88a05e743a007fccf10aa9bf08318779 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Sun, 23 Aug 2026 13:22:21 +0900 Subject: [PATCH 5/7] docs(room): correct the readability claim and record the floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「随時」を落とした。配送については正しく、可読については誤りだった一語で、 これが「送信直前に読み直せる」を無条件に読ませ、読む側なしで作法が成立する という判断を支えていた。届いてはいる、読めるのは次の境界から、という形へ 差し替え、tool 結果の境界で文脈へ入ることを明記した。 床の節を追加。二型の失敗(境界が悪い位置にある / 境界がそもそも無い)、 last_seen を送り手が申告する理由(配達は読了ではない)、既存ロック内での 判定と刻印、弾いて返す形を採る理由、安全側の倒し方、参加者クラスで 書き分けないこと、順番の付与への段差を残さないこと。protocol の表に last_seen と post_result を足し、版を 4 とした。 CI に crates/room-floor の cargo test を追加。テストの配置の節に、床が まさに「読んでも確かめられない」側であることを書いた。 Refs #47 Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 5 ++++ docs/0-requirements.md | 51 +++++++++++++++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93b53a9..cdab4d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: ~/.cargo/git src-tauri/target crates/mcp-config/target + crates/room-floor/target key: rust-${{ runner.os }}-${{ hashFiles('src-tauri/Cargo.lock') }} restore-keys: rust-${{ runner.os }}- @@ -61,6 +62,10 @@ jobs: working-directory: crates/mcp-config run: cargo test + - name: Test the room-floor crate + working-directory: crates/room-floor + run: cargo test + # Gate job: single Required status check for branch protection. # Add new jobs to `needs` when CI grows. No Settings change needed. CI: diff --git a/docs/0-requirements.md b/docs/0-requirements.md index 6861a05..2ebb90e 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -131,14 +131,17 @@ push の形(参照実装 `Liplus-Project/github-webhook-mcp` `local-mcp/src/in | 向き | type | フィールド | |---|---|---| | サイドカー → 部屋 | `hello` | `protocol` / `name` / `hue`(任意) | -| サイドカー → 部屋 | `post` | `message_id` / `content` / `to`(任意) / `ts` | +| サイドカー → 部屋 | `post` | `message_id` / `content` / `to`(任意) / `ts` / `last_seen`(任意) | | 部屋 → サイドカー | `post` | `message_id` / `speaker` / `content` / `to`(任意) / `ts` | +| 部屋 → サイドカー | `post_result` | `message_id` / `delivered` / `missed` | 発言のフレームは `post` 一種類であり、向きによって名前が変わらない。誰が出した発言かはフレームの種別ではなく `speaker` の値で表す。 `speaker` はサイドカーが送らない。部屋が、フレームの届いた接続に紐づく名前(`hello` の `name`)から刻む。送信側が名乗れる欄を持たないため、他の参加者を騙る余地が構造上無く、名簿と表示が食い違うこともない。宛先は名指せるが自分は名乗れない、という非対称がこの性質を作っている。 -未知の `type` は拒否せず無視する。部屋側がフレーム種別を増やしても、旧サイドカーが壊れないため。`protocol` はフレームの形が変わり、サイドカー側が気づく必要があるときに上げる。`say` / `reply` から `post` への統合で `protocol` を 2 へ、`hello` への `hue` の追加で 3 へ上げた。サイドカーはアプリが `.mcp.json` へ書く同梱パスから起動するため、部屋とサイドカーの版が食い違う形が構造上無く、旧フレームとの互換は残していない。 +`post_result` は投稿への返答であり、投稿した接続にのみ返す。fan-out ではない。相関は `message_id` で取る。これにより `say_to_room` は送りっぱなしではなく往復になり、呼び出しそのものが境界になる(後述の「床」)。 + +未知の `type` は拒否せず無視する。部屋側がフレーム種別を増やしても、旧サイドカーが壊れないため。`protocol` はフレームの形が変わり、サイドカー側が気づく必要があるときに上げる。`say` / `reply` から `post` への統合で `protocol` を 2 へ、`hello` への `hue` の追加で 3 へ、`post` の `last_seen` と `post_result` の追加で 4 へ上げた。サイドカーはアプリが `.mcp.json` へ書く同梱パスから起動するため、部屋とサイドカーの版が食い違う形が構造上無く、旧フレームとの互換は残していない。 `to` は両方向で同じ語彙であり、宛先となる参加者の表示名を入れる。任意であり、無いときは `null` ではなくキーごと省く。 @@ -174,7 +177,40 @@ push の形(参照実装 `Liplus-Project/github-webhook-mcp` `local-mcp/src/in - 届いた発言はすべて他の参加者のものである。自分の発言は戻らないため、エージェントは自分を認識する必要がない。 - 先に誰かが答えていたら、その発言を読んでから自分の発言を決める。送る直前にも届いている発言をもう一度見て、言おうとしていたことが既に言われていたら送らず、足りないことがあるときだけ足す。全体宛の問いに全員が答える必要はない。宛先なしの問いに二人の参加者が同内容を二重に返した実測(2026-08-22)に対し、口頭で「順番を待ってその会話を見てから話し始める」と伝えたところ挙動は成立した。足りていなかったのは参加者の能力ではなく、これが部屋の規律に書かれていないことである。 - 順番は沈黙とは別軸である。宛先の節が扱うのは誰に向いた発言かであり、こちらは先に答えが出たときにどうするかである。混ぜて「答えなくてよい」だけを渡すと、全体宛の発言に誰も答えない状態を招く。書き方は「黙れ」ではなく「見てから決める」に寄せる。 -- 作法が「見よ」と言えるのは届いた発言までである。参加者は床の状態——誰が今組み立てているか——を覗けず、部屋のツールは書き込み専用の `say_to_room` 一本である。届いた発言なら、組み立て中に届いた分も channel が随時運んでいるため送信直前に読み直せる。同時に書き始めた二人は取りこぼす。その残りは状態を出す側と床を読む側の二点セットであり、この作法を入れてなお同時発火が痛むと観測されてから判断する(#47)。規律の無い状態での観測を根拠に protocol を変えない。 +- 作法が「見よ」と言えるのは届いた発言までである。参加者は床の状態——誰が今組み立てているか——を覗けない。**届いてはいる。読めるのは次の境界からである。** 組み立て中に届いた発言は channel が運び終えていても、参加者の文脈へ入るのは次の tool 結果が返る位置であり、そこに何が載っているかは相手の送信時刻と自分の境界位置の関係だけで決まる。したがって「送信直前に読み直せる」は無条件には成立しない。作法だけでは閉じない理由はここにあり、閉じるのは次節の床である(#47)。 + +### 床(同時発話の順序付け) + +作法だけでは同時発火が閉じないことは実測で確定している(2026-08-23、部屋の participant による一次観測)。#49 の作法を入れた後も、宛先なしの問いに二人が同内容を二重に返す事象が二度起きた。二度めの二人は、その数分前に上記の機構を解明した当人である。**理解は防止に効かなかった。** + +失敗は二型ある。 + +- **型 1** — 境界は在ったが、相手の着弾より前だった。非決定的である。 +- **型 2** — 境界がそもそも無い。`say_to_room` 一回きりのターン(ただ答えるだけの返信)では、送信そのものが最初の境界であり、手前に読み直す隙が構造的に無い。**部屋で最も頻度の高い形が、最も確実に取りこぼす。** + +射程: ゼロ tool のターンでも、ターン開始前に載っていた発言は読める。読めないのは組み立て中の着弾に限る。 + +閉じるのは部屋である。宛先(#31)はフィルタであって直列化ではなく、順番の作法(#49)は参加者が握れないものを前提にしている。どちらも同時発火を閉じない。 + +- **送り手が `last_seen` を申告する。** 値は、その参加者が実際に見たいちばん新しい発言の `message_id` である。部屋は自分が各接続へ何を配ったかを知っているが、**配達は読了ではない**。配ったものが送り手の文脈へ入ったかどうかは境界の位置で決まり、部屋からは見えない。実際に何を見たかを知っているのは送り手だけである。 +- **判定と刻印は既存ロックの内側で、一つの操作として行う。** `deliver()` は単一経路であり、`Arc>` は既存である。二人が同時に呼んでも順序が付き、先に通った側の発言は、後の側の判定が読む床に既に載っている。判定と配送が割り込める形では両者が同じ空の床を読むため、機構として成立しない。 +- **見ていない発言があるなら配らない。** 戻り値でその発言群を返す。受理して事後に知らせる形は採らない。分散システムの collision detection は中断と再送があって初めて機能するのであり、検出して配ってしまう形はその後半を捨てたものである。 +- **再判断の結果が「送らない」ことを正当とする。** 参加者に再送を義務づけない。Ethernet は同じフレームを再送するが、参加者は読み直して「送らない」を選べる。バックオフが重複そのものの取り消しになる。 +- **部屋は内容を判定しない。** 見落とした発言が話題に関係するかどうかを部屋は判断しない。`to` による絞り込みも行わず、見落としが一件でもあれば弾く。混雑時の呼び出し増は測ってから判断する。 +- **`last_seen` が部屋の知らない値だったときは、見ていないものとして扱う。** 安全側へ倒す。保持窓(512 件)から溢れた古い `message_id` も同じ扱いになる。費用は送り手の往復 1 回であり、他の参加者の取りこぼしにはならない。 +- **`last_seen` を身元の主張として扱わない。** 偽の値を送っても損をするのは送り手自身の重複検出だけであり、他の参加者へ影響しない。認証は不要である。 +- **参加者クラスで書き分けない(#39)。** 画面からの投稿も同じ `deliver()` を通り、同じ判定を受ける。画面は自分が描画した発言の `message_id` を申告する。描画は部屋が受理した後に起きるため、競合の瞬間における順序はこの参加者についても実在する。画面に出たまま読まれていない発言は部屋から見えず、そこは埋めない。 +- **席を取った位置より前の発言は問わない。** 途中から参加した参加者へ、それ以前の発言は配られていない。名乗り直しは席を引き継ぐため、この位置は変わらない。 + +型 2 が消えるのは、**呼び出しそのものが境界を作る** ためである。`say_to_room` は `post_result` を待って結果を返すようになり、ゼロ tool の返信という状態が存在しなくなる。弾かれた発言群は、その戻り値として参加者の文脈へ入る。 + +ツールは増やさない。`say_to_room` に引数(`last_seen`)と戻り値を足すだけであり、「返信ツールは `say_to_room` 1 本に限定する」に触れない。 + +弾かれた側の組み立ては無駄になる。**この費用は受け入れる**(Master、2026-08-23)。現時点で他に案が無いための暫定対応であり、より安い形が見つかれば差し替える。 + +**順番の付与への段差を残さない。** 戻すものを「見落とした発言」から「あなたの順番」へ広げるだけで signature が変わらない形にしてある(`PostOutcome` へのフィールド追加で済む)。順番の付与自体は範囲外であり、二重計算が痛むと測れてから判断する。 + +他システムの調査(2026-08-23): 主要フレームワーク(AutoGen / AG2 GroupChat、Microsoft Agent Framework Group Chat)は orchestrator が次の話者を選ぶ turn-based であり、同時発話が構造上存在しない。並行が要る場合は共有ターンを持たない actor-model へ案内される。**この問題を解いたのではなく、起こらない形にしている。** 最も近い前例は Bounded Autonomy(ライブマルチプレイヤーの LLM キャラクター、2026-04)で、Talk state の原子的ロックにより発話中の割り当てを拒否する。調査は 2 回の検索に基づき、「他に例が無い」ことは主張しない。 ### MCP サーバの実装方式 @@ -204,6 +240,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - 部屋の作法(`instructions`)の初版 - 発言の宛先(部屋 → `post.to` → channel の `meta.to`、送信は `say_to_room` の `to`)と、それを判定材料として名指しする `instructions` - 順番の作法(先に届いた答えを読んでから決める、送信直前の読み直し。`instructions` のみで、protocol もツールも増やしていない) +- 床(`say_to_room` の `last_seen` と `post_result`、既存ロック内での判定と刻印、見落としがあるときの拒否と返却。ツールは 1 本のまま) - 参加者モデル(統一 `post` フレーム、発言者以外の全参加者への配送、接続同一性による自分の発言の抑止と名簿の同一性、人間を含む名簿) - 参加の時点の名乗り(起動ごとに選ぶ名前と色。人間側の名前欄と同じ形、`localStorage` に保持) - 宣言色(`hello` の `hue` / `room_join` の `hue`。宣言 > 自分の accent > 名前からの導出) @@ -221,7 +258,8 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre ### 未実装 -- 複数の AI セッションを同一の部屋へ参加させる運用(同時発話の抑制を含む)。参加者間で発言が届く経路、起動ごとの名乗り、参加者ごとの登録鍵はいずれも実装済みだが、実機での往復はまだ確認していない。2026-08-22 の実測(2 セッションが互いの発言を受け取らない / 2 セッションがどちらも `Claude Code` を名乗る)は #39 と #40 の修正前のものであり、修正後の再計測は済んでいない。 +- 床を入れた後の同時発話の実機観測。参加者間で発言が届く経路、起動ごとの名乗り、参加者ごとの登録鍵、床の判定はいずれも実装済みである。2026-08-22 の実測(2 セッションが互いの発言を受け取らない / 2 セッションがどちらも `Claude Code` を名乗る)は #39 と #40 の修正前のものであり、2026-08-23 の二重応答(#47 の premise)は床の実装前のものである。いずれも修正後の再計測は済んでいない。 +- 順番の付与(弾くだけでなく「あなたは N 人め」を返す形)。戻り値の形は段差を残していないが、二重計算が痛むと測れてから判断する。 - 会話ログの永続化と観測 UI - plugin としての allowlist 掲載(配布の第二段階) @@ -327,11 +365,11 @@ slug は読みやすさのためだけにあり、ASCII 英数字と `-` に落 ## テストの配置 -`.mcp.json` への登録と起動フラグの検査は、`crates/mcp-config/` という tauri 非依存の crate に置く。 +`.mcp.json` への登録と起動フラグの検査は `crates/mcp-config/`、床の判定と刻印は `crates/room-floor/` という、いずれも tauri 非依存の crate に置く。 理由は依存の正しさと、テストが実行できることの両方である。これらのロジックが tauri を必要とする理由はそもそも無い。加えて `src-tauri` 側に置くと、テストバイナリが tauri の依存ツリー全体をリンクするため、GNU ターゲットでは `STATUS_ENTRYPOINT_NOT_FOUND`(`0xc0000139`)でプロセスが起動せず、アサーションが一度も実行されない。これはローカル環境固有ではなく CI でも再現する(run 32431917979)。特定の依存クレートまでは切り分けていない。 -したがって `src-tauri/src/` に残すのは、tauri の `State` / `AppHandle` に触れる部分だけとする。純粋ロジックをそちらへ書き足すと、書いた時点で検証不能になる。 +したがって `src-tauri/src/` に残すのは、tauri の `State` / `AppHandle` に触れる部分だけとする。純粋ロジックをそちらへ書き足すと、書いた時点で検証不能になる。床の順序付けは読んでも確かめられない性質のもの(二人が同時に呼んだとき片方だけが通る)であり、この方針が最も効く箇所である。`src-tauri/src/room.rs` が持つのは、その判定を既存ロックの内側で呼ぶことと、結果をフレームとイベントへ載せることだけである。 CI が実行するもの: @@ -342,6 +380,7 @@ CI が実行するもの: | `npm run sidecar:test` | サイドカーの往復ハーネス | | `cargo check --target x86_64-pc-windows-gnu` | アプリのコンパイル | | `cargo test`(`crates/mcp-config`) | `.mcp.json` マージ保全と起動フラグ検査 | +| `cargo test`(`crates/room-floor`) | 床の判定(未読による拒否、自分の発言の除外、解決できない `last_seen`、席の位置、同時発話の順序付け) | ## 往復が成立しないときの切り分け From 64158660e2559f501c9538b18b1fc77f44e32dca Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Sun, 23 Aug 2026 13:23:09 +0900 Subject: [PATCH 6/7] test(sidecar): assert the missed addressee as an addressee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 宛先の検査が発言者名だけでも通る形だったため、`Master -> Claude Lay:` の 並びで押さえる。素の名前は別のフィールドで一致してしまう。 Refs #47 Co-Authored-By: Claude Opus 5 --- sidecar/test/round-trip.test.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sidecar/test/round-trip.test.mjs b/sidecar/test/round-trip.test.mjs index 1a730db..63d11c0 100644 --- a/sidecar/test/round-trip.test.mjs +++ b/sidecar/test/round-trip.test.mjs @@ -483,10 +483,12 @@ test("a room post reaches the channel, and say_to_room reaches the room", async assertContains(refusal, missed.speaker, "each missed post must name its speaker"); assertContains(refusal, missed.content, "each missed post must carry what was said"); } + // The addressee, as an addressee. Checking for the bare name would pass on + // this post's speaker alone, which is a different field. assertContains( refusal, - "Claude Lay", - "a missed post addressed elsewhere still comes back; the room does not narrow by addressee", + "Master -> Claude Lay:", + "a missed post addressed elsewhere comes back carrying who it was for; the room does not narrow by addressee", ); // The way out of the refusal, named concretely. Being told to try again with // "the newest id" and left to work out which is which is the shape that goes From 918920137b6436cdb98cc6d9222fdcc16f588712 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Sun, 23 Aug 2026 13:27:33 +0900 Subject: [PATCH 7/7] test(sidecar): pin the call itself being the boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 型 2(ゼロ tool の返信)の完了条件は「呼び出し自体が境界を作ることによる」 であり、これまでは拒否の検査に暗黙で乗っているだけだった。フレームが wire に 出たあとも呼び出しが解決していないことを直接押さえる。送信時点で返す実装へ 戻すと落ちることを確認済み。 Refs #47 Co-Authored-By: Claude Opus 5 --- sidecar/test/round-trip.test.mjs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/sidecar/test/round-trip.test.mjs b/sidecar/test/round-trip.test.mjs index 63d11c0..a80fefe 100644 --- a/sidecar/test/round-trip.test.mjs +++ b/sidecar/test/round-trip.test.mjs @@ -509,6 +509,21 @@ test("a room post reaches the channel, and say_to_room reaches the room", async arguments: { content: "届いてる?", last_seen: "m-1" }, }); await nextPost(3); + // The call is itself the boundary, which is what removes the reply that has + // none. The frame is on the wire and the call has still not resolved: what + // the room hands back arrives inside this call, not after the turn is over. + // Resolving on send instead is the zero-tool shape — the send is the first + // boundary, and anything that arrived while composing is unreadable until + // too late (#47 type 2). + const settled = await Promise.race([ + unanswered.then(() => "resolved"), + new Promise((r) => setTimeout(() => r("still waiting"), 200)), + ]); + assert.equal( + settled, + "still waiting", + "say_to_room must not resolve before the room answers; a call that returns on send is not a boundary", + ); roomSocket.close(); const unconfirmed = await unanswered; assert.ok(