From 08b6121cb349604afe52d0f5d17f7d2fc25d8c14 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Sat, 22 Aug 2026 19:22:49 +0900 Subject: [PATCH 1/4] feat(room): collapse say and reply into one post frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 部屋のフレームを `post` 一種類に統一し、人間の発言と AI の発言を同じ 行為として扱う。旧 `say` / `reply` の非対称は配送行の書き忘れではなく 語そのものの形だったため、語を落とした。 - `deliver()` を唯一の投稿経路とし、fan-out と `room-message` emit を 1 箇所へ集約。人間 / AI の分岐が経路から消える。 - 自分の発言の抑止は接続の同一性で判定する。接続ごとに room 側で origin を採番し、fan-out のタグとして frame の「外」に載せる。 wire に出ないため送信側は詐称も設定もできない。名前では判定しない (名前衝突中に他人の発言まで落ちるため / #40)。 - `speaker` は frame から読まず、hello で登録した接続の名前を room が 刻む。名簿と表示が食い違わない。 - 名簿を `participants` へ改称し、画面の人間を `room_join` / `room_post` で着席させる。人間が宛先として名指せるようになる。 - `RoomMessage.kind`(human / agent)を廃止し、表示軸を `own` (自分か他人か)へ置き換える。 - protocol を 2 へ。同梱パス起動により版の食い違いが構造上無いため 旧フレームの互換は残さない。 Refs #39 --- src-tauri/src/lib.rs | 5 +- src-tauri/src/room.rs | 323 ++++++++++++++++++++++++++++++------------ 2 files changed, 232 insertions(+), 96 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5d85030..9661425 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -38,8 +38,9 @@ pub fn run() { config::save_sessions, config::load_sessions, room::room_port, - room::room_agents, - room::room_say, + room::room_participants, + room::room_join, + room::room_post, session::parse_launch_options, session::preview_launch_args, session::start_session, diff --git a/src-tauri/src/room.rs b/src-tauri/src/room.rs index 48efa78..90fdf0a 100644 --- a/src-tauri/src/room.rs +++ b/src-tauri/src/room.rs @@ -6,14 +6,23 @@ //! //! Frames on the wire are the room protocol: //! -//! room -> sidecar : { type: "say", message_id, user, content, to?, ts } -//! sidecar -> room : { type: "hello", protocol, agent } -//! { type: "reply", message_id, agent, content, to?, ts } +//! sidecar -> room : { type: "hello", protocol, name } +//! both ways : { type: "post", message_id, speaker, content, to?, ts } //! -//! `to` is optional in both directions and carries the same vocabulary: the -//! display name of the participant addressed. The room still fans every frame -//! out to every sidecar — narrowing delivery here would make the room hold who -//! heard what, and answering is the agent's judgment, not the room's. +//! 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 +//! earlier protocol had `say` for a person and `reply` for a session, and only +//! `say` was ever fanned out — the asymmetry was not a missing line but the +//! shape of the words, so the words went (#39). +//! +//! `to` is optional and carries the display name of the participant addressed. +//! The room still fans every post out to every participant — narrowing +//! delivery here would make the room hold who heard what, and answering is the +//! participant's judgment, not the room's. +//! +//! `speaker` is stamped by the room from the connection the frame arrived on, +//! never read off the frame. A sender cannot claim to be someone else, and the +//! roster and the attribution cannot disagree. //! //! 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. @@ -32,27 +41,54 @@ use tokio_tungstenite::tungstenite::Message; use uuid::Uuid; /// Bumped when a frame's shape changes in a way a sidecar must notice. -pub const PROTOCOL_VERSION: u32 = 1; +/// +/// 2: `say` and `reply` collapsed into one `post` frame; `agent` became `name`. +pub const PROTOCOL_VERSION: u32 = 2; -/// One line of the room, as the frontend sees it. +/// One post of the room, as the frontend sees it. +/// +/// Carries no participant class. What separates two lines is the name on them. +/// `own` is a self/other axis for display, which is a property of the viewer, +/// not of the speaker. #[derive(Debug, Clone, Serialize)] pub struct RoomMessage { pub message_id: String, - /// "human" for a person, "agent" for a session. - pub kind: String, /// Display name of the speaker. pub speaker: String, pub content: String, pub to: Option, pub ts: String, + /// True when this screen's own participant produced it. + 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, +} + +/// A post 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 +/// property of the connection, which is the one identity a name collision +/// cannot blur (#40). +#[derive(Clone)] +struct Fanout { + origin: String, + frame: String, } #[derive(Debug, Deserialize)] -struct AgentFrame { +struct IncomingFrame { #[serde(rename = "type")] kind: String, message_id: Option, - agent: Option, + name: Option, content: Option, to: Option, ts: Option, @@ -61,30 +97,39 @@ struct AgentFrame { struct RoomInner { port: Option, - agents: BTreeSet, + /// Everyone in the room, people and sessions alike. + participants: BTreeSet, + /// The name this screen's person currently answers to, so a rename + /// replaces the roster entry rather than adding a second one. + local_name: Option, } /// Shared handle to the room socket. Cloneable; all clones share one room. #[derive(Clone)] pub struct RoomState { inner: Arc>, - /// Frames fanned out to every connected sidecar. - to_agents: broadcast::Sender, + /// Posts fanned out to every connected participant but their author. + to_participants: broadcast::Sender, /// Bearer token the sidecar must present. Generated per app run, handed to /// the sidecar through `.mcp.json` env, never written anywhere else. token: String, + /// The screen's own connection. It has no socket, so it needs an identity + /// minted here to sit on the same suppression axis as every other one. + local_origin: String, } impl RoomState { pub fn new() -> Self { - let (to_agents, _) = broadcast::channel(256); + let (to_participants, _) = broadcast::channel(256); RoomState { inner: Arc::new(Mutex::new(RoomInner { port: None, - agents: BTreeSet::new(), + participants: BTreeSet::new(), + local_name: None, })), - to_agents, + to_participants, token: Uuid::new_v4().to_string(), + local_origin: Uuid::new_v4().to_string(), } } @@ -96,23 +141,48 @@ impl RoomState { self.inner.lock().port } - pub fn agents(&self) -> Vec { - self.inner.lock().agents.iter().cloned().collect() + pub fn participants(&self) -> Vec { + self.inner.lock().participants.iter().cloned().collect() } fn set_port(&self, port: u16) { self.inner.lock().port = Some(port); } - fn add_agent(&self, name: &str) { - self.inner.lock().agents.insert(name.to_string()); + fn add_participant(&self, name: &str) { + self.inner.lock().participants.insert(name.to_string()); } - fn remove_agent(&self, name: &str) { - self.inner.lock().agents.remove(name); + fn remove_participant(&self, name: &str) { + self.inner.lock().participants.remove(name); + } + + /// Seat this screen's person in the room under `name`, replacing an + /// earlier seat. + /// + /// Returns true when the roster changed, so a rename that is not a rename + /// does not emit a roster event. + fn seat_local(&self, name: &str) -> bool { + let mut inner = self.inner.lock(); + if inner.local_name.as_deref() == Some(name) { + return false; + } + if let Some(previous) = inner.local_name.take() { + inner.participants.remove(&previous); + } + inner.participants.insert(name.to_string()); + inner.local_name = Some(name.to_string()); + true } } +/// Absent is the key omitted, never an empty one: a participant matching `to` +/// against their own name must not have to rule the empty string out first. +fn normalize_to(to: Option) -> Option { + to.map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()) +} + fn now_iso() -> String { // Tauri already pulls chrono-free time handling in; a plain RFC3339-ish // stamp from SystemTime keeps the dependency list unchanged. @@ -146,6 +216,46 @@ fn now_iso() -> String { ) } +/// Put one post into the room. +/// +/// 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) { + let mut frame = serde_json::json!({ + "type": "post", + "message_id": post.message_id, + "speaker": post.speaker, + "content": post.content, + "ts": post.ts, + }); + // Omitted rather than null when absent. + if let Some(name) = &post.to { + frame["to"] = serde_json::Value::String(name.clone()); + } + + // No subscribers means no session has joined yet. That is not an error — + // the room accepts what is said in it; a later joiner simply missed it. + let _ = room.to_participants.send(Fanout { + origin: origin.to_string(), + frame: frame.to_string(), + }); + + let _ = app.emit( + "room-message", + RoomMessage { + message_id: post.message_id, + speaker: post.speaker, + content: post.content, + to: post.to, + ts: post.ts, + own: origin == room.local_origin, + }, + ); +} + /// Bind the room socket and start accepting sidecars. /// /// Port 0: the OS picks. The port is handed to sidecars through `.mcp.json`, @@ -173,8 +283,8 @@ pub async fn start(app: AppHandle, room: RoomState) -> Result { let app = app.clone(); let room = room.clone(); tokio::spawn(async move { - if let Err(err) = serve_agent(app, room, stream).await { - eprintln!("[room] agent connection ended: {err}"); + if let Err(err) = serve_participant(app, room, stream).await { + eprintln!("[room] participant connection ended: {err}"); } }); } @@ -183,7 +293,7 @@ pub async fn start(app: AppHandle, room: RoomState) -> Result { Ok(port) } -async fn serve_agent( +async fn serve_participant( app: AppHandle, room: RoomState, stream: tokio::net::TcpStream, @@ -212,27 +322,40 @@ async fn serve_agent( .map_err(|e| format!("handshake failed: {e}"))?; let (mut sink, mut source) = ws.split(); - let mut from_room = room.to_agents.subscribe(); + // This connection's identity, minted here. Nothing the far side sends can + // set it or read it, so nothing the far side sends can wear another + // participant's suppression or shed its own. + let origin = Uuid::new_v4().to_string(); + + let mut from_room = room.to_participants.subscribe(); + let own_origin = origin.clone(); let pump = tokio::spawn(async move { - while let Ok(frame) = from_room.recv().await { - if sink.send(Message::Text(frame.into())).await.is_err() { + 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; + } + if sink.send(Message::Text(fanout.frame.into())).await.is_err() { break; } } }); - // Known once the sidecar says hello; used to drop it from the roster. - let mut agent_name: Option = None; + // Known once the participant says hello; used to attribute posts and to + // drop them from the roster. + let mut joined_as: Option = None; while let Some(Ok(msg)) = source.next().await { let Message::Text(text) = msg else { continue }; - let Ok(frame) = serde_json::from_str::(&text) else { + let Ok(frame) = serde_json::from_str::(&text) else { continue; }; match frame.kind.as_str() { "hello" => { - let name = frame.agent.unwrap_or_else(|| "agent".to_string()); + let name = frame.name.unwrap_or_else(|| "session".to_string()); if frame.protocol != Some(PROTOCOL_VERSION) { // Legible mismatch beats a silent half-working room. eprintln!( @@ -240,33 +363,37 @@ async fn serve_agent( frame.protocol ); } - room.add_agent(&name); - agent_name = Some(name); - let _ = app.emit("room-agents", room.agents()); + room.add_participant(&name); + joined_as = Some(name); + let _ = app.emit("room-participants", room.participants()); } - "reply" => { - let speaker = frame - .agent - .or_else(|| agent_name.clone()) - .unwrap_or_else(|| "agent".to_string()); - let message = RoomMessage { - message_id: frame.message_id.unwrap_or_else(|| Uuid::new_v4().to_string()), - kind: "agent".to_string(), - speaker, - content: frame.content.unwrap_or_default(), - to: frame.to, - ts: frame.ts.unwrap_or_else(now_iso), - }; - let _ = app.emit("room-message", message); + "post" => { + // 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( + &app, + &room, + &origin, + Post { + message_id: frame + .message_id + .unwrap_or_else(|| Uuid::new_v4().to_string()), + speaker, + content: frame.content.unwrap_or_default(), + to: normalize_to(frame.to), + ts: frame.ts.unwrap_or_else(now_iso), + }, + ); } _ => {} } } pump.abort(); - if let Some(name) = agent_name { - room.remove_agent(&name); - let _ = app.emit("room-agents", room.agents()); + if let Some(name) = joined_as { + room.remove_participant(&name); + let _ = app.emit("room-participants", room.participants()); } Ok(()) } @@ -279,20 +406,41 @@ pub fn room_port(state: tauri::State) -> Option { } #[tauri::command] -pub fn room_agents(state: tauri::State) -> Vec { - state.agents() +pub fn room_participants(state: tauri::State) -> Vec { + state.participants() } -/// Post a human utterance into the room. +/// Seat this screen's person in the room under `name`. /// -/// The message is echoed back to the frontend through the same `room-message` -/// event the agents' replies use, so the room has one ordering authority -/// rather than two. +/// A person is in the room by being there, not by speaking: without this the +/// roster would list only sessions until the first utterance, and nobody could +/// address someone who had not spoken yet. #[tauri::command] -pub fn room_say( +pub fn room_join( app: AppHandle, state: tauri::State, - user: String, + name: String, +) -> Result<(), String> { + let name = name.trim().to_string(); + if name.is_empty() { + return Err("name is empty".to_string()); + } + if state.seat_local(&name) { + let _ = app.emit("room-participants", state.participants()); + } + Ok(()) +} + +/// 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. +#[tauri::command] +pub fn room_post( + app: AppHandle, + state: tauri::State, + speaker: String, content: String, to: Option, ) -> Result { @@ -301,41 +449,28 @@ pub fn room_say( return Err("content is empty".to_string()); } - // Absent is the key omitted, never an empty one: an agent matching `to` - // against its own name must not have to rule "" out first. Normalising - // here keeps that shape a property of the room rather than of its callers. - let to = to - .map(|name| name.trim().to_string()) - .filter(|name| !name.is_empty()); - - let message_id = Uuid::new_v4().to_string(); - let ts = now_iso(); - - let mut frame = serde_json::json!({ - "type": "say", - "message_id": message_id, - "user": user, - "content": content, - "ts": ts, - }); - // Omitted rather than null when absent, matching the `reply` direction. - if let Some(name) = &to { - frame["to"] = serde_json::Value::String(name.clone()); + let speaker = speaker.trim().to_string(); + if speaker.is_empty() { + return Err("speaker is empty".to_string()); + } + // Speaking is being present. A post under a name the roster has not seen + // seats it, so the two cannot disagree. + if state.seat_local(&speaker) { + let _ = app.emit("room-participants", state.participants()); } - // No subscribers means no session has joined yet. That is not an error — - // the room accepts what is said in it; a later joiner simply missed it. - let _ = state.to_agents.send(frame.to_string()); - - let _ = app.emit( - "room-message", - RoomMessage { + let message_id = Uuid::new_v4().to_string(); + let local_origin = state.local_origin.clone(); + deliver( + &app, + &state, + &local_origin, + Post { message_id: message_id.clone(), - kind: "human".to_string(), - speaker: user, + speaker, content, - to, - ts, + to: normalize_to(to), + ts: now_iso(), }, ); From 15cd14b52891a1ddd52ad783efcce0b3173eed88 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Sat, 22 Aug 2026 19:26:56 +0900 Subject: [PATCH 2/4] feat(sidecar): speak the unified post frame and drop the AI-only manners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit サイドカーを protocol 2 の `post` フレームへ合わせる。 - 送信フレームから `agent` を落とす。発言者は接続から room が刻むため、 自分を名乗る欄そのものを持たない。宛先は名乗れて自分は名乗れない、 という非対称が詐称の余地を消す。 - `hello` の `agent` を `name` へ。受信は `say` から `post` へ。 - `instructions` に「人間と AI を区別しない」前提を明記し、宛先の説明を 参加者一般に対して書き直す。人間も同じやり方で名指せることを示す。 - 自分の発言の抑止はサイドカー側に置かない。room が接続で落とすため こちらは自分を認識する必要がなく、名前で判定しないことが #40 の 名前衝突時に他人の発言を落とさない条件になる。 - channel meta の `user` キーは host 側の契約なので維持し、wire の `speaker` からこの境界で読み替える。 テストは `post` 形へ移行し、次を追加した。 - 送信フレームが `speaker` を持たないこと - 自分と同名の参加者の発言が抑止されずに channel へ届くこと(#40) - `instructions` が人間 / AI を区別しない前提を述べていること Refs #39 --- sidecar/src/index.ts | 64 ++++++++++++++++++---------- sidecar/test/round-trip.test.mjs | 71 ++++++++++++++++++++++++-------- 2 files changed, 96 insertions(+), 39 deletions(-) diff --git a/sidecar/src/index.ts b/sidecar/src/index.ts index 7cd2537..d688cc8 100644 --- a/sidecar/src/index.ts +++ b/sidecar/src/index.ts @@ -15,8 +15,11 @@ * port or the launch moment from its own side. * * Direction of travel: - * room says -> WebSocket frame -> channel notification -> agent reacts - * agent replies -> `say_to_room` tool -> WebSocket frame -> the room + * someone posts -> WebSocket frame -> channel notification -> agent reacts + * this agent posts -> `say_to_room` tool -> WebSocket frame -> the room + * + * Both directions carry the same frame. A person and a session are both + * participants of the room, and what separates them is a name (#39). * * The CLI terminal output is never read as a message source. stdout belongs to * the MCP transport; every log line goes to stderr. @@ -31,11 +34,11 @@ import WebSocket from "ws"; import { randomUUID } from "node:crypto"; const ROOM_URL = process.env.LIPLUS_ROOM_URL ?? ""; -const AGENT_NAME = process.env.LIPLUS_AGENT_NAME ?? "agent"; +const AGENT_NAME = process.env.LIPLUS_AGENT_NAME ?? "session"; const ROOM_TOKEN = process.env.LIPLUS_ROOM_TOKEN ?? ""; const CHAT_ID = process.env.LIPLUS_ROOM_ID ?? "liplus-chat"; -const PROTOCOL_VERSION = 1; +const PROTOCOL_VERSION = 2; function log(line: string): void { process.stderr.write(`[liplus-chat sidecar] ${line}\n`); @@ -43,24 +46,33 @@ function log(line: string): void { // ── Room frames ────────────────────────────────────────────────────────────── // -// Room -> sidecar: -// { type: "say", message_id, user, content, to?, ts } // Sidecar -> room: -// { type: "hello", protocol, agent } -// { type: "reply", message_id, agent, content, to?, ts } +// { type: "hello", protocol, name } +// { type: "post", message_id, content, to?, ts } +// Room -> sidecar: +// { type: "post", message_id, speaker, content, to?, ts } +// +// One frame kind carries speech, whoever produced it. The room stamps +// `speaker` from the connection the frame arrived on, so this side does not +// send it: a participant names an addressee, never itself. // // `to` is optional in both directions and means the same thing on each: the -// display name of the participant addressed. The room fans every frame out to +// display name of the participant addressed. The room fans every post out to // everyone regardless — whether an utterance is yours to answer is decided // here, by the agent, not by the room narrowing its delivery. // +// A participant never receives its own post. The room drops it on the way out, +// judged on the connection it arrived on, so nothing here has to recognise +// itself — and a name collision cannot make this side swallow someone else's +// post (#40). +// // 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. -interface SayFrame { - type: "say"; +interface PostFrame { + type: "post"; message_id?: string; - user?: string; + speaker?: string; content?: string; to?: string; ts?: string; @@ -72,8 +84,12 @@ const INSTRUCTIONS = [ "あなたは liplus-chat の部屋に参加しています。", `この部屋でのあなたの名前は「${AGENT_NAME}」です。`, "", + "この部屋は、人間と AI を区別しません。参加者は全員が同じ参加者であり、", + "違いは名前だけです。発言もひとつの行為で、誰が出しても同じ形で届きます。", + "相手が人間か別のセッションかを気にする必要はありません。", + "", '部屋の発言は として届きます。', - "返信するときは say_to_room ツールを呼んでください。ターミナルへの出力は", + "発言するときは say_to_room ツールを呼んでください。ターミナルへの出力は", "部屋には届きません。", "", "宛先:", @@ -82,9 +98,11 @@ const INSTRUCTIONS = [ "- meta.to が他の参加者の名前なら、あなた宛ではありません。黙ってください。", " 補足したくなっても割り込まないでください。", "- meta.to が無い発言は部屋全体宛です。自分が答えるべきときだけ答えてください。", - "- say_to_room の to 引数で、こちらからも宛先を指定できます。", + "- say_to_room の to 引数で、こちらからも宛先を指定できます。宛先には", + " 人間の参加者も指定できます。指定の仕方は相手によって変わりません。", "", "部屋の作法:", + "- 自分の発言は返ってきません。届いた発言はすべて他の参加者のものです。", "- 返信しない判断は正当です。全員が答えると部屋は読めなくなります。", "- 一度の発言は簡潔に。長い説明が必要なときは、まず要点だけ返してください。", "- 他の参加者の発言を、自分の文脈として取り込まないでください。それぞれが", @@ -148,10 +166,11 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => { } const to = typeof args?.to === "string" ? args.to : 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 sent = sendToRoom({ - type: "reply", + type: "post", message_id: randomUUID(), - agent: AGENT_NAME, content, ...(to ? { to } : {}), ts: new Date().toISOString(), @@ -202,11 +221,14 @@ function sendToRoom(frame: Record): boolean { } } -function pushToChannel(frame: SayFrame): void { +function pushToChannel(frame: PostFrame): void { const content = frame.content ?? ""; if (!content) return; - const user = frame.user ?? "someone"; + // `speaker` on the room's wire, `user` in the channel meta: the latter is + // the host's key and the host renders it, so the name is translated at this + // boundary rather than the room's frame being bent to the host's vocabulary. + const speaker = frame.speaker ?? "someone"; // The addressee rides in meta for the same reason the speaker does: the body // must stay equal to what was said. It is judgment material, not text — the // instructions tell the agent to read it and decide whether to answer. @@ -221,7 +243,7 @@ function pushToChannel(frame: SayFrame): void { meta: { chat_id: CHAT_ID, message_id: frame.message_id ?? randomUUID(), - user, + user: speaker, ...(to ? { to } : {}), ts: frame.ts ?? new Date().toISOString(), }, @@ -247,7 +269,7 @@ function connectRoom(): void { retryCount = 0; lastError = ""; log(`room socket: connected as "${AGENT_NAME}"`); - sendToRoom({ type: "hello", protocol: PROTOCOL_VERSION, agent: AGENT_NAME }); + sendToRoom({ type: "hello", protocol: PROTOCOL_VERSION, name: AGENT_NAME }); pingTimer = setInterval(() => { if (socket.readyState === WebSocket.OPEN) socket.ping(); }, PING_INTERVAL); @@ -262,7 +284,7 @@ function connectRoom(): void { } if (typeof data !== "object" || data === null) return; const frame = data as { type?: string }; - if (frame.type === "say") pushToChannel(frame as SayFrame); + if (frame.type === "post") pushToChannel(frame as PostFrame); // Unknown frame kinds are ignored on purpose; see the frame comment above. }); diff --git a/sidecar/test/round-trip.test.mjs b/sidecar/test/round-trip.test.mjs index db003d4..d4c891b 100644 --- a/sidecar/test/round-trip.test.mjs +++ b/sidecar/test/round-trip.test.mjs @@ -39,7 +39,7 @@ function withTimeout(promise, label) { ]); } -test("room say reaches the channel, and say_to_room reaches the room", async (t) => { +test("a room post reaches the channel, and say_to_room reaches the room", async (t) => { // ── fake room ────────────────────────────────────────────────────────────── const http = createServer(); const wss = new WebSocketServer({ server: http }); @@ -48,7 +48,7 @@ test("room say reaches the channel, and say_to_room reaches the room", async (t) const connected = deferred(); const helloSeen = deferred(); - const replySeen = deferred(); + const postSeen = deferred(); let roomSocket = null; wss.on("connection", (socket) => { @@ -57,7 +57,7 @@ test("room say reaches the channel, and say_to_room reaches the room", async (t) socket.on("message", (raw) => { const frame = JSON.parse(raw.toString()); if (frame.type === "hello") helloSeen.resolve(frame); - if (frame.type === "reply") replySeen.resolve(frame); + if (frame.type === "post") postSeen.resolve(frame); }); }); @@ -152,7 +152,7 @@ test("room say reaches the channel, and say_to_room reaches the room", async (t) assert.match( init.result.instructions ?? "", /say_to_room/, - "instructions must name the reply tool", + "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 @@ -168,6 +168,14 @@ test("room say reaches the channel, and say_to_room reaches the room", async (t) /test-agent/, "instructions must tell the agent the name it answers to", ); + // The model lives in the manners as much as in the frames. An agent told to + // answer "the human" would be reading a distinction the protocol no longer + // carries (#39). + assert.match( + init.result.instructions ?? "", + /人間と AI を区別しません/, + "instructions must state that participants are not split into human and AI", + ); notify("notifications/initialized", {}); @@ -175,20 +183,20 @@ test("room say reaches the channel, and say_to_room reaches the room", async (t) assert.deepEqual( tools.result.tools.map((tool) => tool.name), ["say_to_room"], - "exactly one reply tool is exposed", + "exactly one posting tool is exposed", ); // ── room -> agent ────────────────────────────────────────────────────────── await withTimeout(connected.promise, "sidecar to connect to the room"); const hello = await withTimeout(helloSeen.promise, "hello frame"); - assert.equal(hello.agent, "test-agent"); - assert.equal(hello.protocol, 1); + assert.equal(hello.name, "test-agent"); + assert.equal(hello.protocol, 2); roomSocket.send( JSON.stringify({ - type: "say", + type: "post", message_id: "m-1", - user: "Master", + speaker: "Master", content: "聞こえる?", ts: "2026-08-21T00:00:00.000Z", }), @@ -212,9 +220,9 @@ test("room say reaches the channel, and say_to_room reaches the room", async (t) // ── the addressee rides through to the agent ─────────────────────────────── roomSocket.send( JSON.stringify({ - type: "say", + type: "post", message_id: "m-2", - user: "Master", + speaker: "Master", content: "リンだけ答えて", to: "test-agent", ts: "2026-08-21T00:00:01.000Z", @@ -231,9 +239,9 @@ test("room say reaches the channel, and say_to_room reaches the room", async (t) // agent decides. Filtering here would put "who heard it" in the room. roomSocket.send( JSON.stringify({ - type: "say", + type: "post", message_id: "m-3", - user: "Master", + speaker: "Master", content: "レイはどう思う", to: "other-agent", ts: "2026-08-21T00:00:02.000Z", @@ -243,17 +251,44 @@ test("room say reaches the channel, and say_to_room reaches the room", async (t) const elsewhere = await nextNotification("notifications/claude/channel", 2); assert.equal(elsewhere.params.meta.to, "other-agent"); - // ── agent -> room ────────────────────────────────────────────────────────── + // ── this participant -> room ─────────────────────────────────────────────── const call = await request("tools/call", { name: "say_to_room", arguments: { content: "聞こえてるわ", to: "Master" }, }); assert.ok(!call.result.isError, `tool call failed: ${JSON.stringify(call.result)}`); - const reply = await withTimeout(replySeen.promise, "reply frame"); - assert.equal(reply.agent, "test-agent"); - assert.equal(reply.content, "聞こえてるわ"); - assert.equal(reply.to, "Master"); + const post = await withTimeout(postSeen.promise, "post frame"); + 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"); + // Attribution belongs to the room, stamped from the connection. A sender + // that could name itself could name somebody else. + assert.equal( + "speaker" in post, + false, + "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", + }), + ); + + 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"); // ── a dropped frame must not read as delivered ───────────────────────────── roomSocket.close(); From 8288b311a173369dca851ebd096ce8e883621347 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Sat, 22 Aug 2026 19:28:20 +0900 Subject: [PATCH 3/4] feat(ui): seat the person in the roster and colour by self, not by class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 画面側を参加者モデルへ合わせる。 - `RoomMessage.kind`(human / agent)を廃止し `own` を使う。表示の軸は 「自分か他人か」であり、人間か AI かではない。`styles.css` の `[data-kind="human"]` を `[data-own="true"]` へ。 - `room_join` で起動時と改名時に人間を名簿へ着席させる。発言していなくても 名簿に並び、他の参加者から名指せる。 - 宛先の選択肢を「自分以外の参加者」とする。人間もセッションも同じ列に 並び、指定の仕方は変わらない。 - `room_say` → `room_post`、`room_agents` → `room_participants`、 `room-agents` → `room-participants`。 Refs #39 --- index.html | 7 ++--- src/main.ts | 73 +++++++++++++++++++++++++++++++++++--------------- src/styles.css | 8 +++--- 3 files changed, 61 insertions(+), 27 deletions(-) diff --git a/index.html b/index.html index a7c563c..257daf5 100644 --- a/index.html +++ b/index.html @@ -60,9 +60,10 @@