From 2f5eee3d6eb07b7f47c95f40ebafbca32e488263 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Fri, 25 Sep 2026 06:14:59 +0000 Subject: [PATCH 01/37] test(harness): anchor the credits-expiry case to the real observation time `record_vendor_usage_in` stamps `observed_at = Utc::now()`, and a credits balance is bounded by observation + 7 days. The "past every window" check used a fixed 2026-09-30, so from 2026-09-24 on the credits were still inside their bound and the assertion went red on every clean run (CI included). The product rule is unchanged; the test now measures "past" from the observation it made. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ccteam-harness/src/usage_catalog.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/ccteam-harness/src/usage_catalog.rs b/crates/ccteam-harness/src/usage_catalog.rs index f162e5b6..51b4e7ae 100644 --- a/crates/ccteam-harness/src/usage_catalog.rs +++ b/crates/ccteam-harness/src/usage_catalog.rs @@ -255,8 +255,13 @@ mod tests { // Past every window (weekly reset gone, and beyond the credit bound): // nothing usable survives, so the row is omitted rather than stale. + // The credit bound runs from the REAL observation (`record_*` stamps + // `Utc::now()`), so "past it" is anchored there โ€” a fixed date turned + // this assertion red a week after the fixture's own dates. + let past_everything = + (Utc::now() + WEEKLY_NATURAL).max(at("2026-09-03T00:00:00Z")) + Duration::days(1); assert_eq!( - last_known_usage_in(root.path(), "claude", at("2026-09-30T00:00:00Z")), + last_known_usage_in(root.path(), "claude", past_everything), None ); // An unknown vendor never borrows another's account. From 31b42fe3b3f16bec8ca4f6b39199e7070b4220ca Mon Sep 17 00:00:00 2001 From: randd1024 Date: Fri, 25 Sep 2026 06:14:59 +0000 Subject: [PATCH 02/37] fix(im): a running turn's words reach IM and web while it runs (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs-local/issues/#209. A claude session talked for ten minutes; `/status` could quote it (`๐Ÿ’ฌ โ€ฆ`) but the chat got nothing until the turn ended. #207 taught the pump to release held text on a steer or after one heartbeat, but only codex ever handed it text mid-turn: claude stream-json, the ACP family (grok/opencode/kimi/dsh) and pi all reported a turn's text ONCE, at its end, so the release had nothing to release on six of seven harnesses. Fixed at the adapter contract, so every harness and both front ends inherit it: - Each public message is an `ItemCompleted{AgentMessage}` the moment it is complete. claude: every top-level text block as it arrives (probed on 2.1.280: one complete block per `assistant` line), `result.result` only when the stream never showed it. ACP: deltas have no end marker, so a new tool call completes the message before it; the boundary reports only what is left; a context probe's reply stays quiet. pi: a message that hands over to a tool call is reported at `message_end` (it used to be overwritten by the next one and never reach anyone). - The pump folds what it holds into ONE delivery (one chat message per release, not one per line) and releases it on the boundary, a steer, or one heartbeat โ€” now also by a timer, so a line said just before a silent twenty-minute tool is not held until the tool returns. The due hold enters the same delivery path as an inert marker that skips every vendor-only step (liveness, heartbeat rows, accounting). - A failed turn delivers what it said first as ordinary narration ahead of the failure row, instead of discarding it with the hold; only the failure row is marked failed and closes the turn. - A folded boundary names its last message as the conclusion when the vendor names none (issue #196). - Every boundary publishes exactly one status-bearing Answer. With no answer riding it, that is a content-less frame on the web stream; the IM status line goes to the IM chat only (it rendered on the web as a bubble holding nothing but a status line). A turn whose text went out mid-turn gets a durable closing row (empty `assistant`, status + usage), so a reload keeps its footer and the completed-turn count stays right. - An interim answer only SEALS its progress card (`โ†ณ N tools ยท M files`); `โœ… done` is the turn's end. An answer-less boundary closes its card too, so the next turn no longer edits the previous turn's card. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/execution/acp/translate.rs | 164 +++++- .../src/execution/acp/turn_runner.rs | 1 + .../execution/claude_stream_json/translate.rs | 261 ++++++--- .../src/execution/pi_rpc/translate.rs | 96 +++- crates/ccteam-harness/tests/pi_rpc_test.rs | 31 +- crates/ccteam-im/src/gateway.rs | 507 +++++++++++++++--- crates/ccteam-im/src/mcp/dispatch.rs | 13 +- crates/ccteam-im/src/progress.rs | 24 + 8 files changed, 908 insertions(+), 189 deletions(-) diff --git a/crates/ccteam-harness/src/execution/acp/translate.rs b/crates/ccteam-harness/src/execution/acp/translate.rs index c89fca7f..ba9bf1c8 100644 --- a/crates/ccteam-harness/src/execution/acp/translate.rs +++ b/crates/ccteam-harness/src/execution/acp/translate.rs @@ -1,11 +1,18 @@ -//! Final-only translate: ACP notifications + prompt response โ†’ ThreadEvent. +//! Translate: ACP notifications + prompt response โ†’ ThreadEvent. //! //! Client-started turn-end SoT = matching `session/prompt` JSON-RPC response. //! A vendor that admits an idle control message can also self-start a turn; //! that exceptional turn is opened by its first content update and finalized //! by its own boundary notification. Buffer only `agent_message_chunk`; drop -//! thoughts and `isReplay` frames from the final answer, but emit throttled +//! thoughts and `isReplay` frames from the answer, but emit throttled //! mid-stream liveness events so the gateway silence watchdog sees long work. +//! +//! A message is reported the moment it is COMPLETE, not at the turn's end: +//! ACP streams message deltas with no end marker of their own, so the text +//! gathered so far becomes one `ItemCompleted{AgentMessage}` when the agent +//! moves on to a tool call, and the boundary reports only what is left. The +//! gateway decides when a chat sees it; a turn reported in one piece gave it +//! nothing to release for as long as the turn ran (#209). use std::collections::{HashSet, VecDeque}; use std::sync::{Arc, Mutex as StdMutex}; @@ -36,6 +43,12 @@ pub struct TurnBuffer { /// Every chunk of the turn, in order โ€” this becomes the ANSWER, so it is /// whole by definition and grows with the turn. pub text: String, + /// How much of `text` (bytes) has already been reported as a completed + /// message; the rest is what the next boundary still owes. + delivered: usize, + /// A context probe's reply is data, not conversation (see the turn + /// runner): nothing of it is reported mid-turn. + quiet: bool, /// The bounded TAIL of the same text, kept incrementally. /// /// A read of a running turn used to render the tail by scanning and @@ -52,10 +65,35 @@ impl TurnBuffer { Self { turn_id: turn_id.into(), text: String::new(), + delivered: 0, + quiet: false, narration: crate::NarrationAccumulator::default(), } } + /// The text not yet reported as a completed message, marked reported. + fn take_undelivered(&mut self) -> String { + let rest = self.text[self.delivered..].to_string(); + self.delivered = self.text.len(); + rest + } + + /// Close the message in progress: the agent moved on to other work, so + /// what it has said so far will not grow. `None` when there is nothing + /// new worth a message (or the turn is a quiet probe). + fn seal_message(&mut self) -> Option { + if self.quiet || self.text[self.delivered..].trim().is_empty() { + return None; + } + let text = self.take_undelivered(); + Some(ThreadEvent::ItemCompleted { + item: ThreadItem { + id: format!("{}-msg-{}", self.turn_id, self.delivered), + details: ThreadItemDetails::AgentMessage(text), + }, + }) + } + /// Append one `agent_message_chunk`. ACP's shape is DELTA and one message /// streams at a time, so the accumulator keeps joining the open part; a /// thought chunk never reaches here, so private reasoning is excluded one @@ -301,6 +339,24 @@ impl SessionTranslateState { self.last_liveness_at = None; } + /// Mark the turn just begun as a context probe, whose reply is read as + /// data and never reported as conversation. + pub fn quiet_current_turn(&mut self) { + if let Some(buffer) = self.buffer.as_mut() { + buffer.quiet = true; + } + } + + /// Close the message the agent was writing (see [`TurnBuffer::seal_message`]). + fn seal_message(&mut self, vendor_started: bool) -> Option { + let target = if vendor_started { + self.vendor_started_buffer.as_mut() + } else { + self.buffer.as_mut() + }; + target.and_then(TurnBuffer::seal_message) + } + fn append_message(&mut self, chunk: &str, vendor_started: bool) { let target = if vendor_started { self.vendor_started_buffer.as_mut() @@ -561,13 +617,21 @@ fn apply_session_update( args: update.get("rawInput").cloned().unwrap_or(Value::Null), }, }; - if kind == "tool_call" && status != "completed" { - vec![ThreadEvent::ItemStarted { item }] + // A new tool call ends whatever the agent was saying before it: + // that message is complete, and it goes out ahead of the tool. + let mut events: Vec = if kind == "tool_call" { + state.seal_message(vendor_started).into_iter().collect() + } else { + Vec::new() + }; + events.push(if kind == "tool_call" && status != "completed" { + ThreadEvent::ItemStarted { item } } else if status == "completed" { - vec![ThreadEvent::ItemCompleted { item }] + ThreadEvent::ItemCompleted { item } } else { - vec![ThreadEvent::ItemUpdated { item }] - } + ThreadEvent::ItemUpdated { item } + }); + events } // OpenCode: context occupancy + session-cumulative USD. "usage_update" => { @@ -673,16 +737,17 @@ fn maybe_liveness_event( } fn finalize_vendor_started_turn(state: &mut SessionTranslateState) -> Vec { - let Some(buf) = state.vendor_started_buffer.take() else { + let Some(mut buf) = state.vendor_started_buffer.take() else { return Vec::new(); }; state.last_liveness_at = None; + let text = buf.take_undelivered(); let turn_id = buf.turn_id; vec![ ThreadEvent::ItemCompleted { item: ThreadItem { id: format!("{turn_id}-msg"), - details: ThreadItemDetails::AgentMessage(buf.text), + details: ThreadItemDetails::AgentMessage(text), }, }, ThreadEvent::TurnCompleted { @@ -746,7 +811,8 @@ pub fn finalize_from_prompt_result( .as_ref() .map(|b| b.turn_id.clone()) .unwrap_or_else(|| "unknown".into()); - let text = buf.map(|b| b.text).unwrap_or_default(); + // Only what no earlier message carried: the rest already went out. + let text = buf.map(|mut b| b.take_undelivered()).unwrap_or_default(); let stop = stop_reason_from_prompt_result(result); let terminal_model = model.or_else(|| state.model.clone()); let mut out = Vec::new(); @@ -1162,6 +1228,84 @@ mod tests { }; } + /// Every `AgentMessage` in `events`, in order. + fn said(events: &[ThreadEvent]) -> Vec { + events + .iter() + .filter_map(|event| match event { + ThreadEvent::ItemCompleted { item } => match &item.details { + ThreadItemDetails::AgentMessage(text) => Some(text.clone()), + _ => None, + }, + _ => None, + }) + .collect() + } + + fn chunk(text: &str) -> Notification { + Notification { + method: "session/update".into(), + params: json!({"update": { + "sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": text} + }}), + } + } + + fn tool_call(id: &str) -> Notification { + Notification { + method: "session/update".into(), + params: json!({"update": { + "sessionUpdate": "tool_call", + "toolCallId": id, + "title": "bash", + "status": "pending" + }}), + } + } + + /// #209 โ€” ACP streams deltas with no end-of-message marker, so a turn used + /// to reach the gateway as ONE message at `session/prompt`'s response: + /// whatever the agent said before a twenty-minute build stayed unsaid until + /// the build was over. A tool call is where a message ends. + #[test] + fn a_tool_call_completes_the_message_before_it() { + let mut st = SessionTranslateState::default(); + st.begin_turn("t-seal", Arc::new(Notify::new())); + assert!(said(&apply_notification(&mut st, &chunk("reading "))).is_empty()); + assert!(said(&apply_notification(&mut st, &chunk("the brief"))).is_empty()); + let at_tool = apply_notification(&mut st, &tool_call("tc-1")); + assert_eq!(said(&at_tool), vec!["reading the brief".to_string()]); + assert!( + matches!(at_tool.last(), Some(ThreadEvent::ItemStarted { .. })), + "the message goes out AHEAD of the tool it handed over to: {at_tool:?}" + ); + // A second tool with nothing said in between reports nothing. + assert!(said(&apply_notification(&mut st, &tool_call("tc-2"))).is_empty()); + apply_notification(&mut st, &chunk("all green")); + let boundary = finalize_from_prompt_result(&mut st, &json!({"stopReason": "end_turn"})); + assert_eq!( + said(&boundary), + vec!["all green".to_string()], + "the boundary reports only what no earlier message carried" + ); + } + + /// A context probe's reply is data the runner parses, never conversation. + #[test] + fn a_quiet_probe_reports_nothing_mid_turn() { + let mut st = SessionTranslateState::default(); + st.begin_turn("probe-1", Arc::new(Notify::new())); + st.quiet_current_turn(); + apply_notification(&mut st, &chunk("context: 12k / 200k")); + assert!(said(&apply_notification(&mut st, &tool_call("tc-1"))).is_empty()); + assert_eq!( + st.take_buffer().map(|buffer| buffer.text).as_deref(), + Some("context: 12k / 200k"), + "the probe still reads its whole reply" + ); + } + /// Buffer some partial text, then finalize with `reason`. fn finalize_with_stop_reason(reason: &str) -> Vec { let mut st = SessionTranslateState::default(); diff --git a/crates/ccteam-harness/src/execution/acp/turn_runner.rs b/crates/ccteam-harness/src/execution/acp/turn_runner.rs index 77fae1bb..500210c1 100644 --- a/crates/ccteam-harness/src/execution/acp/turn_runner.rs +++ b/crates/ccteam-harness/src/execution/acp/turn_runner.rs @@ -354,6 +354,7 @@ impl AcpTurnRunner { Arc::clone(&done), Some(Arc::clone(&sent)), ); + st.quiet_current_turn(); NextTurn::Probe { probe, done, sent } } else { NextTurn::Stop diff --git a/crates/ccteam-harness/src/execution/claude_stream_json/translate.rs b/crates/ccteam-harness/src/execution/claude_stream_json/translate.rs index 9082f506..d1cd7e47 100644 --- a/crates/ccteam-harness/src/execution/claude_stream_json/translate.rs +++ b/crates/ccteam-harness/src/execution/claude_stream_json/translate.rs @@ -6,16 +6,20 @@ //! //! ## The contract this honors (gateway `async_event_text`) //! -//! - The turn's **answer** is emitted exactly once as +//! - Every top-level assistant **text block** is emitted as its own //! [`ThreadEvent::ItemCompleted`] carrying -//! [`ThreadItemDetails::AgentMessage`] โ€” the only event the pump -//! forwards to IM as a reply. The answer is EVERY top-level assistant -//! text block of the turn, in order โ€” not just the last one. Claude's -//! `result.result` carries only the final text block, so a reply the -//! model wrote before its next tool call (typically an answer to a -//! human who spoke mid-turn) would otherwise vanish from turns.jsonl, -//! the IM reply and the delegation notification (issue #192). -//! Subagent blocks (`parent_tool_use_id` set) are never the answer. +//! [`ThreadItemDetails::AgentMessage`] the moment it arrives โ€” the only +//! event the pump delivers to a chat. The wire hands over each block +//! COMPLETE (one content block per `assistant` line, several lines per +//! message id; probed on claude 2.1.280), so a block is final when it is +//! read. WHEN it reaches the chat is the gateway's decision, not this +//! translator's: holding every block until `result` is what left a +//! ten-minute turn silent in the chat while `/status` could already quote +//! it (#209) โ€” the gateway's mid-turn release had nothing to release. The +//! turn's answer is still EVERY block, in order (issue #192): the gateway +//! folds what it holds into one delivery. `result.result` is emitted as +//! one more message only when the stream never showed it. Subagent blocks +//! (`parent_tool_use_id` set) are never the answer. //! - The turn's **conclusion** โ€” the text after the last tool call, which //! is what `result.result` carries โ€” rides [`ThreadEvent::TurnCompleted`] //! separately, so a bounded excerpt of the answer (the completion @@ -172,8 +176,9 @@ pub struct StreamTranslator { /// correlatable without holding the lock. active_turn: Option, /// Every top-level assistant text block of the active turn, in stream - /// order โ€” this IS the turn's answer (`result.result` only repeats the - /// last block; see the module doc / issue #192). + /// order โ€” already emitted block by block, kept to recognise a + /// `result.result` the stream never showed and to decide whether the + /// conclusion differs from the whole answer (issue #192/#196). acc_text: String, /// The last top-level text block of the active turn โ€” the conclusion's /// fallback when `result.result` is empty (see the module doc). @@ -378,7 +383,14 @@ impl StreamTranslator { // Task tool's private thread, never to this session's reply. if !text.is_empty() && env.parent_tool_use_id.is_none() { push_paragraph(&mut self.acc_text, &text); - self.last_text = Some(text); + self.last_text = Some(text.clone()); + let id = self.next_item_id(); + out.push(ThreadEvent::ItemCompleted { + item: ThreadItem { + id, + details: ThreadItemDetails::AgentMessage(text), + }, + }); } for ev in items { // Re-id with the translator's counter so item ids are stable @@ -454,19 +466,24 @@ impl StreamTranslator { return out; } - // Success: the answer = every top-level text block seen this turn - // (issue #192 โ€” `result.result` is only the LAST block, so a reply - // written before a further tool call would be dropped). `result` - // is still honoured as the source of truth for text the stream - // never showed us (empty stream, or a tail the blocks lack). Emit - // the answer FIRST (so the pump finalizes the turn's progress epoch - // before the boundary event), then TurnCompleted with usage. + // Success: every top-level text block already went out as it + // arrived. `result` is still honoured as the source of truth for + // text the stream never showed us (empty stream, or a tail the + // blocks lack) โ€” that one is emitted here, BEFORE the boundary, so + // the pump finalizes it as the turn's last message. let mut final_text = std::mem::take(&mut self.acc_text); let last_text = self.last_text.take(); let result_text = r.result.as_deref().filter(|s| !s.is_empty()); if let Some(tail) = result_text { if !final_text.trim_end().ends_with(tail.trim_end()) { push_paragraph(&mut final_text, tail); + let id = self.next_item_id(); + out.push(ThreadEvent::ItemCompleted { + item: ThreadItem { + id, + details: ThreadItemDetails::AgentMessage(tail.to_string()), + }, + }); } } // The conclusion = the vendor's `result.result` (measured: the LAST @@ -476,15 +493,6 @@ impl StreamTranslator { .map(str::to_string) .or(last_text) .filter(|conclusion| conclusion.trim() != final_text.trim()); - if !final_text.is_empty() { - let id = self.next_item_id(); - out.push(ThreadEvent::ItemCompleted { - item: ThreadItem { - id, - details: ThreadItemDetails::AgentMessage(final_text), - }, - }); - } out.push(ThreadEvent::TurnCompleted { turn_id, usage, @@ -591,13 +599,25 @@ mod tests { } fn answer_text(evs: &[ThreadEvent]) -> Option { - evs.iter().find_map(|e| match e { - ThreadEvent::ItemCompleted { item } => match &item.details { - ThreadItemDetails::AgentMessage(t) => Some(t.clone()), + messages(evs).into_iter().next() + } + + /// Every `AgentMessage` these events carry, in order. + fn messages(evs: &[ThreadEvent]) -> Vec { + evs.iter() + .filter_map(|e| match e { + ThreadEvent::ItemCompleted { item } => match &item.details { + ThreadItemDetails::AgentMessage(t) => Some(t.clone()), + _ => None, + }, _ => None, - }, - _ => None, - }) + }) + .collect() + } + + /// Ingest `lines` in order, returning every event they produced. + fn run(t: &mut StreamTranslator, lines: Vec) -> Vec { + lines.into_iter().flat_map(|line| t.ingest(line)).collect() } /// A `system:background_tasks_changed` snapshot carrying `ids`. @@ -724,32 +744,66 @@ mod tests { {"type": "tool_use", "name": "Bash", "input": {"command": "ls"}, "id": "tu-1"}, {"type": "text", "text": "running ls"} ]))); - // The tool_use surfaces as ItemStarted{ToolCall}; the text is - // accumulated (not yet an answer until result). + // The tool_use surfaces as ItemStarted{ToolCall}; only the text is a + // message โ€” and it is one NOW, not at `result` (#209). assert!(evs.iter().any(|e| matches!( e, ThreadEvent::ItemStarted { item } if matches!(&item.details, ThreadItemDetails::ToolCall { name, .. } if name == "Bash") ))); - assert!(answer_text(&evs).is_none()); + assert_eq!(messages(&evs), vec!["running ls".to_string()]); } #[test] - fn result_falls_back_to_accumulated_text() { + fn a_text_block_is_a_message_the_moment_it_arrives() { + // #209 โ€” the gateway can only release what it has been given. A block + // held back until `result` made every mid-turn release a no-op: a + // ten-minute turn reached the chat as one message at its very end. let mut t = StreamTranslator::new(); - t.ingest(assistant( - json!([{"type": "text", "text": "accumulated answer"}]), + let first = t.ingest(assistant( + json!([{"type": "text", "text": "checking the two children"}]), )); - // result with empty `result` โ†’ fall back to accumulated. - let evs = t.ingest(Outbound::TurnResult(ResultMsg { - subtype: "success".into(), - result: None, - is_error: false, - total_cost_usd: None, - usage: None, - session_id: "u-1".into(), - })); - assert_eq!(answer_text(&evs).as_deref(), Some("accumulated answer")); + assert_eq!( + messages(&first), + vec!["checking the two children".to_string()] + ); + let tool = t.ingest(assistant(json!([ + {"type": "tool_use", "name": "Bash", "input": {"command": "make test"}, "id": "tu-1"} + ]))); + assert!(messages(&tool).is_empty(), "a tool call says nothing"); + let second = t.ingest(assistant( + json!([{"type": "text", "text": "tests are green"}]), + )); + assert_eq!(messages(&second), vec!["tests are green".to_string()]); + let boundary = t.ingest(result_ok("tests are green")); + assert!( + messages(&boundary).is_empty(), + "a result the stream already showed is not repeated: {boundary:?}" + ); + assert!(boundary + .iter() + .any(|e| matches!(e, ThreadEvent::TurnCompleted { .. }))); + } + + #[test] + fn result_falls_back_to_accumulated_text() { + let mut t = StreamTranslator::new(); + // result with empty `result` โ†’ the streamed block is the answer. + let evs = run( + &mut t, + vec![ + assistant(json!([{"type": "text", "text": "accumulated answer"}])), + Outbound::TurnResult(ResultMsg { + subtype: "success".into(), + result: None, + is_error: false, + total_cost_usd: None, + usage: None, + session_id: "u-1".into(), + }), + ], + ); + assert_eq!(messages(&evs), vec!["accumulated answer".to_string()]); } #[test] @@ -758,17 +812,20 @@ mod tests { // event per content block; `result.result` = the LAST text block // only. A reply written before a further tool call must survive. let mut t = StreamTranslator::new(); - t.ingest(assistant( - json!([{"type": "text", "text": "ALPHA report here."}]), - )); - t.ingest(assistant(json!([ - {"type": "tool_use", "name": "Bash", "input": {"command": "echo hi"}, "id": "tu-1"} - ]))); - t.ingest(assistant(json!([{"type": "text", "text": "BETA done."}]))); - let evs = t.ingest(result_ok("BETA done.")); + let evs = run( + &mut t, + vec![ + assistant(json!([{"type": "text", "text": "ALPHA report here."}])), + assistant(json!([ + {"type": "tool_use", "name": "Bash", "input": {"command": "echo hi"}, "id": "tu-1"} + ])), + assistant(json!([{"type": "text", "text": "BETA done."}])), + result_ok("BETA done."), + ], + ); assert_eq!( - answer_text(&evs).as_deref(), - Some("ALPHA report here.\n\nBETA done.") + messages(&evs), + vec!["ALPHA report here.".to_string(), "BETA done.".to_string()] ); // issue #196 โ€” the boundary names the block after the last tool call // as the turn's conclusion, so an excerpt can prefer it. @@ -778,15 +835,20 @@ mod tests { #[test] fn conclusion_falls_back_to_the_last_stream_block_without_result_text() { let mut t = StreamTranslator::new(); - t.ingest(assistant(json!([{"type": "text", "text": "narration"}]))); - t.ingest(assistant(json!([ - {"type": "tool_use", "name": "Bash", "input": {"command": "true"}, "id": "tu-1"} - ]))); - t.ingest(assistant(json!([{"type": "text", "text": "the receipt"}]))); - let evs = t.ingest(result_ok("")); + let evs = run( + &mut t, + vec![ + assistant(json!([{"type": "text", "text": "narration"}])), + assistant(json!([ + {"type": "tool_use", "name": "Bash", "input": {"command": "true"}, "id": "tu-1"} + ])), + assistant(json!([{"type": "text", "text": "the receipt"}])), + result_ok(""), + ], + ); assert_eq!( - answer_text(&evs).as_deref(), - Some("narration\n\nthe receipt") + messages(&evs), + vec!["narration".to_string(), "the receipt".to_string()] ); assert_eq!(conclusion_of(&evs).as_deref(), Some("the receipt")); } @@ -796,9 +858,14 @@ mod tests { // The answer IS the conclusion: carrying it twice would only cost the // ledger a copy and the excerpt nothing. let mut t = StreamTranslator::new(); - t.ingest(assistant(json!([{"type": "text", "text": "hi there"}]))); - let evs = t.ingest(result_ok("hi there")); - assert_eq!(answer_text(&evs).as_deref(), Some("hi there")); + let evs = run( + &mut t, + vec![ + assistant(json!([{"type": "text", "text": "hi there"}])), + result_ok("hi there"), + ], + ); + assert_eq!(messages(&evs), vec!["hi there".to_string()]); assert_eq!(conclusion_of(&evs), None); } @@ -807,11 +874,19 @@ mod tests { // `result.result` stays authoritative for anything the blocks never // carried (never dropped, never duplicated). let mut t = StreamTranslator::new(); - t.ingest(assistant(json!([{"type": "text", "text": "narration"}]))); - let evs = t.ingest(result_ok("final from result only")); + let evs = run( + &mut t, + vec![ + assistant(json!([{"type": "text", "text": "narration"}])), + result_ok("final from result only"), + ], + ); assert_eq!( - answer_text(&evs).as_deref(), - Some("narration\n\nfinal from result only") + messages(&evs), + vec![ + "narration".to_string(), + "final from result only".to_string() + ] ); assert_eq!( conclusion_of(&evs).as_deref(), @@ -828,9 +903,14 @@ mod tests { session_id: "u-1".into(), parent_tool_use_id: Some("tu-9".into()), })); - t.ingest(assistant(json!([{"type": "text", "text": "top-level"}]))); - let evs = t.ingest(result_ok("top-level")); - assert_eq!(answer_text(&evs).as_deref(), Some("top-level")); + let evs = run( + &mut t, + vec![ + assistant(json!([{"type": "text", "text": "top-level"}])), + result_ok("top-level"), + ], + ); + assert_eq!(messages(&evs), vec!["top-level".to_string()]); assert_eq!( conclusion_of(&evs), None, @@ -875,16 +955,21 @@ mod tests { #[test] fn string_form_content_collapses_to_text() { let mut t = StreamTranslator::new(); - t.ingest(assistant(json!("just a string"))); - let evs = t.ingest(Outbound::TurnResult(ResultMsg { - subtype: "success".into(), - result: None, - is_error: false, - total_cost_usd: None, - usage: None, - session_id: "u-1".into(), - })); - assert_eq!(answer_text(&evs).as_deref(), Some("just a string")); + let evs = run( + &mut t, + vec![ + assistant(json!("just a string")), + Outbound::TurnResult(ResultMsg { + subtype: "success".into(), + result: None, + is_error: false, + total_cost_usd: None, + usage: None, + session_id: "u-1".into(), + }), + ], + ); + assert_eq!(messages(&evs), vec!["just a string".to_string()]); } #[test] diff --git a/crates/ccteam-harness/src/execution/pi_rpc/translate.rs b/crates/ccteam-harness/src/execution/pi_rpc/translate.rs index dfcf61c2..15a5e5c0 100644 --- a/crates/ccteam-harness/src/execution/pi_rpc/translate.rs +++ b/crates/ccteam-harness/src/execution/pi_rpc/translate.rs @@ -76,14 +76,31 @@ impl PiTurnTranslator { settled: false, } } - PiEvent::MessageEnd { message } => { - if let Err(error) = active.ingest_message(&message) { + PiEvent::MessageEnd { message } => match active.ingest_message(&message) { + // A message that hands over to a tool call is complete and + // the turn goes on: report it now, not at `agent_settled`, + // which only ever sees the LAST message โ€” every earlier one + // was overwritten and never reached anyone (#209). + Ok(Some(text)) => { + active.item_seq += 1; + TranslateOutput { + events: vec![ThreadEvent::ItemCompleted { + item: ThreadItem { + id: format!("{}-agent-{}", active.turn_id, active.item_seq), + details: ThreadItemDetails::AgentMessage(text), + }, + }], + settled: false, + } + } + Ok(None) => TranslateOutput::default(), + Err(error) => { // `agent_settled` remains the sole normal terminal; a bad // message poisons the pending outcome instead of ending it. active.protocol_error = Some(error); + TranslateOutput::default() } - TranslateOutput::default() - } + }, PiEvent::CompactionEnd { usage } => { if let Some(usage) = usage { active.add_usage(usage); @@ -239,7 +256,9 @@ impl PiTurnTranslator { } impl TurnBuffer { - fn ingest_message(&mut self, message: &Value) -> Result<(), String> { + /// Fold one finished message into the turn. `Some(text)` = a message the + /// turn has moved past (it handed over to a tool call), reportable now. + fn ingest_message(&mut self, message: &Value) -> Result, String> { match message.get("role").and_then(Value::as_str) { Some("assistant") => { let usage: PiUsage = @@ -255,7 +274,7 @@ impl TurnBuffer { self.model = Some(format!("{provider}/{model}")); let stop_reason = required_string(message, "stopReason")?; if stop_reason == "pending" { - return Ok(()); + return Ok(None); } let content = message .get("content") @@ -270,15 +289,22 @@ impl TurnBuffer { let has_tool_call = content .iter() .any(|part| part.get("type").and_then(Value::as_str) == Some("toolCall")); + let handed_over = stop_reason == "toolUse" && has_tool_call; + let text = (!text.trim().is_empty()).then_some(text); self.terminal = Some(TerminalMessage { stop_reason, error_message: message .get("errorMessage") .and_then(Value::as_str) .map(str::to_string), - text: (!text.is_empty()).then_some(text), + // Reported here when it is reported now, so the settle + // path can never report it a second time. + text: if handed_over { None } else { text.clone() }, has_tool_call, }); + if handed_over { + return Ok(text); + } } Some("toolResult") => { if let Some(usage) = message.get("usage") { @@ -289,7 +315,7 @@ impl TurnBuffer { } _ => {} } - Ok(()) + Ok(None) } fn add_usage(&mut self, usage: PiUsage) { @@ -405,6 +431,60 @@ mod tests { assert_eq!(*continuation, crate::TurnContinuation::Settled); } + fn said(events: &[ThreadEvent]) -> Vec { + events + .iter() + .filter_map(|event| match event { + ThreadEvent::ItemCompleted { item } => match &item.details { + ThreadItemDetails::AgentMessage(text) => Some(text.clone()), + _ => None, + }, + _ => None, + }) + .collect() + } + + /// #209 โ€” `agent_settled` only ever sees the LAST message: an earlier one + /// that handed over to a tool call was overwritten and never reached the + /// chat, the transcript or a parent. It is complete when it ends, so it is + /// reported then โ€” once. + #[test] + fn a_message_that_hands_over_to_a_tool_is_reported_when_it_ends() { + let mut translator = PiTurnTranslator::default(); + translator.begin("t-mid".into()).unwrap(); + translator.ingest(PiEvent::AgentStart); + let first = translator.ingest(assistant( + "toolUse", + "checking the logs", + true, + usage(1, 1, 0.1, 0), + )); + assert_eq!(said(&first.events), vec!["checking the logs".to_string()]); + assert!(!first.settled); + let last = translator.ingest(assistant("stop", "found it", false, usage(1, 1, 0.1, 0))); + assert!( + said(&last.events).is_empty(), + "the final message waits for settle" + ); + let settled = translator.ingest(PiEvent::AgentSettled); + assert_eq!(said(&settled.events), vec!["found it".to_string()]); + + // A turn that settles right after a handed-over message reports it + // exactly once. + let mut translator = PiTurnTranslator::default(); + translator.begin("t-pre".into()).unwrap(); + translator.ingest(PiEvent::AgentStart); + let preamble = translator.ingest(assistant( + "toolUse", + "running it", + true, + usage(1, 1, 0.1, 0), + )); + assert_eq!(said(&preamble.events), vec!["running it".to_string()]); + let settled = translator.ingest(PiEvent::AgentSettled); + assert!(said(&settled.events).is_empty(), "{:?}", settled.events); + } + #[test] fn low_level_boundaries_and_retry_do_not_complete_before_settled() { let mut translator = PiTurnTranslator::default(); diff --git a/crates/ccteam-harness/tests/pi_rpc_test.rs b/crates/ccteam-harness/tests/pi_rpc_test.rs index 58187301..fe7944dc 100644 --- a/crates/ccteam-harness/tests/pi_rpc_test.rs +++ b/crates/ccteam-harness/tests/pi_rpc_test.rs @@ -417,11 +417,32 @@ async fn settled_terminal_routing_usage_context_and_directives() { (event, kind) => panic!("{message}: unexpected {event:?} / {kind:?}"), } if message == "tool-preamble" { - assert!(!events.iter().any(|event| matches!( - event, - ThreadEvent::ItemCompleted { item } - if matches!(item.details, ThreadItemDetails::AgentMessage(_)) - ))); + // What the agent said before handing over to a tool is part of + // what it said (#209): reported once, ahead of the boundary โ€” + // never dropped because a later message would overwrite it. + let said: Vec<_> = events + .iter() + .enumerate() + .filter_map(|(at, event)| match event { + ThreadEvent::ItemCompleted { item } => match &item.details { + ThreadItemDetails::AgentMessage(text) => Some((at, text.clone())), + _ => None, + }, + _ => None, + }) + .collect(); + let boundary = events + .iter() + .position(|event| matches!(event, ThreadEvent::TurnCompleted { .. })) + .expect("tool-preamble completes"); + assert_eq!( + said.iter() + .map(|(_, text)| text.as_str()) + .collect::>(), + vec!["I will run a tool"], + "{events:?}" + ); + assert!(said[0].0 < boundary, "{events:?}"); } } diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index 28b97ff5..8b45b89b 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -7125,9 +7125,27 @@ impl Gateway { } }; + // Held-text release timer, armed only while a structured turn + // holds something it has said. The hold may delay a message by + // one heartbeat, never longer: waiting for the NEXT vendor event + // to notice the deadline left a line said just before a silent + // twenty-minute tool call unsent until the tool returned (#209). + let hold_deadline = held_since + .filter(|_| structured_turn_open && !pending_answers.is_empty()) + .map(|held| held + heartbeat_interval); + tokio::select! { biased; - maybe = events.next(), if streaming => { + wake = next_pump_wake(&mut events, hold_deadline), if streaming => { + // A due hold enters the SAME delivery path a vendor + // event does, as an inert marker that skips everything + // a vendor event means (liveness, heartbeat rows, + // accounting): the clock ran out, the vendor did + // nothing. + let (maybe, vendor_event) = match wake { + PumpWake::Vendor(maybe) => (maybe, true), + PumpWake::HoldDue => (Some(hold_due_marker()), false), + }; let Some(evt) = maybe else { streaming = false; if reporting { @@ -7212,6 +7230,7 @@ impl Gateway { } continue; }; + if vendor_event { // First event on a rebuilt attachment โ€” but only a // non-`Error` one proves it. A failed re-dial surfaces as // a single `Error` followed by the stream ending again, @@ -7720,6 +7739,11 @@ impl Gateway { } } } + } + // What the turn's boundary may name as its conclusion + // when the vendor names none: the last message of a + // folded batch (see `fold_held`). + let mut held_last: Option = None; let mut answer_texts = match &evt { ThreadEvent::ItemCompleted { item } => match &item.details { ThreadItemDetails::AgentMessage(text) if !text.is_empty() => { @@ -7736,11 +7760,19 @@ impl Gateway { _ => Vec::new(), }, ThreadEvent::TurnCompleted { .. } => { - std::mem::take(&mut pending_answers) + let (text, last) = fold_held(std::mem::take(&mut pending_answers)); + held_last = last; + text.into_iter().collect() } + // What the turn said before it failed is still + // what it said โ€” output that was produced and paid + // for. It goes out as an ordinary message AHEAD of + // the failure instead of being discarded with it. ThreadEvent::TurnFailed { err, .. } | ThreadEvent::Error(err) => { - pending_answers.clear(); - vec![err.message.clone()] + let (text, _) = fold_held(std::mem::take(&mut pending_answers)); + text.into_iter() + .chain(std::iter::once(err.message.clone())) + .collect() } _ => Vec::new(), }; @@ -7757,19 +7789,25 @@ impl Gateway { // so its text is a reply to a human, not narration; // - or the hold has outlived one liveness heartbeat, // the same window that says a working turn may not - // go silent to the outside longer than that. + // go silent to the outside longer than that โ€” + // noticed by the next vendor event or, when the + // vendor is silent, by the hold timer itself. + // What is released goes out FOLDED into one message: + // a working session narrates in short lines, and a + // chat that gets one message per heartbeat can follow + // it without being buzzed once per line. // Cost of releasing: this turn's rows carry no status // (`is_final_answer` needs the boundary), so the - // boundary pays it back as a closing status line. The - // total number of chat messages is unchanged โ€” the - // hold only ever delayed them. + // boundary pays it back as a closing status line. if answer_texts.is_empty() && !pending_answers.is_empty() - && (session.steered_this_turn.load(Ordering::SeqCst) + && (!vendor_event + || session.steered_this_turn.load(Ordering::SeqCst) || held_since .is_some_and(|held| held.elapsed() >= heartbeat_interval)) { - answer_texts = std::mem::take(&mut pending_answers); + let (text, _) = fold_held(std::mem::take(&mut pending_answers)); + answer_texts = text.into_iter().collect(); released_mid_turn = true; } if pending_answers.is_empty() { @@ -7819,6 +7857,23 @@ impl Gateway { turn_had_answer = true; } if is_turn_boundary && answer_texts.is_empty() { + // The boundary closes the turn's last progress card + // even when no answer rides it โ€” otherwise the card + // stays open, and the NEXT turn's steps edit it. + if progress_on && fold.has_activity() && !fold.done() { + fold.mark_done(); + if !flush_progress( + &tx, &session, &session_id, epoch, &fold, + &mut last_sent, &mut last_emit, &mut dirty, + ) { + break; + } + epoch = epoch.saturating_add(1); + } + fold = crate::progress::ProgressFold::new(); + dirty = false; + last_emit = None; + last_sent = None; let live_status = resolved_thread_status( Arc::clone(&session.adapter), session.thread.clone(), @@ -7889,26 +7944,104 @@ impl Gateway { &status, ) }); - let owes_closing = released_mid_turn || !turn_had_answer; - let web_status_frame = chat_key.channel == "web" && owes_closing; - if web_status_frame || closing_line.is_some() { - let status_only = GatewayEvent { - id: format!("gateway-event-{session_id}-status-{vendor_turn}"), - channel: chat_key.channel, - chat_id: chat_key.chat_id, + // The durable half of the same receipt: every row this + // turn wrote went out mid-turn without a status, so + // without a closing row nothing on disk says the turn + // ended โ€” a reload showed no footer, and the completed + // turn count (status-bearing rows) came up short. + // Empty `assistant`: it says nothing new, and every + // reader of answers already skips such a row. + if released_mid_turn { + if let Some(dir) = project_dir.as_ref() { + seq = seq.saturating_add(1); + let record = ccteam_harness::execution::turns_mirror::TurnRecord { + exec_turn_id: thread_event_turn_id(&evt) + .map(str::to_string) + .or_else(|| open_exec_turn.clone()), + continues_exec_turn: open_continues_from.clone(), + turn_id: format!("{session_id}-{seq}"), + ts: chrono::Utc::now(), + vendor: vendor_str(session.vendor).to_string(), + role: session.role.clone(), + user: String::new(), + assistant: String::new(), + usage: turn_terminal_accounting(&evt) + .map(|(_, usage, _)| ccteam_harness::execution::turn_status::usage_value(usage)) + .unwrap_or(serde_json::Value::Null), + status: Some(status.clone()), + tool_calls: Vec::new(), + attachments: Vec::new(), + outcome: None, + error_kind: None, + error: None, + conclusion: match &evt { + ThreadEvent::TurnCompleted { conclusion, .. } => conclusion.clone(), + _ => None, + }, + }; + if let Err(err) = ccteam_harness::execution::turns_mirror::append_turn( + dir, + &session_id, + &record, + ) { + tracing::warn!( + session = %session_id, + error = %err, + "ccteam-im: failed to mirror a turn's closing row to turns.jsonl" + ); + } + } + } + let status_id = format!("gateway-event-{session_id}-status-{vendor_turn}"); + // The IM status line is text for a phone, and ONLY + // for the phone: on the web stream it rendered as a + // bubble holding nothing but a status line. + if let Some(line) = closing_line { + let receipt = GatewayEvent { + id: status_id.clone(), + channel: chat_key.channel.clone(), + chat_id: chat_key.chat_id.clone(), thread_ts: None, - content: closing_line.unwrap_or_default(), + content: line, kind: GatewayEventKind::Answer, attachments: Vec::new(), options: Vec::new(), - status: Some(status), + status: Some(status.clone()), sid: Some(session_id.clone()), slug: Some(session.project.clone()), }; - if !tx.send(status_only) { + if !tx.send_delivery_only(receipt) { break; } } + // Every boundary publishes exactly ONE status-bearing + // Answer: here, a content-less frame โ€” the one signal + // a reader has that the turn is over, since an Answer + // without a status is a message the turn said on its + // way (#209). Delivered to a web chat that is owed a + // receipt; published to every other reader. + let owes_closing = released_mid_turn || !turn_had_answer; + let frame = GatewayEvent { + id: status_id, + channel: chat_key.channel.clone(), + chat_id: chat_key.chat_id.clone(), + thread_ts: None, + content: String::new(), + kind: GatewayEventKind::Answer, + attachments: Vec::new(), + options: Vec::new(), + status: Some(status), + sid: Some(session_id.clone()), + slug: Some(session.project.clone()), + }; + let published = if chat_key.channel == "web" && owes_closing { + tx.send(frame) + } else { + tx.send_broadcast_only(frame) + }; + if !published { + break; + } } if !answer_texts.is_empty() || matches!(&evt, ThreadEvent::TurnCompleted { .. }) @@ -7916,24 +8049,47 @@ impl Gateway { let answer_count = answer_texts.len(); // The turn's conclusion rides its boundary event and belongs // to the FINAL answer row of the turn only (issue #196). + // The vendor's own marker first; else the last message of + // the batch this boundary folded โ€” the text after the + // last tool call, which is what a bounded excerpt should + // lead with (issue #196). Never a copy of the row itself. let turn_conclusion = match &evt { - ThreadEvent::TurnCompleted { conclusion, .. } => conclusion.clone(), + ThreadEvent::TurnCompleted { conclusion, .. } => { + conclusion.clone().or_else(|| held_last.clone()) + } _ => None, - }; + } + .filter(|conclusion| { + answer_texts + .last() + .is_none_or(|text| conclusion.trim() != text.trim()) + }); for (answer_index, text) in answer_texts.into_iter().enumerate() { let is_final_answer = is_turn_boundary && answer_index.saturating_add(1) == answer_count; + // A failure batch leads with what the turn said before + // it failed: that row is ordinary narration. Only the + // failure row itself closes the turn. let boundary_origin = match &evt { - ThreadEvent::TurnFailed { turn_id, .. } => { + ThreadEvent::TurnFailed { turn_id, .. } if is_final_answer => { Some(take_turn_origin(&session, Some(turn_id))) } - ThreadEvent::Error(_) => Some(take_turn_origin(&session, open_exec_turn.as_deref())), + ThreadEvent::Error(_) if is_final_answer => { + Some(take_turn_origin(&session, open_exec_turn.as_deref())) + } _ => None, }; // ----- ANSWER (or error) ----- - // Finalize this turn's status epoch first. + // Finalize this answer's status epoch first. A message + // the turn says on its way (#209) only SEALS the card โ€” + // the work above it is over, the turn is not โ€” so no + // reader takes an interim line for the end of the turn. if progress_on && fold.has_activity() && !fold.done() { - fold.mark_done(); + if is_final_answer || session.protocol.is_terminal() { + fold.mark_done(); + } else { + fold.seal(); + } if !flush_progress( &tx, &session, &session_id, epoch, &fold, &mut last_sent, &mut last_emit, &mut dirty, @@ -8046,7 +8202,7 @@ impl Gateway { // ANSWER +1)+ sid ๆดพ็”Ÿ,็จณๅฎšไธ”ๅฏ grep,้ž้šๆœบใ€‚ๅคฑ่ดฅ // ๅช warn,็ปไธ้˜ปๆ–ญๅ›žๅคๆŠ•้€’ใ€‚ if let Some(dir) = project_dir.as_ref() { - let failure = thread_event_failure(&evt); + let failure = thread_event_failure(&evt).filter(|_| is_final_answer); let record = ccteam_harness::execution::turns_mirror::TurnRecord { // Which EXECUTION turn produced this row. // A restart reconcile rebinds an @@ -8108,11 +8264,12 @@ impl Gateway { // any interim notes); an ordinary assistant // message is only remembered as the boundary // candidate flushed on `TurnCompleted` above. - let is_boundary_evt = matches!( - &evt, - ThreadEvent::TurnFailed { .. } - | ThreadEvent::Error(_) - ); + let is_boundary_evt = is_final_answer + && matches!( + &evt, + ThreadEvent::TurnFailed { .. } + | ThreadEvent::Error(_) + ); turn_covered.push(record.turn_id.clone()); if is_boundary_evt { let notes = turn_notes; @@ -8345,7 +8502,7 @@ impl Gateway { released_mid_turn = false; held_since = None; } - } else if progress_on { + } else if progress_on && vendor_event { // ----- PROGRESS (IM, unchanged) ----- // The fold drives the IM status string; its dirty // signal gates the throttled status edit exactly as @@ -18365,6 +18522,67 @@ fn async_event_text(evt: &ThreadEvent) -> Option { } } +/// What woke the event pump's stream branch: the vendor, or the deadline of +/// text it is holding (#209). +enum PumpWake { + /// The vendor stream's next item (`None` = the stream ended). + Vendor(Option), + /// Held text has waited one heartbeat with the vendor silent. + HoldDue, +} + +/// The vendor's next event, or [`PumpWake::HoldDue`] once `hold_deadline` +/// passes first. Both halves are cancel-safe (a stream's `next` and a sleep), +/// so losing the pump's outer `select!` to another branch drops nothing. +async fn next_pump_wake( + events: &mut futures::stream::BoxStream<'static, ThreadEvent>, + hold_deadline: Option, +) -> PumpWake { + let Some(deadline) = hold_deadline else { + return PumpWake::Vendor(events.next().await); + }; + tokio::select! { + biased; + evt = events.next() => PumpWake::Vendor(evt), + _ = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => PumpWake::HoldDue, + } +} + +/// The event a due hold is carried through the pump as. It matches no arm +/// that reads vendor facts โ€” no answer, no boundary, no accounting โ€” and the +/// pump skips every vendor-only step for it, so it can only release what is +/// already held. +fn hold_due_marker() -> ThreadEvent { + ThreadEvent::ItemUpdated { + item: ccteam_harness::ThreadItem { + id: String::new(), + details: ThreadItemDetails::Reasoning(String::new()), + }, + } +} + +/// Fold held messages into ONE delivery, paragraph by paragraph. Returns the +/// folded text (`None` when nothing was held) and, when more than one message +/// was folded, the last of them โ€” the text after the turn's last tool call, +/// which is what its boundary names as the conclusion when the vendor names +/// none (issue #196). +fn fold_held(held: Vec) -> (Option, Option) { + match held.len() { + 0 => (None, None), + 1 => (held.into_iter().next(), None), + _ => { + let last = held.last().cloned(); + let text = held + .iter() + .map(|part| part.trim()) + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n\n"); + (Some(text), last) + } + } +} + /// Canonical terminal failure carried by an event, independent of vendor. /// The ADAPTER's execution-turn id an event carries, when it has one. /// `Error` carries none โ€” the pump pairs it with the turn it knows is open. @@ -25363,22 +25581,14 @@ mod tests { .await .unwrap(); - let first_turn = [ - recv_answer(&mut events).await, - recv_answer(&mut events).await, - recv_answer(&mut events).await, - ]; - assert!(first_turn[..2].iter().all(|event| event.status.is_none())); + // #209 โ€” what a short turn said is held to its boundary and goes out + // FOLDED: one message, every line in order, one status tail. + let ev = &recv_answer(&mut events).await; + let order = ["checkpoint 1\n\n", "checkpoint 2\n\n", "echo: hello"] + .map(|line| ev.content.find(line)); assert!( - first_turn[..2] - .iter() - .all(|event| !event.content.contains("\n\nโ†’ ")), - "interim messages carry no status tail: {first_turn:?}" - ); - let ev = &first_turn[2]; - assert!( - ev.content.contains("echo: hello"), - "answer still carries the real reply text: {}", + order.iter().all(Option::is_some) && order.is_sorted(), + "the folded answer carries every message, in order: {}", ev.content ); // The echo is the LAST block of the answer. Split it off rather than @@ -25417,36 +25627,36 @@ mod tests { .iter() .filter(|turn| !turn.assistant.is_empty()) .collect(); - assert_eq!(assistant.len(), 3); - assert!(assistant[..2].iter().all(|turn| turn.status.is_none())); + assert_eq!(assistant.len(), 1, "one folded row: {assistant:?}"); assert_eq!( - assistant[2].status.as_ref().map(|status| status.turn), + assistant[0].status.as_ref().map(|status| status.turn), Some(1) ); - assert_ne!(assistant[2].usage, serde_json::Value::Null); + assert!( + assistant[0] + .conclusion + .as_deref() + .is_some_and(|conclusion| conclusion.ends_with("echo: hello") + && !conclusion.contains("checkpoint")), + "a folded row names its last message as the conclusion: {assistant:?}" + ); + assert_ne!(assistant[0].usage, serde_json::Value::Null); gateway .handle_text("mock", "chat-1", "alice", "second") .await .unwrap(); - let second_turn = [ - recv_answer(&mut events).await, - recv_answer(&mut events).await, - recv_answer(&mut events).await, - ]; - assert!(second_turn[..2].iter().all(|event| event.status.is_none())); + let second = recv_answer(&mut events).await; assert_eq!( - second_turn[2].status.as_ref().map(|status| status.turn), + second.status.as_ref().map(|status| status.turn), Some(2), "three assistant messages still count as one vendor turn" ); assert_eq!( - second_turn - .iter() - .filter(|event| event.content.contains("\n\nโ†’ ")) - .count(), + second.content.matches("\n\nโ†’ ").count(), 1, - "one IM status tail per vendor turn" + "one IM status tail per vendor turn: {}", + second.content ); } @@ -26150,6 +26360,14 @@ mod tests { recv_answer(&mut broadcast).await, ]; assert!(web_broadcast.iter().all(|event| event.channel == "web")); + // #209 โ€” the boundary publishes its ONE status-bearing Answer (the + // interim lines above carry none): a content-less frame, never the + // mirror's text. + let boundary = recv_answer(&mut broadcast).await; + assert!( + boundary.content.is_empty() && boundary.status.is_some(), + "{boundary:?}" + ); while let Ok(event) = broadcast.try_recv() { assert!( !matches!(event.kind, GatewayEventKind::Answer), @@ -27505,12 +27723,129 @@ mod tests { assert!(answer.status.is_none(), "a mid-turn row carries no status"); } + /// #209 โ€” the hold is bounded by a CLOCK, not by the vendor's next event. + /// A session that says "running the suite now" and then sits in a silent + /// twenty-minute tool produces no event at all; waiting for one to notice + /// the expired hold kept the line unsent until the tool returned. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_silent_vendor_cannot_hold_what_its_session_said() { + let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude).with_turn_started()); + let proj = tempfile::TempDir::new().unwrap(); + let mut gateway = Gateway::new(fake, "alpha", proj.path()); + let heartbeat = std::time::Duration::from_millis(150); + gateway.set_turn_heartbeat_interval(heartbeat); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::(); + gateway.set_event_sink(tx); + + gateway + .handle_text("mock", "chat-1", "alice", "/new claude reviewer") + .await + .unwrap(); + let mut events = gateway.subscribe_events(); + let asked = Instant::now(); + gateway + .handle_text("mock", "chat-1", "alice", "long job") + .await + .unwrap(); + + // The fake says one line and then nothing, and never ends the turn. + let answer = recv_answer(&mut events).await; + assert!(answer.content.contains("echo: long job"), "{answer:?}"); + assert!( + answer.status.is_none(), + "the turn is still running: {answer:?}" + ); + assert!( + asked.elapsed() >= heartbeat, + "it was held for one heartbeat first, to fold what follows" + ); + assert!(gateway.session_turn_in_flight("s1")); + } + + /// #209 โ€” what a turn said before it failed was produced and paid for; the + /// failure used to `clear()` it with the hold. It goes out as ordinary + /// narration AHEAD of the failure row, and only the failure row is marked + /// failed. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_failed_turn_still_delivers_what_it_said_first() { + let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude)); + let proj = tempfile::TempDir::new().unwrap(); + let mut gateway = Gateway::new(fake.clone(), "alpha", proj.path()); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::(); + gateway.set_event_sink(tx); + gateway + .handle_text("mock", "chat-1", "alice", "/new claude reviewer") + .await + .unwrap(); + let mut events = gateway.subscribe_events(); + let identity = "alpha-reviewer-s1".to_string(); + { + let mut queued = fake.events.lock().await; + queued.push_back(( + identity.clone(), + ThreadEvent::TurnStarted { + turn_id: "t-fail".into(), + opening: ccteam_harness::TurnOpening::Submitted, + }, + )); + queued.push_back(( + identity.clone(), + agent_msg( + |item| ThreadEvent::ItemCompleted { item }, + "checking the logs", + ), + )); + queued.push_back(( + identity.clone(), + ThreadEvent::TurnFailed { + turn_id: "t-fail".into(), + err: ccteam_harness::ThreadErrorEvent { + kind: "vendor_error".into(), + message: "the provider fell over".into(), + }, + usage: Default::default(), + model: None, + }, + )); + } + fake.wake(&identity); + + let said = recv_answer(&mut events).await; + assert_eq!(said.content, "checking the logs", "{said:?}"); + assert!(said.status.is_none(), "{said:?}"); + let failed = recv_answer(&mut events).await; + assert!( + failed.content.contains("the provider fell over"), + "{failed:?}" + ); + assert!( + failed.status.is_some(), + "the failure is the boundary: {failed:?}" + ); + + let rows = + ccteam_harness::execution::turns_mirror::read_all_turns(proj.path(), "s1").unwrap(); + let rows: Vec<_> = rows + .iter() + .filter(|row| !row.assistant.is_empty()) + .collect(); + assert_eq!(rows.len(), 2, "{rows:?}"); + assert_eq!(rows[0].assistant, "checking the logs"); + assert_eq!( + rows[0].outcome, None, + "narration is not the failure: {rows:?}" + ); + assert_eq!(rows[1].outcome.as_deref(), Some("failed"), "{rows:?}"); + } + /// GitHub #206 โ€” text released mid-turn leaves the boundary with nothing /// to attach the turn's status line to, so the boundary owes the chat its /// CLOSING receipt: a chat that read live narration for hours must still /// learn the turn ended, with the model / ctx / turn / cost it ended on. /// The receipt is the status line ALONE โ€” never a second copy of text the - /// chat already has. + /// chat already has. #209 โ€” and the line is phone text, delivered to the + /// IM chat only; the web stream gets the same boundary as a content-less + /// status frame, not a bubble holding nothing but a status line. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_turn_released_mid_flight_closes_with_its_status_line() { let fake = Arc::new( @@ -27530,37 +27865,61 @@ mod tests { let proj = tempfile::TempDir::new().unwrap(); let mut gateway = Gateway::new(fake, "alpha", proj.path()); gateway.set_turn_heartbeat_interval(std::time::Duration::ZERO); - let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); gateway.set_event_sink(tx); gateway .handle_text("mock", "chat-1", "alice", "/new claude reviewer") .await .unwrap(); + while rx.try_recv().is_ok() {} let mut events = gateway.subscribe_events(); gateway .handle_text("mock", "chat-1", "alice", "long job") .await .unwrap(); - let answer = recv_answer(&mut events).await; - assert!(answer.content.contains("echo: long job")); - let closing = recv_answer(&mut events).await; + // The IM chat: the released text, then the status line alone. + let delivered = recv_sink_answers(&mut rx, 2).await; + assert!(delivered[0].content.contains("echo: long job")); + assert!(delivered[0].status.is_none(), "{delivered:?}"); + let receipt = &delivered[1]; assert!( - closing + receipt .content .starts_with("โ†’ alpha/s1 (reviewer) ยท claude ยท claude-sonnet-4-6"), - "the boundary pays back the status line: {closing:?}" + "the boundary pays back the status line: {receipt:?}" ); assert!( - !closing.content.contains("echo: long job"), - "and never re-sends text the chat already has: {closing:?}" + !receipt.content.contains("echo: long job"), + "and never re-sends text the chat already has: {receipt:?}" ); assert_eq!( - closing.status.as_ref().map(|status| status.turn), + receipt.status.as_ref().map(|status| status.turn), Some(1), "the receipt carries the ended turn's own status" ); + + // The web stream: the same text, then a content-less boundary frame. + let answer = recv_answer(&mut events).await; + assert!(answer.content.contains("echo: long job")); + let closing = recv_answer(&mut events).await; + assert!( + closing.content.is_empty(), + "no status-line bubble on the web: {closing:?}" + ); + assert_eq!(closing.status.as_ref().map(|status| status.turn), Some(1)); + + // โ€ฆand the ledger says the turn ended: a status-bearing closing row. + let rows = + ccteam_harness::execution::turns_mirror::read_all_turns(proj.path(), "s1").unwrap(); + let closing_row = rows.last().expect("rows"); + assert!(closing_row.assistant.is_empty(), "{rows:?}"); + assert_eq!( + closing_row.status.as_ref().map(|status| status.turn), + Some(1), + "{rows:?}" + ); } /// A turn that DIES must close its busy window on disk, not just in memory. diff --git a/crates/ccteam-im/src/mcp/dispatch.rs b/crates/ccteam-im/src/mcp/dispatch.rs index dda7faab..4da962c7 100644 --- a/crates/ccteam-im/src/mcp/dispatch.rs +++ b/crates/ccteam-im/src/mcp/dispatch.rs @@ -12048,13 +12048,18 @@ mod session_tool_tests { ); assert_eq!(r["status"], json!("completed"), "narrated: {r}"); let result = r["result_text"].as_str().unwrap(); + // #209 โ€” a short turn's messages reach the boundary folded into ONE + // answer (every line, in order), so the wait returns the whole turn โ€” + // ending on its final message, never cut off at the interim note. assert!( - result.contains("echo: do the wave"), - "wait returns the FINAL answer, not the interim note: {result}" + result.trim_end().ends_with("echo: do the wave"), + "wait returns the turn's answer through its FINAL message: {result}" ); assert!( - !result.contains("interim narration checkpoint"), - "the interim note must not be mistaken for the result: {result}" + result + .find("interim narration checkpoint") + .is_none_or(|at| at < result.find("echo: do the wave").unwrap()), + "the interim note is folded ahead of the result, never mistaken for it: {result}" ); // Async leg (notify path) on a FRESH narrating child: exactly ONE diff --git a/crates/ccteam-im/src/progress.rs b/crates/ccteam-im/src/progress.rs index 4232446e..95889a06 100644 --- a/crates/ccteam-im/src/progress.rs +++ b/crates/ccteam-im/src/progress.rs @@ -217,6 +217,9 @@ pub struct ProgressFold { /// reply) โ€” shown as a head state, never sent as its own answer. drafting: bool, done: bool, + /// Finalized by a message the turn said on its way, not by the turn's end + /// (see [`Self::seal`]). + sealed: bool, tool_total: usize, file_total: usize, } @@ -243,6 +246,15 @@ impl ProgressFold { self.done = true; } + /// Finalize the epoch because the turn said something mid-way: the card + /// is final (no further edits) but the turn is still running, so it must + /// not read `done` โ€” `โ†ณ N tools ยท M files`, the work that led to the + /// message below it (#209). + pub fn seal(&mut self) { + self.done = true; + self.sealed = true; + } + fn bump(&mut self, cat: Category, raw_name: &str) { // Unknown tools fold under the wrench but keep their own label. let label: String = if cat.label == "tool" { @@ -376,6 +388,9 @@ impl ProgressFold { /// Render the current status text (โ‰ค [`MAX_LINES`] lines). pub fn render(&self) -> String { + if self.sealed { + return format!("โ†ณ {} tools ยท {} files", self.tool_total, self.file_total); + } if self.done { return format!( "โœ… done ยท {} tools ยท {} files", @@ -519,6 +534,15 @@ mod tests { assert!(r.contains("โœ๏ธ edit ร—1"), "got: {r}"); } + #[test] + fn a_sealed_segment_never_reads_done() { + let mut f = ProgressFold::new(); + f.apply(&started_tool("t1", "Bash", json!({"command": "ls"}))); + f.seal(); + assert!(f.done(), "a sealed card is final: nothing edits it again"); + assert_eq!(f.render(), "โ†ณ 1 tools ยท 0 files"); + } + #[test] fn done_renders_summary_counts() { let mut f = ProgressFold::new(); From 9889a51bfd264325f10e60dea029bf02d1cc10c5 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Fri, 25 Sep 2026 06:16:14 +0000 Subject: [PATCH 03/37] docs: a running turn's words reach IM and web as it runs (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Usage (en/cn) and the DSH plugin guide describe the current delivery: every harness hands the chat what a session says while its turn runs, folded to at most one message a minute; a mid-turn message seals the progress card (`โ†ณ โ€ฆ`) and only the turn's end reads `โœ… done`; the web keeps the turn running until it ends; a failed turn still delivers what it said first. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/dsh-plugin-cn.md | 3 ++- docs/dsh-plugin.md | 4 +++- docs/usage-cn.md | 4 ++-- docs/usage.md | 4 ++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/dsh-plugin-cn.md b/docs/dsh-plugin-cn.md index 1cada4b1..442a90b9 100644 --- a/docs/dsh-plugin-cn.md +++ b/docs/dsh-plugin-cn.md @@ -214,7 +214,8 @@ harness ็š„็›ฎๅฝ•้‡Œ้€‰**ๆจกๅž‹**ๅ’Œ **effort**๏ผˆ็•™็ฉบๅณ harness ้ป˜่ฎค๏ผ‰๏ผŒ **ๅฏน่ฏ**๏ผš็”จๆˆทๆถˆๆฏๆ˜ฏๆฐ”ๆณก๏ผ›ๅŠฉๆ‰‹ๆถˆๆฏ็”จ DSH ่‡ชๅทฑ็š„ๆธฒๆŸ“ๅ™จๆ˜พ็คบ Markdown๏ผŒๅ›žๅˆ ่ฟ›่กŒไธญๅฎžๆ—ถๆตๅผ๏ผŒๅ›žๅˆ็š„ๆญฅ้ชค๏ผˆๅทฅๅ…ท่ฐƒ็”จใ€ๅ‘ฝไปคใ€ๆ–‡ไปถๆ”นๅŠจใ€ๆœ็ดขใ€ๆ€่€ƒ๏ผ‰ไปฅ็ดงๅ‡‘ -่กŒๅˆ—ๅœจๆ–‡ๆœฌไธŠๆ–นโ€”โ€”่ฟ›่กŒไธญ่ฝฌๅœˆ๏ผŒๅฎŒๆˆๅ˜็ปฟใ€‚้œ€่ฆไบบๅ†ณๅฎš็š„ๆ็คบๆ˜พ็คบไธบๅธฆ้€‰้กน็š„ +่กŒๅˆ—ๅœจๆ–‡ๆœฌไธŠๆ–นโ€”โ€”่ฟ›่กŒไธญ่ฝฌๅœˆ๏ผŒๅฎŒๆˆๅ˜็ปฟใ€‚ไผš่ฏๅœจๅ›žๅˆไธญ้€”่ฏด็š„่ฏๅณๆ—ถๆˆไธบไธ€ๆก +ๅ›žๅค๏ผŒๅ›žๅˆ็…งๅธธ่ฟ›่กŒ๏ผˆ่ฟ›่กŒไธญ็Šถๆ€ไธŽ**ๅœๆญข**ไฟๆŒๅˆฐๅ›žๅˆ็œŸๆญฃ็ป“ๆŸ๏ผ‰ใ€‚้œ€่ฆไบบๅ†ณๅฎš็š„ๆ็คบๆ˜พ็คบไธบๅธฆ้€‰้กน็š„ ๅก็‰‡๏ผŒ็‚นไธ€ไธชๅณๅ›ž็ญ”ใ€‚ๆŽ’้˜Ÿไผš่ฏดๆ˜ŽๆŽ’ๅœจ่ฐๅŽ้ข๏ผ›ๅคฑ่ดฅๆ˜พ็คบ้”™่ฏฏ็ฑปๅž‹๏ผ›ไผš่ฏ็”Ÿๅ‘ฝๅ‘จ ๆœŸไธŽๅง”ๆดพไบ‹ไปถๆ˜พ็คบไธบๅฐๅญ—ๆ็คบใ€‚ๅ›žๅˆ่ฟ›่กŒไธญๅ‘้€้”ฎๅ˜ๆˆ**ๅœๆญข**๏ผˆ้ž็ ดๅๆ€งๆ‰“ๆ–ญ๏ผŒ ไผš่ฏไธŠไธ‹ๆ–‡ไฟ็•™๏ผ‰ใ€‚**Enter** ๅ‘้€๏ผŒ**Shift+Enter** ๆข่กŒ๏ผŒๅผ€ๅคด่พ“ๅ…ฅ `/` ไผš diff --git a/docs/dsh-plugin.md b/docs/dsh-plugin.md index 6566c660..22ade3a5 100644 --- a/docs/dsh-plugin.md +++ b/docs/dsh-plugin.md @@ -276,7 +276,9 @@ error is shown verbatim under the box. **Conversation**: user turns are bubbles; assistant turns render Markdown with DSHโ€™s own renderer, streaming while the turn runs, with the turnโ€™s steps (tool calls, commands, file edits, searches, thinking) as compact rows above -the text โ€” spinning while running, green when done. Human-in-the-loop prompts +the text โ€” spinning while running, green when done. What the session says +mid-turn appears as its own reply while the turn keeps running (working state +and **stop** stay up until the turn actually ends). Human-in-the-loop prompts appear as a card with the choices; pick one to answer. Queued turns say what they are queued behind; failures show their error kind; session lifecycle and delegation events show as small notes. While a turn runs the send button diff --git a/docs/usage-cn.md b/docs/usage-cn.md index d0080b84..16aba81c 100644 --- a/docs/usage-cn.md +++ b/docs/usage-cn.md @@ -349,8 +349,8 @@ list ๆฏ่กŒๅฝขๅฆ‚ `d3 ยท s12 ยท 2026-07-26 09:00 ยท ้ข„่งˆโ€ฆ`(ๅคฑ่ดฅไผšๅธฆๅŽŸ - **ไธๅธฆๅ‰็ผ€็š„ๆถˆๆฏ** โ†’ ๅ‘็ป™ๅฝ“ๅ‰ไผš่ฏใ€‚ - **้ž็ฝ‘ๅ…ณ็š„ `/ๅ‘ฝไปค`**(`/compact`ใ€`/clear`ใ€`/goal`ใ€`/model` โ€ฆ)โ†’ ้€ไผ ็ป™ๅฝ“ๅ‰ agent;ๅผน็ช—ๅž‹(ๅฆ‚ `/model`)ไผšๅผน**้€‰้กนๆŒ‰้’ฎ**,็‚นไธ€ไธ‹ๅณๅบ”็”จใ€‚Claude ๅชๅœจ็ฉบ้—ฒๆ—ถ่งฃๆžๆ–œๆ ๅ‘ฝไปค,turn ่ฟ›่กŒไธญๅ‘ๅ‡บ็š„ไผšๆŽ’้˜Ÿใ€ๅœจ่ฏฅ turn ็ป“ๆŸๅŽ่‡ชๅŠจๆŠ•้€’(ๆœ‰ๅ›žๆ‰ง);turn ไธญๅ‘ๆ™ฎ้€šๆ–‡ๆœฌไปๆ˜ฏๆ’่ฏ่ฟ›ๅฝ“ๅ‰ turnใ€‚`/goal` ๆ˜ฏๅ„ harness ่‡ชๅทฑ็š„็›ฎๆ ‡ๆœบๅˆถ,่กŒไธบไนŸ้šๅฎƒ:codex ไธ€ๆ—ฆ่ฎพไบ†็›ฎๆ ‡ๅฐฑ**่‡ชๅทฑๅผ€ turn ๆŒ็ปญๆŽจ่ฟ›**,็›ดๅˆฐๅฎƒ่‡ชๅทฑๆŠฅ่พพๆˆ(`/goal clear` ๅœ);`/status` ไธคๅฎถ้ƒฝๆ˜พ็คบ ๐ŸŽฏ ็›ฎๆ ‡,vendor ๅœๆญขๆŽจ่ฟ›ๆ—ถ่ฟ˜ไผš็ป™ๅ‡บๅŽŸๅ› ใ€‚ - **ๅ‘ๅ›พ / ๅ‘ๆ–‡ไปถ + ไธ€ๅฅ่ฏดๆ˜Ž** โ†’ agent ่‡ชๅŠจ่ฏปๅ–(ๆŠฅ้”™ๆˆชๅ›พใ€ๆ—ฅๅฟ—้ƒฝ่กŒ);agent ไนŸ่ƒฝๆŠŠๆ–‡ไปถๅ‘ๅ›žไฝ ็š„ chatใ€‚ -- **ๅ›žๅˆ่ฟ›่กŒไธญ** โ†’ ไธ€ๆกๆดป็š„่ฟ›ๅบฆๆถˆๆฏ(ๅฝขๅฆ‚ `โณ workingโ€ฆ ยท ๐Ÿ”ง bash ร—3`),ๆœ€็ปˆ็ญ”ๆกˆๅ•็‹ฌๆˆๆก(ไผšๆ้†’);่ถ…้•ฟๅ›ž็ญ”่‡ชๅŠจๅˆ†็‰‡;agent ไธญ้€”่ฆไฝ ๆ‹ฟไธปๆ„ๆ—ถไผšๅผน**้€‰้กนๆŒ‰้’ฎ**,็‚นไธ€ไธ‹ๅ–‚ๅ›ž็ญ”ๆกˆใ€ๅฎƒ็ปง็ปญๅพ€ไธ‹่ท‘ใ€‚ -- **้•ฟ turn ็ปไธๆฒ‰้ป˜** โ†’ ไธ€ไธช turn ็š„ๅคšๆกๆถˆๆฏๅนณๆ—ถๆ”’ๅˆฐ่พน็•Œไธ€่ตทๆŠ•(่ฟ™ๆ ทๆœ€ๅŽไธ€ๆก่ƒฝๅธฆ็Šถๆ€่กŒ),ไฝ†่ฟ™ไธช็ญ‰ๅพ…ๆœ‰็•Œ:ไฝ ๅœจ turn ไธญๅ‘็š„ๆถˆๆฏ่ขซๅนถ่ฟ›่ฟ™ไธช turn ไน‹ๅŽ,ไผš่ฏไบงๅ‡บ็š„ๆ–‡ๆœฌ**็ซ‹ๅˆป**ๆŠ•็ป™ไฝ (ไฝ ้—ฎไบ†ใ€ๅฎƒ็ญ”ไบ†,ไธๅฟ…็ญ‰ไธ€ไธชๅฏ่ƒฝๅ‡ ๅฐๆ—ถๅŽๆ‰ๆฅใ€็”š่‡ณ่ขซ Stop hook ไธ€็›ดๆ‹’ๅœ็š„่พน็•Œ);turn ่ฟ˜ๅœจ่ท‘่€ŒๆŸๆฎตๆ–‡ๆœฌ่ขซๅŽ‹่ฟ‡ไธ€ๅˆ†้’Ÿ,ไนŸไผšๅ•็‹ฌๆŠ•ๅ‡บใ€‚่ฟ™ๆ ทๆŠ•่ฟ‡็š„ turn,็ป“ๆŸๆ—ถๅช่กฅไธ€ๆก็Šถๆ€่กŒ,็ปไธ้‡ๅคไฝ ๅทฒ็ป่ฏปๅˆฐ็š„ๆญฃๆ–‡ใ€‚ +- **ๅ›žๅˆ่ฟ›่กŒไธญ** โ†’ ไธ€ๆกๆดป็š„่ฟ›ๅบฆๆถˆๆฏ(ๅฝขๅฆ‚ `โณ workingโ€ฆ ยท ๐Ÿ”ง bash ร—3`);ไผš่ฏไธญ้€”่ฏดไบ†่ฏ,่ฟ™ๅผ ๅกๅฐฑๆ”ถๆˆ `โ†ณ 3 tools ยท 1 files`ใ€่ฏไน‹ๅŽๅฆ่ตทไธ€ๅผ ,turn ็š„ๆœ€ๅŽไธ€ๅผ ๅ†™ `โœ… done`ใ€‚ๆœ€็ปˆ็ญ”ๆกˆๅ•็‹ฌๆˆๆก(ไผšๆ้†’);่ถ…้•ฟๅ›ž็ญ”่‡ชๅŠจๅˆ†็‰‡;agent ไธญ้€”่ฆไฝ ๆ‹ฟไธปๆ„ๆ—ถไผšๅผน**้€‰้กนๆŒ‰้’ฎ**,็‚นไธ€ไธ‹ๅ–‚ๅ›ž็ญ”ๆกˆใ€ๅฎƒ็ปง็ปญๅพ€ไธ‹่ท‘ใ€‚ +- **้•ฟ turn ็ปไธๆฒ‰้ป˜** โ†’ ไผš่ฏ่ฏด็š„่ฏๅœจ turn ่ฟ˜ๅœจ่ท‘ๆ—ถๅฐฑๅˆฐ chat,ๆ‰€ๆœ‰ harness ้ƒฝไธ€ๆ ท(claudeใ€codexใ€grokใ€opencodeใ€kimiใ€dshใ€pi)ใ€‚ไธ€ไธช turn ็š„ๅคšๆกๆถˆๆฏไผšๅ…ˆ็Ÿญๆš‚ๆ”’ไฝใ€ๅˆๆˆไธ€ๆกๆŠ•ๅ‡บ,ๆ‰€ไปฅ็Ÿญ turn ไปๆ˜ฏไธ€ๆกๅธฆ็Šถๆ€่กŒ็š„ๅ›žๅค;ไฝ†่ฟ™ไธช็ญ‰ๅพ…ๆœ‰็•Œ:ไฝ ๅœจ turn ไธญๅ‘็š„ๆถˆๆฏ่ขซๅนถ่ฟ›่ฟ™ไธช turn ไน‹ๅŽ,ไผš่ฏไบงๅ‡บ็š„ๆ–‡ๆœฌ**็ซ‹ๅˆป**ๆŠ•็ป™ไฝ (ไฝ ้—ฎไบ†ใ€ๅฎƒ็ญ”ไบ†,ไธๅฟ…็ญ‰ไธ€ไธชๅฏ่ƒฝๅ‡ ๅฐๆ—ถๅŽๆ‰ๆฅใ€็”š่‡ณ่ขซ Stop hook ไธ€็›ดๆ‹’ๅœ็š„่พน็•Œ);ๆ”’ๆปกไธ€ๅˆ†้’Ÿ็š„่ฏไนŸไผš่‡ชๅทฑๆŠ•ๅ‡บ โ€”โ€” ๅ“ชๆ€•ไผš่ฏๆญฃๅกๅœจไธ€ไธช้•ฟๆ—ถ้—ดไธๅ‡บๅฃฐ็š„ๅทฅๅ…ท้‡Œ โ€”โ€” ๆ‰€ไปฅ้•ฟ turn ่ฏป่ตทๆฅๆ˜ฏไธ€ๆฎตไธ่ถ…่ฟ‡ๆฏๅˆ†้’Ÿไธ€ๆก็š„ๅฎžๅ†ตใ€‚่ฟ™ๆ ทๆŠ•่ฟ‡็š„ turn,็ป“ๆŸๆ—ถๅช่กฅไธ€ๆก็Šถๆ€่กŒ,็ปไธ้‡ๅคไฝ ๅทฒ็ป่ฏปๅˆฐ็š„ๆญฃๆ–‡ใ€‚web ๆŽงๅˆถๅฐๅŒๆญฅๆ˜พ็คบ่ฟ™ไบ›ๆถˆๆฏ,ๅนถไธ€็›ดๆŠŠ turn ๆ ‡ไธบ่ฟ›่กŒไธญ(ๅœๆญขๆŒ‰้’ฎๅฏ็”จ),็›ดๅˆฐๅฎƒ็œŸๆญฃ็ป“ๆŸใ€‚turn ๅคฑ่ดฅๆ—ถ,ๅคฑ่ดฅๅ‰่ฏด่ฟ‡็š„่ฏ็…งๆ ทๆŠ•ๅ‡บใ€‚ ### ไบบๅทฅๆ‰นๅ‡†(HITL) diff --git a/docs/usage.md b/docs/usage.md index ee0107c1..512d938a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -368,8 +368,8 @@ List lines look like `d3 ยท s12 ยท 2026-07-26 09:00 ยท previewโ€ฆ` (failed rows - **Messages without a prefix** go to the current session. - **Non-gateway slash commands** (`/compact`, `/clear`, `/goal`, `/model`, etc.) pass through to the current agent. Picker commands such as `/model` become option buttons. Claude executes a slash command only when idle, so one sent while a turn is running is queued and delivered right after that turn ends (you get a receipt); plain text sent mid-turn is still steered into the running turn. `/goal` is the vendor's own goal, so it behaves like the vendor's: Codex starts working toward the objective on its own as soon as it is set and keeps going until it reports the goal met (`/goal clear` stops it), and `/status` shows the goal for both harnesses โ€” with the reason when the vendor has stopped advancing it. - **Images or files plus a note** are read by the agent automatically (screenshots and logs work well). Agents can send files back to chat. -- **During an in-flight turn,** ccteam keeps a live progress message such as `working... ยท bash x3`. The final answer arrives separately and long answers are chunked. If the agent asks a question, it appears as option buttons; tap one and the agent continues. -- **A long turn is never silent.** A turn's messages are normally batched to its end so the last one can carry the status line, but that wait is bounded: text a session produces after it absorbed your mid-turn message goes to the chat at once (you asked, it answered โ€” nothing waits for a boundary that may be hours away, or that a Stop hook may refuse entirely), and anything held longer than a minute while the turn runs on is delivered on its own. When a turn's text went out that way, the turn's end arrives as the status line alone, never as a second copy of what you already read. +- **During an in-flight turn,** ccteam keeps a live progress message such as `working... ยท bash x3`. When the session says something mid-turn, that card is closed as `โ†ณ 3 tools ยท 1 files` and a fresh one follows the message; the turn's last card reads `โœ… done`. The final answer arrives separately and long answers are chunked. If the agent asks a question, it appears as option buttons; tap one and the agent continues. +- **A long turn is never silent.** What a session says reaches the chat while its turn is still running, on every harness (claude, codex, grok, opencode, kimi, dsh, pi). A turn's messages are held briefly and folded into one, so a short turn still arrives as a single reply carrying the status line; the hold is bounded: text a session produces after it absorbed your mid-turn message goes out at once (you asked, it answered โ€” nothing waits for a boundary that may be hours away, or that a Stop hook may refuse entirely), and anything held for a minute goes out on its own, even while the session sits in a long silent tool call โ€” so a long turn reads as a running commentary of at most one message a minute. When a turn's text went out that way, its end arrives as the status line alone, never as a second copy of what you already read. The web console shows the same messages as they arrive and keeps the turn marked running (Stop stays available) until the turn actually ends. A turn that fails still delivers what it said before the failure. ### Human-in-the-Loop (HITL) From 615eb013a516dabe9bb710086220c677acf716c3 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Fri, 25 Sep 2026 06:18:36 +0000 Subject: [PATCH 04/37] fix(im): only an open structured turn seals a card mid-turn (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stream that opens no structured turn delivers its answer immediately, and that answer is the only end the pump will ever see โ€” sealing its card `โ†ณ` left such a turn with no `โœ… done` at all (im_progress_test caught it). Seal only while a structured turn is known to continue; pin the card sequence of a structured turn end to end (sealed card, the message, the boundary's done). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ccteam-im/src/gateway.rs | 103 ++++++++++++++++++++++++++++++-- 1 file changed, 99 insertions(+), 4 deletions(-) diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index 8b45b89b..85ca9d94 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -8081,11 +8081,17 @@ impl Gateway { }; // ----- ANSWER (or error) ----- // Finalize this answer's status epoch first. A message - // the turn says on its way (#209) only SEALS the card โ€” - // the work above it is over, the turn is not โ€” so no - // reader takes an interim line for the end of the turn. + // a structured turn says on its way (#209) only SEALS + // the card โ€” the work above it is over, the turn is + // not โ€” so no reader takes an interim line for the end + // of the turn. Without an open structured turn (or on + // the terminal protocol) the answer is the only end + // this pump will ever see, and it stays `done`. if progress_on && fold.has_activity() && !fold.done() { - if is_final_answer || session.protocol.is_terminal() { + if is_final_answer + || !structured_turn_open + || session.protocol.is_terminal() + { fold.mark_done(); } else { fold.seal(); @@ -27762,6 +27768,95 @@ mod tests { assert!(gateway.session_turn_in_flight("s1")); } + /// #209 โ€” a message a structured turn says on its way closes the progress + /// card above it WITHOUT calling the turn done: web readers counted a + /// `โœ… done` card as the end of the turn and dropped Stop mid-turn. Only the + /// boundary writes `โœ… done`, and an answer-less boundary still closes its + /// card. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn an_interim_message_seals_its_card_and_only_the_boundary_is_done() { + let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude)); + let proj = tempfile::TempDir::new().unwrap(); + let mut gateway = Gateway::new(fake.clone(), "alpha", proj.path()); + gateway.set_turn_heartbeat_interval(std::time::Duration::ZERO); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::(); + gateway.set_event_sink(tx); + gateway + .handle_text("mock", "chat-1", "alice", "/new claude reviewer") + .await + .unwrap(); + let mut events = gateway.subscribe_events(); + let identity = "alpha-reviewer-s1".to_string(); + let tool = |id: &str, done: bool| { + let item = ThreadItem { + id: id.into(), + details: ThreadItemDetails::ToolCall { + name: "Bash".into(), + args: serde_json::json!({"command": "make test"}), + }, + }; + if done { + ThreadEvent::ItemCompleted { item } + } else { + ThreadEvent::ItemStarted { item } + } + }; + { + let mut queued = fake.events.lock().await; + for event in [ + ThreadEvent::TurnStarted { + turn_id: "t-cards".into(), + opening: ccteam_harness::TurnOpening::Submitted, + }, + tool("tool-1", false), + tool("tool-1", true), + agent_msg( + |item| ThreadEvent::ItemCompleted { item }, + "tests are running", + ), + tool("tool-2", false), + tool("tool-2", true), + ThreadEvent::TurnCompleted { + turn_id: "t-cards".into(), + usage: Default::default(), + model: None, + conclusion: None, + continuation: ccteam_harness::TurnContinuation::Settled, + }, + ] { + queued.push_back((identity.clone(), event)); + } + } + fake.wake(&identity); + + let mut finals: Vec<(String, bool)> = Vec::new(); + let mut boundary_seen = false; + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !boundary_seen { + let ev = events.recv().await.expect("broadcast open"); + match &ev.kind { + GatewayEventKind::Progress { done: true, .. } => { + finals.push((ev.content.clone(), false)); + } + GatewayEventKind::Answer if ev.status.is_some() => boundary_seen = true, + GatewayEventKind::Answer => finals.push((ev.content.clone(), true)), + _ => {} + } + } + }) + .await + .expect("the turn reaches its boundary"); + assert_eq!( + finals, + vec![ + ("โ†ณ 1 tools ยท 0 files".to_string(), false), + ("tests are running".to_string(), true), + ("โœ… done ยท 1 tools ยท 0 files".to_string(), false), + ], + "sealed card, the message, then the boundary's done card" + ); + } + /// #209 โ€” what a turn said before it failed was produced and paid for; the /// failure used to `clear()` it with the hold. It goes out as ordinary /// narration AHEAD of the failure row, and only the failure row is marked From 70b63b1e8edda6c9fa07b9321a91990d4b022422 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Fri, 25 Sep 2026 06:40:55 +0000 Subject: [PATCH 05/37] fix(im): say which answers are interim, and keep a turn's pieces whole (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review of the first #209 cut: - A new pump numbered rows by COUNTING rows with text, so a closing row (empty `assistant`) re-issued its id after every resume โ€” and a turn id already in `notified_turns` silently swallowed the completion reusing it. Rows are now numbered after the highest `-N` on disk. - "No status = still running" was an inference, and a wrong one: the terminal protocol's replies, slash-command replies, schedule failures and recovered answers carry no status and end their exchange all the same, and a web reader treating them as interim stayed busy forever. `GatewayEvent.interim` now says it positively โ€” set only on an answer a structured turn delivered with that turn still running โ€” and the SSE payload carries `interim: true` on exactly those. - The turn watchdog read any answer as "the turn answered" and disarmed; an interim line no longer moves it. - A parent was told only the last row of a turn that reached the ledger in pieces. The live boundary signal, the restart reconcile and the inline `agent{wait}` result now fold every row of the execution turn into its answer (issue #192), with the last row as the conclusion when the vendor marks none (issue #196). - A whitespace-only message is never delivered; the CLI's own `` message (an API error, a usage limit) is left to the turn's `result`, which already reports it, instead of reaching the chat twice. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../execution/claude_stream_json/translate.rs | 41 +++- crates/ccteam-im/src/daemon.rs | 2 + crates/ccteam-im/src/delegation.rs | 124 ++++++++++ crates/ccteam-im/src/gateway.rs | 225 ++++++++++++++++-- crates/ccteam-im/src/hitl.rs | 1 + crates/ccteam-im/src/mcp/dispatch.rs | 15 +- crates/ccteam-im/tests/inbound_wiring_test.rs | 1 + crates/ccteam-web/src/ring.rs | 1 + crates/ccteam-web/src/routes/agents.rs | 3 + crates/ccteam-web/src/routes/sessions_api.rs | 20 ++ 10 files changed, 409 insertions(+), 24 deletions(-) diff --git a/crates/ccteam-harness/src/execution/claude_stream_json/translate.rs b/crates/ccteam-harness/src/execution/claude_stream_json/translate.rs index d1cd7e47..05559396 100644 --- a/crates/ccteam-harness/src/execution/claude_stream_json/translate.rs +++ b/crates/ccteam-harness/src/execution/claude_stream_json/translate.rs @@ -379,9 +379,14 @@ impl StreamTranslator { } } let (text, items) = extract_blocks(&env.message); + // The CLI's own `` message (an API error, a usage limit) + // is not something the model said: the turn's `result` reports it โ€” + // as the failure, or as result text the stream never showed โ€” so + // emitting it here too would put it in the chat twice. + let synthetic = env.message.get("model").and_then(|v| v.as_str()) == Some(""); // Subagent narration (`parent_tool_use_id` set) belongs to the // Task tool's private thread, never to this session's reply. - if !text.is_empty() && env.parent_tool_use_id.is_none() { + if !text.trim().is_empty() && env.parent_tool_use_id.is_none() && !synthetic { push_paragraph(&mut self.acc_text, &text); self.last_text = Some(text.clone()); let id = self.next_item_id(); @@ -785,6 +790,40 @@ mod tests { .any(|e| matches!(e, ThreadEvent::TurnCompleted { .. }))); } + #[test] + fn the_clis_synthetic_message_is_left_to_the_result() { + // A usage limit / API error arrives as a `` assistant line + // AND as the failing `result`; only the result may report it, or the + // chat shows it twice. + let mut t = StreamTranslator::new(); + let evs = run( + &mut t, + vec![ + Outbound::Assistant(MessageEnvelope { + message: json!({"role": "assistant", "model": "", + "content": [{"type": "text", "text": "You've hit your session limit"}]}), + session_id: "u-1".into(), + parent_tool_use_id: None, + }), + assistant(json!([{"type": "text", "text": " \n"}])), + Outbound::TurnResult(ResultMsg { + subtype: "success".into(), + result: Some("You've hit your session limit".into()), + is_error: true, + total_cost_usd: None, + usage: None, + session_id: "u-1".into(), + }), + ], + ); + assert!(messages(&evs).is_empty(), "{evs:?}"); + let failed = evs.iter().find_map(|e| match e { + ThreadEvent::TurnFailed { err, .. } => Some(err.message.clone()), + _ => None, + }); + assert_eq!(failed.as_deref(), Some("You've hit your session limit")); + } + #[test] fn result_falls_back_to_accumulated_text() { let mut t = StreamTranslator::new(); diff --git a/crates/ccteam-im/src/daemon.rs b/crates/ccteam-im/src/daemon.rs index 802c37dd..b2dac2da 100644 --- a/crates/ccteam-im/src/daemon.rs +++ b/crates/ccteam-im/src/daemon.rs @@ -2217,6 +2217,7 @@ mod tests { let consumer = spawn_gateway_event_consumer(outbox_root.path().to_path_buf(), rx, channels); let reaction_event = |on: bool| GatewayEvent { + interim: false, id: format!("gateway-reaction-{on}"), channel: "telegram".to_string(), chat_id: "chat-7".to_string(), @@ -2284,6 +2285,7 @@ mod tests { let consumer = spawn_gateway_event_consumer(outbox_root.path().to_path_buf(), rx, channels); let ev = |on: bool| GatewayEvent { + interim: false, id: format!("r-{on}"), channel: "telegram".to_string(), chat_id: "chat-7".to_string(), diff --git a/crates/ccteam-im/src/delegation.rs b/crates/ccteam-im/src/delegation.rs index ac66c368..731b7da0 100644 --- a/crates/ccteam-im/src/delegation.rs +++ b/crates/ccteam-im/src/delegation.rs @@ -462,6 +462,75 @@ pub(crate) fn read_budget_for(total_chars: usize) -> usize { total_chars.clamp(AGENT_READ_MIN_MAX_CHARS, AGENT_READ_MAX_MAX_CHARS) } +/// The answer of one execution turn, from the rows it reached its reader as. +/// +/// A long turn is delivered as it goes (#209), so one turn can be several +/// ledger rows. The turn's answer is still EVERY one of them, in order (issue +/// #192) โ€” a parent told only the last row would lose the reply its child gave +/// to a mid-turn steer. The conclusion is the vendor's own marker, else โ€” when +/// there is more than one row โ€” the last row: the text after the turn's last +/// tool call, which an excerpt leads with (issue #196). Never a copy of the +/// whole answer. +pub(crate) fn fold_turn_answer( + parts: &[&str], + conclusion: Option<&str>, +) -> (String, Option) { + let answer = match parts { + [single] => (*single).to_string(), + _ => parts + .iter() + .map(|part| part.trim()) + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n\n"), + }; + let conclusion = conclusion + .map(str::to_string) + .or_else(|| match parts { + [_, .., last] => Some(last.trim().to_string()), + _ => None, + }) + .filter(|conclusion| !conclusion.is_empty() && conclusion.trim() != answer.trim()); + (answer, conclusion) +} + +/// `answered` widened to its whole execution turn: every row of the same turn +/// up to and including it, folded by [`fold_turn_answer`]. A row with no +/// execution identity, or a failed one, is its own answer. +pub(crate) fn turn_answer_record( + all: &[ccteam_harness::execution::turns_mirror::TurnRecord], + answered: &ccteam_harness::execution::turns_mirror::TurnRecord, +) -> ccteam_harness::execution::turns_mirror::TurnRecord { + let Some(exec) = answered + .exec_turn_id + .as_deref() + .filter(|_| !answered.failed()) + else { + return answered.clone(); + }; + let Some(at) = all.iter().position(|row| row.turn_id == answered.turn_id) else { + return answered.clone(); + }; + let parts: Vec<&str> = all[..=at] + .iter() + .filter(|row| { + row.exec_turn_id.as_deref() == Some(exec) + && !row.assistant.trim().is_empty() + && row.outcome.is_none() + }) + .map(|row| row.assistant.as_str()) + .collect(); + if parts.len() < 2 { + return answered.clone(); + } + let (assistant, conclusion) = fold_turn_answer(&parts, answered.conclusion.as_deref()); + ccteam_harness::execution::turns_mirror::TurnRecord { + assistant, + conclusion, + ..answered.clone() + } +} + /// The pointer a truncated answer carries: the exact one-call recipe for the /// whole text, so the reader never has to guess a budget or page through /// history (issue #194: the frugal, correct read was discoverable only by @@ -691,6 +760,61 @@ mod tests { assert!(!t.contains("interim note(s)")); } + #[test] + fn a_turn_delivered_in_pieces_answers_with_every_piece() { + // #209 โ€” a long turn reaches its reader as several rows; its answer is + // all of them, and the last one leads an excerpt. + let (answer, conclusion) = + fold_turn_answer(&["replied to the steer", " ", "all green\n"], None); + assert_eq!(answer, "replied to the steer\n\nall green"); + assert_eq!(conclusion.as_deref(), Some("all green")); + // The vendor's own marker wins; a single row is its own answer. + let (_, marked) = fold_turn_answer(&["a", "b"], Some("the receipt")); + assert_eq!(marked.as_deref(), Some("the receipt")); + assert_eq!( + fold_turn_answer(&["only"], None), + ("only".to_string(), None) + ); + } + + #[test] + fn an_answered_row_widens_to_its_whole_turn() { + use ccteam_harness::execution::turns_mirror::TurnRecord; + let row = |id: &str, exec: &str, text: &str| TurnRecord { + exec_turn_id: Some(exec.into()), + continues_exec_turn: None, + turn_id: id.into(), + ts: chrono::Utc::now(), + vendor: "claude".into(), + role: String::new(), + user: String::new(), + assistant: text.into(), + usage: serde_json::Value::Null, + status: None, + tool_calls: Vec::new(), + attachments: Vec::new(), + outcome: None, + error_kind: None, + error: None, + conclusion: None, + }; + let all = vec![ + row("s9-1", "x-0", "an older turn"), + row("s9-2", "x-1", "started the build"), + row("s9-3", "x-1", ""), + row("s9-4", "x-1", "build green"), + row("s9-5", "x-2", "a later turn"), + ]; + let answer = turn_answer_record(&all, &all[3]); + assert_eq!( + answer.turn_id, "s9-4", + "still names the row it answered with" + ); + assert_eq!(answer.assistant, "started the build\n\nbuild green"); + assert_eq!(answer.conclusion.as_deref(), Some("build green")); + assert_eq!(turn_answer_record(&all, &all[4]).assistant, "a later turn"); + } + #[test] fn notification_text_folds_interim_notes() { let summary = DelegationSummary { diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index 85ca9d94..646a4b4d 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -1395,8 +1395,9 @@ fn stranded_boundary_signals( // inventing a resolution off them is the mis-attribution this // design removes. // - // Within one execution turn the rows still fold (latest text - // wins, earlier ones counted as interim notes): after a + // Within one execution turn the rows still fold into ONE answer + // (every row, in order โ€” #192/#209; earlier ones also counted as + // interim notes): after a // restart the child is idle by construction โ€” its process died // with the daemon โ€” so the "task finished / child idle" shape // is the honest one, and a chatty child cannot flood its @@ -1421,11 +1422,20 @@ fn stranded_boundary_signals( } for (exec_turn_id, rows) in groups { let last = rows.last().expect("a group holds at least one row"); + // The turn's answer is every row it wrote, not its last piece (#209) + // โ€” unless it failed, when the failure row is what it ended on (the + // live boundary reports the same). + let (tail, conclusion) = if last.failed() { + (last.assistant.clone(), last.conclusion.clone()) + } else { + let parts: Vec<&str> = rows.iter().map(|row| row.assistant.as_str()).collect(); + crate::delegation::fold_turn_answer(&parts, last.conclusion.as_deref()) + }; pending.push(crate::delegation::DelegationSignal { child_sid: child_sid.to_string(), turn_id: last.turn_id.clone(), exec_turn_id: Some(exec_turn_id), - tail: last.assistant.clone(), + tail, vendor, host: host.to_string(), boundary: true, @@ -1453,7 +1463,7 @@ fn stranded_boundary_signals( .map(|status| status.turn) .unwrap_or_default(), error_kind: last.error_kind.clone(), - conclusion: last.conclusion.clone(), + conclusion, }); } pending @@ -1890,6 +1900,7 @@ impl DelegationProgressEmitter { .strip_prefix("delegation_") .unwrap_or(&record.event); let event = GatewayEvent { + interim: false, id: format!( "delegation-{relation}-{}-{}-{}", record.parent_sid, @@ -2082,6 +2093,13 @@ pub struct GatewayEvent { pub content: String, /// Per-turn status snapshot carried by answer events. pub status: Option, + /// An answer a structured turn delivered on its way, with that turn + /// still running (#209). Said positively, never inferred: plenty of + /// answers carry no `status` and end their exchange all the same (the + /// terminal protocol's replies, slash-command replies, recovered + /// answers), and a reader that took "no status" for "still running" + /// kept those sessions busy forever. + pub interim: bool, /// Answer (new message) vs. live progress (edited status message). pub kind: GatewayEventKind, /// Outbound file attachments (V0.8.4 P2b โ€” `chat_send_file`). Empty @@ -4058,6 +4076,7 @@ impl Gateway { /// delivery). The durable twin is the progress event the caller appends. fn emit_session_lifecycle(&self, sid: &str, slug: &str, state: &str, reason: &str) { self.emit_user_signal(GatewayEvent { + interim: false, id: format!( "session-{state}-{sid}-{}", chrono::Utc::now().timestamp_millis() @@ -4480,6 +4499,7 @@ impl Gateway { ), ); g.emit_user_signal(GatewayEvent { + interim: false, id: format!("gateway-recovered-{turn_id}"), channel: reply_to.channel.clone(), chat_id: reply_to.chat_id.clone(), @@ -5820,6 +5840,7 @@ impl Gateway { .map(|d| d.as_nanos()) .unwrap_or(0); self.emit_user_signal(GatewayEvent { + interim: false, id: format!("gateway-picker-{}-{nanos}", chat.chat_id), channel: chat.channel.clone(), chat_id: chat.chat_id.clone(), @@ -6963,13 +6984,8 @@ impl Gateway { .and_then(|dir| { ccteam_harness::execution::turns_mirror::read_all_turns(dir, &session_id).ok() }) - .map(|turns| { - turns - .iter() - .filter(|turn| !turn.assistant.is_empty()) - .count() as u64 - }) - .unwrap_or(0); // assistant-row count โ†’ monotonic turn number + .map(|turns| last_answer_seq(&session_id, &turns)) + .unwrap_or(0); let mut vendor_turn: u64 = project_dir .as_ref() .and_then(|dir| { @@ -6999,6 +7015,9 @@ impl Gateway { // boundary's final answer โ€” plus every mirrored turn id (batch // dedup bookkeeping) and the interim count. let mut turn_last_answer: Option<(String, String)> = None; + // Every text row the open turn has written, in order โ€” its answer + // when it closes (a long turn is written in pieces, #209). + let mut turn_said: Vec = Vec::new(); let mut turn_covered: Vec = Vec::new(); let mut turn_notes: usize = 0; // Assistant messages a turn-bounded protocol produced that are @@ -7384,6 +7403,7 @@ impl Gateway { .map(|k| k.clone()) .unwrap_or_else(|_| session.owner.clone()); if !tx.send(GatewayEvent { + interim: false, id: format!("gateway-reaction-clear-{session_id}-{ack_msg_id}"), channel: ack_target.channel.clone(), chat_id: ack_target.chat_id.clone(), @@ -7746,7 +7766,9 @@ impl Gateway { let mut held_last: Option = None; let mut answer_texts = match &evt { ThreadEvent::ItemCompleted { item } => match &item.details { - ThreadItemDetails::AgentMessage(text) if !text.is_empty() => { + // A message of nothing but whitespace says + // nothing: never a blank chat message or row. + ThreadItemDetails::AgentMessage(text) if !text.trim().is_empty() => { if session.protocol.is_terminal() || !structured_turn_open { vec![text.clone()] } else { @@ -7998,6 +8020,7 @@ impl Gateway { // bubble holding nothing but a status line. if let Some(line) = closing_line { let receipt = GatewayEvent { + interim: false, id: status_id.clone(), channel: chat_key.channel.clone(), chat_id: chat_key.chat_id.clone(), @@ -8022,6 +8045,7 @@ impl Gateway { // receipt; published to every other reader. let owes_closing = released_mid_turn || !turn_had_answer; let frame = GatewayEvent { + interim: false, id: status_id, channel: chat_key.channel.clone(), chat_id: chat_key.chat_id.clone(), @@ -8067,6 +8091,12 @@ impl Gateway { for (answer_index, text) in answer_texts.into_iter().enumerate() { let is_final_answer = is_turn_boundary && answer_index.saturating_add(1) == answer_count; + // Only a structured turn that goes on after this + // message makes it interim (#209); every other + // answer ends what its reader is waiting on. + let interim = !is_final_answer + && structured_turn_open + && !session.protocol.is_terminal(); // A failure batch leads with what the turn said before // it failed: that row is ordinary narration. Only the // failure row itself closes the turn. @@ -8110,7 +8140,13 @@ impl Gateway { last_sent = None; seq = seq.saturating_add(1); - session.visible_events.fetch_add(1, Ordering::SeqCst); + // The turn watchdog reads a move here as "the turn + // answered" and disarms: a message the turn says on + // its way is not that, or its first line would + // silence the stall warning for the rest of the turn. + if !interim { + session.visible_events.fetch_add(1, Ordering::SeqCst); + } let (status, meta_status) = if is_final_answer { let live_status = resolved_thread_status( Arc::clone(&session.adapter), @@ -8281,6 +8317,7 @@ impl Gateway { let notes = turn_notes; turn_notes = 0; turn_last_answer = None; + turn_said.clear(); let covered = std::mem::take(&mut turn_covered); if let Some(dtx) = delegation_tx.as_ref() { let _ = dtx.send(crate::delegation::DelegationPulse::Signal(crate::delegation::DelegationSignal { @@ -8321,6 +8358,7 @@ impl Gateway { record.turn_id.clone(), text.clone(), )); + turn_said.push(text.clone()); } } Err(err) => { @@ -8419,6 +8457,7 @@ impl Gateway { || channel == "web" || latest_turn_origin(&session) == TurnOrigin::User; let answer = GatewayEvent { + interim, id: format!("gateway-event-{session_id}-{seq}"), channel, chat_id, @@ -8458,17 +8497,26 @@ impl Gateway { let completed_origin = take_turn_origin(&session, Some(turn_id)); let mirror_answer = mirror_last_answer.take(); let finished = turn_last_answer.take(); + let said = std::mem::take(&mut turn_said); let covered = std::mem::take(&mut turn_covered); let notes = turn_notes; turn_notes = 0; - if let (Some(dtx), Some((final_turn, final_text))) = + if let (Some(dtx), Some((final_turn, _))) = (delegation_tx.as_ref(), finished.as_ref()) { + // The parent is told the whole turn โ€” every row + // it wrote, in order โ€” not the last piece a long + // turn happened to end on (#209, issue #192). + let parts: Vec<&str> = said.iter().map(String::as_str).collect(); + let (tail, conclusion) = crate::delegation::fold_turn_answer( + &parts, + turn_conclusion.as_deref(), + ); let _ = dtx.send(crate::delegation::DelegationPulse::Signal(crate::delegation::DelegationSignal { child_sid: session_id.clone(), turn_id: final_turn.clone(), exec_turn_id: Some(turn_id.clone()), - tail: final_text.clone(), + tail, vendor: pump_vendor, host: pump_host.clone(), boundary: true, @@ -8479,7 +8527,7 @@ impl Gateway { context_pct: turn_last_context_pct.take(), turn: vendor_turn, error_kind: None, - conclusion: turn_conclusion.clone(), + conclusion, })); } if let Some((final_text, reply_to)) = mirror_answer { @@ -9227,6 +9275,7 @@ impl Gateway { fn emit_scheduled_changed(&self, item: &crate::scheduled::ScheduledItem) { let _ = self.events_broadcast.send(GatewayEvent { + interim: false, id: format!("scheduled-changed-{}-{}", item.sid, item.id), channel: "web".to_string(), chat_id: "web-api".to_string(), @@ -9357,6 +9406,7 @@ impl Gateway { (&entry.item.reply_channel, &entry.item.reply_chat_id) { self.emit_user_signal(GatewayEvent { + interim: false, id: format!("scheduled-failed-{}", entry.item.id), channel: channel.clone(), chat_id: chat_id.clone(), @@ -10089,6 +10139,7 @@ impl Gateway { *pending = Some(message_id.to_string()); } self.emit_user_signal(GatewayEvent { + interim: false, id: format!("gateway-reaction-add-{session_id}-{message_id}"), channel: chat.channel.clone(), chat_id: chat.chat_id.clone(), @@ -10413,6 +10464,7 @@ impl Gateway { let body = render_choice_text(&prompt); if let Some(tx) = self.event_sink.clone() { let _ = tx.send(GatewayEvent { + interim: false, id: format!("gateway-choice-{session_id}-{}", prompt.token), channel: chat.channel.clone(), chat_id: chat.chat_id.clone(), @@ -10579,6 +10631,7 @@ impl Gateway { "ccteam-im: session never authenticated with its own principal โ€” its ccteam tools are riding another identity (children will mount as roots and its project scope is not its own); neither the credential ccteam handed this session nor `/mcp` process provenance ever reached it (vendor kept a same-named `ccteam` entry AND the peer could not be resolved to this session's process tree โ€” non-linux daemon host, or its MCP client never dialed in)" ); self.emit_user_signal(GatewayEvent { + interim: false, id: format!("principal-unused-{sid}"), channel: String::new(), chat_id: String::new(), @@ -13327,6 +13380,7 @@ impl Gateway { }); } self.emit_user_signal(GatewayEvent { + interim: false, id: format!("session-evicted-{sid}"), channel: String::new(), chat_id: String::new(), @@ -16143,6 +16197,7 @@ impl Gateway { let body = render_choice_text(&prompt); if let Some(tx) = event_sink { let _ = tx.send(GatewayEvent { + interim: false, id: format!("gateway-choice-{sid}-{}", prompt.token), channel: chat.channel, chat_id: chat.chat_id, @@ -16449,6 +16504,7 @@ impl Gateway { .map(|d| d.as_nanos()) .unwrap_or(0); self.emit_user_signal(GatewayEvent { + interim: false, id: format!("gateway-directive-{sid}-{nanos}-{i}"), channel: chat.channel.clone(), chat_id: chat.chat_id.clone(), @@ -17210,6 +17266,7 @@ impl Gateway { /// `session_lifecycle` listener refreshes the rail with no client change. fn emit_session_renamed(&self, sid: &str, slug: &str) { self.emit_user_signal(GatewayEvent { + interim: false, id: format!("session-renamed-{sid}"), channel: String::new(), chat_id: String::new(), @@ -17949,6 +18006,7 @@ fn emit_turn_stall_warning( tune the window via CCTEAM_IM_GATEWAY_TURN_TIMEOUT_MS (0 = off)." ); let _ = tx.send(GatewayEvent { + interim: false, id: format!("gateway-timeout-{session_id}-{turn_id}"), channel, chat_id, @@ -18369,6 +18427,7 @@ fn mirror_internal_web_answer( return; }; let _ = tx.send_delivery_only(GatewayEvent { + interim: false, id: format!("gateway-mirror-{session_id}-{seq}"), channel, chat_id, @@ -18405,6 +18464,7 @@ fn emit_progress( // `send` returns false only when the mpsc consumer is gone; surface that as // emit_progress's "sink closed โ†’ pump should stop" signal. tx.send(GatewayEvent { + interim: false, id: format!("gateway-progress-{status_key}"), channel, chat_id, @@ -18435,6 +18495,7 @@ fn emit_activity( let status_key = format!("{session_id}-{epoch}"); let content = activity.summary.clone(); tx.send(GatewayEvent { + interim: false, id: format!("gateway-activity-{status_key}-{}", activity.item_id), channel, chat_id, @@ -18528,6 +18589,23 @@ fn async_event_text(evt: &ThreadEvent) -> Option { } } +/// The highest `-` row id this pump family has written โ€” where a new +/// pump resumes numbering. COUNTING rows was a proxy that held only while +/// every such row had text: a closing row (empty `assistant`, #209) is not +/// counted, so the next pump re-issued its id โ€” and a turn id already in +/// `notified_turns` silently swallows the completion that reuses it. +fn last_answer_seq( + session_id: &str, + turns: &[ccteam_harness::execution::turns_mirror::TurnRecord], +) -> u64 { + let prefix = format!("{session_id}-"); + turns + .iter() + .filter_map(|turn| turn.turn_id.strip_prefix(&prefix)?.parse::().ok()) + .max() + .unwrap_or(0) +} + /// What woke the event pump's stream branch: the vendor, or the deadline of /// text it is holding (#209). enum PumpWake { @@ -18573,6 +18651,10 @@ fn hold_due_marker() -> ThreadEvent { /// which is what its boundary names as the conclusion when the vendor names /// none (issue #196). fn fold_held(held: Vec) -> (Option, Option) { + let held: Vec = held + .into_iter() + .filter(|part| !part.trim().is_empty()) + .collect(); match held.len() { 0 => (None, None), 1 => (held.into_iter().next(), None), @@ -18581,7 +18663,6 @@ fn fold_held(held: Vec) -> (Option, Option) { let text = held .iter() .map(|part| part.trim()) - .filter(|part| !part.is_empty()) .collect::>() .join("\n\n"); (Some(text), last) @@ -21493,6 +21574,7 @@ mod tests { fn fake_event(sid: Option<&str>) -> GatewayEvent { GatewayEvent { + interim: false, id: "e1".into(), channel: "web".into(), chat_id: "web-api".into(), @@ -26347,6 +26429,10 @@ mod tests { assert_eq!(web[0].chat_id, "web-api"); assert_eq!(web[0].content, "background checkpoint"); assert_eq!(web[1].content, "background final"); + assert!( + web.iter().all(|event| !event.interim), + "no structured turn is open, so nothing says the turn goes on: {web:?}" + ); let mirror = answers .iter() .find(|event| event.channel == "telegram") @@ -27729,6 +27815,103 @@ mod tests { assert!(answer.status.is_none(), "a mid-turn row carries no status"); } + /// #209 โ€” a new pump numbers rows after the highest one on disk. Counting + /// text rows re-issued a closing row's id after every resume, and a turn id + /// already in `notified_turns` silently swallowed the completion reusing it. + #[test] + fn a_new_pump_numbers_past_every_row_it_wrote() { + let row = |id: &str, text: &str| ccteam_harness::execution::turns_mirror::TurnRecord { + exec_turn_id: None, + continues_exec_turn: None, + turn_id: id.into(), + ts: chrono::Utc::now(), + vendor: "claude".into(), + role: String::new(), + user: String::new(), + assistant: text.into(), + usage: serde_json::Value::Null, + status: None, + tool_calls: Vec::new(), + attachments: Vec::new(), + outcome: None, + error_kind: None, + error: None, + conclusion: None, + }; + let rows = vec![ + row("s7-1", "released mid-turn"), + row("s7-2", "released mid-turn"), + row("s7-3", ""), + row("input-18d8", ""), + row("s7-recovered-1790000000000", "recovered"), + row("interrupted:sj-1", "cut"), + row("s70-9", "another session's prefix"), + ]; + assert_eq!(last_answer_seq("s7", &rows), 3); + assert_eq!(last_answer_seq("s7", &[]), 0); + } + + /// #209 โ€” the parent hears the WHOLE turn: a long turn reaches the ledger + /// in pieces, and telling the parent only the last piece dropped the reply + /// its child gave to a mid-turn steer (issue #192). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_turn_written_in_pieces_reports_every_piece_to_its_parent() { + let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude)); + let proj = tempfile::TempDir::new().unwrap(); + let mut gateway = Gateway::new(fake.clone(), "alpha", proj.path()); + gateway.set_turn_heartbeat_interval(std::time::Duration::ZERO); + let (dtx, mut drx) = tokio::sync::mpsc::unbounded_channel(); + gateway.set_delegation_notifier_tx(dtx); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::(); + gateway.set_event_sink(tx); + gateway + .handle_text("mock", "chat-1", "alice", "/new claude reviewer") + .await + .unwrap(); + let identity = "alpha-reviewer-s1".to_string(); + { + let mut queued = fake.events.lock().await; + for event in [ + ThreadEvent::TurnStarted { + turn_id: "t-pieces".into(), + opening: ccteam_harness::TurnOpening::Submitted, + }, + agent_msg( + |item| ThreadEvent::ItemCompleted { item }, + "replied to the steer", + ), + agent_msg(|item| ThreadEvent::ItemCompleted { item }, "all green"), + ThreadEvent::TurnCompleted { + turn_id: "t-pieces".into(), + usage: Default::default(), + model: None, + conclusion: None, + continuation: ccteam_harness::TurnContinuation::Settled, + }, + ] { + queued.push_back((identity.clone(), event)); + } + } + fake.wake(&identity); + + let signal = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if let Some(crate::delegation::DelegationPulse::Signal(signal)) = drx.recv().await { + if signal.boundary { + return signal; + } + } + } + }) + .await + .expect("the boundary signals the parent"); + assert_eq!(signal.tail, "replied to the steer\n\nall green"); + assert_eq!(signal.conclusion.as_deref(), Some("all green")); + // The watchdog was never told the turn answered by its interim lines. + let visible = gateway.sessions["s1"].visible_events.load(Ordering::SeqCst); + assert_eq!(visible, 0, "interim lines never disarm the stall watchdog"); + } + /// #209 โ€” the hold is bounded by a CLOCK, not by the vendor's next event. /// A session that says "running the suite now" and then sits in a silent /// twenty-minute tool produces no event at all; waiting for one to notice @@ -27761,6 +27944,7 @@ mod tests { answer.status.is_none(), "the turn is still running: {answer:?}" ); + assert!(answer.interim, "and it says so: {answer:?}"); assert!( asked.elapsed() >= heartbeat, "it was held for one heartbeat first, to fold what follows" @@ -27907,12 +28091,13 @@ mod tests { let said = recv_answer(&mut events).await; assert_eq!(said.content, "checking the logs", "{said:?}"); - assert!(said.status.is_none(), "{said:?}"); + assert!(said.status.is_none() && said.interim, "{said:?}"); let failed = recv_answer(&mut events).await; assert!( failed.content.contains("the provider fell over"), "{failed:?}" ); + assert!(!failed.interim, "{failed:?}"); assert!( failed.status.is_some(), "the failure is the boundary: {failed:?}" @@ -28004,6 +28189,10 @@ mod tests { "no status-line bubble on the web: {closing:?}" ); assert_eq!(closing.status.as_ref().map(|status| status.turn), Some(1)); + assert!( + answer.interim && !closing.interim, + "{answer:?} / {closing:?}" + ); // โ€ฆand the ledger says the turn ended: a status-bearing closing row. let rows = diff --git a/crates/ccteam-im/src/hitl.rs b/crates/ccteam-im/src/hitl.rs index 6de41f77..3d75a5b2 100644 --- a/crates/ccteam-im/src/hitl.rs +++ b/crates/ccteam-im/src/hitl.rs @@ -394,6 +394,7 @@ async fn ask_external_choice( } if sink .send(GatewayEvent { + interim: false, id: format!("permission-{token}"), channel: ctx.channel.clone(), chat_id: ctx.chat_id.clone(), diff --git a/crates/ccteam-im/src/mcp/dispatch.rs b/crates/ccteam-im/src/mcp/dispatch.rs index 4da962c7..21a35fdc 100644 --- a/crates/ccteam-im/src/mcp/dispatch.rs +++ b/crates/ccteam-im/src/mcp/dispatch.rs @@ -908,6 +908,7 @@ fn build_send_file_event( ) })?; Ok(crate::gateway::GatewayEvent { + interim: false, id: format!("chat-send-file-{slug}-{role}-{seq}"), channel, chat_id, @@ -1100,6 +1101,7 @@ async fn execute_interaction_ask( // Render the buttons in IM. if sink .send(GatewayEvent { + interim: false, id: format!("interaction-{token}"), channel, chat_id, @@ -1330,6 +1332,7 @@ async fn execute_permission_ask( // Render the approve/deny buttons in IM. if sink .send(GatewayEvent { + interim: false, id: format!("permission-{token}"), channel, chat_id, @@ -3991,13 +3994,15 @@ async fn finish_dispatch_wait( // we cannot name is reported as absent, not as somebody else's. // Without one โ€” an admin caller waiting on the child's next // boundary โ€” the newest row is what was waited for. + // โ€ฆwidened to the whole execution turn it closed: a long turn + // reaches the ledger in pieces (#209), and its answer is all of + // them (issue #192), not the piece that happened to be last. let last = match answered_turn.as_deref() { - Some(turn_id) => all.into_iter().find(|t| t.turn_id == turn_id), - None if request_id.is_none() => { - all.into_iter().rev().find(|t| !t.assistant.is_empty()) - } + Some(turn_id) => all.iter().find(|t| t.turn_id == turn_id), + None if request_id.is_none() => all.iter().rev().find(|t| !t.assistant.is_empty()), None => None, - }; + } + .map(|answered| crate::delegation::turn_answer_record(&all, answered)); // Session-ledger telemetry (MCP-DX-1): cumulative cost + raw // tokens, same semantics as agent_read/collect (tokens present // even for vendors with no USD price table). diff --git a/crates/ccteam-im/tests/inbound_wiring_test.rs b/crates/ccteam-im/tests/inbound_wiring_test.rs index ce40df68..83115e95 100644 --- a/crates/ccteam-im/tests/inbound_wiring_test.rs +++ b/crates/ccteam-im/tests/inbound_wiring_test.rs @@ -2918,6 +2918,7 @@ async fn daemon_delivers_gateway_event_attachment_to_channel() { // a file-bearing event before the daemon's consumer drains it. let (tx, rx) = tokio::sync::mpsc::unbounded_channel::(); tx.send(GatewayEvent { + interim: false, id: "csf-1".into(), channel: "telegram".into(), chat_id: "chat-77".into(), diff --git a/crates/ccteam-web/src/ring.rs b/crates/ccteam-web/src/ring.rs index f57824db..6bba3ccb 100644 --- a/crates/ccteam-web/src/ring.rs +++ b/crates/ccteam-web/src/ring.rs @@ -262,6 +262,7 @@ mod tests { fn ev(id: &str, sid: &str) -> GatewayEvent { GatewayEvent { + interim: false, id: id.to_string(), channel: "web".to_string(), chat_id: "c".to_string(), diff --git a/crates/ccteam-web/src/routes/agents.rs b/crates/ccteam-web/src/routes/agents.rs index ff851323..a09816f0 100644 --- a/crates/ccteam-web/src/routes/agents.rs +++ b/crates/ccteam-web/src/routes/agents.rs @@ -793,6 +793,7 @@ mod tests { #[test] fn event_visible_admin_keeps_unattributed_but_not_a_tenants_project() { let mut ev = GatewayEvent { + interim: false, id: "e".into(), channel: String::new(), chat_id: String::new(), @@ -829,6 +830,7 @@ mod tests { #[test] fn event_visible_tenant_fails_closed_on_missing_slug() { let ev = GatewayEvent { + interim: false, id: "e".into(), channel: String::new(), chat_id: String::new(), @@ -851,6 +853,7 @@ mod tests { #[test] fn event_visible_tenant_matches_own_slug_only() { let mut ev = GatewayEvent { + interim: false, id: "e".into(), channel: String::new(), chat_id: String::new(), diff --git a/crates/ccteam-web/src/routes/sessions_api.rs b/crates/ccteam-web/src/routes/sessions_api.rs index e1d93ad8..8b6172ed 100644 --- a/crates/ccteam-web/src/routes/sessions_api.rs +++ b/crates/ccteam-web/src/routes/sessions_api.rs @@ -1501,6 +1501,7 @@ pub(crate) fn parse_last_event_id(headers: &HeaderMap, query: &SessionEventsQuer fn synthetic_approval_event(sid: &str, prompt: &ChoicePrompt) -> GatewayEvent { use ccteam_im::gateway::GatewayEventKind; GatewayEvent { + interim: false, id: format!("permission-{}", prompt.token), channel: String::new(), chat_id: String::new(), @@ -2110,6 +2111,11 @@ pub(crate) fn session_event_payload(ev: &GatewayEvent) -> serde_json::Value { if done { payload["done"] = serde_json::Value::Bool(true); } + // #209 โ€” said only when true: an answer the turn delivered on its way, + // with the turn still running. Every other answer ends the exchange. + if ev.interim { + payload["interim"] = serde_json::Value::Bool(true); + } if let Some(status) = &ev.status { payload["status"] = serde_json::to_value(status).unwrap_or(json!(null)); } @@ -2492,6 +2498,7 @@ mod tests { fn gw_event(sid: Option<&str>) -> GatewayEvent { use ccteam_im::gateway::GatewayEventKind; GatewayEvent { + interim: false, id: "e1".into(), channel: "web".into(), chat_id: "web-api".into(), @@ -2566,6 +2573,18 @@ mod tests { assert_eq!(prog["done"], true); } + /// #209 โ€” `interim` is said only when true: an answer a running turn + /// delivered on its way. Every other answer (no field) ends the exchange, + /// whether or not it carries a status. + #[test] + fn session_event_marks_only_an_interim_answer() { + let mut interim = gw_event(Some("s1")); + interim.interim = true; + assert_eq!(session_event_payload(&interim)["interim"], true); + let plain = session_event_payload(&gw_event(Some("s1"))); + assert!(plain.get("interim").is_none(), "{plain}"); + } + /// WEB-TS-1 โ€” every frame carries a server-side `ts` in the same RFC 3339 /// (`โ€ฆZ`) shape the mirrored `TurnRecord.ts` writes into turns.jsonl, so a /// live row and a history row share one clock/format. @@ -3396,6 +3415,7 @@ mod tests { let tmp = tempfile::TempDir::new().unwrap(); let app = test_app_with_gateway(tmp.path()); let ans = |id: &str, sid: &str| GatewayEvent { + interim: false, id: id.to_string(), channel: "web".into(), chat_id: "web-api".into(), From 5a1aed1b8d23c5bb958e0eb905683ccb67da9594 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Fri, 25 Sep 2026 06:48:31 +0000 Subject: [PATCH 06/37] fix(im): a watchdog heads-up and an approval prompt are interim (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both go out while the turn is still running โ€” one says it went quiet, the other waits on a human โ€” so neither may end a reader's turn. Mark them `interim` at the source (the silence watchdog, the HITL prompt and its SSE re-seed on reconnect) instead of teaching each front end the exceptions. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ccteam-im/src/gateway.rs | 3 ++- crates/ccteam-im/src/hitl.rs | 3 ++- crates/ccteam-web/src/routes/sessions_api.rs | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index 646a4b4d..86928112 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -18006,7 +18006,8 @@ fn emit_turn_stall_warning( tune the window via CCTEAM_IM_GATEWAY_TURN_TIMEOUT_MS (0 = off)." ); let _ = tx.send(GatewayEvent { - interim: false, + // A heads-up about a turn that is still running, not its end (#209). + interim: true, id: format!("gateway-timeout-{session_id}-{turn_id}"), channel, chat_id, diff --git a/crates/ccteam-im/src/hitl.rs b/crates/ccteam-im/src/hitl.rs index 3d75a5b2..2cde0c11 100644 --- a/crates/ccteam-im/src/hitl.rs +++ b/crates/ccteam-im/src/hitl.rs @@ -394,7 +394,8 @@ async fn ask_external_choice( } if sink .send(GatewayEvent { - interim: false, + // The turn is waiting on this answer, not over (#209). + interim: true, id: format!("permission-{token}"), channel: ctx.channel.clone(), chat_id: ctx.chat_id.clone(), diff --git a/crates/ccteam-web/src/routes/sessions_api.rs b/crates/ccteam-web/src/routes/sessions_api.rs index 8b6172ed..a2b140d2 100644 --- a/crates/ccteam-web/src/routes/sessions_api.rs +++ b/crates/ccteam-web/src/routes/sessions_api.rs @@ -1501,7 +1501,8 @@ pub(crate) fn parse_last_event_id(headers: &HeaderMap, query: &SessionEventsQuer fn synthetic_approval_event(sid: &str, prompt: &ChoicePrompt) -> GatewayEvent { use ccteam_im::gateway::GatewayEventKind; GatewayEvent { - interim: false, + // Re-seeds the live prompt above: the turn is waiting, not over (#209). + interim: true, id: format!("permission-{}", prompt.token), channel: String::new(), chat_id: String::new(), From 1604125bffc00cc82f0d1cc170aeb2b201102a44 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Fri, 25 Sep 2026 06:04:26 +0000 Subject: [PATCH 07/37] fix(web): an interim answer is shown live and never ends the turn (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway now delivers what a session says DURING a long turn: zero or more interim `answer` frames without a `status`, then exactly one status-bearing boundary answer (final reply, or a status-only closing frame with empty content). `progress{done:true}` now only seals one progress card; each interim answer seals the current card and a new epoch starts after it. The SPA read "an answer arrived" or "a card finalized" as "the turn is over", which under the new contract is wrong: - SessionView derived busy from a count of `done` progress frames, so the first interim answer after tool activity dropped the Stop button and the streaming cursor while the turn kept running, and a boundary with no sealed card before it left busy stuck forever. The count also shrank once the 500-frame ring started dropping old frames. - The team view ended a node's pulse on any answer or sealed card and reset the selected node's activity fold on any answer. - mergeHistory dropped a late answer whose text matched ANY mirrored row, so a session repeating a short line mid-turn could lose it on a reconnect reseed. - A status-only closing frame / history row rendered nothing, so its `turn N ยท ctx` footer was lost. One predicate, `isTurnBoundary` (answer with a status), now answers "is the turn over" everywhere. Busy is a send-time watermark into the SSE buffer (`turnInFlight`), anchored on the frame's identity so ring eviction cannot end or resurrect a turn. A status-only boundary draws no bubble and footers the turn's last reply, live and in history, never crossing a user row or an already-footered reply. The reseed dedup matches the page tail by position instead of anywhere-equal text. The global stream parser now keeps `status` so the team view can tell the two apart. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../web/src/hooks/useAgentsEvents.test.ts | 12 ++ .../web/src/hooks/useAgentsEvents.ts | 7 + .../web/src/hooks/useSessionEvents.test.ts | 54 +++++++ .../web/src/hooks/useSessionEvents.ts | 47 +++++- .../web/src/lib/agentsReducer.test.ts | 21 ++- .../ccteam-web/web/src/lib/agentsReducer.ts | 23 +-- .../ccteam-web/web/src/pages/AgentsView.tsx | 18 +-- .../web/src/pages/SessionView.test.tsx | 151 ++++++++++++++++++ .../ccteam-web/web/src/pages/SessionView.tsx | 42 +++-- .../web/src/pages/chatTranscript.test.ts | 141 ++++++++++++++++ .../web/src/pages/chatTranscript.ts | 118 +++++++++++--- 11 files changed, 571 insertions(+), 63 deletions(-) diff --git a/crates/ccteam-web/web/src/hooks/useAgentsEvents.test.ts b/crates/ccteam-web/web/src/hooks/useAgentsEvents.test.ts index dc32bcc9..14c9dcc8 100644 --- a/crates/ccteam-web/web/src/hooks/useAgentsEvents.test.ts +++ b/crates/ccteam-web/web/src/hooks/useAgentsEvents.test.ts @@ -94,6 +94,18 @@ describe("parseAgentsEvent", () => { expect(ev).toMatchObject({ kind: "session_lifecycle", sid: "s4", slug: "demo" }); }); + it("keeps an answer's status so an interim answer and the turn boundary differ (#209)", () => { + const status = { model: "m", context: null, turn: 4, cost_usd: null, tokens_total: null }; + const boundary = parseAgentsEvent( + JSON.stringify({ kind: "answer", sid: "s1", content: "", status }), + ); + expect(boundary!.status).toEqual(status); + const interim = parseAgentsEvent( + JSON.stringify({ kind: "answer", sid: "s1", content: "checkingโ€ฆ", status: null }), + ); + expect(interim!.status).toBeUndefined(); + }); + it("defaults an unrecognized kind to answer and missing content to ''", () => { const ev = parseAgentsEvent(JSON.stringify({ foo: "bar" })); expect(ev).toMatchObject({ kind: "answer", content: "" }); diff --git a/crates/ccteam-web/web/src/hooks/useAgentsEvents.ts b/crates/ccteam-web/web/src/hooks/useAgentsEvents.ts index 37aa385a..9750f9b9 100644 --- a/crates/ccteam-web/web/src/hooks/useAgentsEvents.ts +++ b/crates/ccteam-web/web/src/hooks/useAgentsEvents.ts @@ -21,6 +21,7 @@ import { useEffect, useRef, useState } from "react"; import { createAuthedEventSource } from "../lib/authedEventSource"; import { shouldAcceptEventSeq } from "./useSessionEvents"; import type { SessionActivity } from "./useSessionEvents"; +import type { TurnStatus } from "../lib/sessionsApi"; /** One frame off the global SSE stream: every ordinary per-session event * (`answer`/`progress`/`activity`, now carrying `slug`) PLUS a delegation @@ -37,6 +38,9 @@ export interface AgentsEvent { content: string; done?: boolean; activity?: SessionActivity; + /** Answer-only: present on the turn boundary, absent on an interim answer + * (`isTurnBoundary`, #209). */ + status?: TurnStatus; /** Delegation-only: one of spawned|dispatched|completed|notified| * collected|stopped|denied. */ relation?: string; @@ -95,6 +99,9 @@ export function parseAgentsEvent(raw: string): AgentsEvent | null { if (typeof obj.sid === "string") event.sid = obj.sid; if (typeof obj.slug === "string") event.slug = obj.slug; if (obj.done === true) event.done = true; + if (typeof obj.status === "object" && obj.status !== null) { + event.status = obj.status as TurnStatus; + } if (typeof obj.activity === "object" && obj.activity !== null) { const a = obj.activity as Record; event.activity = { diff --git a/crates/ccteam-web/web/src/hooks/useSessionEvents.test.ts b/crates/ccteam-web/web/src/hooks/useSessionEvents.test.ts index 1e8d2d84..30e5fe1a 100644 --- a/crates/ccteam-web/web/src/hooks/useSessionEvents.test.ts +++ b/crates/ccteam-web/web/src/hooks/useSessionEvents.test.ts @@ -10,11 +10,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { appendSessionEvent, foldSessionLiveness, + isTurnBoundary, parseSessionEvent, sessionEventsUrl, startSessionEventStream, shouldAcceptEventSeq, SESSION_RING_CAP, + turnInFlight, type SessionEvent, } from "./useSessionEvents"; @@ -378,6 +380,58 @@ describe("appendSessionEvent ring buffer", () => { }); }); +describe("turn boundary vs interim answer (#209)", () => { + const STATUS = { model: "m", context: null, turn: 3, cost_usd: null, tokens_total: null }; + const parse = (frame: Record) => parseSessionEvent(JSON.stringify(frame))!; + + it("only a status-bearing answer ends the turn", () => { + // Interim: the session said something mid-turn โ€” no status, still running. + const interim = parse({ id: "a1", kind: "answer", content: "checkingโ€ฆ" }); + expect(interim.status).toBeUndefined(); + expect(isTurnBoundary(interim)).toBe(false); + // A null status is the same as none (never a boundary). + expect(isTurnBoundary(parse({ kind: "answer", content: "x", status: null }))).toBe(false); + // The boundary, with the final reply or status-only (empty content). + expect(isTurnBoundary(parse({ kind: "answer", content: "final", status: STATUS }))).toBe(true); + expect(isTurnBoundary(parse({ kind: "answer", content: "", status: STATUS }))).toBe(true); + // A sealed progress card is not a turn boundary, even the last one. + const sealed = parse({ kind: "progress", content: "โ†ณ 3 tools ยท 1 files", done: true }); + expect(sealed.done).toBe(true); + expect(isTurnBoundary(sealed)).toBe(false); + }); + + it("turnInFlight: running until a boundary arrives AFTER the send", () => { + const before: SessionEvent = { kind: "answer", content: "old", status: STATUS }; + const interim: SessionEvent = { kind: "answer", content: "checkingโ€ฆ" }; + const sealed: SessionEvent = { kind: "progress", content: "โ†ณ 1 tools", done: true }; + const boundary: SessionEvent = { kind: "answer", content: "", status: STATUS }; + + expect(turnInFlight([before], null)).toBe(false); // nothing sent from here + const mark = { after: before }; + // The previous turn's boundary sits BEFORE the watermark: not ours. + expect(turnInFlight([before], mark)).toBe(true); + expect(turnInFlight([before, sealed, interim], mark)).toBe(true); + expect(turnInFlight([before, sealed, interim, boundary], mark)).toBe(false); + // Sent into an empty buffer. + expect(turnInFlight([interim], { after: null })).toBe(true); + expect(turnInFlight([interim, boundary], { after: null })).toBe(false); + }); + + it("turnInFlight survives the ring dropping the watermark frame", () => { + const mark = { after: { kind: "activity", content: "" } as SessionEvent }; + let events: SessionEvent[] = [mark.after]; + for (let i = 0; i < SESSION_RING_CAP + 5; i++) { + events = appendSessionEvent(events, { kind: "progress", content: String(i), done: true }); + } + expect(events).not.toContain(mark.after); + // Many sealed cards later the turn is still runningโ€ฆ + expect(turnInFlight(events, mark)).toBe(true); + // โ€ฆand ends on its boundary. + events = appendSessionEvent(events, { kind: "answer", content: "done", status: STATUS }); + expect(turnInFlight(events, mark)).toBe(false); + }); +}); + describe("foldSessionLiveness (WEB-STATUS-1 head-dot state)", () => { const lifecycle = (state?: string): SessionEvent => ({ kind: "session_lifecycle", diff --git a/crates/ccteam-web/web/src/hooks/useSessionEvents.ts b/crates/ccteam-web/web/src/hooks/useSessionEvents.ts index a0f77904..e5168934 100644 --- a/crates/ccteam-web/web/src/hooks/useSessionEvents.ts +++ b/crates/ccteam-web/web/src/hooks/useSessionEvents.ts @@ -3,7 +3,7 @@ // Generalizes `useProgressStream`'s EventSource dance to the W2 endpoint // GET /api/v1/sessions/{sid}/events // which streams `event: progress` frames whose payload is the W2 shape -// { id, sid, kind: "answer" | "progress", content, ts, done?, options? } +// { id, sid, kind: "answer" | "progress", content, ts, done?, status?, options? } // (see `crates/ccteam-web/src/routes/sessions_api.rs::session_event_payload`). // `options` is the non-empty label list of an approval ChoicePrompt โ€” the // hook surfaces it so ChatConsole can render "session sX wants to run โ€ฆ @@ -52,10 +52,11 @@ export interface SessionActivity { /** One event line off the per-session SSE stream (the W2 payload shape). * `kind` is "answer" (assistant reply / approval prompt), "progress" - * (status edit, `done` on the finalizing one), or "activity" (a structured - * per-step `activity` payload, v0.8.19). `options` present + non-empty - * marks an approval prompt; `token` is then the pending-resolution token - * the web resolve path POSTs back (R-H1). */ + * (status edit; `done` once that progress CARD is sealed โ€” not the turn, + * see {@link isTurnBoundary}), or "activity" (a structured per-step + * `activity` payload, v0.8.19). `options` present + non-empty marks an + * approval prompt; `token` is then the pending-resolution token the web + * resolve path POSTs back (R-H1). */ export interface SessionEvent { id?: string; sid?: string; @@ -80,9 +81,45 @@ export interface SessionEvent { * branch (`ccteam-web/src/routes/sessions_api.rs`). */ state?: string; reason?: string; + /** Present on exactly one `answer` per turn: the turn boundary. Absent on + * an interim answer (see {@link isTurnBoundary}). */ status?: TurnStatus; } +/** Whether `ev` ends its session's turn (#209). A session now speaks DURING + * a long turn: each thing it says mid-turn arrives as an interim `answer` + * with no `status`, and the turn is still running after it. The turn ends + * on exactly one status-bearing `answer` โ€” non-empty `content` = the final + * reply, empty = a status-only closing frame. `progress{done:true}` only + * seals one progress card (each interim answer seals the current one) and + * says nothing about the turn; a turn can also end with no sealed card at + * all. Every "is the turn over?" question in the SPA asks this predicate โ€” + * never "did an answer arrive" or "did a card finalize". */ +export function isTurnBoundary(ev: { kind: string; status?: TurnStatus | null }): boolean { + return ev.kind === "answer" && ev.status != null; +} + +/** The send-time watermark of a turn submitted from this page: the newest + * frame already buffered when it left (`null` = the buffer was empty). */ +export interface TurnMark { + after: SessionEvent | null; +} + +/** Whether the turn sent at `mark` is still running: no turn boundary has + * arrived since the send. Anchored on the watermark frame's IDENTITY rather + * than a count or an index: the ring drops its oldest frames once full, so a + * count shrinks and an index shifts in the middle of exactly the long turns + * this has to survive. A watermark that has aged out of the ring means every + * buffered frame is newer than the send. Pure โ€” unit-testable. */ +export function turnInFlight(events: readonly SessionEvent[], mark: TurnMark | null): boolean { + if (!mark) return false; + const start = mark.after ? events.lastIndexOf(mark.after) + 1 : 0; + for (let index = start; index < events.length; index += 1) { + if (isTurnBoundary(events[index]!)) return false; + } + return true; +} + export interface UseSessionEventsResult { /** Ring buffer of events, newest at the END. Capped โ€” oldest drop. */ events: SessionEvent[]; diff --git a/crates/ccteam-web/web/src/lib/agentsReducer.test.ts b/crates/ccteam-web/web/src/lib/agentsReducer.test.ts index ba13e445..2aeac096 100644 --- a/crates/ccteam-web/web/src/lib/agentsReducer.test.ts +++ b/crates/ccteam-web/web/src/lib/agentsReducer.test.ts @@ -70,20 +70,33 @@ describe("sidsActiveWithin", () => { expect(sidsActiveWithin(events, 15_000, now).has("s1")).toBe(false); }); - it("a finalizing progress (done:true) ends the pulse even if recent", () => { + const STATUS = { model: "m", context: null, turn: 2, cost_usd: null, tokens_total: null }; + + it("a sealed progress card (done:true) does not end the pulse mid-turn (#209)", () => { const now = 100_000; const events = [ ts({ sid: "s1", kind: "activity", receivedAt: now - 500 }), ts({ sid: "s1", kind: "progress", done: true, receivedAt: now - 200 }), ]; - expect(sidsActiveWithin(events, 15_000, now).has("s1")).toBe(false); + expect(sidsActiveWithin(events, 15_000, now).has("s1")).toBe(true); + }); + + it("an interim answer (no status) keeps the pulse โ€” the turn goes on (#209)", () => { + const now = 100_000; + const events = [ + ts({ sid: "s1", kind: "activity", receivedAt: now - 500 }), + ts({ sid: "s1", kind: "answer", content: "checkingโ€ฆ", receivedAt: now - 200 }), + ]; + expect(sidsActiveWithin(events, 15_000, now).has("s1")).toBe(true); }); - it("an answer ends the pulse", () => { + it("the turn boundary (a status-bearing answer) ends the pulse", () => { const now = 100_000; const events = [ ts({ sid: "s1", kind: "activity", receivedAt: now - 500 }), - ts({ sid: "s1", kind: "answer", receivedAt: now - 200 }), + ts({ sid: "s1", kind: "answer", content: "checkingโ€ฆ", receivedAt: now - 400 }), + ts({ sid: "s1", kind: "progress", done: true, receivedAt: now - 300 }), + ts({ sid: "s1", kind: "answer", content: "", status: STATUS, receivedAt: now - 200 }), ]; expect(sidsActiveWithin(events, 15_000, now).has("s1")).toBe(false); }); diff --git a/crates/ccteam-web/web/src/lib/agentsReducer.ts b/crates/ccteam-web/web/src/lib/agentsReducer.ts index 868c943c..4f47bf67 100644 --- a/crates/ccteam-web/web/src/lib/agentsReducer.ts +++ b/crates/ccteam-web/web/src/lib/agentsReducer.ts @@ -4,6 +4,7 @@ import type { AgentEdge } from "./agentsApi"; import type { AgentsEvent } from "../hooks/useAgentsEvents"; +import { isTurnBoundary } from "../hooks/useSessionEvents"; /** Fold one delegation frame into the edge list: `dispatched` marks (or * creates) the parentโ†’child edge active; `completed`/`notified`/`stopped`/ @@ -64,11 +65,14 @@ export interface TimestampedAgentsEvent extends AgentsEvent { } /** Which sids currently look "in-turn" (pulse the node ring): the LAST - * `progress`/`activity` frame for that sid landed within `windowMs` (default - * 15s, per tech-design) of `nowMs`, and no finalizing `progress{done:true}` - * or `answer` has landed for it since. Processes events in order so a later - * terminal frame always wins over an earlier non-terminal one. Pure + - * DOM-free; `nowMs` is injectable for deterministic tests. */ + * in-turn frame for that sid โ€” `progress` / `activity`, or an interim + * `answer` (the session speaking mid-turn, #209) โ€” landed within `windowMs` + * (default 15s, per tech-design) of `nowMs`, and no turn boundary + * ({@link isTurnBoundary}) has landed for it since. A sealed progress card + * (`progress{done:true}`) neither ends nor extends the pulse: it closes one + * card, not the turn. Processes events in order so a later boundary always + * wins over an earlier in-turn frame. Pure + DOM-free; `nowMs` is + * injectable for deterministic tests. */ export function sidsActiveWithin( events: TimestampedAgentsEvent[], windowMs: number = 15_000, @@ -77,15 +81,12 @@ export function sidsActiveWithin( const lastNonTerminalAt = new Map(); for (const ev of events) { if (!ev.sid) continue; - if (ev.kind === "progress" && ev.done) { - lastNonTerminalAt.delete(ev.sid); // a finalizing progress ends the pulse - continue; - } - if (ev.kind === "answer") { + if (isTurnBoundary(ev)) { lastNonTerminalAt.delete(ev.sid); continue; } - if (ev.kind === "progress" || ev.kind === "activity") { + if (ev.kind === "progress" && ev.done) continue; + if (ev.kind === "progress" || ev.kind === "activity" || ev.kind === "answer") { lastNonTerminalAt.set(ev.sid, ev.receivedAt); } } diff --git a/crates/ccteam-web/web/src/pages/AgentsView.tsx b/crates/ccteam-web/web/src/pages/AgentsView.tsx index 840168d7..0d6ad621 100644 --- a/crates/ccteam-web/web/src/pages/AgentsView.tsx +++ b/crates/ccteam-web/web/src/pages/AgentsView.tsx @@ -58,7 +58,7 @@ import { useProjectsStore } from "../hooks/useProjectsStore"; import { VendorChip } from "../components/VendorChip"; import { copyText } from "../lib/clipboard"; import { getHistory, type SessionHistoryEvent } from "../lib/sessionsApi"; -import { emptyFold, foldActivity, renderFold, type ActivityFold } from "./chatTranscript"; +import { currentTurnFold, emptyFold, renderFold, type ActivityFold } from "./chatTranscript"; import { vendorDotClass } from "../lib/vendors"; import { makeT, tr, type Lang } from "../lib/i18n"; import { relativeTime } from "./railHelpers"; @@ -893,16 +893,12 @@ export default function AgentsView({ const selectedNode = graph.nodes.find((node) => node.sid === selected) ?? null; - const activityFold: ActivityFold = useMemo(() => { - let fold = emptyFold(); - if (!selected) return fold; - for (const ev of timestamped) { - if (ev.sid !== selected) continue; - if (ev.kind === "activity" && ev.activity) fold = foldActivity(fold, ev.activity); - else if (ev.kind === "answer") fold = emptyFold(); // a new answer ends the turn - } - return fold; - }, [timestamped, selected]); + // The selected session's current turn: interim answers keep counting, only + // the turn boundary starts a fresh fold (#209). + const activityFold: ActivityFold = useMemo( + () => (selected ? currentTurnFold(timestamped, selected) : emptyFold()), + [timestamped, selected], + ); useEffect(() => { if (!selected || historyBySid[selected]) return; diff --git a/crates/ccteam-web/web/src/pages/SessionView.test.tsx b/crates/ccteam-web/web/src/pages/SessionView.test.tsx index 5f440703..8c0dd55f 100644 --- a/crates/ccteam-web/web/src/pages/SessionView.test.tsx +++ b/crates/ccteam-web/web/src/pages/SessionView.test.tsx @@ -919,6 +919,157 @@ describe("SessionView history seed vs live stream (GitHub #186)", () => { }); }); +describe("SessionView working state across interim answers (#209)", () => { + interface StreamState { + events: SessionEvent[]; + connected: boolean; + connectionEpoch: number; + lastError: string | null; + gatewayUnavailable: boolean; + } + const STATUS = { model: "m", context: null, turn: 3, cost_usd: null, tokens_total: null }; + + async function mountTurnView(box: { stream: StreamState }) { + const harness = createHookHarness(); + vi.resetModules(); + vi.doMock("react", async () => ({ + ...(await vi.importActual("react")), + ...harness.hooks, + })); + vi.doMock("../hooks/useSessionEvents", async () => ({ + ...(await vi.importActual( + "../hooks/useSessionEvents", + )), + useSessionEvents: () => box.stream, + })); + vi.doMock("../lib/sessionsApi", async () => ({ + ...(await vi.importActual("../lib/sessionsApi")), + getHistory: vi.fn().mockResolvedValue({ sid: "s9", events: [] }), + getSessionStatus: vi.fn().mockResolvedValue({ + sid: "s9", + model: null, + context: null, + status_line: null, + }), + submitTurn: vi.fn().mockResolvedValue(undefined), + listScheduled: vi.fn().mockResolvedValue([]), + getDaemonTimezone: vi.fn().mockResolvedValue("UTC"), + })); + const View = (await import("./SessionView")).default; + return () => harness.render(() => View({ sid: "s9", session: SESSION })); + } + + function unmockTurnView() { + vi.doUnmock("react"); + vi.doUnmock("../hooks/useSessionEvents"); + vi.doUnmock("../lib/sessionsApi"); + vi.resetModules(); + } + + /** The composer element (the harness renders SessionView only, so its + * children stay elements whose props we can read). */ + function composerOf(value: unknown): Record | null { + if (Array.isArray(value)) { + for (const child of value) { + const found = composerOf(child); + if (found) return found; + } + return null; + } + if (!value || typeof value !== "object") return null; + const props = (value as { props?: Record }).props; + if (!props) return null; + if (typeof props.onSend === "function" && "busy" in props) return props; + return composerOf(props.children); + } + + const settle = async () => { + await Promise.resolve(); + await Promise.resolve(); + }; + + /** Fold the new frames (effects), then read the settled tree. */ + function push(box: { stream: StreamState }, render: () => T, ...events: SessionEvent[]): T { + box.stream = { ...box.stream, events: [...box.stream.events, ...events] }; + render(); + return render(); + } + + const healthy = (): StreamState => ({ + events: [{ id: "old", kind: "answer", content: "previous turn", status: { ...STATUS, turn: 2 } }], + connected: true, + connectionEpoch: 1, + lastError: null, + gatewayUnavailable: false, + }); + + it("an interim answer shows live while Stop + the cursor stay up until the boundary", async () => { + const box = { stream: healthy() }; + try { + const render = await mountTurnView(box); + render(); + await settle(); + let tree = render(); + expect(composerOf(tree)?.busy).toBe(false); + + (composerOf(tree)?.onSend as (content: string) => void)("run the suite"); + tree = render(); + expect(composerOf(tree)?.busy).toBe(true); + + // Mid-turn: a tool runs, its card seals (`done`), the session speaks. + tree = push( + box, + render, + { + id: "act-1", + kind: "activity", + content: "", + activity: { kind: "tool_call", name: "Bash", summary: "", status: "started", item_id: "b1" }, + }, + { id: "gateway-progress-s9-1", kind: "progress", content: "โ†ณ 1 tools ยท 0 files", done: true }, + { id: "a1", kind: "answer", content: "checkingโ€ฆ" }, + ); + expect(collectElementText(tree)).toContain("checkingโ€ฆ"); + expect(collectElementText(tree)).toContain("โ†ณ 1 tools ยท 0 files"); + expect(composerOf(tree)?.busy).toBe(true); + expect(findByTestId(tree, "streaming-cursor")).not.toBeNull(); + + // The turn boundary: the final reply carries the turn's status. + tree = push(box, render, { id: "a2", kind: "answer", content: "all green", status: STATUS }); + expect(collectElementText(tree)).toContain("all green"); + expect(composerOf(tree)?.busy).toBe(false); + expect(findByTestId(tree, "streaming-cursor")).toBeNull(); + } finally { + unmockTurnView(); + } + }); + + it("a status-only boundary with no sealed card ends the turn and footers its last bubble", async () => { + const box = { stream: healthy() }; + try { + const render = await mountTurnView(box); + render(); + await settle(); + let tree = render(); + (composerOf(tree)?.onSend as (content: string) => void)("status?"); + tree = push(box, render, { id: "a1", kind: "answer", content: "checkingโ€ฆ" }); + expect(composerOf(tree)?.busy).toBe(true); + + tree = push(box, render, { id: "st", kind: "answer", content: "", status: STATUS }); + expect(composerOf(tree)?.busy).toBe(false); + expect(findByTestId(tree, "streaming-cursor")).toBeNull(); + const text = collectElementText(tree); + expect(text.filter((value) => value === "checkingโ€ฆ")).toHaveLength(1); + // The closing frame drew no bubble of its own (two agent footers: the + // previous turn's and this one's); its status footers "checkingโ€ฆ". + expect(text).toContain("claude ยท s9 ยท m ยท turn 3"); + expect(text.filter((value) => /^claude ยท s9/.test(value))).toHaveLength(2); + } finally { + unmockTurnView(); + } + }); +}); + describe("BusyHeartbeat (GitHub #186 B)", () => { it("renders elapsed time and the last event clock while a turn runs", () => { const since = Date.now() - 754_000; // 12:34 ago diff --git a/crates/ccteam-web/web/src/pages/SessionView.tsx b/crates/ccteam-web/web/src/pages/SessionView.tsx index 1f6e0e7e..974bf2d0 100644 --- a/crates/ccteam-web/web/src/pages/SessionView.tsx +++ b/crates/ccteam-web/web/src/pages/SessionView.tsx @@ -25,7 +25,12 @@ import CostPill from "../components/CostPill"; import { Markdown } from "../components/Markdown"; import { TerminalView } from "../components/TerminalView"; import { VendorChip } from "../components/VendorChip"; -import { foldSessionLiveness, useSessionEvents } from "../hooks/useSessionEvents"; +import { + foldSessionLiveness, + turnInFlight, + useSessionEvents, + type TurnMark, +} from "../hooks/useSessionEvents"; import { makeT, type Lang } from "../lib/i18n"; import { defaultDraft, normalizeDraft, vendorSpec, type ComposerDraft } from "../lib/vendors"; import { @@ -197,7 +202,9 @@ export default function SessionView({ // wins over what the user typed. const [editingTitle, setEditingTitle] = useState(false); const [pendingTitle, setPendingTitle] = useState(null); - const [busyMark, setBusyMark] = useState(null); + // The turn this page sent, as a watermark into the SSE buffer (see + // `turnInFlight`); `null` = no turn of ours is running. + const [turnMark, setTurnMark] = useState(null); const [busySince, setBusySince] = useState(null); const [historyError, setHistoryError] = useState(null); const [rows, setRows] = useState(() => loadRows(sid)); @@ -247,10 +254,12 @@ export default function SessionView({ setRows((current) => mergeHistory(current, seeded, events, barrier)); foldedRef.current = events.length; } - const latest = [...seeded].reverse().find((row) => row.kind === "assistant" && row.status); - if (latest?.status) { - setStatusModel(latest.status.model ?? null); - setCtxPct(contextPct(latest.status.context)); + // Newest turn status in the page โ€” a status-only closing row counts + // even when it had no reply of its own to footer (#209). + const latest = [...h.events].reverse().find((ev) => ev.status)?.status; + if (latest) { + setStatusModel(latest.model ?? null); + setCtxPct(contextPct(latest.context)); } setHistoryPage({ hasMore: h.has_more === true, @@ -322,9 +331,19 @@ export default function SessionView({ saveRows(sid, rows); }, [sid, rows]); + // ---- working state of the turn we sent (#209) ------------------------------ + // Ends on the turn BOUNDARY only โ€” the one status-bearing answer. Neither an + // interim answer (the session talking mid-turn; Stop + the cursor must stay) + // nor a sealed progress card (`done` closes one card, not the turn) ends + // it, and a boundary with no sealed card before it still does. + const busy = turnInFlight(events, turnMark); + if (turnMark !== null && !busy) { + // Retire the finished turn's watermark (render-phase adjust) so its + // boundary aging out of the ring can never resurrect it. + setTurnMark(null); + } + // ---- per-session status (model + effort + ctx%) -------------------------- - const doneCount = events.reduce((n, ev) => (ev.done ? n + 1 : n), 0); - const busy = busyMark !== null && doneCount === busyMark; const lastEventTs = events.length > 0 ? events[events.length - 1]?.ts : undefined; useEffect(() => { let cancelled = false; @@ -423,10 +442,11 @@ export default function SessionView({ .filter(Boolean); const shown = names.length > 0 ? `${content}\n๐Ÿ“Ž ${names.join(", ")}` : content; pushRow({ kind: "user", content: shown }); - setBusyMark(doneCount); + const buffered = eventsRef.current; + setTurnMark({ after: buffered[buffered.length - 1] ?? null }); setBusySince(Date.now()); submitTurn(sid, content, attachments).catch((e) => { - setBusyMark(null); + setTurnMark(null); setBusySince(null); const detail = e instanceof Error ? e.message : "unknown"; pushRow({ @@ -435,7 +455,7 @@ export default function SessionView({ }); }); }, - [sid, pushRow, doneCount], + [sid, pushRow], ); // ---- resolve a HITL approval prompt (gateway pending machinery) ---------- diff --git a/crates/ccteam-web/web/src/pages/chatTranscript.test.ts b/crates/ccteam-web/web/src/pages/chatTranscript.test.ts index baae4f78..44330567 100644 --- a/crates/ccteam-web/web/src/pages/chatTranscript.test.ts +++ b/crates/ccteam-web/web/src/pages/chatTranscript.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from "vitest"; import { appendEvent, appendRow, + currentTurnFold, eventToRow, foldActivity, emptyFold, @@ -225,6 +226,13 @@ describe("chatTranscript eventToRow", () => { expect(r).not.toBeNull(); expect(r!.kind).toBe("system"); }); + + it("a card sealed mid-turn reads its own text, never a hardcoded `done` (#209)", () => { + const r = eventToRow( + ev({ id: "gateway-progress-s9-1", kind: "progress", content: "โ†ณ 3 tools ยท 1 files", done: true }), + ); + expect(r).toMatchObject({ kind: "system", content: "โ†ณ 3 tools ยท 1 files" }); + }); }); describe("chatTranscript activity fold (v0.8.21 โ€” mirrors IM ProgressFold)", () => { @@ -316,6 +324,74 @@ describe("chatTranscript activity fold (v0.8.21 โ€” mirrors IM ProgressFold)", ( }); }); +describe("chatTranscript interim answers and the turn boundary (#209)", () => { + const STATUS = { model: "m", context: null, turn: 3, cost_usd: null, tokens_total: null }; + const step = (item: string, name = "Bash"): SessionEvent => ({ + kind: "activity", + content: "", + activity: { kind: "tool_call", name, summary: "", status: "started", item_id: item }, + }); + + it("an interim answer is a live bubble; the status-only boundary footers it, no empty bubble", () => { + let rows: TranscriptRow[] = [{ id: "u1", kind: "user", content: "go" }]; + rows = appendEvent(rows, step("b1")); + rows = appendEvent(rows, { id: "p1", kind: "progress", content: "โ†ณ 1 tools ยท 0 files", done: true }); + rows = appendEvent(rows, { id: "a1", kind: "answer", content: "checkingโ€ฆ" }); + rows = appendEvent(rows, step("r1", "Read")); + rows = appendEvent(rows, { id: "a2", kind: "answer", content: "found it" }); + expect(rows.map((r) => r.kind)).toEqual(["user", "activity", "system", "assistant", "activity", "assistant"]); + expect(rows[3]!.status).toBeUndefined(); + expect(rows[5]!.status).toBeUndefined(); + + rows = appendEvent(rows, { id: "st", kind: "answer", content: "", status: STATUS }); + expect(rows).toHaveLength(6); // no bubble for the closing frame + expect(rows[5]!.status).toEqual(STATUS); // the turn's last answer carries the footer + expect(rows[3]!.status).toBeUndefined(); + }); + + it("the footer walks back over activity/system rows but never past the turn's user row", () => { + let rows: TranscriptRow[] = [{ id: "a0", kind: "assistant", content: "earlier" }]; + rows = appendEvent(rows, { id: "a1", kind: "answer", content: "said" }); + rows = appendEvent(rows, step("b1")); + rows = appendEvent(rows, { id: "p1", kind: "progress", content: "โœ… done ยท 1 tools", done: true }); + rows = appendEvent(rows, { id: "st", kind: "answer", content: "", status: STATUS }); + expect(rows[1]!.status).toEqual(STATUS); + + const asked: TranscriptRow[] = [ + { id: "a0", kind: "assistant", content: "unfinished" }, + { id: "u1", kind: "user", content: "next" }, + ]; + expect(appendEvent(asked, { id: "st2", kind: "answer", content: "", status: STATUS })).toBe(asked); + + const footered: TranscriptRow[] = [{ id: "a0", kind: "assistant", content: "x", status: STATUS }]; + const later = { ...STATUS, turn: 4 }; + expect(appendEvent(footered, { id: "st3", kind: "answer", content: "", status: later })).toBe( + footered, + ); + }); + + it("a boundary that carries the final reply is that reply's own bubble + footer", () => { + const rows = appendEvent([], { id: "f", kind: "answer", content: "final", status: STATUS }); + expect(rows).toEqual([ + expect.objectContaining({ kind: "assistant", content: "final", status: STATUS }), + ]); + }); + + it("currentTurnFold counts the whole turn across interim answers; the boundary resets it", () => { + const on = (sid: string, ev: SessionEvent) => ({ ...ev, sid }); + const events = [ + on("s1", step("b1")), + on("s1", { kind: "answer", content: "checkingโ€ฆ" }), + on("s2", step("x1", "Edit")), + on("s1", step("r1", "Read")), + ]; + expect(renderFold(currentTurnFold(events, "s1"))).toBe("โณ workingโ€ฆ ยท ๐Ÿ”ง bash ร—1 ยท ๐Ÿ“– read ร—1"); + const ended = [...events, on("s1", { kind: "answer", content: "", status: STATUS })]; + expect(currentTurnFold(ended, "s1")).toEqual(emptyFold()); + expect(renderFold(currentTurnFold(ended, "s2"))).toBe("โณ workingโ€ฆ ยท โœ๏ธ edit ร—1"); + }); +}); + describe("chatTranscript historyToRows", () => { it("expands mirrored turns into user+assistant rows", () => { const events: SessionHistoryEvent[] = [ @@ -337,6 +413,39 @@ describe("chatTranscript historyToRows", () => { expect(rows.map((r) => r.ts)).toEqual(["2026-06-06T00:00:00Z", "2026-06-06T00:00:00Z"]); }); + it("a status-only closing row is no bubble; its status footers the turn's last answer (#209)", () => { + const status = { model: "m", context: null, turn: 7, cost_usd: null, tokens_total: null }; + const events: SessionHistoryEvent[] = [ + { turn_id: "t1", ts: "2026-09-25T00:00:00Z", role: "", user: "go", assistant: "" }, + { turn_id: "t2", ts: "2026-09-25T00:01:00Z", role: "", user: "", assistant: "checkingโ€ฆ" }, + { turn_id: "t3", ts: "2026-09-25T00:09:00Z", role: "", user: "", assistant: "all green" }, + { turn_id: "t4", ts: "2026-09-25T00:09:01Z", role: "", user: "", assistant: "", status }, + ]; + const rows = historyToRows(events); + expect(rows.map((r) => [r.kind, r.content])).toEqual([ + ["user", "go"], + ["assistant", "checkingโ€ฆ"], + ["assistant", "all green"], + ]); + expect(rows[1]!.status).toBeUndefined(); // interim stays footer-less + expect(rows[2]!.status).toEqual(status); + }); + + it("a closing row never footers another turn's answer", () => { + const prev = { model: "m", context: null, turn: 1, cost_usd: null, tokens_total: null }; + const next = { ...prev, turn: 2 }; + const rows = historyToRows([ + { turn_id: "t1", ts: "a", role: "", user: "", assistant: "earlier", status: prev }, + { turn_id: "t2", ts: "b", role: "", user: "", assistant: "", status: next }, + { turn_id: "t3", ts: "c", role: "", user: "", assistant: "unfinished" }, + { turn_id: "t4", ts: "d", role: "", user: "new question", assistant: "" }, + { turn_id: "t5", ts: "e", role: "", user: "", assistant: "", status: next }, + ]); + expect(rows.map((r) => r.content)).toEqual(["earlier", "unfinished", "new question"]); + expect(rows[0]!.status).toEqual(prev); // already footered: kept + expect(rows[1]!.status).toBeUndefined(); // behind the new turn's user row + }); + it("renders an attachment-only mirrored turn after reload", () => { const events: SessionHistoryEvent[] = [ { @@ -389,6 +498,38 @@ describe("chatTranscript mergeHistory (GitHub #186 A)", () => { expect(rows.filter((r) => r.content === "late")).toHaveLength(1); }); + it("keeps a live interim line that repeats an OLDER mirrored line (#209)", () => { + // The session said "checkingโ€ฆ" earlier in the page, then finished; mid + // the next turn it says "checkingโ€ฆ" again while the reseed is in flight. + const seeded = historyToRows([ + { turn_id: "t1", ts: "x", role: "", user: "q", assistant: "" }, + { turn_id: "t2", ts: "x", role: "", user: "", assistant: "checkingโ€ฆ" }, + { turn_id: "t3", ts: "x", role: "", user: "", assistant: "done" }, + ]); + const rows = mergeHistory([], seeded, [answer("e9", "checkingโ€ฆ")], 0); + expect(rows.map((r) => r.content)).toEqual(["q", "checkingโ€ฆ", "done", "checkingโ€ฆ"]); + }); + + it("dedups only the page's tail, in order", () => { + const seeded = historyToRows([ + { turn_id: "t1", ts: "x", role: "", user: "q", assistant: "" }, + { turn_id: "t2", ts: "x", role: "", user: "", assistant: "a1" }, + { turn_id: "t3", ts: "x", role: "", user: "", assistant: "a2" }, + ]); + // a2 was mirrored before the server read the page; a3 after it. + const rows = mergeHistory([], seeded, [answer("e2", "a2"), answer("e3", "a3")], 0); + expect(rows.map((r) => r.content)).toEqual(["q", "a1", "a2", "a3"]); + }); + + it("a late status-only boundary footers the page's last answer, once", () => { + const status = { model: "m", context: null, turn: 5, cost_usd: null, tokens_total: null }; + const seeded = historyToRows(page("working on it")); + const boundary: SessionEvent = { id: "st", kind: "answer", content: "", status }; + const rows = mergeHistory([], seeded, [boundary], 0); + expect(rows.map((r) => r.content)).toEqual(["q", "working on it"]); + expect(rows[1]!.status).toEqual(status); + }); + it("treats frames delivered before the request as mirrored by the page", () => { const seeded = historyToRows(page("")); // barrier=1: the reply was buffered BEFORE the request left โ†’ the page diff --git a/crates/ccteam-web/web/src/pages/chatTranscript.ts b/crates/ccteam-web/web/src/pages/chatTranscript.ts index f6bf5a4d..90e66164 100644 --- a/crates/ccteam-web/web/src/pages/chatTranscript.ts +++ b/crates/ccteam-web/web/src/pages/chatTranscript.ts @@ -9,10 +9,11 @@ // owns its OWN transcript (a per-sid localStorage key), so switching the // sid view NEVER mixes two sessions' rows. -import type { - SessionActivity, - SessionEvent, - SessionEventOption, +import { + isTurnBoundary, + type SessionActivity, + type SessionEvent, + type SessionEventOption, } from "../hooks/useSessionEvents"; import type { OutboundAttachmentRef, SessionHistoryEvent, TurnStatus } from "../lib/sessionsApi"; @@ -120,7 +121,9 @@ export function eventToRow(ev: SessionEvent): TranscriptRow | null { attachments: ev.attachments, }; } - // progress โ€” only surface a finalizing edit with text (status churn is noise). + // progress โ€” only surface a sealed card with text (status churn is noise). + // It reads the frame's own text: mid-turn an interim answer seals the card + // as `โ†ณ 3 tools ยท 1 files`, only the boundary's reads `โœ… done ยท โ€ฆ` (#209). if (ev.done && ev.content) { return { id: ev.id ?? nextRowId("system"), kind: "system", content: ev.content, ts: ev.ts }; } @@ -242,13 +245,63 @@ export function renderFold(fold: ActivityFold): string { return `${head} ยท ${counts}`; } +/** `sid`'s activity folded over its CURRENT turn (the team view's side + * panel): every step since that session's last turn boundary. An interim + * answer is the session speaking mid-turn (#209) โ€” the turn goes on, so the + * count keeps growing across it; only the status-bearing boundary starts a + * fresh fold. Pure โ€” unit-testable. */ +export function currentTurnFold( + events: readonly { + sid?: string; + kind: string; + activity?: SessionActivity; + status?: TurnStatus; + }[], + sid: string, +): ActivityFold { + let fold = emptyFold(); + for (const ev of events) { + if (ev.sid !== sid) continue; + if (ev.kind === "activity" && ev.activity) fold = foldActivity(fold, ev.activity); + else if (isTurnBoundary(ev)) fold = emptyFold(); + } + return fold; +} + +/** Index of the assistant row a status-only turn boundary footers (#209), or + * `-1`. A long turn's replies go out mid-turn with no status, and the turn + * closes with a frame (live) / row (history) that has the status but no + * text: its `turn N ยท ctx` footer belongs on the turn's LAST reply. Walks + * back over activity / progress / approval rows; a user row (where the turn + * began) or an already-footered reply (a previous turn's boundary) stops the + * walk, so a footer never lands on another turn's bubble. */ +function turnFooterIndex(rows: readonly TranscriptRow[]): number { + for (let index = rows.length - 1; index >= 0; index -= 1) { + const row = rows[index]!; + if (row.kind === "user") return -1; + if (row.kind !== "assistant") continue; + return row.status ? -1 : index; + } + return -1; +} + /** Reduce one SSE {@link SessionEvent} into the transcript, FOLDING a run of * consecutive structured activity steps into a single counter row. Any other - * event (answer / approval / finalizing progress, or a bare activity frame + * event (answer / approval / sealed progress card, or a bare activity frame * with no payload) lands as its own row via {@link eventToRow} โ€” which * naturally "closes" the current fold, so the next activity starts a fresh - * one. This is the single entry the live SSE loop uses. */ + * one. An interim answer is therefore a normal bubble that also closes the + * fold; a status-only turn boundary draws no bubble and hands its status to + * the turn's last reply as the footer (#209). This is the single entry the + * live SSE loop uses. */ export function appendEvent(rows: TranscriptRow[], ev: SessionEvent): TranscriptRow[] { + if (isTurnBoundary(ev) && ev.status && !ev.content && !ev.attachments?.length) { + const at = turnFooterIndex(rows); + if (at < 0) return rows; + const next = [...rows]; + next[at] = { ...rows[at]!, status: ev.status }; + return next; + } if (ev.kind === "activity" && ev.activity) { const last = rows[rows.length - 1]; if (last && last.kind === "activity" && last.fold) { @@ -282,9 +335,16 @@ export function appendEvent(rows: TranscriptRow[], ev: SessionEvent): Transcript * approval prompts never enter history, so an unresolved approval row that * arrived on this stream is carried over from `current`. Frames after the * barrier raced the server's read and are re-applied, deduplicating a - * reply the page already mirrors by content, so a reply can neither vanish + * reply the page already mirrors, so a reply can neither vanish * (`setRows(history)` over a just-folded answer โ€” the "TG has it, web shows - * a bare completed turn" report) nor double. Pure โ€” unit-testable. */ + * a bare completed turn" report) nor double. + * + * The dedup is by POSITION, not by "this text appears somewhere in the + * page": the late replies the page already holds are the ones fanned out + * before the server read turns.jsonl, i.e. the FIRST late replies, and they + * are the page's LAST assistant rows in the same order. Anything else with + * equal text is a new line โ€” a session repeating "checkingโ€ฆ" mid-turn + * (#209) โ€” and must render. Pure โ€” unit-testable. */ export function mergeHistory( current: TranscriptRow[], seeded: TranscriptRow[], @@ -292,6 +352,7 @@ export function mergeHistory( barrier: number, ): TranscriptRow[] { let next = [...seeded]; + const late = events.slice(barrier); const delivered = new Set( events.slice(0, barrier).flatMap((ev) => (ev.id ? [ev.id] : [])), ); @@ -305,26 +366,38 @@ export function mergeHistory( next = appendRow(next, row); } } - for (const ev of events.slice(barrier)) { - const isApproval = ev.options !== undefined && ev.options.length > 0; - if ( - ev.kind === "answer" && - !isApproval && - ev.content && - seeded.some((r) => r.kind === "assistant" && r.content === ev.content) - ) { - continue; // already mirrored into this page - } + const mirrored = mirroredLateReplies(seeded, late); + for (const ev of late) { + if (mirrored.has(ev)) continue; // already mirrored into this page if (ev.id && next.some((r) => r.id === ev.id)) continue; next = appendEvent(next, ev); } return next; } +/** The late replies `seeded` already mirrors: the longest run of the first + * late text replies whose contents equal, in order, the page's last + * assistant rows (see {@link mergeHistory}). */ +function mirroredLateReplies(seeded: TranscriptRow[], late: SessionEvent[]): Set { + const replies = late.filter( + (ev) => ev.kind === "answer" && !(ev.options && ev.options.length > 0) && ev.content, + ); + const page = seeded.filter((row) => row.kind === "assistant").map((row) => row.content); + for (let overlap = Math.min(replies.length, page.length); overlap > 0; overlap -= 1) { + const tail = page.slice(page.length - overlap); + if (tail.every((content, index) => replies[index]!.content === content)) { + return new Set(replies.slice(0, overlap)); + } + } + return new Set(); +} + /** Seed a transcript from mirrored history (`GET /sessions/{sid}`). Each * turn yields a user row (when it had a prompt) then an assistant row - * (when it had a reply). Used to populate a reopened per-session page - * before the live SSE takes over. */ + * (when it had a reply). A reply without a status is one the session gave + * mid-turn โ€” a normal bubble; a closing row with a status but no reply + * (#209) draws nothing and footers the turn's last reply instead. Used to + * populate a reopened per-session page before the live SSE takes over. */ export function historyToRows(events: SessionHistoryEvent[]): TranscriptRow[] { const rows: TranscriptRow[] = []; for (const ev of events) { @@ -340,6 +413,9 @@ export function historyToRows(events: SessionHistoryEvent[]): TranscriptRow[] { status: ev.status, attachments: ev.attachments, }); + } else if (ev.status) { + const at = turnFooterIndex(rows); + if (at >= 0) rows[at] = { ...rows[at]!, status: ev.status }; } } return rows; From cf32cbfcc5db700e908eb701e761882e7d2850c5 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Fri, 25 Sep 2026 06:47:05 +0000 Subject: [PATCH 08/37] fix(web): only an answer marked interim keeps the turn running (#209) The server now marks a mid-turn message positively with `interim: true`. Reading "no status" as "still running" was wrong: terminal-protocol replies, slash-command replies, failure notices and recovered answers carry no status and end their exchange all the same, so those sessions stayed busy forever. `isTurnBoundary` is now "an answer not marked interim"; an approval prompt is excluded too, since the turn is blocked on the human, not finished (before #209 an approval never ended busy either). Both parsers read `interim`; the team view's parser also reads approval options and parses `scheduled_changed` as itself instead of as an answer (which pulsed the node for a window). Review fixes: - The history reseed matched mirrored late replies as a contiguous run, so a late reply turns.jsonl never stores (the watchdog heads-up, a slash reply, an IM-decorated reply) broke the run and doubled every reply after it; and a new line identical to the page's last reply was dropped. The page tail is now matched as an in-order subsequence of the late replies, anchored on the newest pre-request frame the page holds; when content cannot decide, a doubled line wins over a lost one. The IM context tag / status-line suffix is recognized. - A send read `eventsRef`, which catches up only in an effect: a send between a commit and its effects anchored behind a boundary already on screen and ended busy at once. It now reads the rendered frames. - A status-only closing frame skips standalone answers (no interim marker, no status) when picking the reply to footer, and a closing row that opens a history page now footers the earlier page's last reply on "load earlier". - The team panel's recent turns skip closing rows (blank lines that also took one of the three slots). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../web/src/hooks/useAgentsEvents.test.ts | 29 ++- .../web/src/hooks/useAgentsEvents.ts | 31 ++-- .../web/src/hooks/useSessionEvents.test.ts | 33 +++- .../web/src/hooks/useSessionEvents.ts | 82 +++++---- .../web/src/lib/agentsReducer.test.ts | 33 +++- .../ccteam-web/web/src/lib/agentsReducer.ts | 12 +- .../ccteam-web/web/src/pages/AgentsView.tsx | 12 +- .../web/src/pages/SessionView.test.tsx | 137 +++++++++++++- .../ccteam-web/web/src/pages/SessionView.tsx | 26 ++- .../web/src/pages/chatTranscript.test.ts | 108 ++++++++++- .../web/src/pages/chatTranscript.ts | 167 +++++++++++++----- 11 files changed, 538 insertions(+), 132 deletions(-) diff --git a/crates/ccteam-web/web/src/hooks/useAgentsEvents.test.ts b/crates/ccteam-web/web/src/hooks/useAgentsEvents.test.ts index 14c9dcc8..de911405 100644 --- a/crates/ccteam-web/web/src/hooks/useAgentsEvents.test.ts +++ b/crates/ccteam-web/web/src/hooks/useAgentsEvents.test.ts @@ -94,16 +94,29 @@ describe("parseAgentsEvent", () => { expect(ev).toMatchObject({ kind: "session_lifecycle", sid: "s4", slug: "demo" }); }); - it("keeps an answer's status so an interim answer and the turn boundary differ (#209)", () => { - const status = { model: "m", context: null, turn: 4, cost_usd: null, tokens_total: null }; - const boundary = parseAgentsEvent( - JSON.stringify({ kind: "answer", sid: "s1", content: "", status }), - ); - expect(boundary!.status).toEqual(status); + it("keeps the interim marker and approval options, so the team view can tell a running turn (#209)", () => { const interim = parseAgentsEvent( - JSON.stringify({ kind: "answer", sid: "s1", content: "checkingโ€ฆ", status: null }), + JSON.stringify({ kind: "answer", sid: "s1", content: "checkingโ€ฆ", interim: true }), + ); + expect(interim!.interim).toBe(true); + const final = parseAgentsEvent(JSON.stringify({ kind: "answer", sid: "s1", content: "done" })); + expect(final!.interim).toBeUndefined(); + const approval = parseAgentsEvent( + JSON.stringify({ + kind: "answer", + sid: "s1", + content: "run it?", + options: [{ label: "Approve", id: "allow" }, { bogus: true }], + }), + ); + expect(approval!.options).toEqual([{ label: "Approve", id: "allow" }]); + }); + + it("parses a scheduled_changed frame as itself, never as an answer (#209)", () => { + const ev = parseAgentsEvent( + JSON.stringify({ kind: "scheduled_changed", sid: "s1", slug: "demo", content: "" }), ); - expect(interim!.status).toBeUndefined(); + expect(ev).toMatchObject({ kind: "scheduled_changed", sid: "s1" }); }); it("defaults an unrecognized kind to answer and missing content to ''", () => { diff --git a/crates/ccteam-web/web/src/hooks/useAgentsEvents.ts b/crates/ccteam-web/web/src/hooks/useAgentsEvents.ts index 9750f9b9..d75cb420 100644 --- a/crates/ccteam-web/web/src/hooks/useAgentsEvents.ts +++ b/crates/ccteam-web/web/src/hooks/useAgentsEvents.ts @@ -19,9 +19,8 @@ import { useEffect, useRef, useState } from "react"; import { createAuthedEventSource } from "../lib/authedEventSource"; -import { shouldAcceptEventSeq } from "./useSessionEvents"; -import type { SessionActivity } from "./useSessionEvents"; -import type { TurnStatus } from "../lib/sessionsApi"; +import { parseEventOptions, shouldAcceptEventSeq } from "./useSessionEvents"; +import type { SessionActivity, SessionEventOption } from "./useSessionEvents"; /** One frame off the global SSE stream: every ordinary per-session event * (`answer`/`progress`/`activity`, now carrying `slug`) PLUS a delegation @@ -34,13 +33,21 @@ export interface AgentsEvent { id?: string; sid?: string; slug?: string; - kind: "answer" | "progress" | "activity" | "delegation" | "session_lifecycle"; + kind: + | "answer" + | "progress" + | "activity" + | "delegation" + | "session_lifecycle" + | "scheduled_changed"; content: string; done?: boolean; activity?: SessionActivity; - /** Answer-only: present on the turn boundary, absent on an interim answer - * (`isTurnBoundary`, #209). */ - status?: TurnStatus; + /** Answer-only (#209): said mid-turn, the turn is still running + * (`isTurnBoundary`). */ + interim?: boolean; + /** Answer-only: an approval prompt's options โ€” the turn waits on a human. */ + options?: SessionEventOption[]; /** Delegation-only: one of spawned|dispatched|completed|notified| * collected|stopped|denied. */ relation?: string; @@ -90,7 +97,9 @@ export function parseAgentsEvent(raw: string): AgentsEvent | null { ? "delegation" : obj.kind === "session_lifecycle" ? "session_lifecycle" - : "answer"; + : obj.kind === "scheduled_changed" + ? "scheduled_changed" + : "answer"; const event: AgentsEvent = { kind, content: typeof obj.content === "string" ? obj.content : "", @@ -99,9 +108,9 @@ export function parseAgentsEvent(raw: string): AgentsEvent | null { if (typeof obj.sid === "string") event.sid = obj.sid; if (typeof obj.slug === "string") event.slug = obj.slug; if (obj.done === true) event.done = true; - if (typeof obj.status === "object" && obj.status !== null) { - event.status = obj.status as TurnStatus; - } + if (obj.interim === true) event.interim = true; + const options = parseEventOptions(obj.options); + if (options) event.options = options; if (typeof obj.activity === "object" && obj.activity !== null) { const a = obj.activity as Record; event.activity = { diff --git a/crates/ccteam-web/web/src/hooks/useSessionEvents.test.ts b/crates/ccteam-web/web/src/hooks/useSessionEvents.test.ts index 30e5fe1a..e2d53212 100644 --- a/crates/ccteam-web/web/src/hooks/useSessionEvents.test.ts +++ b/crates/ccteam-web/web/src/hooks/useSessionEvents.test.ts @@ -384,16 +384,27 @@ describe("turn boundary vs interim answer (#209)", () => { const STATUS = { model: "m", context: null, turn: 3, cost_usd: null, tokens_total: null }; const parse = (frame: Record) => parseSessionEvent(JSON.stringify(frame))!; - it("only a status-bearing answer ends the turn", () => { - // Interim: the session said something mid-turn โ€” no status, still running. - const interim = parse({ id: "a1", kind: "answer", content: "checkingโ€ฆ" }); - expect(interim.status).toBeUndefined(); + it("only an answer the server does not mark interim ends the turn (contract v2)", () => { + // Interim: said mid-turn, marked explicitly โ€” the turn is still running. + const interim = parse({ id: "a1", kind: "answer", content: "checkingโ€ฆ", interim: true }); + expect(interim.interim).toBe(true); expect(isTurnBoundary(interim)).toBe(false); - // A null status is the same as none (never a boundary). - expect(isTurnBoundary(parse({ kind: "answer", content: "x", status: null }))).toBe(false); - // The boundary, with the final reply or status-only (empty content). + // Anything but `true` is no interim marker at all. + expect(parse({ kind: "answer", content: "x", interim: false }).interim).toBeUndefined(); + expect(parse({ kind: "answer", content: "x", interim: "yes" }).interim).toBeUndefined(); + // Every other answer ends it: the final reply, the status-only closing + // frame, and a reply with no status at all (terminal protocol, a slash + // command, a recovered answer) โ€” "no status" is never "still running". expect(isTurnBoundary(parse({ kind: "answer", content: "final", status: STATUS }))).toBe(true); expect(isTurnBoundary(parse({ kind: "answer", content: "", status: STATUS }))).toBe(true); + expect(isTurnBoundary(parse({ kind: "answer", content: "model set" }))).toBe(true); + // An approval prompt asks the human and waits: the turn goes on. + const approval = parse({ + kind: "answer", + content: "run it?", + options: [{ label: "Approve", id: "allow" }], + }); + expect(isTurnBoundary(approval)).toBe(false); // A sealed progress card is not a turn boundary, even the last one. const sealed = parse({ kind: "progress", content: "โ†ณ 3 tools ยท 1 files", done: true }); expect(sealed.done).toBe(true); @@ -402,7 +413,7 @@ describe("turn boundary vs interim answer (#209)", () => { it("turnInFlight: running until a boundary arrives AFTER the send", () => { const before: SessionEvent = { kind: "answer", content: "old", status: STATUS }; - const interim: SessionEvent = { kind: "answer", content: "checkingโ€ฆ" }; + const interim: SessionEvent = { kind: "answer", content: "checkingโ€ฆ", interim: true }; const sealed: SessionEvent = { kind: "progress", content: "โ†ณ 1 tools", done: true }; const boundary: SessionEvent = { kind: "answer", content: "", status: STATUS }; @@ -417,6 +428,12 @@ describe("turn boundary vs interim answer (#209)", () => { expect(turnInFlight([interim, boundary], { after: null })).toBe(false); }); + it("turnInFlight: a status-less reply ends the exchange (terminal / slash command)", () => { + const mark = { after: null }; + expect(turnInFlight([{ kind: "answer", content: "model set to opus" }], mark)).toBe(false); + expect(turnInFlight([{ kind: "answer", content: "tmux reply" }], mark)).toBe(false); + }); + it("turnInFlight survives the ring dropping the watermark frame", () => { const mark = { after: { kind: "activity", content: "" } as SessionEvent }; let events: SessionEvent[] = [mark.after]; diff --git a/crates/ccteam-web/web/src/hooks/useSessionEvents.ts b/crates/ccteam-web/web/src/hooks/useSessionEvents.ts index e5168934..66bad78c 100644 --- a/crates/ccteam-web/web/src/hooks/useSessionEvents.ts +++ b/crates/ccteam-web/web/src/hooks/useSessionEvents.ts @@ -3,7 +3,7 @@ // Generalizes `useProgressStream`'s EventSource dance to the W2 endpoint // GET /api/v1/sessions/{sid}/events // which streams `event: progress` frames whose payload is the W2 shape -// { id, sid, kind: "answer" | "progress", content, ts, done?, status?, options? } +// { id, sid, kind: "answer" | "progress", content, ts, done?, status?, interim?, options? } // (see `crates/ccteam-web/src/routes/sessions_api.rs::session_event_payload`). // `options` is the non-empty label list of an approval ChoicePrompt โ€” the // hook surfaces it so ChatConsole can render "session sX wants to run โ€ฆ @@ -81,22 +81,32 @@ export interface SessionEvent { * branch (`ccteam-web/src/routes/sessions_api.rs`). */ state?: string; reason?: string; - /** Present on exactly one `answer` per turn: the turn boundary. Absent on - * an interim answer (see {@link isTurnBoundary}). */ + /** Answer-only: the turn's status (`turn N ยท ctx`), on the answer that + * closes a turn โ€” the final reply, or a status-only frame with empty + * `content`. Its absence says nothing about the turn (see + * {@link isTurnBoundary}). */ status?: TurnStatus; + /** Answer-only (#209): `true` = said mid-turn, the turn is still running. + * The server says it positively; absent on every other answer. */ + interim?: boolean; } -/** Whether `ev` ends its session's turn (#209). A session now speaks DURING - * a long turn: each thing it says mid-turn arrives as an interim `answer` - * with no `status`, and the turn is still running after it. The turn ends - * on exactly one status-bearing `answer` โ€” non-empty `content` = the final - * reply, empty = a status-only closing frame. `progress{done:true}` only - * seals one progress card (each interim answer seals the current one) and - * says nothing about the turn; a turn can also end with no sealed card at - * all. Every "is the turn over?" question in the SPA asks this predicate โ€” - * never "did an answer arrive" or "did a card finalize". */ -export function isTurnBoundary(ev: { kind: string; status?: TurnStatus | null }): boolean { - return ev.kind === "answer" && ev.status != null; +/** Whether `ev` ends the exchange its session was working on (#209). A + * session now speaks DURING a long turn, and the server marks each such + * mid-turn message `interim: true` โ€” the turn is still running after it. + * Every other answer ends the exchange, with or without a `status`: many + * replies carry none and are still final (the terminal protocol's replies, + * slash-command replies, recovered answers), so "no status" must never be + * read as "still running". Two frames are not an end either: an approval + * prompt (`options`) is a question the turn is blocked on, and + * `progress{done:true}` only seals one progress card. Every "is the turn + * over?" question in the SPA asks this predicate. */ +export function isTurnBoundary(ev: { + kind: string; + interim?: boolean; + options?: readonly unknown[]; +}): boolean { + return ev.kind === "answer" && ev.interim !== true && !(ev.options && ev.options.length > 0); } /** The send-time watermark of a turn submitted from this page: the newest @@ -183,6 +193,28 @@ export function shouldAcceptEventSeq( return { accept: true, nextHighest: seq }; } +/** An approval prompt's options off a raw frame, or `undefined` for none. + * v0.8.7 review-fix (R-H1) โ€” options are `{label, id}` objects; the id is + * the decision value the resolve path sends back as `selection`. A malformed + * entry (no string label) is dropped so a half-formed frame can't render a + * broken chip. Shared with the team view's parser. */ +export function parseEventOptions(raw: unknown): SessionEventOption[] | undefined { + if (!Array.isArray(raw)) return undefined; + const opts: SessionEventOption[] = []; + for (const o of raw) { + if (typeof o === "object" && o !== null) { + const rec = o as Record; + if (typeof rec.label === "string") { + opts.push({ + label: rec.label, + id: typeof rec.id === "string" ? rec.id : "", + }); + } + } + } + return opts.length > 0 ? opts : undefined; +} + /** Parse one raw SSE `progress` payload into a {@link SessionEvent}, or * `null` for garbage / a non-object (dropped silently โ€” the next frame * lands clean). Pure + DOM-free so it is unit-testable in node env. */ @@ -217,6 +249,7 @@ export function parseSessionEvent(raw: string): SessionEvent | null { if (typeof obj.status === "object" && obj.status !== null) { event.status = obj.status as TurnStatus; } + if (obj.interim === true) event.interim = true; if (obj.done === true) event.done = true; // v0.8.19 โ€” the structured per-step activity object (DOM-free, defensive: // a malformed `activity` is simply dropped, leaving a bare "activity" event @@ -231,25 +264,8 @@ export function parseSessionEvent(raw: string): SessionEvent | null { item_id: typeof a.item_id === "string" ? a.item_id : "", }; } - if (Array.isArray(obj.options)) { - // v0.8.7 review-fix (R-H1) โ€” options are `{label, id}` objects; the id is - // the decision value the resolve path sends back as `selection`. Drop any - // malformed entry (no string label) so a half-formed frame can't render a - // broken chip. - const opts: SessionEventOption[] = []; - for (const o of obj.options) { - if (typeof o === "object" && o !== null) { - const rec = o as Record; - if (typeof rec.label === "string") { - opts.push({ - label: rec.label, - id: typeof rec.id === "string" ? rec.id : "", - }); - } - } - } - if (opts.length > 0) event.options = opts; - } + const options = parseEventOptions(obj.options); + if (options) event.options = options; if (Array.isArray(obj.attachments)) { const attachments: OutboundAttachmentRef[] = []; for (const attachment of obj.attachments) { diff --git a/crates/ccteam-web/web/src/lib/agentsReducer.test.ts b/crates/ccteam-web/web/src/lib/agentsReducer.test.ts index 2aeac096..d9f91d1f 100644 --- a/crates/ccteam-web/web/src/lib/agentsReducer.test.ts +++ b/crates/ccteam-web/web/src/lib/agentsReducer.test.ts @@ -70,8 +70,6 @@ describe("sidsActiveWithin", () => { expect(sidsActiveWithin(events, 15_000, now).has("s1")).toBe(false); }); - const STATUS = { model: "m", context: null, turn: 2, cost_usd: null, tokens_total: null }; - it("a sealed progress card (done:true) does not end the pulse mid-turn (#209)", () => { const now = 100_000; const events = [ @@ -81,23 +79,44 @@ describe("sidsActiveWithin", () => { expect(sidsActiveWithin(events, 15_000, now).has("s1")).toBe(true); }); - it("an interim answer (no status) keeps the pulse โ€” the turn goes on (#209)", () => { + it("an interim answer keeps the pulse โ€” the turn goes on (#209)", () => { + const now = 100_000; + const events = [ + ts({ sid: "s1", kind: "activity", receivedAt: now - 500 }), + ts({ sid: "s1", kind: "answer", content: "checkingโ€ฆ", interim: true, receivedAt: now - 200 }), + ]; + expect(sidsActiveWithin(events, 15_000, now).has("s1")).toBe(true); + }); + + it("an approval prompt keeps the pulse โ€” the turn waits on a human (#209)", () => { const now = 100_000; const events = [ ts({ sid: "s1", kind: "activity", receivedAt: now - 500 }), - ts({ sid: "s1", kind: "answer", content: "checkingโ€ฆ", receivedAt: now - 200 }), + ts({ + sid: "s1", + kind: "answer", + content: "run it?", + options: [{ label: "Approve", id: "allow" }], + receivedAt: now - 200, + }), ]; expect(sidsActiveWithin(events, 15_000, now).has("s1")).toBe(true); }); - it("the turn boundary (a status-bearing answer) ends the pulse", () => { + it("any answer not marked interim ends the pulse, with or without a status", () => { const now = 100_000; const events = [ ts({ sid: "s1", kind: "activity", receivedAt: now - 500 }), - ts({ sid: "s1", kind: "answer", content: "checkingโ€ฆ", receivedAt: now - 400 }), + ts({ sid: "s1", kind: "answer", content: "checkingโ€ฆ", interim: true, receivedAt: now - 400 }), ts({ sid: "s1", kind: "progress", done: true, receivedAt: now - 300 }), - ts({ sid: "s1", kind: "answer", content: "", status: STATUS, receivedAt: now - 200 }), + ts({ sid: "s1", kind: "answer", content: "tmux reply", receivedAt: now - 200 }), ]; expect(sidsActiveWithin(events, 15_000, now).has("s1")).toBe(false); }); + + it("a scheduled_changed frame never pulses a node", () => { + const now = 100_000; + const events = [ts({ sid: "s1", kind: "scheduled_changed", receivedAt: now - 200 })]; + expect(sidsActiveWithin(events, 15_000, now).has("s1")).toBe(false); + }); }); diff --git a/crates/ccteam-web/web/src/lib/agentsReducer.ts b/crates/ccteam-web/web/src/lib/agentsReducer.ts index 4f47bf67..3ba94bf2 100644 --- a/crates/ccteam-web/web/src/lib/agentsReducer.ts +++ b/crates/ccteam-web/web/src/lib/agentsReducer.ts @@ -65,12 +65,12 @@ export interface TimestampedAgentsEvent extends AgentsEvent { } /** Which sids currently look "in-turn" (pulse the node ring): the LAST - * in-turn frame for that sid โ€” `progress` / `activity`, or an interim - * `answer` (the session speaking mid-turn, #209) โ€” landed within `windowMs` - * (default 15s, per tech-design) of `nowMs`, and no turn boundary - * ({@link isTurnBoundary}) has landed for it since. A sealed progress card - * (`progress{done:true}`) neither ends nor extends the pulse: it closes one - * card, not the turn. Processes events in order so a later boundary always + * in-turn frame for that sid โ€” `progress` / `activity`, or an answer that is + * not the end of the exchange (an interim line, an approval prompt; #209) โ€” + * landed within `windowMs` (default 15s, per tech-design) of `nowMs`, and no + * end ({@link isTurnBoundary}) has landed for it since. A sealed progress + * card (`progress{done:true}`) neither ends nor extends the pulse: it closes + * one card, not the turn. Processes events in order so a later end always * wins over an earlier in-turn frame. Pure + DOM-free; `nowMs` is * injectable for deterministic tests. */ export function sidsActiveWithin( diff --git a/crates/ccteam-web/web/src/pages/AgentsView.tsx b/crates/ccteam-web/web/src/pages/AgentsView.tsx index 0d6ad621..d9b28d79 100644 --- a/crates/ccteam-web/web/src/pages/AgentsView.tsx +++ b/crates/ccteam-web/web/src/pages/AgentsView.tsx @@ -58,7 +58,13 @@ import { useProjectsStore } from "../hooks/useProjectsStore"; import { VendorChip } from "../components/VendorChip"; import { copyText } from "../lib/clipboard"; import { getHistory, type SessionHistoryEvent } from "../lib/sessionsApi"; -import { currentTurnFold, emptyFold, renderFold, type ActivityFold } from "./chatTranscript"; +import { + currentTurnFold, + emptyFold, + recentHistoryTurns, + renderFold, + type ActivityFold, +} from "./chatTranscript"; import { vendorDotClass } from "../lib/vendors"; import { makeT, tr, type Lang } from "../lib/i18n"; import { relativeTime } from "./railHelpers"; @@ -903,7 +909,9 @@ export default function AgentsView({ useEffect(() => { if (!selected || historyBySid[selected]) return; getHistory(selected) - .then((h) => setHistoryBySid((prev) => ({ ...prev, [selected]: h.events.slice(-3) }))) + .then((h) => + setHistoryBySid((prev) => ({ ...prev, [selected]: recentHistoryTurns(h.events, 3) })), + ) .catch(() => setHistoryBySid((prev) => ({ ...prev, [selected]: [] }))); }, [selected, historyBySid]); diff --git a/crates/ccteam-web/web/src/pages/SessionView.test.tsx b/crates/ccteam-web/web/src/pages/SessionView.test.tsx index 8c0dd55f..8d8150c1 100644 --- a/crates/ccteam-web/web/src/pages/SessionView.test.tsx +++ b/crates/ccteam-web/web/src/pages/SessionView.test.tsx @@ -246,6 +246,21 @@ function createHookHarness() { for (const effect of effects) effect(); return tree; }, + /** A committed render whose passive effects have NOT run yet โ€” the + * window in which a user event can fire against the new tree. */ + renderDeferred(component: () => T): { tree: T; flush: () => void } { + cursor = 0; + pendingEffects = []; + const tree = component(); + const effects = pendingEffects; + pendingEffects = []; + return { + tree, + flush: () => { + for (const effect of effects) effect(); + }, + }; + }, }; } @@ -929,7 +944,10 @@ describe("SessionView working state across interim answers (#209)", () => { } const STATUS = { model: "m", context: null, turn: 3, cost_usd: null, tokens_total: null }; - async function mountTurnView(box: { stream: StreamState }) { + async function mountTurnView( + box: { stream: StreamState }, + history = vi.fn().mockResolvedValue({ sid: "s9", events: [] }), + ) { const harness = createHookHarness(); vi.resetModules(); vi.doMock("react", async () => ({ @@ -944,7 +962,7 @@ describe("SessionView working state across interim answers (#209)", () => { })); vi.doMock("../lib/sessionsApi", async () => ({ ...(await vi.importActual("../lib/sessionsApi")), - getHistory: vi.fn().mockResolvedValue({ sid: "s9", events: [] }), + getHistory: history, getSessionStatus: vi.fn().mockResolvedValue({ sid: "s9", model: null, @@ -956,7 +974,10 @@ describe("SessionView working state across interim answers (#209)", () => { getDaemonTimezone: vi.fn().mockResolvedValue("UTC"), })); const View = (await import("./SessionView")).default; - return () => harness.render(() => View({ sid: "s9", session: SESSION })); + const view = () => View({ sid: "s9", session: SESSION }); + return Object.assign(() => harness.render(view), { + deferred: () => harness.renderDeferred(view), + }); } function unmockTurnView() { @@ -1027,7 +1048,7 @@ describe("SessionView working state across interim answers (#209)", () => { activity: { kind: "tool_call", name: "Bash", summary: "", status: "started", item_id: "b1" }, }, { id: "gateway-progress-s9-1", kind: "progress", content: "โ†ณ 1 tools ยท 0 files", done: true }, - { id: "a1", kind: "answer", content: "checkingโ€ฆ" }, + { id: "a1", kind: "answer", content: "checkingโ€ฆ", interim: true }, ); expect(collectElementText(tree)).toContain("checkingโ€ฆ"); expect(collectElementText(tree)).toContain("โ†ณ 1 tools ยท 0 files"); @@ -1052,7 +1073,7 @@ describe("SessionView working state across interim answers (#209)", () => { await settle(); let tree = render(); (composerOf(tree)?.onSend as (content: string) => void)("status?"); - tree = push(box, render, { id: "a1", kind: "answer", content: "checkingโ€ฆ" }); + tree = push(box, render, { id: "a1", kind: "answer", content: "checkingโ€ฆ", interim: true }); expect(composerOf(tree)?.busy).toBe(true); tree = push(box, render, { id: "st", kind: "answer", content: "", status: STATUS }); @@ -1068,6 +1089,112 @@ describe("SessionView working state across interim answers (#209)", () => { unmockTurnView(); } }); + + it("a reply with no status and no interim marker ends the working state (contract v2)", async () => { + // A terminal-protocol reply, a slash-command reply, a recovered answer: + // none will ever be followed by a status-bearing answer. + const box = { stream: healthy() }; + try { + const render = await mountTurnView(box); + render(); + await settle(); + let tree = render(); + (composerOf(tree)?.onSend as (content: string) => void)("/model opus"); + expect(composerOf(render())?.busy).toBe(true); + tree = push(box, render, { id: "r1", kind: "answer", content: "model set to opus" }); + expect(collectElementText(tree)).toContain("model set to opus"); + expect(composerOf(tree)?.busy).toBe(false); + } finally { + unmockTurnView(); + } + }); + + it("an approval prompt keeps the working state: the turn waits, it did not end", async () => { + const box = { stream: healthy() }; + try { + const render = await mountTurnView(box); + render(); + await settle(); + const tree = render(); + (composerOf(tree)?.onSend as (content: string) => void)("deploy"); + const prompted = push(box, render, { + id: "permission-p1", + kind: "answer", + content: "wants to run: rm -rf dist", + options: [{ label: "Approve", id: "allow" }], + token: "p1", + }); + expect(composerOf(prompted)?.busy).toBe(true); + } finally { + unmockTurnView(); + } + }); + + it("a send right after a boundary rendered (effects not yet run) still marks the new turn", async () => { + const box = { stream: healthy() }; + try { + const render = await mountTurnView(box); + render(); + await settle(); + let tree = render(); + (composerOf(tree)?.onSend as (content: string) => void)("first"); + tree = push(box, render, { id: "a1", kind: "answer", content: "working", interim: true }); + expect(composerOf(tree)?.busy).toBe(true); + + // The first turn's boundary is committed; before its passive effects + // run, the user sends the next message from that very tree. + box.stream = { + ...box.stream, + events: [...box.stream.events, { id: "b1", kind: "answer", content: "done", status: STATUS }], + }; + const committed = render.deferred(); + expect(composerOf(committed.tree)?.busy).toBe(false); + (composerOf(committed.tree)?.onSend as (content: string) => void)("second"); + committed.flush(); + // The second turn is running: the first turn's boundary is behind it. + expect(composerOf(render())?.busy).toBe(true); + } finally { + unmockTurnView(); + } + }); + + it("load-earlier footers the earlier page's last reply when the newer page starts with its closing row", async () => { + const box = { stream: { ...healthy(), events: [] } }; + const closing = { ...STATUS, turn: 9 }; + const history = vi + .fn() + .mockResolvedValueOnce({ + sid: "s9", + events: [ + { turn_id: "t4", ts: "d", role: "", user: "", assistant: "", status: closing }, + { turn_id: "t5", ts: "e", role: "", user: "next question", assistant: "" }, + ], + next_before: "cursor-1", + has_more: true, + }) + .mockResolvedValueOnce({ + sid: "s9", + events: [ + { turn_id: "t2", ts: "b", role: "", user: "go", assistant: "" }, + { turn_id: "t3", ts: "c", role: "", user: "", assistant: "long answer" }, + ], + next_before: null, + has_more: false, + }); + try { + const render = await mountTurnView(box, history); + render(); + await settle(); + const tree = render(); + (findByTestId(tree, "load-earlier")?.props.onClick as () => void)(); + await settle(); + const text = collectElementText(render()); + expect(text.indexOf("long answer")).toBeLessThan(text.indexOf("next question")); + expect(text.some((value) => value.endsWith("claude ยท s9 ยท m ยท turn 9"))).toBe(true); + } finally { + unmockTurnView(); + } + }); }); describe("BusyHeartbeat (GitHub #186 B)", () => { diff --git a/crates/ccteam-web/web/src/pages/SessionView.tsx b/crates/ccteam-web/web/src/pages/SessionView.tsx index 974bf2d0..acf0fc1b 100644 --- a/crates/ccteam-web/web/src/pages/SessionView.tsx +++ b/crates/ccteam-web/web/src/pages/SessionView.tsx @@ -47,11 +47,13 @@ import { type OutboundAttachmentRef, type SessionView as SessionSummary, type ScheduledItem, + type TurnStatus, } from "../lib/sessionsApi"; import { appendEvent, appendRow, historyToRows, + leadingClosingStatus, loadRows, mergeHistory, nextRowId, @@ -226,6 +228,9 @@ export default function SessionView({ hasMore: false, nextBefore: null as string | null, loadingEarlier: false, + // The oldest loaded page opened with a closing row (#209): its status + // footers the last reply of the next earlier page. + closedBy: undefined as TurnStatus | undefined, }); useEffect(() => { eventsRef.current = events; @@ -265,6 +270,7 @@ export default function SessionView({ hasMore: h.has_more === true, nextBefore: h.next_before ?? null, loadingEarlier: false, + closedBy: leadingClosingStatus(h.events), }); setHistoryError(null); }) @@ -298,12 +304,13 @@ export default function SessionView({ getHistory(sid, { before }) .then((history) => { if (request !== historyRequestRef.current) return; - const earlier = historyToRows(history.events); + const earlier = historyToRows(history.events, historyPage.closedBy); if (earlier.length > 0) setRows((current) => [...earlier, ...current]); setHistoryPage({ hasMore: history.has_more === true, nextBefore: history.next_before ?? null, loadingEarlier: false, + closedBy: leadingClosingStatus(history.events), }); }) .catch(() => { @@ -332,10 +339,11 @@ export default function SessionView({ }, [sid, rows]); // ---- working state of the turn we sent (#209) ------------------------------ - // Ends on the turn BOUNDARY only โ€” the one status-bearing answer. Neither an - // interim answer (the session talking mid-turn; Stop + the cursor must stay) - // nor a sealed progress card (`done` closes one card, not the turn) ends - // it, and a boundary with no sealed card before it still does. + // Ends on the first answer that ends the exchange (`isTurnBoundary`): any + // answer the server does not mark interim, with or without a status. An + // interim line (the session talking mid-turn; Stop + the cursor must stay), + // an approval prompt, or a sealed progress card (`done` closes one card, + // not the turn) does not end it. const busy = turnInFlight(events, turnMark); if (turnMark !== null && !busy) { // Retire the finished turn's watermark (render-phase adjust) so its @@ -442,8 +450,10 @@ export default function SessionView({ .filter(Boolean); const shown = names.length > 0 ? `${content}\n๐Ÿ“Ž ${names.join(", ")}` : content; pushRow({ kind: "user", content: shown }); - const buffered = eventsRef.current; - setTurnMark({ after: buffered[buffered.length - 1] ?? null }); + // The frames this tree RENDERED, not `eventsRef` โ€” that ref catches up + // in an effect, and a send landing between a commit and its effects + // would anchor behind a boundary already on screen (ending busy at once). + setTurnMark({ after: events[events.length - 1] ?? null }); setBusySince(Date.now()); submitTurn(sid, content, attachments).catch((e) => { setTurnMark(null); @@ -455,7 +465,7 @@ export default function SessionView({ }); }); }, - [sid, pushRow], + [sid, pushRow, events], ); // ---- resolve a HITL approval prompt (gateway pending machinery) ---------- diff --git a/crates/ccteam-web/web/src/pages/chatTranscript.test.ts b/crates/ccteam-web/web/src/pages/chatTranscript.test.ts index 44330567..723d9064 100644 --- a/crates/ccteam-web/web/src/pages/chatTranscript.test.ts +++ b/crates/ccteam-web/web/src/pages/chatTranscript.test.ts @@ -14,8 +14,10 @@ import { foldActivity, emptyFold, historyToRows, + leadingClosingStatus, loadRows, mergeHistory, + recentHistoryTurns, renderFold, rowsKeyFor, saveRows, @@ -336,9 +338,9 @@ describe("chatTranscript interim answers and the turn boundary (#209)", () => { let rows: TranscriptRow[] = [{ id: "u1", kind: "user", content: "go" }]; rows = appendEvent(rows, step("b1")); rows = appendEvent(rows, { id: "p1", kind: "progress", content: "โ†ณ 1 tools ยท 0 files", done: true }); - rows = appendEvent(rows, { id: "a1", kind: "answer", content: "checkingโ€ฆ" }); + rows = appendEvent(rows, { id: "a1", kind: "answer", content: "checkingโ€ฆ", interim: true }); rows = appendEvent(rows, step("r1", "Read")); - rows = appendEvent(rows, { id: "a2", kind: "answer", content: "found it" }); + rows = appendEvent(rows, { id: "a2", kind: "answer", content: "found it", interim: true }); expect(rows.map((r) => r.kind)).toEqual(["user", "activity", "system", "assistant", "activity", "assistant"]); expect(rows[3]!.status).toBeUndefined(); expect(rows[5]!.status).toBeUndefined(); @@ -351,7 +353,7 @@ describe("chatTranscript interim answers and the turn boundary (#209)", () => { it("the footer walks back over activity/system rows but never past the turn's user row", () => { let rows: TranscriptRow[] = [{ id: "a0", kind: "assistant", content: "earlier" }]; - rows = appendEvent(rows, { id: "a1", kind: "answer", content: "said" }); + rows = appendEvent(rows, { id: "a1", kind: "answer", content: "said", interim: true }); rows = appendEvent(rows, step("b1")); rows = appendEvent(rows, { id: "p1", kind: "progress", content: "โœ… done ยท 1 tools", done: true }); rows = appendEvent(rows, { id: "st", kind: "answer", content: "", status: STATUS }); @@ -370,6 +372,20 @@ describe("chatTranscript interim answers and the turn boundary (#209)", () => { ); }); + it("the footer skips a standalone notice and lands on the turn's last interim reply", () => { + let rows: TranscriptRow[] = [{ id: "u1", kind: "user", content: "go" }]; + rows = appendEvent(rows, { id: "a1", kind: "answer", content: "running the suite", interim: true }); + // A watchdog heads-up / slash reply: not an interim line, no status. + rows = appendEvent(rows, { id: "w", kind: "answer", content: "โฑ๏ธ turn went silent" }); + expect(rows[2]).toMatchObject({ kind: "assistant", standalone: true }); + rows = appendEvent(rows, { id: "st", kind: "answer", content: "", status: STATUS }); + expect(rows[1]!.status).toEqual(STATUS); + expect(rows[2]!.status).toBeUndefined(); + // Interim lines and the final reply are the turn's own, never standalone. + expect(eventToRow({ kind: "answer", content: "x", interim: true })!.standalone).toBeUndefined(); + expect(eventToRow({ kind: "answer", content: "x", status: STATUS })!.standalone).toBeUndefined(); + }); + it("a boundary that carries the final reply is that reply's own bubble + footer", () => { const rows = appendEvent([], { id: "f", kind: "answer", content: "final", status: STATUS }); expect(rows).toEqual([ @@ -381,7 +397,7 @@ describe("chatTranscript interim answers and the turn boundary (#209)", () => { const on = (sid: string, ev: SessionEvent) => ({ ...ev, sid }); const events = [ on("s1", step("b1")), - on("s1", { kind: "answer", content: "checkingโ€ฆ" }), + on("s1", { kind: "answer", content: "checkingโ€ฆ", interim: true }), on("s2", step("x1", "Edit")), on("s1", step("r1", "Read")), ]; @@ -389,6 +405,9 @@ describe("chatTranscript interim answers and the turn boundary (#209)", () => { const ended = [...events, on("s1", { kind: "answer", content: "", status: STATUS })]; expect(currentTurnFold(ended, "s1")).toEqual(emptyFold()); expect(renderFold(currentTurnFold(ended, "s2"))).toBe("โณ workingโ€ฆ ยท โœ๏ธ edit ร—1"); + // A reply with no interim marker ends the turn too, status or not. + const replied = [...events, on("s1", { kind: "answer", content: "tmux reply" })]; + expect(currentTurnFold(replied, "s1")).toEqual(emptyFold()); }); }); @@ -446,6 +465,37 @@ describe("chatTranscript historyToRows", () => { expect(rows[1]!.status).toBeUndefined(); // behind the new turn's user row }); + it("a closing row that starts a page footers the earlier page's last reply on load-earlier", () => { + const status = { model: "m", context: null, turn: 9, cost_usd: null, tokens_total: null }; + const newer: SessionHistoryEvent[] = [ + { turn_id: "t4", ts: "d", role: "", user: "", assistant: "", status }, + { turn_id: "t5", ts: "e", role: "", user: "next", assistant: "" }, + ]; + const older: SessionHistoryEvent[] = [ + { turn_id: "t2", ts: "b", role: "", user: "go", assistant: "" }, + { turn_id: "t3", ts: "c", role: "", user: "", assistant: "long answer" }, + ]; + expect(historyToRows(newer).map((r) => r.content)).toEqual(["next"]); + const carried = leadingClosingStatus(newer); + expect(carried).toEqual(status); + const earlier = historyToRows(older, carried); + expect(earlier[1]).toMatchObject({ content: "long answer", status }); + // A page whose closing row follows its own reply carries nothing over. + expect(leadingClosingStatus([...older, newer[0]!])).toBeUndefined(); + expect(leadingClosingStatus(older)).toBeUndefined(); + }); + + it("recentHistoryTurns skips closing rows (nothing to show) when picking the last N", () => { + const status = { model: "m", context: null, turn: 2, cost_usd: null, tokens_total: null }; + const events: SessionHistoryEvent[] = [ + { turn_id: "t1", ts: "a", role: "", user: "go", assistant: "" }, + { turn_id: "t2", ts: "b", role: "", user: "", assistant: "one" }, + { turn_id: "t3", ts: "c", role: "", user: "", assistant: "two" }, + { turn_id: "t4", ts: "d", role: "", user: "", assistant: "", status }, + ]; + expect(recentHistoryTurns(events, 3).map((ev) => ev.turn_id)).toEqual(["t1", "t2", "t3"]); + }); + it("renders an attachment-only mirrored turn after reload", () => { const events: SessionHistoryEvent[] = [ { @@ -521,6 +571,56 @@ describe("chatTranscript mergeHistory (GitHub #186 A)", () => { expect(rows.map((r) => r.content)).toEqual(["q", "a1", "a2", "a3"]); }); + it("a late reply the page never stores does not break the dedup of the rest (#209)", () => { + // The โฑ๏ธ heads-up went out between A and B but is not in turns.jsonl. + const seeded = historyToRows([ + { turn_id: "t1", ts: "x", role: "", user: "go", assistant: "" }, + { turn_id: "t2", ts: "x", role: "", user: "", assistant: "A" }, + { turn_id: "t3", ts: "x", role: "", user: "", assistant: "B" }, + ]); + const late = [answer("e1", "A"), answer("w", "โฑ๏ธ turn went silent"), answer("e2", "B")]; + const rows = mergeHistory([], seeded, late, 0); + expect(rows.map((r) => r.content)).toEqual(["go", "A", "B", "โฑ๏ธ turn went silent"]); + }); + + it("anchors on what arrived before the request: an earlier-read tail is not re-added", () => { + const seeded = historyToRows([ + { turn_id: "t0", ts: "x", role: "", user: "", assistant: "Z" }, + { turn_id: "t1", ts: "x", role: "", user: "", assistant: "A" }, + { turn_id: "t2", ts: "x", role: "", user: "", assistant: "B" }, + ]); + const events = [answer("e0", "Z"), answer("e1", "A"), answer("w", "โฑ๏ธ"), answer("e2", "B")]; + const rows = mergeHistory([], seeded, events, 1); + expect(rows.map((r) => r.content)).toEqual(["Z", "A", "B", "โฑ๏ธ"]); + }); + + it("keeps a new line identical to the page's last reply when that one arrived before the request", () => { + const seeded = historyToRows([ + { turn_id: "t1", ts: "x", role: "", user: "q", assistant: "" }, + { turn_id: "t2", ts: "x", role: "", user: "", assistant: "W" }, + { turn_id: "t3", ts: "x", role: "", user: "", assistant: "checkingโ€ฆ" }, + ]); + // "checkingโ€ฆ" #1 was delivered BEFORE the request left (the page holds + // it); #2 arrived after โ€” a new line, said again. + const events = [answer("e1", "W"), answer("e2", "checkingโ€ฆ"), answer("e3", "checkingโ€ฆ")]; + const rows = mergeHistory([], seeded, events, 2); + expect(rows.map((r) => r.content)).toEqual(["q", "W", "checkingโ€ฆ", "checkingโ€ฆ"]); + }); + + it("recognizes a late reply the IM leg decorated (context tag, status line)", () => { + const seeded = historyToRows([ + { turn_id: "t1", ts: "x", role: "", user: "go", assistant: "" }, + { turn_id: "t2", ts: "x", role: "", user: "", assistant: "A" }, + { turn_id: "t3", ts: "x", role: "", user: "", assistant: "all green" }, + ]); + const late = [ + answer("e1", "[s9 demo claude ] A"), + answer("e2", "all green\n\nโœ… demo/s9 ยท claude ยท turn 3"), + ]; + const rows = mergeHistory([], seeded, late, 0); + expect(rows.map((r) => r.content)).toEqual(["go", "A", "all green"]); + }); + it("a late status-only boundary footers the page's last answer, once", () => { const status = { model: "m", context: null, turn: 5, cost_usd: null, tokens_total: null }; const seeded = historyToRows(page("working on it")); diff --git a/crates/ccteam-web/web/src/pages/chatTranscript.ts b/crates/ccteam-web/web/src/pages/chatTranscript.ts index 90e66164..f0e9aadc 100644 --- a/crates/ccteam-web/web/src/pages/chatTranscript.ts +++ b/crates/ccteam-web/web/src/pages/chatTranscript.ts @@ -52,6 +52,12 @@ export interface TranscriptRow { * ProgressFold โ€” a turn's many tool/think steps collapse into ONE counter * row. Present โ‡’ render {@link TranscriptRow.content} as the fold line. */ fold?: ActivityFold; + /** Assistant-only, live: an answer that is neither a mid-turn line nor a + * turn's status-bearing end (a watchdog heads-up, a slash-command or + * terminal-protocol reply, a failure notice). Complete on its own, it is no + * turn's last reply, so a later status-only closing frame never footers it + * (#209). */ + standalone?: boolean; } export const ROWS_CAP = 400; @@ -119,6 +125,7 @@ export function eventToRow(ev: SessionEvent): TranscriptRow | null { ts: ev.ts, status: ev.status, attachments: ev.attachments, + ...(ev.interim !== true && !ev.status ? { standalone: true } : {}), }; } // progress โ€” only surface a sealed card with text (status churn is noise). @@ -246,16 +253,17 @@ export function renderFold(fold: ActivityFold): string { } /** `sid`'s activity folded over its CURRENT turn (the team view's side - * panel): every step since that session's last turn boundary. An interim - * answer is the session speaking mid-turn (#209) โ€” the turn goes on, so the - * count keeps growing across it; only the status-bearing boundary starts a + * panel): every step since that session's last turn end. An interim answer + * is the session speaking mid-turn (#209) โ€” the turn goes on, so the count + * keeps growing across it; only an end ({@link isTurnBoundary}) starts a * fresh fold. Pure โ€” unit-testable. */ export function currentTurnFold( events: readonly { sid?: string; kind: string; activity?: SessionActivity; - status?: TurnStatus; + interim?: boolean; + options?: readonly unknown[]; }[], sid: string, ): ActivityFold { @@ -272,14 +280,15 @@ export function currentTurnFold( * `-1`. A long turn's replies go out mid-turn with no status, and the turn * closes with a frame (live) / row (history) that has the status but no * text: its `turn N ยท ctx` footer belongs on the turn's LAST reply. Walks - * back over activity / progress / approval rows; a user row (where the turn - * began) or an already-footered reply (a previous turn's boundary) stops the - * walk, so a footer never lands on another turn's bubble. */ + * back over activity / progress / approval rows and standalone answers + * (no turn's reply); a user row (where the turn began) or an + * already-footered reply (a previous turn's end) stops the walk, so a + * footer never lands on another turn's bubble. */ function turnFooterIndex(rows: readonly TranscriptRow[]): number { for (let index = rows.length - 1; index >= 0; index -= 1) { const row = rows[index]!; if (row.kind === "user") return -1; - if (row.kind !== "assistant") continue; + if (row.kind !== "assistant" || row.standalone) continue; return row.status ? -1 : index; } return -1; @@ -334,17 +343,11 @@ export function appendEvent(rows: TranscriptRow[], ev: SessionEvent): Transcript * represented by `seeded` (or is transient activity) โ€” with ONE exception: * approval prompts never enter history, so an unresolved approval row that * arrived on this stream is carried over from `current`. Frames after the - * barrier raced the server's read and are re-applied, deduplicating a - * reply the page already mirrors, so a reply can neither vanish - * (`setRows(history)` over a just-folded answer โ€” the "TG has it, web shows - * a bare completed turn" report) nor double. - * - * The dedup is by POSITION, not by "this text appears somewhere in the - * page": the late replies the page already holds are the ones fanned out - * before the server read turns.jsonl, i.e. the FIRST late replies, and they - * are the page's LAST assistant rows in the same order. Anything else with - * equal text is a new line โ€” a session repeating "checkingโ€ฆ" mid-turn - * (#209) โ€” and must render. Pure โ€” unit-testable. */ + * barrier raced the server's read and are re-applied, minus the replies the + * page already mirrors ({@link mirroredLateReplies}), so a reply can neither + * vanish (`setRows(history)` over a just-folded answer โ€” the "TG has it, + * web shows a bare completed turn" report) nor double. Pure โ€” + * unit-testable. */ export function mergeHistory( current: TranscriptRow[], seeded: TranscriptRow[], @@ -352,7 +355,6 @@ export function mergeHistory( barrier: number, ): TranscriptRow[] { let next = [...seeded]; - const late = events.slice(barrier); const delivered = new Set( events.slice(0, barrier).flatMap((ev) => (ev.id ? [ev.id] : [])), ); @@ -366,8 +368,8 @@ export function mergeHistory( next = appendRow(next, row); } } - const mirrored = mirroredLateReplies(seeded, late); - for (const ev of late) { + const mirrored = mirroredLateReplies(seeded, events, barrier); + for (const ev of events.slice(barrier)) { if (mirrored.has(ev)) continue; // already mirrored into this page if (ev.id && next.some((r) => r.id === ev.id)) continue; next = appendEvent(next, ev); @@ -375,31 +377,96 @@ export function mergeHistory( return next; } -/** The late replies `seeded` already mirrors: the longest run of the first - * late text replies whose contents equal, in order, the page's last - * assistant rows (see {@link mergeHistory}). */ -function mirroredLateReplies(seeded: TranscriptRow[], late: SessionEvent[]): Set { - const replies = late.filter( - (ev) => ev.kind === "answer" && !(ev.options && ev.options.length > 0) && ev.content, - ); - const page = seeded.filter((row) => row.kind === "assistant").map((row) => row.content); - for (let overlap = Math.min(replies.length, page.length); overlap > 0; overlap -= 1) { - const tail = page.slice(page.length - overlap); - if (tail.every((content, index) => replies[index]!.content === content)) { - return new Set(replies.slice(0, overlap)); +/** A text reply a history page can hold (approval prompts never enter it). */ +function isMirrorableReply(ev: SessionEvent): boolean { + return ev.kind === "answer" && !(ev.options && ev.options.length > 0) && !!ev.content; +} + +/** Whether a live reply is the one a history row mirrors. The IM leg may + * decorate what the page stores raw: a leading `[sid project vendor role] ` + * context tag and a trailing `\n\n`. */ +function mirrors(stored: string, live: string): boolean { + if (live === stored) return true; + const body = live.replace(/^\[[^\]\n]*\] /, ""); + return body === stored || body.startsWith(`${stored}\n\n`); +} + +/** Match `texts` in order into `replies`, skipping replies that match + * nothing, each text taking the EARLIEST reply left; `null` when some text + * finds none. */ +function matchInOrder(texts: readonly string[], replies: readonly SessionEvent[]): SessionEvent[] | null { + const matched: SessionEvent[] = []; + let at = 0; + for (const text of texts) { + while (at < replies.length && !mirrors(text, replies[at]!.content)) at += 1; + if (at === replies.length) return null; + matched.push(replies[at]!); + at += 1; + } + return matched; +} + +/** The late replies (`events[barrier..]`) that `seeded` already holds. + * + * The page's replies end with the late ones the gateway mirrored before the + * server read turns.jsonl โ€” the FIRST mirrored late replies, in order. Late + * replies the page never stores (a โฑ๏ธ heads-up, a slash-command reply) can + * sit anywhere among them, so the page's tail is matched as an in-order + * SUBSEQUENCE of the late replies, never as a contiguous run (#209). + * + * How long that tail is, content alone cannot always say โ€” a session may + * say the same line twice. The frames that arrived BEFORE the request + * anchor it: the page holds each of those, so the newest one the page holds + * sits right before the tail. Where the anchor still leaves a choice, the + * shorter tail wins: a doubled line beats a lost one. With nothing to + * anchor on (a fresh mount), the longest matching tail is taken. */ +function mirroredLateReplies( + seeded: readonly TranscriptRow[], + events: readonly SessionEvent[], + barrier: number, +): Set { + const page = seeded.filter((row) => row.kind === "assistant" && row.content).map((row) => row.content); + const late = events.slice(barrier).filter(isMirrorableReply); + let longest = Math.min(page.length, late.length); + while (longest > 0 && !matchInOrder(page.slice(page.length - longest), late)) longest -= 1; + let tail = longest; + const anchor = events + .slice(0, barrier) + .filter(isMirrorableReply) + .reverse() + .find((ev) => page.some((text) => mirrors(text, ev.content))); + if (anchor) { + // The anchor sits right before the tail; failing that (rows the stream + // never delivered in between), somewhere before it. + let adjacent = -1; + for (let length = 0; length <= longest && adjacent < 0; length += 1) { + const before = page[page.length - length - 1]; + if (before !== undefined && mirrors(before, anchor.content)) adjacent = length; } + const lastSeen = page.findLastIndex((text) => mirrors(text, anchor.content)); + tail = adjacent >= 0 ? adjacent : Math.min(longest, page.length - 1 - lastSeen); } - return new Set(); + return new Set(matchInOrder(page.slice(page.length - tail), late) ?? []); } /** Seed a transcript from mirrored history (`GET /sessions/{sid}`). Each * turn yields a user row (when it had a prompt) then an assistant row * (when it had a reply). A reply without a status is one the session gave * mid-turn โ€” a normal bubble; a closing row with a status but no reply - * (#209) draws nothing and footers the turn's last reply instead. Used to - * populate a reopened per-session page before the live SSE takes over. */ -export function historyToRows(events: SessionHistoryEvent[]): TranscriptRow[] { + * (#209) draws nothing and footers the turn's last reply instead. When the + * NEXT (newer) page opened with such a closing row, pass its status as + * `closedBy` ({@link leadingClosingStatus}) so this page's last reply gets + * it. Used to populate a reopened per-session page before the live SSE + * takes over, and for each "load earlier" page. */ +export function historyToRows( + events: SessionHistoryEvent[], + closedBy?: TurnStatus, +): TranscriptRow[] { const rows: TranscriptRow[] = []; + const footer = (status: TurnStatus) => { + const at = turnFooterIndex(rows); + if (at >= 0) rows[at] = { ...rows[at]!, status }; + }; for (const ev of events) { if (ev.user) { rows.push({ id: `${ev.turn_id}-u`, kind: "user", content: ev.user, ts: ev.ts }); @@ -414,13 +481,33 @@ export function historyToRows(events: SessionHistoryEvent[]): TranscriptRow[] { attachments: ev.attachments, }); } else if (ev.status) { - const at = turnFooterIndex(rows); - if (at >= 0) rows[at] = { ...rows[at]!, status: ev.status }; + footer(ev.status); } } + if (closedBy) footer(closedBy); return rows; } +/** The status of a closing row that OPENS a history page, before any row of + * its own to footer: it closes the turn whose last reply is on the earlier + * page, which {@link historyToRows} is handed as `closedBy`. */ +export function leadingClosingStatus(events: SessionHistoryEvent[]): TurnStatus | undefined { + for (const ev of events) { + if (ev.user || ev.assistant || (ev.attachments && ev.attachments.length > 0)) return undefined; + if (ev.status) return ev.status; + } + return undefined; +} + +/** The last `count` mirrored turns that have something to show โ€” a closing + * row (#209) carries only a status, and would be a blank line. */ +export function recentHistoryTurns( + events: SessionHistoryEvent[], + count: number, +): SessionHistoryEvent[] { + return events.filter((ev) => ev.user || ev.assistant).slice(-count); +} + /** Load a sid's persisted transcript from localStorage. Returns `[]` on * miss / parse error / storage disabled. The `store` arg is injectable so * tests don't need a DOM `localStorage`. */ From 79d5286661d39d26bd7728c46c9ad7d0b38c8984 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Fri, 25 Sep 2026 06:02:14 +0000 Subject: [PATCH 09/37] fix(dsh): an interim answer is shown live and never ends the turn (#209) The gateway now delivers what a session says during a long turn as interim answers (kind "answer", no status) and closes every turn with exactly one boundary answer carrying the TurnStatus object - possibly with empty content, a status-only closing frame. The workbench treated every answer as the end of the turn, so each interim line hid the working row and Stop, bumped the badge, and re-read history, and the closing frame left an empty row behind. The BFF also read `status` with a string accessor while the wire carries an object, so the two kinds could not be told apart at all. - contract: type `status` as TurnStatus and add `isTurnBoundary`, the one predicate both halves ask (status present, not a choice prompt). - bff: parse the status object (null or absent = interim) through one shared answer parser; `turn_done` and the tree re-read fire only at the boundary, and a closed progress card (done) no longer re-reads the tree, since a long turn closes several cards. - store: an interim answer settles into a row with the steps that finished before it and keeps the turn working (live, Stop, timer and the queued chip stay); the boundary ends it, and an empty boundary draws nothing - leftover live steps join the last settled row. A late step update lands on the row that holds the step, history drops blank assistant rows, and reconcile pairs each settled row with its own canonical row from the newest end so repeated lines keep their steps. - chat: history and statusline are re-read at the boundary only. Co-Authored-By: Claude Opus 5.5 (1M context) --- dsh-plugins/ccteam-ui/src/bff.ts | 86 ++++++++--- dsh-plugins/ccteam-ui/src/client/Chat.tsx | 9 +- dsh-plugins/ccteam-ui/src/client/store.ts | 132 +++++++++++++++-- dsh-plugins/ccteam-ui/src/shared/contract.ts | 46 +++++- .../ccteam-ui/tests/client-chat.test.tsx | 107 ++++++++++++++ .../ccteam-ui/tests/client-store.test.ts | 139 +++++++++++++++++- dsh-plugins/ccteam-ui/tests/host-bff.test.ts | 30 ++++ dsh-plugins/ccteam-ui/tests/host-sse.test.ts | 67 ++++++++- 8 files changed, 562 insertions(+), 54 deletions(-) create mode 100644 dsh-plugins/ccteam-ui/tests/client-chat.test.tsx diff --git a/dsh-plugins/ccteam-ui/src/bff.ts b/dsh-plugins/ccteam-ui/src/bff.ts index de291580..89ee4307 100644 --- a/dsh-plugins/ccteam-ui/src/bff.ts +++ b/dsh-plugins/ccteam-ui/src/bff.ts @@ -74,7 +74,9 @@ import { type TeamNode, type TranscriptRow, type TurnAttachmentInput, + type TurnStatus, type VendorAvailability, + isTurnBoundary, } from './shared/contract.js' import { SseHub, type SseFrame, type UpstreamSource } from './sse.js' @@ -991,7 +993,10 @@ export function attachmentRefs(value: unknown, sid: string): AttachmentRef[] | u /** * One upstream history event carries BOTH halves of a turn (`user` + * `assistant`, either possibly empty), so it fans out into up to two contract - * rows. Ids are suffixed to stay unique. + * rows. Ids are suffixed to stay unique. A long turn is several events โ€” its + * interim answers (no `status`) and a closing one carrying the turn's + * `status`, whose `assistant` is EMPTY when the last thing said was already + * delivered mid-turn; an empty half is never a row. */ export function transcriptRows(events: unknown[], sid = ''): TranscriptRow[] { const rows: TranscriptRow[] = [] @@ -1003,7 +1008,7 @@ export function transcriptRows(events: unknown[], sid = ''): TranscriptRow[] { const user = stringOf(row.user) ?? '' const assistant = stringOf(row.assistant) ?? '' const vendor = emptyToUndefined(stringOf(row.vendor)) - const status = emptyToUndefined(stringOf(row.status)) + const status = turnStatusOf(row.status) const attachments = attachmentRefs(row.attachments, sid) const usageRow = asRecord(row.usage) const usage = usageRow === undefined @@ -1043,10 +1048,14 @@ export function transcriptRows(events: unknown[], sid = ''): TranscriptRow[] { * and lifecycle frames for whichever sid they concern (clients filter). * * `session_lifecycle` and `delegation` change the tree's shape; a completed - * turn changes its cost/turn counters. Turn completion is `kind:"answer"` - * (the finalizing `progress`+`done` frame is only emitted when the turn had - * tool activity, so it is a hint, not the signal). An `answer` carrying - * `options` is a human-in-the-loop prompt, not a finished turn. + * turn changes its cost/turn counters. Turn completion is the turn's one + * BOUNDARY answer โ€” the `answer` carrying a `status` snapshot + * ({@link isTurnBoundary}), content or not. Everything else a turn emits is + * mid-turn and completes nothing: an interim `answer` (no `status`) is the + * session talking while it works, an `answer` with `options` is a + * human-in-the-loop prompt, and `progress`+`done` only closes one status card + * (a long turn closes several), so none of them feeds the badge or re-reads + * the tree. */ export function translateGlobal(frame: SseFrame): PanelEvent[] { if (frame.event === 'reconnect_hint' || frame.event === 'gateway_unavailable') return [] @@ -1082,12 +1091,11 @@ export function translateGlobal(frame: SseFrame): PanelEvent[] { }, ] } - if (kind === 'answer' && data.options === undefined) { + if (kind === 'answer' && isTurnBoundary(answerOf(sid ?? '', data))) { return sid === undefined ? [{ kind: 'graph' }] : [{ kind: 'graph' }, { kind: 'turn_done', sid }] } - if (kind === 'progress' && data.done === true) return [{ kind: 'graph' }] return [] } @@ -1104,20 +1112,7 @@ export function translateSession(sid: string, frame: SseFrame): PanelEvent[] { const kind = stringOf(data.kind) const ts = stringOf(data.ts) const wrap = (event: SessionEvent): PanelEvent[] => [{ kind: 'session', sid, event }] - if (kind === 'answer') { - const options = choiceOptions(data.options) - const token = stringOf(data.token) - return wrap({ - kind: 'answer', - id: stringOf(data.id) ?? '', - content: stringOf(data.content) ?? '', - ...defined('ts', ts), - ...defined('status', emptyToUndefined(stringOf(data.status))), - ...defined('attachments', attachmentRefs(data.attachments, sid)), - ...(options.length > 0 ? { options } : {}), - ...defined('token', token), - }) - } + if (kind === 'answer') return wrap(answerOf(sid, data)) if (kind === 'activity') { const step = stepOf(data.activity, stringOf(data.content)) return step === undefined ? [] : wrap({ kind: 'activity', step, ...defined('ts', ts) }) @@ -1134,6 +1129,53 @@ export function translateSession(sid: string, frame: SseFrame): PanelEvent[] { return [] } +/** + * One upstream `answer` payload as the contract's answer event โ€” the single + * parser both streams share, so the global feed's boundary test and the + * session stream see the same event. + */ +function answerOf(sid: string, data: Record): Extract { + const options = choiceOptions(data.options) + return { + kind: 'answer', + id: stringOf(data.id) ?? '', + content: stringOf(data.content) ?? '', + ...defined('ts', stringOf(data.ts)), + ...defined('status', turnStatusOf(data.status)), + ...defined('attachments', attachmentRefs(data.attachments, sid)), + ...(options.length > 0 ? { options } : {}), + ...defined('token', stringOf(data.token)), + } +} + +/** + * Upstream `TurnStatus` (`{model, context: {used_tokens, window_tokens, + * source}, turn, cost_usd, tokens_total}`, any field null) โ†’ the contract's + * camelCase {@link TurnStatus}. The wire carries it as an OBJECT, and only on + * a turn's boundary; absent / `null` / anything but an object is no status, + * which is what makes an answer interim. + */ +export function turnStatusOf(value: unknown): TurnStatus | undefined { + const row = asRecord(value) + if (row === undefined) return undefined + const context = asRecord(row.context) + return { + ...defined('model', emptyToUndefined(stringOf(row.model))), + ...(context === undefined + ? {} + : { + context: { + ...defined('usedTokens', numberOf(context.used_tokens)), + ...defined('windowTokens', numberOf(context.window_tokens)), + ...defined('source', emptyToUndefined(stringOf(context.source))), + }, + }), + ...defined('turn', numberOf(row.turn)), + ...defined('costUsd', numberOf(row.cost_usd)), + ...defined('tokensTotal', numberOf(row.tokens_total)), + } +} + function choiceOptions(value: unknown): ChoiceOption[] { const options: ChoiceOption[] = [] for (const entry of arrayOf(value)) { diff --git a/dsh-plugins/ccteam-ui/src/client/Chat.tsx b/dsh-plugins/ccteam-ui/src/client/Chat.tsx index 14865076..b3216884 100644 --- a/dsh-plugins/ccteam-ui/src/client/Chat.tsx +++ b/dsh-plugins/ccteam-ui/src/client/Chat.tsx @@ -26,6 +26,7 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' import type { AttachmentRef, ChoiceOption, ModelsCatalog, Step, TeamNode } from '../shared/contract.js' +import { isTurnBoundary } from '../shared/contract.js' import type { ApiClient } from './api.js' import { Composer } from './Composer.js' import type { ComposerAttachment } from './Composer.js' @@ -346,9 +347,11 @@ export function Chat({ sid, project, chat, node, models, selectedStep, api, disp onEvent(event) { if (event.kind === 'session' && event.sid === sid) { dispatch({ type: 'session_event', sid, event: event.event, now: Date.now() }) - if (event.event.kind === 'answer' && event.event.options === undefined) { - // The canonical turn is on disk now: reconcile the settled - // live turn against it, and refresh the statusline. + if (isTurnBoundary(event.event)) { + // The whole turn is on disk now: reconcile the rows settled + // from the stream against it, and refresh the statusline. An + // interim answer is the turn still talking โ€” re-reading + // history per line would only refetch a page mid-write. api .call('session.history', { sid }) .then((history) => { diff --git a/dsh-plugins/ccteam-ui/src/client/store.ts b/dsh-plugins/ccteam-ui/src/client/store.ts index 5de63bed..d19a017b 100644 --- a/dsh-plugins/ccteam-ui/src/client/store.ts +++ b/dsh-plugins/ccteam-ui/src/client/store.ts @@ -27,6 +27,7 @@ import type { TurnUsage, VendorAvailability, } from '../shared/contract.js' +import { isTurnBoundary } from '../shared/contract.js' /** The seven spawnable vendors, in display order. */ export const VENDORS: readonly string[] = ['claude', 'codex', 'grok', 'opencode', 'kimi', 'pi', 'dsh'] @@ -107,7 +108,11 @@ export type ChatRow = lifecycle?: { state: string; reason?: string } } -/** The in-flight assistant turn (narrative snapshot + structured steps). */ +/** + * The in-flight assistant turn (narrative snapshot + structured steps). An + * interim answer settles what came before it into its own row, so `live` is + * what happened since the session last said something. + */ export interface LiveTurn { id: string content: string @@ -421,6 +426,24 @@ function reconcile(existing: ChatRow[], canonical: ChatRow[]): ChatRow[] { for (let i = rows.length - 1; i >= 0; i -= 1) if (rows[i]!.kind === 'assistant') return i return -1 })() + // A turn now settles several ephemeral rows (its interim answers, then the + // final one), and short lines repeat ("Checkingโ€ฆ", "Done."). Pair them from + // the newest end, each canonical row claimed once, so every ephemeral row + // hands its steps to its OWN canonical row rather than to the first older + // row that happens to say the same thing. + const targets = new Map() + const claimed = new Set() + for (let e = existing.length - 1; e >= 0; e -= 1) { + const row = existing[e]! + if (row.kind !== 'assistant' || row.ephemeral !== true || row.content === '') continue + for (let i = rows.length - 1; i >= 0; i -= 1) { + const canon = rows[i]! + if (claimed.has(i) || canon.kind !== 'assistant' || canon.content !== row.content) continue + claimed.add(i) + targets.set(row, i) + break + } + } const tail: ChatRow[] = [] for (const row of existing) { if (row.kind === 'user') { @@ -429,7 +452,7 @@ function reconcile(existing: ChatRow[], canonical: ChatRow[]): ChatRow[] { } if (row.kind === 'assistant') { if (row.ephemeral !== true) continue - const target = rows.findIndex(r => r.kind === 'assistant' && r.content === row.content) + const target = targets.get(row) ?? -1 if (target !== -1) { const canon = rows[target]! if (canon.kind === 'assistant' && row.steps.length > 0 && canon.steps.length === 0) { @@ -472,6 +495,84 @@ function completeSteps(steps: Step[]): Step[] { : steps.map(s => (s.status === 'completed' ? s : { ...s, status: 'completed' })) } +type AnswerEvent = Extract +type AssistantRow = Extract + +/** + * An assistant row with nothing to show โ€” no text, no attachment, no step โ€” + * is not a row: a status-only closing frame (or history's empty closing row) + * must not leave an invisible entry in the transcript. + */ +function isBlankAssistant(row: ChatRow): boolean { + return row.kind === 'assistant' + && row.content === '' + && row.steps.length === 0 + && (row.attachments === undefined || row.attachments.length === 0) +} + +function answerRow(event: AnswerEvent, steps: Step[]): AssistantRow { + return { + kind: 'assistant', + id: `answer-${event.id}`, + content: event.content, + steps, + ephemeral: true, + ...(event.ts === undefined ? {} : { ts: event.ts }), + ...(event.attachments === undefined ? {} : { attachments: event.attachments }), + } +} + +/** Index of the settled (ephemeral) assistant row holding a step, newest first; -1 when none. */ +function settledStepRow(rows: ChatRow[], itemId: string): number { + for (let i = rows.length - 1; i >= 0; i -= 1) { + const row = rows[i]! + if (row.kind === 'assistant' && row.ephemeral === true && row.steps.some(s => s.itemId === itemId)) return i + } + return -1 +} + +/** + * Something the session said mid-turn. It shows at once, carrying the steps + * that finished before it, and the turn keeps running: the working row, its + * timer and Stop stay up, the queued chip stays (the turn it waits behind is + * not over), and steps still in flight stay live. The narrative snapshot is + * dropped with the settled steps โ€” it described them, not what comes next. + */ +function settleInterim(chat: ChatState, event: AnswerEvent): ChatState { + const id = `answer-${event.id}` + if (chat.rows.some(r => r.id === id)) return chat + const done = chat.live === null ? [] : chat.live.steps.filter(s => s.status === 'completed') + const row = answerRow(event, done) + if (isBlankAssistant(row)) return chat + const live = chat.live === null + ? null + : { ...chat.live, content: '', steps: chat.live.steps.filter(s => s.status !== 'completed') } + return { ...chat, rows: [...chat.rows, row], live, activity: 'working', waiting: false } +} + +/** + * The turn's one boundary. Its text (when it has any) is the final answer and + * takes the remaining steps; an EMPTY boundary is the status-only closing + * frame, which shows nothing of its own โ€” steps still live join the row the + * session last said something in, or stand alone when it said nothing โ€” and + * still ends the working state. + */ +function closeTurn(chat: ChatState, event: AnswerEvent): ChatState { + const notices = chat.notices.filter(notice => notice.kind !== 'queued') + const ended: ChatState = { ...chat, live: null, activity: 'idle', waiting: false, notices } + const id = `answer-${event.id}` + if (chat.rows.some(r => r.id === id)) return ended + const steps = chat.live === null ? [] : completeSteps(chat.live.steps) + const row = answerRow(event, steps) + if (isBlankAssistant(row)) return ended + const last = chat.rows.at(-1) + if (row.content === '' && row.attachments === undefined && last?.kind === 'assistant' && last.ephemeral === true) { + const rows = chat.rows.slice(0, -1) + return { ...ended, rows: [...rows, { ...last, steps: steps.reduce(upsertStep, last.steps) }] } + } + return { ...ended, rows: [...chat.rows, row] } +} + function applySessionEvent(chat: ChatState, action: Extract): ChatState { const event = action.event switch (event.kind) { @@ -485,6 +586,16 @@ function applySessionEvent(chat: ChatState, action: Extract s.itemId === event.step.itemId) === true + const settledAt = inLive ? -1 : settledStepRow(chat.rows, event.step.itemId) + if (settledAt !== -1) { + const rows = chat.rows.slice() + const row = rows[settledAt]! + if (row.kind === 'assistant') rows[settledAt] = { ...row, steps: upsertStep(row.steps, event.step) } + return { ...chat, rows, activity: 'working', waiting: false } + } const live: LiveTurn = chat.live ?? { id: `live-${action.now}`, content: '', steps: [], startedAt: action.now } return { ...chat, @@ -494,7 +605,6 @@ function applySessionEvent(chat: ChatState, action: Extract notice.kind !== 'queued') if (event.options !== undefined && event.options.length > 0 && event.token !== undefined) { const row: ChatRow = { kind: 'choice', @@ -504,20 +614,10 @@ function applySessionEvent(chat: ChatState, action: Extract r.id === row.id)) return chat + const notices = chat.notices.filter(notice => notice.kind !== 'queued') return { ...chat, rows: [...chat.rows, row], notices, activity: 'idle', waiting: true } } - const steps = chat.live === null ? [] : completeSteps(chat.live.steps) - const settled: ChatRow = { - kind: 'assistant', - id: `answer-${event.id}`, - content: event.content, - steps, - ephemeral: true, - ...(event.ts === undefined ? {} : { ts: event.ts }), - ...(event.attachments === undefined ? {} : { attachments: event.attachments }), - } - if (chat.rows.some(r => r.id === settled.id)) return { ...chat, live: null, activity: 'idle', waiting: false, notices } - return { ...chat, rows: [...chat.rows, settled], live: null, activity: 'idle', waiting: false, notices } + return isTurnBoundary(event) ? closeTurn(chat, event) : settleInterim(chat, event) } case 'lifecycle': { // Only transitions a reader must know about become rows; bookkeeping @@ -675,7 +775,7 @@ export function reduce(state: ConsoleState, action: Action): ConsoleState { } case 'history_loaded': { const chat = chatOf(state, action.sid) - const canonical = action.rows.map(rowFromTranscript) + const canonical = action.rows.map(rowFromTranscript).filter(row => !isBlankAssistant(row)) if (action.older === true) { const known = new Set(chat.rows.map(r => r.id)) const fresh = canonical.filter(r => !known.has(r.id)) diff --git a/dsh-plugins/ccteam-ui/src/shared/contract.ts b/dsh-plugins/ccteam-ui/src/shared/contract.ts index 44d1de28..0f1503e3 100644 --- a/dsh-plugins/ccteam-ui/src/shared/contract.ts +++ b/dsh-plugins/ccteam-ui/src/shared/contract.ts @@ -246,13 +246,28 @@ export interface TurnUsage { outputTokens?: number } +/** + * The per-turn status snapshot ccteam stamps on a turn's boundary answer + * (upstream `TurnStatus`: model, context window, turn number, cumulative cost + * and tokens). Its PRESENCE is what marks the boundary โ€” see + * {@link isTurnBoundary}; the fields themselves are advisory. + */ +export interface TurnStatus { + model?: string + context?: { usedTokens?: number; windowTokens?: number; source?: string } + turn?: number + costUsd?: number + tokensTotal?: number +} + export interface TranscriptRow { turnId: string role: 'user' | 'assistant' content: string ts?: string vendor?: string - status?: string + /** Present only on the row that closed its turn; absent = an interim answer. */ + status?: TurnStatus attachments?: AttachmentRef[] usage?: TurnUsage } @@ -366,10 +381,14 @@ export interface StatusResponse { /** * One per-session frame, translated from ccteam's session stream: * - `progress`: the running turn's narrative so far (a snapshot, not a delta; - * `done` closes the status line); + * `done` means this status card will not be edited again โ€” NOT that the + * turn ended: a long turn closes several cards); * - `activity`: one structured step started/completed; - * - `answer`: a delivered assistant message โ€” the turn's result, or a - * human-in-the-loop prompt when `options` are present; + * - `answer`: something the session said. Without `status` it is an INTERIM + * answer โ€” said mid-turn while the turn keeps running; with `status` it is + * the turn's one BOUNDARY (its content may be empty: a status-only closing + * frame that ends the turn but carries nothing to show); with `options` it + * is a human-in-the-loop prompt. {@link isTurnBoundary} is the one test; * - `lifecycle`: the session changed state (started / evicted / stopped โ€ฆ). */ export type SessionEvent = @@ -380,16 +399,31 @@ export type SessionEvent = id: string content: string ts?: string - status?: string + status?: TurnStatus attachments?: AttachmentRef[] options?: ChoiceOption[] token?: string } | { kind: 'lifecycle'; state: string; reason?: string; ts?: string } +/** + * Whether a session frame ends its turn. ccteam delivers what a session says + * while a turn runs as interim answers and closes every turn with exactly one + * answer carrying the turn's `status` snapshot, so "an answer arrived" is NOT + * "the turn finished" โ€” only this is. A choice prompt (non-empty `options`) + * suspends the turn on a human; it does not end it. Both halves (the BFF's + * `turn_done` feed and the workbench's working state / reconcile) ask here. + */ +export function isTurnBoundary(event: SessionEvent): boolean { + return event.kind === 'answer' + && event.status !== undefined + && (event.options === undefined || event.options.length === 0) +} + /** * One SSE frame from the host. `graph` frames invalidate the team tree; - * `turn_done` marks a completed turn (badge counter feed); `delegation` + * `turn_done` marks a completed turn โ€” once per turn, at its boundary, never + * for an interim answer (badge counter feed); `delegation` * frames narrate parent/child relations; `session` frames carry one * {@link SessionEvent} for the sid the client subscribed to. Unknown kinds * must be ignored by the client (forward-compat). diff --git a/dsh-plugins/ccteam-ui/tests/client-chat.test.tsx b/dsh-plugins/ccteam-ui/tests/client-chat.test.tsx new file mode 100644 index 00000000..9b85bfe1 --- /dev/null +++ b/dsh-plugins/ccteam-ui/tests/client-chat.test.tsx @@ -0,0 +1,107 @@ +/** + * The chat view over one long turn, rendered to static markup with the + * primitives replaced by plain elements: what the session says mid-turn shows + * at once while the working row and Stop stay up, and the status-only frame + * that closes the turn takes both down without leaving a row of its own. + */ +import { describe, expect, it, vi } from 'vitest' +import { renderToStaticMarkup } from 'react-dom/server' + +vi.mock('@deepseek-ai/dsh-client-ui-primitives', async () => { + const React = await import('react') + const h = React.createElement + const icon = () => null + return { + Button: ({ children, disabled }: Record) => h('button', { disabled: disabled === true }, children as never), + MarkdownText: ({ text }: { text: string }) => h('p', { 'data-md': '' }, text), + Menu: ({ anchor }: { anchor: unknown }) => anchor as never, + StateDot: ({ state }: { state: string }) => h('i', { 'data-dot': state }), + IconApiOutline14: icon, + IconChevronDownOutline14: icon, + IconCloseFill14: icon, + IconCodeOutline16: icon, + IconEditOutline16: icon, + IconGlobeOutline14: icon, + IconLoadingOutline16: icon, + IconPaperclipOutline16: icon, + IconSendOutline16: icon, + IconSparkle16: icon, + IconStopFill16: icon, + IconThinkOutline16: icon, + } +}) + +const { Chat } = await import('../src/client/Chat.js') +const { zh } = await import('../src/client/locales.js') +const { chatOf, initialState, reduce } = await import('../src/client/store.js') +type Action = import('../src/client/store.js').Action +type ChatState = import('../src/client/store.js').ChatState +type ApiClient = import('../src/client/api.js').ApiClient +type SessionEvent = import('../src/shared/contract.js').SessionEvent +type T = import('../src/client/slots.js').T + +const t = ((key: string, params?: Record): string => { + let text: string = (zh as Record)[key] ?? key + for (const [name, value] of Object.entries(params ?? {})) text = text.replace(`{${name}}`, String(value)) + return text +}) as unknown as T + +const NOW = 1_700_000_000_000 +const api = {} as unknown as ApiClient +const on = (event: SessionEvent): Action => ({ type: 'session_event', sid: 's1', event, now: NOW }) + +function chatAfter(actions: Action[]): ChatState { + return chatOf(actions.reduce(reduce, initialState()), 's1') +} + +function render(chat: ChatState): string { + return renderToStaticMarkup( + {}} + t={t} + onSelectStep={() => {}} + />, + ) +} + +/** Assistant blocks in the transcript (settled rows and the live steps block). */ +function assistantBlocks(html: string): number { + return [...html.matchAll(/
/g)].length +} + +const MID_TURN: Action[] = [ + { type: 'send_started', sid: 's1', text: 'fix the build' }, + on({ kind: 'activity', step: { itemId: 't1', kind: 'command_exec', name: 'cargo', summary: 'cargo build', status: 'started' } }), + on({ kind: 'activity', step: { itemId: 't1', kind: 'command_exec', name: 'cargo', summary: 'cargo build', status: 'completed' } }), + on({ kind: 'answer', id: 'a1', content: 'The build fails in the linker; patching the flags next.' }), +] + +describe('chat: a long turn', () => { + it('an interim answer renders at once and the working row and Stop stay up', () => { + const html = render(chatAfter(MID_TURN)) + expect(html).toContain('The build fails in the linker; patching the flags next.') + expect(html).toContain(zh['chat.working']) + expect(html).toContain('data-stop=""') + expect(assistantBlocks(html)).toBe(1) + }) + + it('the status-only closing frame ends the working state and adds no row', () => { + const html = render(chatAfter([ + ...MID_TURN, + on({ kind: 'answer', id: 'close', content: '', status: { turn: 4, costUsd: 0.3 } }), + ])) + expect(html).toContain('The build fails in the linker; patching the flags next.') + expect(html).not.toContain(zh['chat.working']) + expect(html).not.toContain('data-stop=""') + // One assistant block (the interim answer, with its step) and no empty one. + expect(assistantBlocks(html)).toBe(1) + expect(html).not.toMatch(/
<\/div>/) + }) +}) diff --git a/dsh-plugins/ccteam-ui/tests/client-store.test.ts b/dsh-plugins/ccteam-ui/tests/client-store.test.ts index 5d6913a7..957ea0ab 100644 --- a/dsh-plugins/ccteam-ui/tests/client-store.test.ts +++ b/dsh-plugins/ccteam-ui/tests/client-store.test.ts @@ -19,7 +19,8 @@ import { } from '../src/client/store.js' import type { Action, ConsoleState, StorageLike } from '../src/client/store.js' import { formatCost, formatElapsed, formatTokens, modelDirective, relativeTime, titleFromTask, vendorGlyph } from '../src/client/format.js' -import type { TeamGraph, TeamNode } from '../src/shared/contract.js' +import { isTurnBoundary } from '../src/shared/contract.js' +import type { SessionEvent, Step, TeamGraph, TeamNode, TurnStatus } from '../src/shared/contract.js' function run(actions: Action[], start = initialState()): ConsoleState { return actions.reduce(reduce, start) @@ -27,6 +28,15 @@ function run(actions: Action[], start = initialState()): ConsoleState { const NOW = 1_700_000_000_000 +/** What a turn's boundary answer carries; its presence is the boundary. */ +const STATUS: TurnStatus = { model: 'opus', turn: 1, costUsd: 0.1 } + +const step = (itemId: string, status: 'started' | 'completed'): Step => ({ itemId, kind: 'tool_call', name: 'Bash', summary: itemId, status }) +const on = (event: SessionEvent): Action => ({ type: 'session_event', sid: 's1', event, now: NOW }) +const activity = (itemId: string, status: 'started' | 'completed'): Action => on({ kind: 'activity', step: step(itemId, status) }) +const interim = (id: string, content: string): Action => on({ kind: 'answer', id, content }) +const boundary = (id: string, content: string): Action => on({ kind: 'answer', id, content, status: STATUS }) + describe('selection', () => { it('selecting a session records it as a recent and clears the step selection', () => { let state = run([{ type: 'open_panel' }, { type: 'select_step', sid: 's0', itemId: 'x' }]) @@ -116,7 +126,7 @@ describe('chat: live turn', () => { it('an answer settles the live turn into an ephemeral row carrying its steps', () => { let state = run([ { type: 'session_event', sid: 's1', event: { kind: 'activity', step: { itemId: 't1', kind: 'tool_call', name: 'Bash', summary: 'ls', status: 'started' } }, now: NOW }, - { type: 'session_event', sid: 's1', event: { kind: 'answer', id: 'e9', content: 'done.' }, now: NOW }, + { type: 'session_event', sid: 's1', event: { kind: 'answer', id: 'e9', content: 'done.', status: STATUS }, now: NOW }, ]) const chat = chatOf(state, 's1') expect(chat.live).toBeNull() @@ -143,7 +153,7 @@ describe('chat: live turn', () => { it('steps attached to a canonical row survive later history reloads', () => { let state = run([ { type: 'session_event', sid: 's1', event: { kind: 'activity', step: { itemId: 't1', kind: 'tool_call', name: 'Bash', summary: 'ls', status: 'started' } }, now: NOW }, - { type: 'session_event', sid: 's1', event: { kind: 'answer', id: 'e9', content: 'done.' }, now: NOW }, + { type: 'session_event', sid: 's1', event: { kind: 'answer', id: 'e9', content: 'done.', status: STATUS }, now: NOW }, { type: 'history_loaded', sid: 's1', rows: [{ turnId: 'u1:assistant', role: 'assistant', content: 'done.' }], hasMore: false }, { type: 'history_loaded', sid: 's1', rows: [{ turnId: 'u1:assistant', role: 'assistant', content: 'done.' }, { turnId: 'u2:user', role: 'user', content: 'more' }], hasMore: false }, ]) @@ -210,6 +220,122 @@ describe('chat: live turn', () => { }) }) +describe('chat: mid-turn answers', () => { + it('only an answer carrying a status is the turn boundary', () => { + expect(isTurnBoundary({ kind: 'answer', id: 'a', content: 'x' })).toBe(false) + expect(isTurnBoundary({ kind: 'answer', id: 'a', content: 'x', status: STATUS })).toBe(true) + expect(isTurnBoundary({ kind: 'answer', id: 'a', content: '', status: {} })).toBe(true) + // A choice prompt suspends the turn on a human; it does not end it. + expect(isTurnBoundary({ kind: 'answer', id: 'a', content: '?', status: STATUS, options: [{ id: 'y', label: 'y' }], token: 't' })).toBe(false) + expect(isTurnBoundary({ kind: 'progress', content: '', done: true })).toBe(false) + }) + + it('an interim answer shows at once and the turn keeps working', () => { + const state = run([ + { type: 'send_started', sid: 's1', text: 'fix it' }, + activity('t1', 'started'), + activity('t1', 'completed'), + activity('t2', 'started'), + on({ kind: 'progress', content: 'Bash(t1) ยท Bash(t2)', done: false }), + interim('a1', 'found the bug, patching now'), + ]) + const chat = chatOf(state, 's1') + expect(chat.activity).toBe('working') + expect(chat.waiting).toBe(false) + expect(chat.live).not.toBeNull() + expect(chat.rows.map(r => r.kind)).toEqual(['user', 'assistant']) + const said = chat.rows[1] + expect(said).toMatchObject({ kind: 'assistant', id: 'answer-a1', content: 'found the bug, patching now', ephemeral: true }) + // What finished before it settles with it; what is still running stays live. + if (said?.kind === 'assistant') expect(said.steps.map(s => s.itemId)).toEqual(['t1']) + expect(chat.live?.steps.map(s => [s.itemId, s.status])).toEqual([['t2', 'started']]) + expect(chat.live?.content).toBe('') + // Nor does it count as a finished turn on the badge. + expect(state.badge).toBe(0) + }) + + it('a step that finishes after an interim answer settled it updates in place', () => { + const state = run([ + activity('t1', 'started'), + on({ kind: 'progress', content: '', done: true }), + interim('a1', 'waiting on the build'), + activity('t1', 'completed'), + ]) + const chat = chatOf(state, 's1') + const said = chat.rows[0] + if (said?.kind === 'assistant') expect(said.steps.map(s => [s.itemId, s.status])).toEqual([['t1', 'completed']]) + expect(chat.live?.steps ?? []).toEqual([]) + expect(chat.activity).toBe('working') + }) + + it('the status-only closing frame ends the turn and renders no row', () => { + const state = run([ + { type: 'send_started', sid: 's1', text: 'go' }, + interim('a1', 'on it'), + interim('a2', 'done: all green'), + boundary('close', ''), + ]) + const chat = chatOf(state, 's1') + expect(chat.activity).toBe('idle') + expect(chat.live).toBeNull() + expect(chat.rows.map(r => r.id)).toEqual(['local-1', 'answer-a1', 'answer-a2']) + expect(chat.rows.some(r => r.kind === 'assistant' && r.content === '' && r.steps.length === 0)).toBe(false) + }) + + it('steps still live at a status-only close join the row the session last spoke in', () => { + const state = run([ + interim('a1', 'running the suite'), + activity('t9', 'started'), + boundary('close', ''), + ]) + const chat = chatOf(state, 's1') + expect(chat.rows).toHaveLength(1) + const said = chat.rows[0] + if (said?.kind === 'assistant') expect(said.steps.map(s => [s.itemId, s.status])).toEqual([['t9', 'completed']]) + // With nothing said at all, the steps stand as their own (visible) row. + const silent = chatOf(run([activity('t1', 'started'), boundary('close', '')]), 's1') + expect(silent.rows).toHaveLength(1) + expect(silent.rows[0]).toMatchObject({ kind: 'assistant', content: '', steps: [{ itemId: 't1', status: 'completed' }] }) + expect(silent.activity).toBe('idle') + }) + + it('reconcile hands each interim row its own canonical row, repeated lines included', () => { + let state = run([ + { type: 'history_loaded', sid: 's1', rows: [ + { turnId: 'p1:user', role: 'user', content: 'earlier' }, + { turnId: 'p1:assistant', role: 'assistant', content: 'Checking.' }, + ], hasMore: false }, + { type: 'send_started', sid: 's1', text: 'go' }, + activity('t1', 'completed'), + interim('a1', 'Checking.'), + activity('t2', 'completed'), + interim('a2', 'Checking.'), + activity('t3', 'started'), + boundary('a3', 'Fixed.'), + ]) + state = reduce(state, { type: 'history_loaded', sid: 's1', rows: [ + { turnId: 'p1:user', role: 'user', content: 'earlier' }, + { turnId: 'p1:assistant', role: 'assistant', content: 'Checking.' }, + { turnId: 'q1:user', role: 'user', content: 'go' }, + { turnId: 'q1:assistant', role: 'assistant', content: 'Checking.' }, + { turnId: 'q2:assistant', role: 'assistant', content: 'Checking.' }, + { turnId: 'q3:assistant', role: 'assistant', content: 'Fixed.', status: STATUS }, + // The empty closing row of a long turn is not a row. + { turnId: 'q4:assistant', role: 'assistant', content: '', status: STATUS }, + ], hasMore: false }) + const rows = chatOf(state, 's1').rows + expect(rows.map(r => r.id)).toEqual(['p1:user', 'p1:assistant', 'q1:user', 'q1:assistant', 'q2:assistant', 'q3:assistant']) + const stepsOf = (id: string): string[] => { + const row = rows.find(r => r.id === id) + return row?.kind === 'assistant' ? row.steps.map(s => s.itemId) : [] + } + expect(stepsOf('p1:assistant')).toEqual([]) + expect(stepsOf('q1:assistant')).toEqual(['t1']) + expect(stepsOf('q2:assistant')).toEqual(['t2']) + expect(stepsOf('q3:assistant')).toEqual(['t3']) + }) +}) + describe('chat: sending and history', () => { it('an optimistic user row is replaced by its canonical row', () => { let state = run([{ type: 'send_started', sid: 's1', text: 'hello' }]) @@ -220,7 +346,7 @@ describe('chat: sending and history', () => { expect(rows[0]).toMatchObject({ kind: 'user', id: 'u1:user' }) }) - it('queued and failed receipts become notices; an answer clears queued ones', () => { + it('queued and failed receipts become notices; the turn boundary clears queued ones', () => { let state = run([ { type: 'send_started', sid: 's1', text: 'a' }, { type: 'send_settled', sid: 's1', receipt: { ok: true, queued: true, queuedBehind: 's0' } }, @@ -228,7 +354,10 @@ describe('chat: sending and history', () => { expect(chatOf(state, 's1').notices).toEqual([{ id: 1, kind: 'queued', queuedBehind: 's0' }]) state = reduce(state, { type: 'send_settled', sid: 's1', receipt: { ok: false, errorKind: 'bad_request', error: 'nope' } }) expect(chatOf(state, 's1').notices).toHaveLength(2) - state = reduce(state, { type: 'session_event', sid: 's1', event: { kind: 'answer', id: 'e1', content: 'ok' }, now: NOW }) + // Mid-turn talk does not release what the message is queued behind. + state = reduce(state, { type: 'session_event', sid: 's1', event: { kind: 'answer', id: 'e0', content: 'still busy' }, now: NOW }) + expect(chatOf(state, 's1').notices.map(n => n.kind)).toEqual(['queued', 'error']) + state = reduce(state, { type: 'session_event', sid: 's1', event: { kind: 'answer', id: 'e1', content: 'ok', status: STATUS }, now: NOW }) expect(chatOf(state, 's1').notices.map(n => n.kind)).toEqual(['error']) expect(reduce(state, { type: 'send_settled', sid: 's1', receipt: { ok: true } })).toBe(state) }) diff --git a/dsh-plugins/ccteam-ui/tests/host-bff.test.ts b/dsh-plugins/ccteam-ui/tests/host-bff.test.ts index f5dad97c..7f8a8397 100644 --- a/dsh-plugins/ccteam-ui/tests/host-bff.test.ts +++ b/dsh-plugins/ccteam-ui/tests/host-bff.test.ts @@ -224,6 +224,36 @@ describe('method dispatch', () => { expect(older.nextBefore).toBe('cursor-2') }) + it('session.history keeps a long turn\'s interim answers and drops its empty closing row', async () => { + const status = { model: 'opus', context: { used_tokens: 1000, window_tokens: 200_000, source: 'reported' }, turn: 2, cost_usd: 0.5, tokens_total: 1234 } + const fake = new FakeFetch().on('/api/v1/sessions/s1?', { + json: { + sid: 's1', + events: [ + { turn_id: 's1-1', ts: '2026-08-25T10:00:00Z', user: 'fix it', assistant: 'reading the code' }, + { turn_id: 's1-2', ts: '2026-08-25T10:03:00Z', user: '', assistant: 'found it, patching' }, + { turn_id: 's1-3', ts: '2026-08-25T10:09:00Z', user: '', assistant: '', status }, + { turn_id: 's1-4', ts: '2026-08-25T10:10:00Z', user: 'thanks', assistant: 'you are welcome', status: { ...status, turn: 3 } }, + ], + next_before: null, + has_more: false, + }, + }) + const page = (await post(bff(fake), 'session.history', { sid: 's1' })).json() as { rows: Array> } + + expect(page.rows.map(row => row.turnId)).toEqual(['s1-1:user', 's1-1:assistant', 's1-2:assistant', 's1-4:user', 's1-4:assistant']) + // Interim rows carry no status; the boundary row's status is the parsed object. + expect(page.rows[1]!.status).toBeUndefined() + expect(page.rows[2]!.status).toBeUndefined() + expect(page.rows[4]!.status).toEqual({ + model: 'opus', + context: { usedTokens: 1000, windowTokens: 200_000, source: 'reported' }, + turn: 3, + costUsd: 0.5, + tokensTotal: 1234, + }) + }) + it('session.send posts JSON to the turn route and reports a plain acceptance', async () => { const fake = new FakeFetch().on('/turn', { status: 202, json: { accepted: true } }) const res = await post(bff(fake), 'session.send', { sid: 's1', text: 'go' }) diff --git a/dsh-plugins/ccteam-ui/tests/host-sse.test.ts b/dsh-plugins/ccteam-ui/tests/host-sse.test.ts index e3dec589..7a547337 100644 --- a/dsh-plugins/ccteam-ui/tests/host-sse.test.ts +++ b/dsh-plugins/ccteam-ui/tests/host-sse.test.ts @@ -227,13 +227,76 @@ describe('frame translation', () => { data: JSON.stringify(data), }) - it('treats kind:"answer" as the reliable turn-completion signal', () => { - expect(translateGlobal(frame({ kind: 'answer', sid: 's1', content: 'hi' }))).toEqual([ + /** The upstream `TurnStatus` object a turn's boundary answer carries. */ + const STATUS = { + model: 'opus', + context: { used_tokens: 42_000, window_tokens: 200_000, source: 'derived' }, + turn: 3, + cost_usd: 0.25, + tokens_total: 9000, + } + + it('treats the answer carrying a status as the turn boundary', () => { + expect(translateGlobal(frame({ kind: 'answer', sid: 's1', content: 'hi', status: STATUS }))).toEqual([ + { kind: 'graph' }, + { kind: 'turn_done', sid: 's1' }, + ]) + // The status-only closing frame (nothing left to say) ends the turn too. + expect(translateGlobal(frame({ kind: 'answer', sid: 's1', content: '', status: STATUS }))).toEqual([ { kind: 'graph' }, { kind: 'turn_done', sid: 's1' }, ]) }) + it('an interim answer (no status) completes nothing: no turn_done, no tree re-read', () => { + expect(translateGlobal(frame({ kind: 'answer', sid: 's1', content: 'looking at the tests' }))).toEqual([]) + expect(translateGlobal(frame({ kind: 'answer', sid: 's1', content: 'still going', status: null }))).toEqual([]) + }) + + it('one long turn feeds the badge exactly once, however much it says mid-turn', () => { + const turn = [ + frame({ kind: 'answer', sid: 's1', id: 'a1', content: 'first I will read the code' }), + frame({ kind: 'progress', sid: 's1', content: 'Read(src/bff.ts)', done: true }), + frame({ kind: 'answer', sid: 's1', id: 'a2', content: 'now the tests' }), + frame({ kind: 'progress', sid: 's1', content: 'Bash(npm test)', done: true }), + frame({ kind: 'answer', sid: 's1', id: 'a3', content: 'all green' }), + frame({ kind: 'answer', sid: 's1', id: 'a4', content: '', status: STATUS }), + ] + const events = turn.flatMap(translateGlobal) + expect(events.filter(event => event.kind === 'turn_done')).toEqual([{ kind: 'turn_done', sid: 's1' }]) + // A closed progress card is not a turn end either, so the tree is re-read once. + expect(events.filter(event => event.kind === 'graph')).toHaveLength(1) + }) + + it('carries the boundary status as an object and leaves interim answers without one', () => { + expect(translateSession('s1', frame({ kind: 'answer', sid: 's1', id: 'a4', content: '', status: STATUS }))).toEqual([{ + kind: 'session', + sid: 's1', + event: { + kind: 'answer', + id: 'a4', + content: '', + status: { + model: 'opus', + context: { usedTokens: 42_000, windowTokens: 200_000, source: 'derived' }, + turn: 3, + costUsd: 0.25, + tokensTotal: 9000, + }, + }, + }]) + // Null-valued fields drop out rather than becoming NaN/'' noise. + expect(translateSession('s1', frame({ + kind: 'answer', sid: 's1', id: 'a5', content: 'x', + status: { model: null, context: null, turn: 1, cost_usd: null, tokens_total: null }, + }))).toEqual([{ kind: 'session', sid: 's1', event: { kind: 'answer', id: 'a5', content: 'x', status: { turn: 1 } } }]) + expect(translateSession('s1', frame({ kind: 'answer', sid: 's1', id: 'a1', content: 'mid-turn' }))).toEqual([{ + kind: 'session', + sid: 's1', + event: { kind: 'answer', id: 'a1', content: 'mid-turn' }, + }]) + }) + it('does not mistake a human-in-the-loop prompt for a completed turn', () => { const prompt = frame({ kind: 'answer', From aa7a1dcb90e996a3591091b8199f105924593d25 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Fri, 25 Sep 2026 06:41:09 +0000 Subject: [PATCH 10/37] fix(dsh): only an answer marked interim keeps the turn running (#209) Inferring "interim" from a missing status was wrong: terminal-protocol replies, slash-command receipts (the composer's own /model switch), scheduled-send notices and answers recovered after a restart all carry no status and all end their turn. They left the chat "working" for good - no turn_done, Stop could not clear it, and the statusline stopped refreshing after /model. The gateway now marks mid-turn deliveries explicitly with `interim: true`, so the BFF carries that flag and `isTurnBoundary` becomes "an answer, not interim, not a choice prompt"; status is advisory again. A review of the first pass found the store settling steps the wrong way: - A done progress card completed every live step, and a timer seal lands mid-tool by design, so running tools were marked finished and moved into the interim row. A done card now only makes the card final: steps finish on their own completion events, and whatever still runs settles at the turn's end. Nor does a sealed card clear a pending choice. - A late event for a settled step rewrote it in its row, so ACP's stable per-turn pulse id flipped a finished row back to a spinner for good. A settled step is final: only a completion may refresh it, and the lookup is scoped to the running turn because some vendors reuse tool ids per turn. - An interim answer no longer clears "waiting" on a pending choice. - Reconcile placed rows with no canonical counterpart by heuristic (onto the last canonical answer, or at the tail), so a tool-only turn's steps row gave its steps to the next turn's answer, and a file sent mid-turn without text vanished. Unmatched rows now keep their place next to the row they followed; provisional rows pair with canonical ones from the newest end, each claimed once. A status-only close leaves its leftover steps as their own row where the live block showed them. Co-Authored-By: Claude Opus 5.5 (1M context) --- dsh-plugins/ccteam-ui/src/bff.ts | 28 ++- dsh-plugins/ccteam-ui/src/client/store.ts | 189 ++++++++++-------- dsh-plugins/ccteam-ui/src/shared/contract.ts | 46 +++-- .../ccteam-ui/tests/client-chat.test.tsx | 13 +- .../ccteam-ui/tests/client-store.test.ts | 179 ++++++++++++++--- dsh-plugins/ccteam-ui/tests/host-sse.test.ts | 47 +++-- 6 files changed, 333 insertions(+), 169 deletions(-) diff --git a/dsh-plugins/ccteam-ui/src/bff.ts b/dsh-plugins/ccteam-ui/src/bff.ts index 89ee4307..d2b822c0 100644 --- a/dsh-plugins/ccteam-ui/src/bff.ts +++ b/dsh-plugins/ccteam-ui/src/bff.ts @@ -994,9 +994,8 @@ export function attachmentRefs(value: unknown, sid: string): AttachmentRef[] | u * One upstream history event carries BOTH halves of a turn (`user` + * `assistant`, either possibly empty), so it fans out into up to two contract * rows. Ids are suffixed to stay unique. A long turn is several events โ€” its - * interim answers (no `status`) and a closing one carrying the turn's - * `status`, whose `assistant` is EMPTY when the last thing said was already - * delivered mid-turn; an empty half is never a row. + * interim answers and a closing one, whose `assistant` is EMPTY when the last + * thing said was already delivered mid-turn; an empty half is never a row. */ export function transcriptRows(events: unknown[], sid = ''): TranscriptRow[] { const rows: TranscriptRow[] = [] @@ -1048,14 +1047,12 @@ export function transcriptRows(events: unknown[], sid = ''): TranscriptRow[] { * and lifecycle frames for whichever sid they concern (clients filter). * * `session_lifecycle` and `delegation` change the tree's shape; a completed - * turn changes its cost/turn counters. Turn completion is the turn's one - * BOUNDARY answer โ€” the `answer` carrying a `status` snapshot - * ({@link isTurnBoundary}), content or not. Everything else a turn emits is - * mid-turn and completes nothing: an interim `answer` (no `status`) is the - * session talking while it works, an `answer` with `options` is a - * human-in-the-loop prompt, and `progress`+`done` only closes one status card - * (a long turn closes several), so none of them feeds the badge or re-reads - * the tree. + * turn changes its cost/turn counters. Turn completion is any `answer` that + * is not marked `interim` ({@link isTurnBoundary}) โ€” status or not, content + * or not. The rest completes nothing: an `interim` answer is the session + * talking while it works, an `answer` with `options` is a human-in-the-loop + * prompt, and `progress`+`done` only makes one status card final (a long + * turn seals several), so none of them feeds the badge or re-reads the tree. */ export function translateGlobal(frame: SseFrame): PanelEvent[] { if (frame.event === 'reconnect_hint' || frame.event === 'gateway_unavailable') return [] @@ -1132,7 +1129,8 @@ export function translateSession(sid: string, frame: SseFrame): PanelEvent[] { /** * One upstream `answer` payload as the contract's answer event โ€” the single * parser both streams share, so the global feed's boundary test and the - * session stream see the same event. + * session stream see the same event. Only a literal `interim: true` marks a + * mid-turn message; anything else is a turn-ending answer, as it always was. */ function answerOf(sid: string, data: Record): Extract { const options = choiceOptions(data.options) @@ -1141,6 +1139,7 @@ function answerOf(sid: string, data: Record): Extract 0 ? { options } : {}), @@ -1151,9 +1150,8 @@ function answerOf(sid: string, data: Record): Extract r.kind === 'user').map(r => r.content)) - const lastAssistantIndex = (() => { - for (let i = rows.length - 1; i >= 0; i -= 1) if (rows[i]!.kind === 'assistant') return i - return -1 - })() - // A turn now settles several ephemeral rows (its interim answers, then the - // final one), and short lines repeat ("Checkingโ€ฆ", "Done."). Pair them from - // the newest end, each canonical row claimed once, so every ephemeral row - // hands its steps to its OWN canonical row rather than to the first older - // row that happens to say the same thing. - const targets = new Map() + const pageIndex = new Map(rows.map((row, i) => [row.id, i])) + // A turn settles several provisional rows and short lines repeat + // ("Checkingโ€ฆ", "Done."), so pair them from the newest end, each canonical + // row claimed once โ€” and never one the chat already shows as itself. const claimed = new Set() + for (const row of existing) { + const at = isCanonicalRow(row) ? pageIndex.get(row.id) : undefined + if (at !== undefined) claimed.add(at) + } + const pairs = new Map() for (let e = existing.length - 1; e >= 0; e -= 1) { const row = existing[e]! - if (row.kind !== 'assistant' || row.ephemeral !== true || row.content === '') continue + const provisional = (row.kind === 'user' && row.local === true) + || (row.kind === 'assistant' && row.ephemeral === true && row.content !== '') + if (!provisional) continue for (let i = rows.length - 1; i >= 0; i -= 1) { const canon = rows[i]! - if (claimed.has(i) || canon.kind !== 'assistant' || canon.content !== row.content) continue + if (claimed.has(i) || canon.kind !== row.kind || canon.content !== row.content) continue claimed.add(i) - targets.set(row, i) + pairs.set(row, i) break } } - const tail: ChatRow[] = [] + // Walk the chat in order: a matched row moves the anchor to its canonical + // place; an unmatched one is kept right after the anchor (or, before any + // match, right before the first one โ€” else at the end, the newest place). + const front: ChatRow[] = [] + const before = new Map() + const after = new Map() + let pending: ChatRow[] = [] + let anchor: number | 'front' | null = null for (const row of existing) { - if (row.kind === 'user') { - if (row.local === true && !canonicalUserTexts.has(row.content)) tail.push(row) + const at = isCanonicalRow(row) ? pageIndex.get(row.id) : pairs.get(row) + if (at !== undefined) { + const canon = rows[at]! + if (row.kind === 'assistant' && row.ephemeral === true && row.steps.length > 0 + && canon.kind === 'assistant' && canon.steps.length === 0) { + rows[at] = { ...canon, steps: row.steps } + } + if (pending.length > 0) before.set(at, [...(before.get(at) ?? []), ...pending]) + pending = [] + anchor = at continue } - if (row.kind === 'assistant') { - if (row.ephemeral !== true) continue - const target = targets.get(row) ?? -1 - if (target !== -1) { - const canon = rows[target]! - if (canon.kind === 'assistant' && row.steps.length > 0 && canon.steps.length === 0) { - rows[target] = { ...canon, steps: row.steps } - } - continue - } - if (lastAssistantIndex !== -1 && row.steps.length > 0) { - const canon = rows[lastAssistantIndex]! - if (canon.kind === 'assistant' && canon.steps.length === 0 && row.content === '') { - rows[lastAssistantIndex] = { ...canon, steps: row.steps } - continue - } - } - tail.push(row) + if (isCanonicalRow(row)) { + front.push(row) + anchor = 'front' continue } if (row.kind === 'choice' && row.resolved !== undefined) continue - tail.push(row) + if (anchor === null) pending.push(row) + else if (anchor === 'front') front.push(row) + else after.set(anchor, [...(after.get(anchor) ?? []), row]) } - return [...rows, ...tail] + const merged = front + rows.forEach((row, i) => { + merged.push(...(before.get(i) ?? []), row, ...(after.get(i) ?? [])) + }) + return [...merged, ...pending] } /** Lifecycle states worth a transcript row. */ @@ -522,21 +543,23 @@ function answerRow(event: AnswerEvent, steps: Step[]): AssistantRow { } } -/** Index of the settled (ephemeral) assistant row holding a step, newest first; -1 when none. */ -function settledStepRow(rows: ChatRow[], itemId: string): number { +/** Index of the newest assistant row holding a step; -1 when none. */ +function stepRow(rows: ChatRow[], itemId: string): number { for (let i = rows.length - 1; i >= 0; i -= 1) { const row = rows[i]! - if (row.kind === 'assistant' && row.ephemeral === true && row.steps.some(s => s.itemId === itemId)) return i + if (row.kind === 'assistant' && row.steps.some(s => s.itemId === itemId)) return i } return -1 } /** * Something the session said mid-turn. It shows at once, carrying the steps - * that finished before it, and the turn keeps running: the working row, its - * timer and Stop stay up, the queued chip stays (the turn it waits behind is - * not over), and steps still in flight stay live. The narrative snapshot is - * dropped with the settled steps โ€” it described them, not what comes next. + * that FINISHED before it (each on its own completion event โ€” a running tool + * stays live however long it takes), and the turn keeps running: the working + * row, its timer and Stop stay up, the queued chip stays (the turn it waits + * behind is not over), and a pending choice keeps waiting on its human. The + * narrative snapshot is dropped with the settled steps โ€” it described them, + * not what comes next. */ function settleInterim(chat: ChatState, event: AnswerEvent): ChatState { const id = `answer-${event.id}` @@ -546,55 +569,58 @@ function settleInterim(chat: ChatState, event: AnswerEvent): ChatState { if (isBlankAssistant(row)) return chat const live = chat.live === null ? null - : { ...chat.live, content: '', steps: chat.live.steps.filter(s => s.status !== 'completed') } - return { ...chat, rows: [...chat.rows, row], live, activity: 'working', waiting: false } + : { + ...chat.live, + content: '', + steps: chat.live.steps.filter(s => s.status !== 'completed'), + settled: [...(chat.live.settled ?? []), ...done.map(s => s.itemId)], + } + return { ...chat, rows: [...chat.rows, row], live, activity: 'working' } } /** - * The turn's one boundary. Its text (when it has any) is the final answer and - * takes the remaining steps; an EMPTY boundary is the status-only closing - * frame, which shows nothing of its own โ€” steps still live join the row the - * session last said something in, or stand alone when it said nothing โ€” and - * still ends the working state. + * A turn-ending answer. Whatever is still running settles here, and only + * here: its text (when it has any) is the final answer and takes those steps; + * an EMPTY one (the status-only closing frame) shows nothing of its own โ€” the + * leftover steps become their own row, where the live block already showed + * them, and with none there is no row at all โ€” and still ends the turn. */ function closeTurn(chat: ChatState, event: AnswerEvent): ChatState { const notices = chat.notices.filter(notice => notice.kind !== 'queued') const ended: ChatState = { ...chat, live: null, activity: 'idle', waiting: false, notices } const id = `answer-${event.id}` if (chat.rows.some(r => r.id === id)) return ended - const steps = chat.live === null ? [] : completeSteps(chat.live.steps) - const row = answerRow(event, steps) - if (isBlankAssistant(row)) return ended - const last = chat.rows.at(-1) - if (row.content === '' && row.attachments === undefined && last?.kind === 'assistant' && last.ephemeral === true) { - const rows = chat.rows.slice(0, -1) - return { ...ended, rows: [...rows, { ...last, steps: steps.reduce(upsertStep, last.steps) }] } - } - return { ...ended, rows: [...chat.rows, row] } + const row = answerRow(event, chat.live === null ? [] : completeSteps(chat.live.steps)) + return isBlankAssistant(row) ? ended : { ...ended, rows: [...chat.rows, row] } } function applySessionEvent(chat: ChatState, action: Extract): ChatState { const event = action.event switch (event.kind) { case 'progress': { - const live: LiveTurn = chat.live ?? { id: `live-${action.now}`, content: '', steps: [], startedAt: action.now } - const nextLive: LiveTurn = { - ...live, - content: event.content !== '' ? event.content : live.content, - steps: event.done ? completeSteps(live.steps) : live.steps, + // A `done` card is only final โ€” sealed mid-turn by a timer that fires + // whatever is running, or the turn's last card. It finishes no step, + // ends no turn and answers no pending choice. + if (event.done) { + if (chat.live === null || event.content === '' || event.content === chat.live.content) return chat + return { ...chat, live: { ...chat.live, content: event.content } } } - return { ...chat, live: nextLive, activity: event.done ? chat.activity : 'working', waiting: false } + const live: LiveTurn = chat.live ?? { id: `live-${action.now}`, content: '', steps: [], startedAt: action.now } + const nextLive: LiveTurn = { ...live, content: event.content !== '' ? event.content : live.content } + return { ...chat, live: nextLive, activity: 'working', waiting: false } } case 'activity': { - // A step an interim answer already settled into its row keeps updating - // there (a late `completed`), never as a second copy in the live turn. - const inLive = chat.live?.steps.some(s => s.itemId === event.step.itemId) === true - const settledAt = inLive ? -1 : settledStepRow(chat.rows, event.step.itemId) - if (settledAt !== -1) { + // A step this turn already settled into a row is final: a late + // completion may refresh it there, but nothing turns it back into a + // spinner or copies it into the live turn. + if (chat.live?.settled?.includes(event.step.itemId) === true + && !chat.live.steps.some(s => s.itemId === event.step.itemId)) { + const at = stepRow(chat.rows, event.step.itemId) + const row = at === -1 ? undefined : chat.rows[at] + if (event.step.status !== 'completed' || row?.kind !== 'assistant') return chat const rows = chat.rows.slice() - const row = rows[settledAt]! - if (row.kind === 'assistant') rows[settledAt] = { ...row, steps: upsertStep(row.steps, event.step) } - return { ...chat, rows, activity: 'working', waiting: false } + rows[at] = { ...row, steps: upsertStep(row.steps, event.step) } + return { ...chat, rows } } const live: LiveTurn = chat.live ?? { id: `live-${action.now}`, content: '', steps: [], startedAt: action.now } return { @@ -789,13 +815,10 @@ export function reduce(state: ConsoleState, action: Action): ConsoleState { } // Rows already paged in (older than this page) stay in front of the page. const pageIds = new Set(canonical.map(r => r.id)) - const isCanonical = (r: ChatRow): boolean => - (r.kind === 'user' && r.local !== true) || (r.kind === 'assistant' && r.ephemeral !== true) - const paged = chat.rows.filter(r => isCanonical(r) && !pageIds.has(r.id)) - const merged = reconcile(chat.rows.filter(r => !paged.includes(r)), canonical) + const paged = chat.rows.filter(r => isCanonicalRow(r) && !pageIds.has(r.id)) return withChat(state, action.sid, { ...chat, - rows: [...paged, ...merged], + rows: reconcile(chat.rows, canonical), loading: false, error: null, ...(chat.nextBefore === undefined || paged.length === 0 diff --git a/dsh-plugins/ccteam-ui/src/shared/contract.ts b/dsh-plugins/ccteam-ui/src/shared/contract.ts index 0f1503e3..f52c471a 100644 --- a/dsh-plugins/ccteam-ui/src/shared/contract.ts +++ b/dsh-plugins/ccteam-ui/src/shared/contract.ts @@ -247,10 +247,11 @@ export interface TurnUsage { } /** - * The per-turn status snapshot ccteam stamps on a turn's boundary answer - * (upstream `TurnStatus`: model, context window, turn number, cumulative cost - * and tokens). Its PRESENCE is what marks the boundary โ€” see - * {@link isTurnBoundary}; the fields themselves are advisory. + * The per-turn status snapshot ccteam stamps on the answer that closes a + * structured turn (upstream `TurnStatus`: model, context window, turn number, + * cumulative cost and tokens). Advisory only: plenty of turn-ending answers + * carry none (terminal-protocol replies, slash-command receipts, notices), so + * it never decides whether a turn ended โ€” {@link isTurnBoundary} does. */ export interface TurnStatus { model?: string @@ -266,7 +267,7 @@ export interface TranscriptRow { content: string ts?: string vendor?: string - /** Present only on the row that closed its turn; absent = an interim answer. */ + /** The closing snapshot, on the row that closed a structured turn. */ status?: TurnStatus attachments?: AttachmentRef[] usage?: TurnUsage @@ -381,14 +382,16 @@ export interface StatusResponse { /** * One per-session frame, translated from ccteam's session stream: * - `progress`: the running turn's narrative so far (a snapshot, not a delta; - * `done` means this status card will not be edited again โ€” NOT that the - * turn ended: a long turn closes several cards); + * `done` means this status card is final โ€” sealed mid-turn, or the last + * one โ€” never that the turn ended, and never that its steps finished: + * steps finish on their own `activity` completions); * - `activity`: one structured step started/completed; - * - `answer`: something the session said. Without `status` it is an INTERIM - * answer โ€” said mid-turn while the turn keeps running; with `status` it is - * the turn's one BOUNDARY (its content may be empty: a status-only closing - * frame that ends the turn but carries nothing to show); with `options` it - * is a human-in-the-loop prompt. {@link isTurnBoundary} is the one test; + * - `answer`: something the session said. `interim: true` marks a message + * said MID-TURN โ€” the turn keeps running; any other answer ends its turn, + * with or without a `status` snapshot (an empty `content` with a `status` + * is the status-only closing frame: it ends the turn and shows nothing); + * with `options` it is a human-in-the-loop prompt. {@link isTurnBoundary} + * is the one test; * - `lifecycle`: the session changed state (started / evicted / stopped โ€ฆ). */ export type SessionEvent = @@ -399,6 +402,8 @@ export type SessionEvent = id: string content: string ts?: string + /** Said mid-turn: the turn is still running. Absent on every turn-ending answer. */ + interim?: true status?: TurnStatus attachments?: AttachmentRef[] options?: ChoiceOption[] @@ -408,22 +413,23 @@ export type SessionEvent = /** * Whether a session frame ends its turn. ccteam delivers what a session says - * while a turn runs as interim answers and closes every turn with exactly one - * answer carrying the turn's `status` snapshot, so "an answer arrived" is NOT - * "the turn finished" โ€” only this is. A choice prompt (non-empty `options`) - * suspends the turn on a human; it does not end it. Both halves (the BFF's - * `turn_done` feed and the workbench's working state / reconcile) ask here. + * while a turn runs as answers marked `interim`; every other answer ends its + * turn โ€” a structured turn's closing answer, a terminal-protocol reply, a + * slash-command receipt, a notice โ€” whether or not it carries a `status`. A + * choice prompt (non-empty `options`) suspends the turn on a human; it does + * not end it. Both halves (the BFF's `turn_done` feed and the workbench's + * working state / reconcile) ask here. */ export function isTurnBoundary(event: SessionEvent): boolean { return event.kind === 'answer' - && event.status !== undefined + && event.interim !== true && (event.options === undefined || event.options.length === 0) } /** * One SSE frame from the host. `graph` frames invalidate the team tree; - * `turn_done` marks a completed turn โ€” once per turn, at its boundary, never - * for an interim answer (badge counter feed); `delegation` + * `turn_done` marks a completed turn โ€” at its turn-ending answer, never for + * an interim one (badge counter feed); `delegation` * frames narrate parent/child relations; `session` frames carry one * {@link SessionEvent} for the sid the client subscribed to. Unknown kinds * must be ignored by the client (forward-compat). diff --git a/dsh-plugins/ccteam-ui/tests/client-chat.test.tsx b/dsh-plugins/ccteam-ui/tests/client-chat.test.tsx index 9b85bfe1..d27da5be 100644 --- a/dsh-plugins/ccteam-ui/tests/client-chat.test.tsx +++ b/dsh-plugins/ccteam-ui/tests/client-chat.test.tsx @@ -80,7 +80,7 @@ const MID_TURN: Action[] = [ { type: 'send_started', sid: 's1', text: 'fix the build' }, on({ kind: 'activity', step: { itemId: 't1', kind: 'command_exec', name: 'cargo', summary: 'cargo build', status: 'started' } }), on({ kind: 'activity', step: { itemId: 't1', kind: 'command_exec', name: 'cargo', summary: 'cargo build', status: 'completed' } }), - on({ kind: 'answer', id: 'a1', content: 'The build fails in the linker; patching the flags next.' }), + on({ kind: 'answer', id: 'a1', content: 'The build fails in the linker; patching the flags next.', interim: true }), ] describe('chat: a long turn', () => { @@ -104,4 +104,15 @@ describe('chat: a long turn', () => { expect(assistantBlocks(html)).toBe(1) expect(html).not.toMatch(/
<\/div>/) }) + + it('a turn-ending answer with no status (the model switch receipt) takes Stop down', () => { + const html = render(chatAfter([ + { type: 'send_started', sid: 's1', text: '/model opus' }, + on({ kind: 'progress', content: '', done: false }), + on({ kind: 'answer', id: 'r1', content: 'switched model โ†’ opus' }), + ])) + expect(html).toContain('switched model โ†’ opus') + expect(html).not.toContain(zh['chat.working']) + expect(html).not.toContain('data-stop=""') + }) }) diff --git a/dsh-plugins/ccteam-ui/tests/client-store.test.ts b/dsh-plugins/ccteam-ui/tests/client-store.test.ts index 957ea0ab..3aa79191 100644 --- a/dsh-plugins/ccteam-ui/tests/client-store.test.ts +++ b/dsh-plugins/ccteam-ui/tests/client-store.test.ts @@ -28,13 +28,14 @@ function run(actions: Action[], start = initialState()): ConsoleState { const NOW = 1_700_000_000_000 -/** What a turn's boundary answer carries; its presence is the boundary. */ +/** The snapshot a structured turn's closing answer carries (advisory). */ const STATUS: TurnStatus = { model: 'opus', turn: 1, costUsd: 0.1 } const step = (itemId: string, status: 'started' | 'completed'): Step => ({ itemId, kind: 'tool_call', name: 'Bash', summary: itemId, status }) const on = (event: SessionEvent): Action => ({ type: 'session_event', sid: 's1', event, now: NOW }) const activity = (itemId: string, status: 'started' | 'completed'): Action => on({ kind: 'activity', step: step(itemId, status) }) -const interim = (id: string, content: string): Action => on({ kind: 'answer', id, content }) +const interim = (id: string, content: string, extra: Partial = {}): Action => + on({ kind: 'answer', id, content, interim: true, ...extra } as SessionEvent) const boundary = (id: string, content: string): Action => on({ kind: 'answer', id, content, status: STATUS }) describe('selection', () => { @@ -107,7 +108,7 @@ describe('recents', () => { }) describe('chat: live turn', () => { - it('progress content is a snapshot, steps upsert by item id and complete on done', () => { + it('progress content is a snapshot, steps upsert by item id, and a done card finishes no step', () => { let state = run([ { type: 'session_event', sid: 's1', event: { kind: 'activity', step: { itemId: 't1', kind: 'tool_call', name: 'Bash', summary: 'ls', status: 'started' } }, now: NOW }, { type: 'session_event', sid: 's1', event: { kind: 'progress', content: 'looking', done: false }, now: NOW }, @@ -120,13 +121,14 @@ describe('chat: live turn', () => { expect(live?.steps[0]?.status).toBe('completed') state = reduce(state, { type: 'session_event', sid: 's1', event: { kind: 'activity', step: { itemId: 't2', kind: 'command_exec', name: 'cargo', summary: 'cargo test', status: 'started' } }, now: NOW }) state = reduce(state, { type: 'session_event', sid: 's1', event: { kind: 'progress', content: '', done: true }, now: NOW }) - expect(chatOf(state, 's1').live?.steps.every(s => s.status === 'completed')).toBe(true) + // Steps finish on their own completion events; the turn's end settles the rest. + expect(chatOf(state, 's1').live?.steps.map(s => [s.itemId, s.status])).toEqual([['t1', 'completed'], ['t2', 'started']]) }) it('an answer settles the live turn into an ephemeral row carrying its steps', () => { let state = run([ { type: 'session_event', sid: 's1', event: { kind: 'activity', step: { itemId: 't1', kind: 'tool_call', name: 'Bash', summary: 'ls', status: 'started' } }, now: NOW }, - { type: 'session_event', sid: 's1', event: { kind: 'answer', id: 'e9', content: 'done.', status: STATUS }, now: NOW }, + { type: 'session_event', sid: 's1', event: { kind: 'answer', id: 'e9', content: 'done.' }, now: NOW }, ]) const chat = chatOf(state, 's1') expect(chat.live).toBeNull() @@ -153,7 +155,7 @@ describe('chat: live turn', () => { it('steps attached to a canonical row survive later history reloads', () => { let state = run([ { type: 'session_event', sid: 's1', event: { kind: 'activity', step: { itemId: 't1', kind: 'tool_call', name: 'Bash', summary: 'ls', status: 'started' } }, now: NOW }, - { type: 'session_event', sid: 's1', event: { kind: 'answer', id: 'e9', content: 'done.', status: STATUS }, now: NOW }, + { type: 'session_event', sid: 's1', event: { kind: 'answer', id: 'e9', content: 'done.' }, now: NOW }, { type: 'history_loaded', sid: 's1', rows: [{ turnId: 'u1:assistant', role: 'assistant', content: 'done.' }], hasMore: false }, { type: 'history_loaded', sid: 's1', rows: [{ turnId: 'u1:assistant', role: 'assistant', content: 'done.' }, { turnId: 'u2:user', role: 'user', content: 'more' }], hasMore: false }, ]) @@ -221,15 +223,27 @@ describe('chat: live turn', () => { }) describe('chat: mid-turn answers', () => { - it('only an answer carrying a status is the turn boundary', () => { - expect(isTurnBoundary({ kind: 'answer', id: 'a', content: 'x' })).toBe(false) - expect(isTurnBoundary({ kind: 'answer', id: 'a', content: 'x', status: STATUS })).toBe(true) - expect(isTurnBoundary({ kind: 'answer', id: 'a', content: '', status: {} })).toBe(true) + it('every answer ends its turn unless it is marked interim', () => { + expect(isTurnBoundary({ kind: 'answer', id: 'a', content: 'x', interim: true })).toBe(false) + expect(isTurnBoundary({ kind: 'answer', id: 'a', content: 'x' })).toBe(true) + expect(isTurnBoundary({ kind: 'answer', id: 'a', content: '', status: STATUS })).toBe(true) // A choice prompt suspends the turn on a human; it does not end it. - expect(isTurnBoundary({ kind: 'answer', id: 'a', content: '?', status: STATUS, options: [{ id: 'y', label: 'y' }], token: 't' })).toBe(false) + expect(isTurnBoundary({ kind: 'answer', id: 'a', content: '?', options: [{ id: 'y', label: 'y' }], token: 't' })).toBe(false) expect(isTurnBoundary({ kind: 'progress', content: '', done: true })).toBe(false) }) + it('an answer with no status still ends the turn (a /model receipt, a terminal reply)', () => { + const state = run([ + { type: 'send_started', sid: 's1', text: '/model opus' }, + on({ kind: 'progress', content: '', done: false }), + on({ kind: 'answer', id: 'r1', content: 'switched model โ†’ opus' }), + ]) + const chat = chatOf(state, 's1') + expect(chat.activity).toBe('idle') + expect(chat.live).toBeNull() + expect(chat.rows.map(r => r.id)).toEqual(['local-1', 'answer-r1']) + }) + it('an interim answer shows at once and the turn keeps working', () => { const state = run([ { type: 'send_started', sid: 's1', text: 'fix it' }, @@ -254,20 +268,79 @@ describe('chat: mid-turn answers', () => { expect(state.badge).toBe(0) }) - it('a step that finishes after an interim answer settled it updates in place', () => { + it('a card sealed mid-tool finishes nothing: the running tool stays live and completes where it runs', () => { const state = run([ activity('t1', 'started'), - on({ kind: 'progress', content: '', done: true }), + // The release timer seals the card and delivers what was said, mid-tool. + on({ kind: 'progress', content: 'โ†ณ cargo build', done: true }), interim('a1', 'waiting on the build'), activity('t1', 'completed'), ]) const chat = chatOf(state, 's1') const said = chat.rows[0] - if (said?.kind === 'assistant') expect(said.steps.map(s => [s.itemId, s.status])).toEqual([['t1', 'completed']]) - expect(chat.live?.steps ?? []).toEqual([]) + if (said?.kind === 'assistant') expect(said.steps).toEqual([]) + expect(chat.live?.steps.map(s => [s.itemId, s.status])).toEqual([['t1', 'completed']]) + expect(chat.activity).toBe('working') + }) + + it('a late completion of a settled step refreshes it in its row; nothing makes it spin again', () => { + let state = run([ + activity('t1', 'started'), + activity('t1', 'completed'), + interim('a1', 'read the file'), + on({ kind: 'activity', step: { ...step('t1', 'completed'), summary: 'read 120 lines' } }), + ]) + let said = chatOf(state, 's1').rows[0] + if (said?.kind === 'assistant') expect(said.steps).toMatchObject([{ itemId: 't1', status: 'completed', summary: 'read 120 lines' }]) + state = reduce(state, activity('t1', 'started')) + said = chatOf(state, 's1').rows[0] + if (said?.kind === 'assistant') expect(said.steps.map(s => s.status)).toEqual(['completed']) + expect(chatOf(state, 's1').live?.steps).toEqual([]) + }) + + it('an ACP liveness pulse stays live across interim answers and settles with the turn', () => { + const pulse = (): Action => on({ kind: 'activity', step: { itemId: 'T7-live-msg', kind: 'thinking', name: '', summary: 'โ€ฆ', status: 'started' } }) + const state = run([ + pulse(), + on({ kind: 'progress', content: 'โ†ณ โ€ฆ', done: true }), + interim('a1', 'first half'), + pulse(), + interim('a2', 'second half'), + pulse(), + boundary('close', ''), + ]) + const chat = chatOf(state, 's1') + const spinning = chat.rows.flatMap(r => (r.kind === 'assistant' ? r.steps : [])).filter(s => s.status !== 'completed') + expect(spinning).toEqual([]) + expect(chat.rows.map(r => r.id)).toEqual(['answer-a1', 'answer-a2', 'answer-close']) + expect(chat.rows[2]).toMatchObject({ content: '', steps: [{ itemId: 'T7-live-msg', status: 'completed' }] }) + }) + + it('a step id reused by a later turn shows live in that turn, not on the old row', () => { + const state = run([ + activity('call:0', 'completed'), + interim('a1', 'listed it'), + boundary('b1', 'done'), + activity('call:0', 'started'), + ]) + const chat = chatOf(state, 's1') + expect(chat.live?.steps.map(s => [s.itemId, s.status])).toEqual([['call:0', 'started']]) + const first = chat.rows[0] + if (first?.kind === 'assistant') expect(first.steps.map(s => s.status)).toEqual(['completed']) expect(chat.activity).toBe('working') }) + it('an interim answer, or a card sealed under it, does not override a pending choice', () => { + const state = run([ + on({ kind: 'answer', id: 'p1', content: 'Allow rm?', options: [{ id: 'y', label: 'Yes' }], token: 'tok' }), + on({ kind: 'progress', content: 'โ†ณ asking', done: true }), + interim('a1', 'I need your approval to delete the build dir'), + ]) + const chat = chatOf(state, 's1') + expect(chat.waiting).toBe(true) + expect(chat.rows.map(r => r.kind)).toEqual(['choice', 'assistant']) + }) + it('the status-only closing frame ends the turn and renders no row', () => { const state = run([ { type: 'send_started', sid: 's1', text: 'go' }, @@ -282,21 +355,71 @@ describe('chat: mid-turn answers', () => { expect(chat.rows.some(r => r.kind === 'assistant' && r.content === '' && r.steps.length === 0)).toBe(false) }) - it('steps still live at a status-only close join the row the session last spoke in', () => { - const state = run([ + it('steps still running at a status-only close become their own row, after what was said', () => { + const chat = chatOf(run([ interim('a1', 'running the suite'), activity('t9', 'started'), boundary('close', ''), + ]), 's1') + expect(chat.rows.map(r => r.id)).toEqual(['answer-a1', 'answer-close']) + expect(chat.rows[0]).toMatchObject({ steps: [] }) + expect(chat.rows[1]).toMatchObject({ kind: 'assistant', content: '', steps: [{ itemId: 't9', status: 'completed' }] }) + expect(chat.activity).toBe('idle') + }) + + it('a tool-only turn keeps its steps row in place, and the next turn keeps its own steps', () => { + let state = run([ + { type: 'history_loaded', sid: 's1', rows: [{ turnId: 'p1:user', role: 'user', content: 'hi' }, { turnId: 'p1:assistant', role: 'assistant', content: 'hello' }], hasMore: false }, + { type: 'send_started', sid: 's1', text: 'touch x' }, + activity('t1', 'started'), + boundary('close1', ''), + { type: 'history_loaded', sid: 's1', rows: [ + { turnId: 'p1:user', role: 'user', content: 'hi' }, + { turnId: 'p1:assistant', role: 'assistant', content: 'hello' }, + { turnId: 'q1:user', role: 'user', content: 'touch x' }, + ], hasMore: false }, ]) - const chat = chatOf(state, 's1') - expect(chat.rows).toHaveLength(1) - const said = chat.rows[0] - if (said?.kind === 'assistant') expect(said.steps.map(s => [s.itemId, s.status])).toEqual([['t9', 'completed']]) - // With nothing said at all, the steps stand as their own (visible) row. - const silent = chatOf(run([activity('t1', 'started'), boundary('close', '')]), 's1') - expect(silent.rows).toHaveLength(1) - expect(silent.rows[0]).toMatchObject({ kind: 'assistant', content: '', steps: [{ itemId: 't1', status: 'completed' }] }) - expect(silent.activity).toBe('idle') + expect(chatOf(state, 's1').rows.map(r => r.id)).toEqual(['p1:user', 'p1:assistant', 'q1:user', 'answer-close1']) + state = run([ + { type: 'send_started', sid: 's1', text: 'now y' }, + activity('t2', 'started'), + boundary('b2', 'made y'), + { type: 'history_loaded', sid: 's1', rows: [ + { turnId: 'p1:user', role: 'user', content: 'hi' }, + { turnId: 'p1:assistant', role: 'assistant', content: 'hello' }, + { turnId: 'q1:user', role: 'user', content: 'touch x' }, + { turnId: 'r1:user', role: 'user', content: 'now y' }, + { turnId: 'r1:assistant', role: 'assistant', content: 'made y' }, + ], hasMore: false }, + ], state) + const rows = chatOf(state, 's1').rows + expect(rows.map(r => r.id)).toEqual(['p1:user', 'p1:assistant', 'q1:user', 'answer-close1', 'r1:user', 'r1:assistant']) + const stepsOf = (id: string): string[] => { + const row = rows.find(r => r.id === id) + return row?.kind === 'assistant' ? row.steps.map(s => s.itemId) : [] + } + expect(stepsOf('p1:assistant')).toEqual([]) + expect(stepsOf('answer-close1')).toEqual(['t1']) + expect(stepsOf('r1:assistant')).toEqual(['t2']) + }) + + it('a file sent mid-turn with no text keeps its row and its steps through reconcile', () => { + const file = { kind: 'file' as const, name: 'report.pdf', url: '/ccteam/api/attachment?sid=s1&name=report.pdf' } + let state = run([ + { type: 'send_started', sid: 's1', text: 'make the report' }, + activity('t1', 'completed'), + interim('f1', '', { attachments: [file] }), + activity('t2', 'started'), + boundary('b1', 'report attached above'), + ]) + state = reduce(state, { type: 'history_loaded', sid: 's1', rows: [ + { turnId: 'q1:user', role: 'user', content: 'make the report' }, + { turnId: 'q2:assistant', role: 'assistant', content: 'report attached above' }, + ], hasMore: false }) + const rows = chatOf(state, 's1').rows + expect(rows.map(r => r.id)).toEqual(['q1:user', 'answer-f1', 'q2:assistant']) + expect(rows[1]).toMatchObject({ attachments: [file], steps: [{ itemId: 't1' }] }) + expect(rows[2]).toMatchObject({ steps: [{ itemId: 't2', status: 'completed' }] }) }) it('reconcile hands each interim row its own canonical row, repeated lines included', () => { @@ -355,9 +478,9 @@ describe('chat: sending and history', () => { state = reduce(state, { type: 'send_settled', sid: 's1', receipt: { ok: false, errorKind: 'bad_request', error: 'nope' } }) expect(chatOf(state, 's1').notices).toHaveLength(2) // Mid-turn talk does not release what the message is queued behind. - state = reduce(state, { type: 'session_event', sid: 's1', event: { kind: 'answer', id: 'e0', content: 'still busy' }, now: NOW }) + state = reduce(state, { type: 'session_event', sid: 's1', event: { kind: 'answer', id: 'e0', content: 'still busy', interim: true }, now: NOW }) expect(chatOf(state, 's1').notices.map(n => n.kind)).toEqual(['queued', 'error']) - state = reduce(state, { type: 'session_event', sid: 's1', event: { kind: 'answer', id: 'e1', content: 'ok', status: STATUS }, now: NOW }) + state = reduce(state, { type: 'session_event', sid: 's1', event: { kind: 'answer', id: 'e1', content: 'ok' }, now: NOW }) expect(chatOf(state, 's1').notices.map(n => n.kind)).toEqual(['error']) expect(reduce(state, { type: 'send_settled', sid: 's1', receipt: { ok: true } })).toBe(state) }) diff --git a/dsh-plugins/ccteam-ui/tests/host-sse.test.ts b/dsh-plugins/ccteam-ui/tests/host-sse.test.ts index 7a547337..894c493e 100644 --- a/dsh-plugins/ccteam-ui/tests/host-sse.test.ts +++ b/dsh-plugins/ccteam-ui/tests/host-sse.test.ts @@ -227,7 +227,7 @@ describe('frame translation', () => { data: JSON.stringify(data), }) - /** The upstream `TurnStatus` object a turn's boundary answer carries. */ + /** The upstream `TurnStatus` object a structured turn's closing answer carries. */ const STATUS = { model: 'opus', context: { used_tokens: 42_000, window_tokens: 200_000, source: 'derived' }, @@ -236,39 +236,36 @@ describe('frame translation', () => { tokens_total: 9000, } - it('treats the answer carrying a status as the turn boundary', () => { - expect(translateGlobal(frame({ kind: 'answer', sid: 's1', content: 'hi', status: STATUS }))).toEqual([ - { kind: 'graph' }, - { kind: 'turn_done', sid: 's1' }, - ]) + it('treats every answer not marked interim as a completed turn, status or not', () => { + const done = [{ kind: 'graph' }, { kind: 'turn_done', sid: 's1' }] + expect(translateGlobal(frame({ kind: 'answer', sid: 's1', content: 'hi', status: STATUS }))).toEqual(done) // The status-only closing frame (nothing left to say) ends the turn too. - expect(translateGlobal(frame({ kind: 'answer', sid: 's1', content: '', status: STATUS }))).toEqual([ - { kind: 'graph' }, - { kind: 'turn_done', sid: 's1' }, - ]) + expect(translateGlobal(frame({ kind: 'answer', sid: 's1', content: '', status: STATUS }))).toEqual(done) + // A terminal-protocol reply, a slash-command receipt, a notice: no status, still the end. + expect(translateGlobal(frame({ kind: 'answer', sid: 's1', content: 'switched model โ†’ opus' }))).toEqual(done) + expect(translateGlobal(frame({ kind: 'answer', sid: 's1', content: 'x', interim: false }))).toEqual(done) }) - it('an interim answer (no status) completes nothing: no turn_done, no tree re-read', () => { - expect(translateGlobal(frame({ kind: 'answer', sid: 's1', content: 'looking at the tests' }))).toEqual([]) - expect(translateGlobal(frame({ kind: 'answer', sid: 's1', content: 'still going', status: null }))).toEqual([]) + it('an interim answer completes nothing: no turn_done, no tree re-read', () => { + expect(translateGlobal(frame({ kind: 'answer', sid: 's1', content: 'looking at the tests', interim: true }))).toEqual([]) }) it('one long turn feeds the badge exactly once, however much it says mid-turn', () => { const turn = [ - frame({ kind: 'answer', sid: 's1', id: 'a1', content: 'first I will read the code' }), - frame({ kind: 'progress', sid: 's1', content: 'Read(src/bff.ts)', done: true }), - frame({ kind: 'answer', sid: 's1', id: 'a2', content: 'now the tests' }), - frame({ kind: 'progress', sid: 's1', content: 'Bash(npm test)', done: true }), - frame({ kind: 'answer', sid: 's1', id: 'a3', content: 'all green' }), + frame({ kind: 'answer', sid: 's1', id: 'a1', content: 'first I will read the code', interim: true }), + frame({ kind: 'progress', sid: 's1', content: 'โ†ณ Read(src/bff.ts)', done: true }), + frame({ kind: 'answer', sid: 's1', id: 'a2', content: 'now the tests', interim: true }), + frame({ kind: 'progress', sid: 's1', content: 'โœ… done', done: true }), + frame({ kind: 'answer', sid: 's1', id: 'a3', content: 'all green', interim: true }), frame({ kind: 'answer', sid: 's1', id: 'a4', content: '', status: STATUS }), ] const events = turn.flatMap(translateGlobal) expect(events.filter(event => event.kind === 'turn_done')).toEqual([{ kind: 'turn_done', sid: 's1' }]) - // A closed progress card is not a turn end either, so the tree is re-read once. + // A final progress card is not a turn end either, so the tree is re-read once. expect(events.filter(event => event.kind === 'graph')).toHaveLength(1) }) - it('carries the boundary status as an object and leaves interim answers without one', () => { + it('carries the interim mark and the closing status (an object) through to the chat', () => { expect(translateSession('s1', frame({ kind: 'answer', sid: 's1', id: 'a4', content: '', status: STATUS }))).toEqual([{ kind: 'session', sid: 's1', @@ -290,10 +287,16 @@ describe('frame translation', () => { kind: 'answer', sid: 's1', id: 'a5', content: 'x', status: { model: null, context: null, turn: 1, cost_usd: null, tokens_total: null }, }))).toEqual([{ kind: 'session', sid: 's1', event: { kind: 'answer', id: 'a5', content: 'x', status: { turn: 1 } } }]) - expect(translateSession('s1', frame({ kind: 'answer', sid: 's1', id: 'a1', content: 'mid-turn' }))).toEqual([{ + expect(translateSession('s1', frame({ kind: 'answer', sid: 's1', id: 'a1', content: 'mid-turn', interim: true }))).toEqual([{ + kind: 'session', + sid: 's1', + event: { kind: 'answer', id: 'a1', content: 'mid-turn', interim: true }, + }]) + // Only a literal `true` marks a message interim. + expect(translateSession('s1', frame({ kind: 'answer', sid: 's1', id: 'a2', content: 'end', interim: 'yes' }))).toEqual([{ kind: 'session', sid: 's1', - event: { kind: 'answer', id: 'a1', content: 'mid-turn' }, + event: { kind: 'answer', id: 'a2', content: 'end' }, }]) }) From b0e2636d84af0a3e5e50df903cfeb193fe3666a5 Mon Sep 17 00:00:00 2001 From: randd1024 Date: Fri, 25 Sep 2026 07:04:55 +0000 Subject: [PATCH 11/37] fix(im): a turn's answer is delimited by its own rows (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second independent review of #209: - An answer-less boundary never moved `visible_events`, so a turn whose every line went out mid-turn stayed armed in the stall watchdog and was reported silent โ€” and STUCK to every reader โ€” minutes after it had finished. The boundary disarms it now, whatever rides it. - `chat_send_file` is called from inside a running turn; its event now says `interim` while the caller's turn is in flight, so a web reader no longer drops Stop when a file arrives mid-turn. - `turn_answer_record` is the one definition of "the answer a turn id names" โ€” for the notification, the restart reconcile, `agent{wait}` and now `agent_read{turn}`, so a completion's "read the rest" pointer returns the answer it counted. The turn is delimited by its own rows (pi and codex-exec reuse execution ids across restarts): walking back it stops at the previous turn's status row, a terminal outcome or another turn's text; walking forward the closing row lends the status and the vendor's conclusion the pieces went out without. - The pump's per-turn text list restarts at `TurnStarted` and on a failure boundary, so neither text said outside a turn nor an unappendable failure leaks into the next turn's answer. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ccteam-im/src/delegation.rs | 93 ++++++++++++++++++++++++---- crates/ccteam-im/src/gateway.rs | 42 ++++++++----- crates/ccteam-im/src/mcp/dispatch.rs | 14 ++++- 3 files changed, 120 insertions(+), 29 deletions(-) diff --git a/crates/ccteam-im/src/delegation.rs b/crates/ccteam-im/src/delegation.rs index 731b7da0..3c028423 100644 --- a/crates/ccteam-im/src/delegation.rs +++ b/crates/ccteam-im/src/delegation.rs @@ -494,9 +494,19 @@ pub(crate) fn fold_turn_answer( (answer, conclusion) } -/// `answered` widened to its whole execution turn: every row of the same turn -/// up to and including it, folded by [`fold_turn_answer`]. A row with no -/// execution identity, or a failed one, is its own answer. +/// `answered` widened to its whole execution turn โ€” the ONE definition of "the +/// answer a turn id names", shared by the parent's notification, the restart +/// reconcile, the inline `agent{wait}` result and `agent_read{turn}`. +/// +/// The turn is delimited by its own rows, not by its execution id alone (pi +/// and codex-exec mint ids from a per-process counter, so a restart reuses +/// them): walking back from `answered`, it takes every text row of the same +/// execution turn and stops at the previous turn's end โ€” a status-bearing +/// row, a terminal `outcome`, or another turn's text. Rows without text (user +/// rows, a steer) are stepped over. Walking forward, the turn's closing row +/// (#209: empty text, the status its pieces went out without) lends the +/// answer its status and the vendor's conclusion. A row with no execution +/// identity, or a failed one, is its own answer. pub(crate) fn turn_answer_record( all: &[ccteam_harness::execution::turns_mirror::TurnRecord], answered: &ccteam_harness::execution::turns_mirror::TurnRecord, @@ -511,22 +521,48 @@ pub(crate) fn turn_answer_record( let Some(at) = all.iter().position(|row| row.turn_id == answered.turn_id) else { return answered.clone(); }; - let parts: Vec<&str> = all[..=at] + let same_turn = |row: &ccteam_harness::execution::turns_mirror::TurnRecord| { + row.exec_turn_id.as_deref() == Some(exec) + }; + let mut parts: Vec<&str> = vec![answered.assistant.as_str()]; + for row in all[..at].iter().rev() { + if row.status.is_some() || row.outcome.is_some() { + break; + } + if row.assistant.trim().is_empty() { + continue; + } + if !same_turn(row) { + break; + } + parts.push(row.assistant.as_str()); + } + parts.reverse(); + let closing = all[at + 1..] .iter() - .filter(|row| { - row.exec_turn_id.as_deref() == Some(exec) - && !row.assistant.trim().is_empty() - && row.outcome.is_none() - }) - .map(|row| row.assistant.as_str()) - .collect(); - if parts.len() < 2 { + .take_while(|row| row.assistant.trim().is_empty()) + .find(|row| same_turn(row) && row.status.is_some() && row.outcome.is_none()); + if parts.len() < 2 && closing.is_none() { return answered.clone(); } - let (assistant, conclusion) = fold_turn_answer(&parts, answered.conclusion.as_deref()); + let vendor_conclusion = closing + .and_then(|row| row.conclusion.as_deref()) + .or(answered.conclusion.as_deref()); + let (assistant, conclusion) = fold_turn_answer(&parts, vendor_conclusion); ccteam_harness::execution::turns_mirror::TurnRecord { assistant, conclusion, + status: answered + .status + .clone() + .or_else(|| closing.and_then(|row| row.status.clone())), + usage: if answered.usage.is_null() { + closing + .map(|row| row.usage.clone()) + .unwrap_or(serde_json::Value::Null) + } else { + answered.usage.clone() + }, ..answered.clone() } } @@ -813,6 +849,37 @@ mod tests { assert_eq!(answer.assistant, "started the build\n\nbuild green"); assert_eq!(answer.conclusion.as_deref(), Some("build green")); assert_eq!(turn_answer_record(&all, &all[4]).assistant, "a later turn"); + + // pi / codex-exec reuse execution ids after a restart: a previous + // run's turn with the SAME id ended on a status row, and nothing of it + // joins the new turn. The new turn's closing row lends its status and + // the vendor's conclusion. + let status = ccteam_harness::TurnStatus { + model: None, + context: None, + turn: 7, + cost_usd: None, + tokens_total: None, + }; + let mut ended = row("s9-1", "pi-s9-1", "previous run's answer"); + ended.status = Some(status.clone()); + let mut steer = row("input-1", "pi-s9-1", ""); + steer.exec_turn_id = None; + steer.user = "and the logs?".into(); + let mut closing = row("s9-4", "pi-s9-1", ""); + closing.status = Some(status.clone()); + closing.conclusion = Some("the receipt".into()); + let all = vec![ + ended, + row("s9-2", "pi-s9-1", "new run piece"), + steer, + row("s9-3", "pi-s9-1", "new run final"), + closing, + ]; + let answer = turn_answer_record(&all, &all[3]); + assert_eq!(answer.assistant, "new run piece\n\nnew run final"); + assert_eq!(answer.status.map(|status| status.turn), Some(7)); + assert_eq!(answer.conclusion.as_deref(), Some("the receipt")); } #[test] diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index 86928112..16a6d76c 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -1422,20 +1422,15 @@ fn stranded_boundary_signals( } for (exec_turn_id, rows) in groups { let last = rows.last().expect("a group holds at least one row"); - // The turn's answer is every row it wrote, not its last piece (#209) - // โ€” unless it failed, when the failure row is what it ended on (the - // live boundary reports the same). - let (tail, conclusion) = if last.failed() { - (last.assistant.clone(), last.conclusion.clone()) - } else { - let parts: Vec<&str> = rows.iter().map(|row| row.assistant.as_str()).collect(); - crate::delegation::fold_turn_answer(&parts, last.conclusion.as_deref()) - }; + // The turn's answer is every row it wrote, not its last piece (#209), + // with the status its closing row carries โ€” the same answer the live + // boundary and `agent{wait}` report (a failed turn is its failure row). + let answer = crate::delegation::turn_answer_record(all_turns, last); pending.push(crate::delegation::DelegationSignal { child_sid: child_sid.to_string(), turn_id: last.turn_id.clone(), exec_turn_id: Some(exec_turn_id), - tail, + tail: answer.assistant.clone(), vendor, host: host.to_string(), boundary: true, @@ -1456,14 +1451,14 @@ fn stranded_boundary_signals( || last.error.is_some(), interim_notes: rows.len().saturating_sub(1), covered_turns: rows.iter().map(|t| t.turn_id.clone()).collect(), - context_pct: crate::delegation::context_pct(last.status.as_ref()), - turn: last + context_pct: crate::delegation::context_pct(answer.status.as_ref()), + turn: answer .status .as_ref() .map(|status| status.turn) .unwrap_or_default(), error_kind: last.error_kind.clone(), - conclusion, + conclusion: answer.conclusion.clone(), }); } pending @@ -7324,6 +7319,9 @@ impl Gateway { // preserves the original elapsed time. if let ThreadEvent::TurnStarted { turn_id, opening } = &evt { structured_turn_open = true; + // What an earlier stretch said outside any turn is + // not this turn's answer. + turn_said.clear(); // The execution turn a delegation request is bound // to. Recording it here is what lets a boundary // resolve exactly the requests that asked for THIS @@ -7879,6 +7877,12 @@ impl Gateway { turn_had_answer = true; } if is_turn_boundary && answer_texts.is_empty() { + // The turn has ended visibly even though no answer + // rides its boundary: disarm the stall watchdog here, + // or a turn whose every line went out mid-turn is + // reported silent โ€” and STUCK โ€” minutes after it + // finished. + session.visible_events.fetch_add(1, Ordering::SeqCst); // The boundary closes the turn's last progress card // even when no answer rides it โ€” otherwise the card // stays open, and the NEXT turn's steps edit it. @@ -8547,6 +8551,10 @@ impl Gateway { open_continues_from = None; } if matches!(&evt, ThreadEvent::TurnFailed { .. } | ThreadEvent::Error(_)) { + // Cleared here, not only on a durable failure row: + // a row that failed to append must not carry this + // turn's text into the next one's answer. + turn_said.clear(); structured_turn_open = false; open_exec_turn = None; open_continues_from = None; @@ -27908,9 +27916,13 @@ mod tests { .expect("the boundary signals the parent"); assert_eq!(signal.tail, "replied to the steer\n\nall green"); assert_eq!(signal.conclusion.as_deref(), Some("all green")); - // The watchdog was never told the turn answered by its interim lines. + // Only the boundary tells the watchdog the turn answered: its two + // interim lines never did, and the answer-less boundary still does. let visible = gateway.sessions["s1"].visible_events.load(Ordering::SeqCst); - assert_eq!(visible, 0, "interim lines never disarm the stall watchdog"); + assert_eq!( + visible, 1, + "the boundary, and only the boundary, disarms the watchdog" + ); } /// #209 โ€” the hold is bounded by a CLOCK, not by the vendor's next event. diff --git a/crates/ccteam-im/src/mcp/dispatch.rs b/crates/ccteam-im/src/mcp/dispatch.rs index 21a35fdc..46ccccb5 100644 --- a/crates/ccteam-im/src/mcp/dispatch.rs +++ b/crates/ccteam-im/src/mcp/dispatch.rs @@ -691,6 +691,14 @@ async fn run_chat_send_file( .as_ref() .map(|target| (target.channel.clone(), target.chat_id.clone())); let mut event = build_send_file_event(args, seq, event_target)?; + // A file the session sends from inside its running turn is part of that + // turn, not its end (#209): a reader that took it for the boundary + // dropped Stop for the rest of the turn. + if let (Some(gateway), Some(caller)) = + (gateway, args.get("_caller_sid").and_then(|v| v.as_str())) + { + event.interim = gateway.lock().await.session_turn_in_flight(caller); + } if event.channel == "web" { let session = live_target .as_ref() @@ -4099,8 +4107,12 @@ fn exact_collected_turn( .find(|turn| is_transcript_row(turn)) .map(|turn| turn.turn_id.clone()); let row = all.iter().find(|turn| turn.turn_id == turn_id)?; + // The row's whole execution turn (#209): a completion's "read the rest" + // pointer names the turn's last row, and must return the same folded + // answer the notification counted, not only its last piece. + let row = crate::delegation::turn_answer_record(all, row); Some(TranscriptPage { - rows: vec![collected_turn_row(row)], + rows: vec![collected_turn_row(&row)], cursor: Some(row.turn_id.clone()), remaining: 0, latest, From 6541b81e6b7ce0aef1130a283d717aa00f999f6c Mon Sep 17 00:00:00 2001 From: randd1024 Date: Fri, 25 Sep 2026 07:11:30 +0000 Subject: [PATCH 12/37] fix(harness): an ACP chunk with no open turn sends no liveness pulse (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thought/message chunk arriving with no turn buffer open is dropped from every answer, but its liveness pulse still went out as `pending-live-*` โ€” and read downstream (the pump's progress fold, a web client) as a new turn starting after the last one had ended. With nothing running there is nothing for a pulse to keep alive. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/execution/acp/translate.rs | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/ccteam-harness/src/execution/acp/translate.rs b/crates/ccteam-harness/src/execution/acp/translate.rs index ba9bf1c8..f7f72d28 100644 --- a/crates/ccteam-harness/src/execution/acp/translate.rs +++ b/crates/ccteam-harness/src/execution/acp/translate.rs @@ -713,6 +713,18 @@ fn maybe_liveness_event( kind: &str, details: ThreadItemDetails, ) -> Vec { + // No open turn โ†’ nothing is running for the pulse to keep alive: the + // chunk itself is dropped from every answer, and a pulse for it read to + // the gateway (and to a web client) as a new turn starting after the last + // one had ended (#209). + let Some(turn_id) = state + .vendor_started_buffer + .as_ref() + .or(state.buffer.as_ref()) + .map(|b| b.turn_id.clone()) + else { + return Vec::new(); + }; let now = Instant::now(); if state .last_liveness_at @@ -721,12 +733,6 @@ fn maybe_liveness_event( return Vec::new(); } state.last_liveness_at = Some(now); - let turn_id = state - .vendor_started_buffer - .as_ref() - .or(state.buffer.as_ref()) - .map(|b| b.turn_id.as_str()) - .unwrap_or("pending"); vec![ThreadEvent::ItemUpdated { item: ThreadItem { // Stable per-kind id so ProgressFold updates one card, not a flood. @@ -1291,6 +1297,21 @@ mod tests { ); } + /// #209 โ€” with no turn open there is nothing for a liveness pulse to keep + /// alive; one used to go out as `pending-live-*` and read downstream as a + /// new turn starting after the last one had ended. + #[test] + fn no_open_turn_means_no_liveness_pulse() { + let mut st = SessionTranslateState::default(); + assert!(apply_notification(&mut st, &chunk("stray")).is_empty()); + st.begin_turn("t-live", Arc::new(Notify::new())); + let pulse = apply_notification(&mut st, &chunk("working")); + assert!( + matches!(pulse.as_slice(), [ThreadEvent::ItemUpdated { item }] if item.id == "t-live-live-msg"), + "{pulse:?}" + ); + } + /// A context probe's reply is data the runner parses, never conversation. #[test] fn a_quiet_probe_reports_nothing_mid_turn() { From 0cabce567dfde1f9b558300a11ced07e215d8a8a Mon Sep 17 00:00:00 2001 From: randd1024 Date: Fri, 25 Sep 2026 07:07:52 +0000 Subject: [PATCH 13/37] fix(dsh): nothing after a turn's end reopens a phantom working turn (#209) closeTurn dropped the live turn and, with it, what the turn had settled, so any activity arriving after the boundary took the "open a new live turn" path and set the chat working until the next turn-ending answer. Two sources do this by design: ACP's liveness pulse, which falls back to a `pending-live-*` id once no turn buffer is open (and whose fold also emits a fresh progress card), and a late completion for a step of the turn that just ended (the boundary force-completes whatever was still running). The chat now remembers the ended turn's step ids until a new turn is under way. Between turns, an event for one of those steps refreshes its row on a completion and is otherwise dropped; a liveness pulse, and the card a pulse folds into, open nothing. A genuinely new turn still shows working: a send, an interim answer or choice prompt, or tool activity with an unseen id clears the guard. Co-Authored-By: Claude Opus 5.5 (1M context) --- dsh-plugins/ccteam-ui/src/client/store.ts | 50 +++++++++++--- .../ccteam-ui/tests/client-store.test.ts | 67 ++++++++++++++++++- 2 files changed, 105 insertions(+), 12 deletions(-) diff --git a/dsh-plugins/ccteam-ui/src/client/store.ts b/dsh-plugins/ccteam-ui/src/client/store.ts index 5d73d39a..537f98a7 100644 --- a/dsh-plugins/ccteam-ui/src/client/store.ts +++ b/dsh-plugins/ccteam-ui/src/client/store.ts @@ -133,6 +133,14 @@ export interface ChatState { activity: Activity | undefined /** A choice prompt is pending (the working indicator yields to it). */ waiting: boolean + /** + * Step ids of the turn that just ended, kept until a new turn is under way + * (a send, an interim answer or prompt, tool activity never seen before). + * Between turns a late event for one of them, a liveness pulse, or the + * card such a pulse folds into belongs to the ended turn and must not + * reopen a working turn. null before any turn ended in view. + */ + ended: string[] | null loading: boolean loadingOlder: boolean error: string | null @@ -348,6 +356,7 @@ const EMPTY_CHAT: ChatState = { live: null, activity: undefined, waiting: false, + ended: null, loading: false, loadingOlder: false, error: null, @@ -543,6 +552,15 @@ function answerRow(event: AnswerEvent, steps: Step[]): AssistantRow { } } +/** + * ACP's throttled liveness pulse (`{turn}-live-{kind}`, `pending-live-โ€ฆ` when + * no turn buffer is open): proof that bytes streamed, never that a new turn + * began. + */ +function isLivenessPulse(itemId: string): boolean { + return itemId.includes('-live-') +} + /** Index of the newest assistant row holding a step; -1 when none. */ function stepRow(rows: ChatRow[], itemId: string): number { for (let i = rows.length - 1; i >= 0; i -= 1) { @@ -575,7 +593,7 @@ function settleInterim(chat: ChatState, event: AnswerEvent): ChatState { steps: chat.live.steps.filter(s => s.status !== 'completed'), settled: [...(chat.live.settled ?? []), ...done.map(s => s.itemId)], } - return { ...chat, rows: [...chat.rows, row], live, activity: 'working' } + return { ...chat, rows: [...chat.rows, row], live, activity: 'working', ended: null } } /** @@ -587,7 +605,8 @@ function settleInterim(chat: ChatState, event: AnswerEvent): ChatState { */ function closeTurn(chat: ChatState, event: AnswerEvent): ChatState { const notices = chat.notices.filter(notice => notice.kind !== 'queued') - const ended: ChatState = { ...chat, live: null, activity: 'idle', waiting: false, notices } + const steps = chat.live === null ? [] : [...(chat.live.settled ?? []), ...chat.live.steps.map(s => s.itemId)] + const ended: ChatState = { ...chat, live: null, activity: 'idle', waiting: false, notices, ended: steps } const id = `answer-${event.id}` if (chat.rows.some(r => r.id === id)) return ended const row = answerRow(event, chat.live === null ? [] : completeSteps(chat.live.steps)) @@ -605,29 +624,38 @@ function applySessionEvent(chat: ChatState, action: Extract s.itemId === event.step.itemId)) { - const at = stepRow(chat.rows, event.step.itemId) + // A step already settled into a row โ€” by an interim answer this turn, + // or by the turn that just ended โ€” is final: a late completion may + // refresh it there, but nothing turns it back into a spinner, copies it + // into the live turn, or reopens a working turn for it. + const itemId = event.step.itemId + const settled = chat.live === null + ? chat.ended?.includes(itemId) === true + : chat.live.settled?.includes(itemId) === true && !chat.live.steps.some(s => s.itemId === itemId) + if (settled) { + const at = stepRow(chat.rows, itemId) const row = at === -1 ? undefined : chat.rows[at] if (event.step.status !== 'completed' || row?.kind !== 'assistant') return chat const rows = chat.rows.slice() rows[at] = { ...row, steps: upsertStep(row.steps, event.step) } return { ...chat, rows } } + // Between turns a liveness pulse is the ended turn's stray, not a new one. + if (chat.live === null && chat.ended !== null && isLivenessPulse(itemId)) return chat const live: LiveTurn = chat.live ?? { id: `live-${action.now}`, content: '', steps: [], startedAt: action.now } return { ...chat, live: { ...live, steps: upsertStep(live.steps, event.step) }, activity: 'working', waiting: false, + ended: null, } } case 'answer': { @@ -641,7 +669,7 @@ function applySessionEvent(chat: ChatState, action: Extract r.id === row.id)) return chat const notices = chat.notices.filter(notice => notice.kind !== 'queued') - return { ...chat, rows: [...chat.rows, row], notices, activity: 'idle', waiting: true } + return { ...chat, rows: [...chat.rows, row], notices, activity: 'idle', waiting: true, ended: null } } return isTurnBoundary(event) ? closeTurn(chat, event) : settleInterim(chat, event) } @@ -845,7 +873,7 @@ export function reduce(state: ConsoleState, action: Action): ConsoleState { ...(action.attachments === undefined || action.attachments.length === 0 ? {} : { attachments: action.attachments }), } return { - ...withChat(state, action.sid, { ...chat, rows: [...chat.rows, row], notices: [], waiting: false }), + ...withChat(state, action.sid, { ...chat, rows: [...chat.rows, row], notices: [], waiting: false, ended: null }), nextLocalId: state.nextLocalId + 1, } } @@ -886,7 +914,7 @@ export function reduce(state: ConsoleState, action: Action): ConsoleState { return withChat(state, action.sid, { ...chat, rows, - ...(action.type === 'choice_resolved' ? { waiting: false, activity: 'working' as Activity } : {}), + ...(action.type === 'choice_resolved' ? { waiting: false, activity: 'working' as Activity, ended: null } : {}), }) } case 'delegation': { diff --git a/dsh-plugins/ccteam-ui/tests/client-store.test.ts b/dsh-plugins/ccteam-ui/tests/client-store.test.ts index 3aa79191..3ce59581 100644 --- a/dsh-plugins/ccteam-ui/tests/client-store.test.ts +++ b/dsh-plugins/ccteam-ui/tests/client-store.test.ts @@ -316,11 +316,12 @@ describe('chat: mid-turn answers', () => { expect(chat.rows[2]).toMatchObject({ content: '', steps: [{ itemId: 'T7-live-msg', status: 'completed' }] }) }) - it('a step id reused by a later turn shows live in that turn, not on the old row', () => { + it('a step id reused by the next turn shows live in that turn, not on the old row', () => { const state = run([ activity('call:0', 'completed'), interim('a1', 'listed it'), boundary('b1', 'done'), + { type: 'send_started', sid: 's1', text: 'again' }, activity('call:0', 'started'), ]) const chat = chatOf(state, 's1') @@ -459,6 +460,70 @@ describe('chat: mid-turn answers', () => { }) }) +describe('chat: after a turn ends', () => { + const pulse = (itemId: string): Action => on({ kind: 'activity', step: { itemId, kind: 'thinking', name: '', summary: 'โ€ฆ', status: 'update' } }) + const idle = (state: ConsoleState): void => { + const chat = chatOf(state, 's1') + expect(chat.activity).toBe('idle') + expect(chat.live).toBeNull() + } + + it('a late completion of the ended turn\'s step refreshes its row and reopens nothing', () => { + let state = run([ + { type: 'send_started', sid: 's1', text: 'build it' }, + activity('t1', 'started'), + boundary('b1', 'kicked off the build'), + on({ kind: 'activity', step: { ...step('t1', 'completed'), summary: 'build finished in 3m' } }), + ]) + idle(state) + expect(chatOf(state, 's1').rows[1]).toMatchObject({ id: 'answer-b1', steps: [{ itemId: 't1', status: 'completed', summary: 'build finished in 3m' }] }) + // Anything but a completion for it is dropped โ€” even once the row is canonical. + state = run([ + { type: 'history_loaded', sid: 's1', rows: [ + { turnId: 'q1:user', role: 'user', content: 'build it' }, + { turnId: 'q1:assistant', role: 'assistant', content: 'kicked off the build' }, + ], hasMore: false }, + activity('t1', 'started'), + ], state) + idle(state) + expect(chatOf(state, 's1').rows[1]).toMatchObject({ id: 'q1:assistant', steps: [{ itemId: 't1', status: 'completed' }] }) + }) + + it('a stray liveness pulse (and the card it folds into) after the turn ends opens no working turn', () => { + const state = run([ + pulse('T7-live-thought'), + boundary('b1', 'done'), + pulse('pending-live-thought'), + on({ kind: 'progress', content: '๐Ÿ’ญ', done: false }), + pulse('T7-live-thought'), + ]) + idle(state) + expect(chatOf(state, 's1').rows.map(r => r.id)).toEqual(['answer-b1']) + }) + + it('a genuinely new turn still shows working: a send, unseen tool activity, an interim answer', () => { + const ended: Action[] = [activity('t1', 'started'), boundary('b1', 'done'), pulse('pending-live-thought')] + const afterSend = chatOf(run([ + ...ended, + { type: 'send_started', sid: 's1', text: 'next' }, + on({ kind: 'progress', content: '๐Ÿ’ญ', done: false }), + ]), 's1') + expect(afterSend.activity).toBe('working') + expect(afterSend.live?.content).toBe('๐Ÿ’ญ') + + const fromElsewhere = chatOf(run([ + ...ended, + activity('t2', 'started'), + on({ kind: 'progress', content: 'Bash(t2)', done: false }), + ]), 's1') + expect(fromElsewhere.activity).toBe('working') + expect(fromElsewhere.live).toMatchObject({ content: 'Bash(t2)', steps: [{ itemId: 't2', status: 'started' }] }) + + const spoke = chatOf(run([...ended, interim('a2', 'picking this up from IM')]), 's1') + expect(spoke.activity).toBe('working') + }) +}) + describe('chat: sending and history', () => { it('an optimistic user row is replaced by its canonical row', () => { let state = run([{ type: 'send_started', sid: 's1', text: 'hello' }]) From 457171cbeb691c5d16156b348887a448b0473dfc Mon Sep 17 00:00:00 2001 From: firstintent <239836187+firstintent@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:43:40 +0800 Subject: [PATCH 14/37] feat(im): a threaded channel scopes session focus to the thread (#19) On Telegram every agent session shares one bot stream, so concurrent sessions talk over each other. Slack gives each session its own thread; this is the channel-neutral gateway half: a thread is the session's tab (the IM twin of the web console's per-session tab), while ownership, the ACL principal and the current project stay per conversation. - ChatKey carries an optional thread. Owner/identity keys never do (canonical_owner drops it in every branch), so a session started in one thread is owned by, listed in and addressable from every thread of the conversation. - FocusRoutes takes a FocusScope at construction (no Default): current_project is per conversation, current_session per thread. Every accessor and the routing.json load normalize through one key fn; threads_bound_to answers which threads a sid is the tab of. The spawn single-flight claim keys by the same thread slot. - handle_message / handle_message_shared / inbound_may_spawn / has_current_session take the thread. The daemon passes the inbound thread_ts only when Channel::session_threads() is true (new trait method, default false; MockChannel::with_session_threads for tests). - Every outbound event built from a session's reply_to or a chat key stamps that key's thread: answers, closing status receipts, progress, activity, reactions, pickers, choice prompts, stall warnings, recovered turns, scheduled-failure notices, HITL prompts (HitlPromptContext), and the MCP chat_send_file / interaction / permission prompts (reply_target_for now returns a ReplyTarget with thread_ts). - An internal turn (a delegation completion notification) resets reply_to to an owner-derived target, and an owner has no thread: the answer read as unfocused and lost its IM leg. with_bound_thread puts the session's thread back (the current reply_to's thread while it is still bound to the sid, else the most recent bound thread), applied through owner_reply_target / owner_reply_target_for_meta at every owner-derived reset, plus /role and the thread-keeping resume_stopped_session{,_shared}_as cores. Telegram, Lark and web never pass a thread, so their routing, focus and delivery are unchanged. Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com> --- crates/ccteam-im/src/daemon.rs | 47 + crates/ccteam-im/src/gateway.rs | 875 +++++++++++++++--- crates/ccteam-im/src/hitl.rs | 22 +- crates/ccteam-im/src/mcp/dispatch.rs | 33 +- crates/ccteam-im/src/transport/mod.rs | 11 + .../ccteam-im/src/transport/providers/mock.rs | 17 + crates/ccteam-im/tests/inbound_wiring_test.rs | 112 +++ 7 files changed, 977 insertions(+), 140 deletions(-) diff --git a/crates/ccteam-im/src/daemon.rs b/crates/ccteam-im/src/daemon.rs index b2dac2da..6a3b0582 100644 --- a/crates/ccteam-im/src/daemon.rs +++ b/crates/ccteam-im/src/daemon.rs @@ -1439,6 +1439,12 @@ fn spawn_inbound_consumer( continue; }; + // The thread reaches the gateway only from a channel whose + // contract is "one thread = one session" (Slack). Everyone else + // (Telegram, Lark, web) never passes one, whatever the platform + // happened to fill in โ€” their routing stays the single stream. + let thread = inbound_session_thread(channel.as_ref(), &msg); + let restore_incomplete = !*restore_complete.borrow(); if clean_payload.split_whitespace().next() == Some("/sessions") && restore_incomplete { // Startup restore deliberately runs outside the gateway lock @@ -1467,6 +1473,7 @@ fn spawn_inbound_consumer( &msg.channel, &msg.reply_target, &msg.sender, + thread.as_deref(), &msg.id, &clean_payload, &msg.attachments, @@ -1524,6 +1531,7 @@ fn spawn_inbound_consumer( &msg.channel, &msg.reply_target, &msg.sender, + thread.as_deref(), &clean_payload, msg.selection.is_some(), ) @@ -1544,6 +1552,7 @@ fn spawn_inbound_consumer( &msg.channel, &msg.reply_target, &msg.sender, + thread.as_deref(), &msg.id, &clean_payload, &msg.attachments, @@ -1570,6 +1579,7 @@ fn spawn_inbound_consumer( &msg.channel, &msg.reply_target, &msg.sender, + thread.as_deref(), &msg.id, &clean_payload, &msg.attachments, @@ -1590,6 +1600,19 @@ fn spawn_inbound_consumer( }) } +/// The thread an inbound message hands the gateway: its platform thread when +/// `channel` gives every session its own thread +/// ([`crate::transport::Channel::session_threads`]), else `None` โ€” so a channel +/// without that contract routes as a single stream even if its provider filled +/// in a thread id. +fn inbound_session_thread(channel: &dyn Channel, msg: &ChannelMessage) -> Option { + if channel.session_threads() { + msg.thread_ts.clone() + } else { + None + } +} + /// Send the outcome of one `handle_message`/`handle_message_shared` call to /// the originating channel โ€” shared by `spawn_inbound_consumer`'s inline and /// backgrounded-spawn branches so the reply-delivery + latency logging can @@ -2123,6 +2146,30 @@ pub fn _link_check(_c: &Credentials) {} mod tests { use super::*; + /// Only a channel whose contract is "one thread = one session" hands the + /// gateway a thread; any other channel routes as a single stream even + /// when its provider filled in a thread id. + #[test] + fn only_a_session_threading_channel_hands_the_gateway_a_thread() { + use crate::transport::providers::mock::MockChannel; + let msg = ChannelMessage { + id: "m-1".into(), + sender: "alice".into(), + reply_target: "conv-1".into(), + content: "hi".into(), + channel: "mock".into(), + timestamp: 0, + thread_ts: Some("1700000000.000100".into()), + attachments: Vec::new(), + selection: None, + }; + assert_eq!(inbound_session_thread(&MockChannel::new(), &msg), None); + assert_eq!( + inbound_session_thread(&MockChannel::new().with_session_threads(), &msg), + Some("1700000000.000100".to_string()) + ); + } + /// v0.8.20 F2 โ€” one channel per tenant bot, keyed `"@"` /// (the unique routing key); a tenant with no IM creds yields no channel. #[cfg(feature = "telegram")] diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index 16a6d76c..1b43fbc4 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -69,6 +69,15 @@ struct ChatKey { channel: String, chat_id: String, user_id: String, + /// The platform thread a message lives in, on a channel that gives every + /// agent session its own thread ([`crate::transport::Channel::session_threads`], + /// Slack). A thread is the session's TAB inside the conversation โ€” the IM + /// twin of the web console's per-session tab: session FOCUS is per thread, + /// while ownership, ACL and the current project stay per conversation + /// (`chat_id`). `None` on every other channel, and on every owner/identity + /// key (see [`canonical_owner`]), so a single-stream chat is unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + thread: Option, } impl ChatKey { @@ -77,9 +86,22 @@ impl ChatKey { channel: channel.to_string(), chat_id: chat_id.to_string(), user_id: user_id.to_string(), + thread: None, } } + /// The same conversation key, placed in `thread` (an empty id is no + /// thread: a provider without one must not mint an empty "tab"). + fn with_thread(mut self, thread: Option<&str>) -> Self { + self.thread = thread.filter(|t| !t.is_empty()).map(str::to_string); + self + } + + /// This key with its thread dropped โ€” the conversation it belongs to. + fn conversation(&self) -> ChatKey { + self.clone().with_thread(None) + } + /// v0.8.18 ๆŸฑ2 โ€” canonical owner identity (`"channel:chat_id"`) recorded on /// a project this chat creates. The `chat_id` is the free per-chat identity /// (Telegram chat_id / Lark open_id); `user_id` is excluded so the project @@ -112,28 +134,69 @@ impl ChatKey { /// answer on the web console and nothing on Telegram. /// /// Focus follows ownership: the chat is the unit, not one member of it. + /// This is the CONVERSATION scope โ€” the thread is dropped too, because the + /// conversation's current project is shared by all of its threads. fn focus_key(&self) -> ChatKey { ChatKey::new(&self.channel, &self.chat_id, &self.chat_id) } + + /// [`Self::focus_key`] with the thread KEPT โ€” the THREAD scope session + /// focus is addressed by. On a channel without threads this is the very + /// same key, so a single-stream chat routes exactly as before. + fn thread_focus_key(&self) -> ChatKey { + self.focus_key().with_thread(self.thread.as_deref()) + } +} + +/// Which slice of a conversation one [`FocusRoutes`] table addresses. Chosen +/// at construction โ€” there is no default, because the wrong scope is a silent +/// routing bug rather than a compile error. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FocusScope { + /// One route per conversation (`current_project`): every thread of a + /// conversation works in the same project. + Conversation, + /// One route per thread (`current_session`): a thread is a session's tab, + /// so each thread talks to its own session. Identical to `Conversation` + /// for a channel that never passes a thread. + Thread, } /// A per-chat FOCUS table โ€” `current_project` (chat โ†’ the project it is /// working in) and `current_session` (chat โ†’ the session it is talking to). /// -/// A type rather than a bare `BTreeMap` so [`ChatKey::focus_key`] normalization -/// has exactly ONE home: the inner map is private and every accessor takes a -/// plain `&ChatKey` and normalizes it here, so a reader or writer added later -/// is covered by construction rather than by remembering. Both tables use it โ€” -/// they share the key shape, so they shared the split. +/// A type rather than a bare `BTreeMap` so key normalization has exactly ONE +/// home ([`Self::key`], per the table's [`FocusScope`]): the inner map is +/// private and every accessor takes a plain `&ChatKey` and normalizes it here, +/// so a reader or writer added later is covered by construction rather than by +/// remembering. Both tables use it โ€” they share the key shape and differ only +/// in scope (`current_project` per conversation, `current_session` per thread). /// /// Cheap to clone (`Arc`): each detached event pump keeps its own handle to the /// session table to answer "is this session still its chat's focus?". -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] struct FocusRoutes { + scope: FocusScope, inner: Arc>>, } impl FocusRoutes { + fn new(scope: FocusScope) -> Self { + Self { + scope, + inner: Arc::default(), + } + } + + /// The ONE key normalization every accessor (and [`Self::load_saved`]) + /// goes through: the chat, never a member of it, at this table's scope. + fn key(&self, chat: &ChatKey) -> ChatKey { + match self.scope { + FocusScope::Conversation => chat.focus_key(), + FocusScope::Thread => chat.thread_focus_key(), + } + } + /// Read the table, RECOVERING a poisoned lock. /// /// A panic under the lock leaves a `BTreeMap` that is still structurally @@ -159,7 +222,7 @@ impl FocusRoutes { /// What this chat is currently pointed at, if anything. fn get(&self, chat: &ChatKey) -> Option { - self.read().get(&chat.focus_key()).cloned() + self.read().get(&self.key(chat)).cloned() } fn contains(&self, chat: &ChatKey) -> bool { @@ -168,18 +231,28 @@ impl FocusRoutes { /// Point this chat at `value`, replacing whatever it pointed at before. fn set(&self, chat: &ChatKey, value: impl Into) { - self.write().insert(chat.focus_key(), value.into()); + self.write().insert(self.key(chat), value.into()); } fn remove(&self, chat: &ChatKey) { - self.write().remove(&chat.focus_key()); + self.write().remove(&self.key(chat)); } /// Is `sid` the session this chat is talking to right now? fn is_focused(&self, chat: &ChatKey, value: &str) -> bool { + self.read().get(&self.key(chat)).is_some_and(|v| v == value) + } + + /// The threads of conversation `(channel, chat_id)` whose route points at + /// `value`, ascending. Thread ids sort by time on the platform that has + /// them (a Slack `ts` is fixed-width epoch seconds), so the LAST one is the + /// most recent tab. Always empty for a channel that never passes a thread. + fn threads_bound_to(&self, channel: &str, chat_id: &str, value: &str) -> Vec { self.read() - .get(&chat.focus_key()) - .is_some_and(|v| v == value) + .iter() + .filter(|(chat, v)| chat.channel == channel && chat.chat_id == chat_id && *v == value) + .filter_map(|(chat, _)| chat.thread.clone()) + .collect() } /// Drop every route whose VALUE fails `keep`. @@ -233,7 +306,7 @@ impl FocusRoutes { ) { let mut collapsed: BTreeMap = BTreeMap::new(); for route in saved { - let key = route.chat.focus_key(); + let key = self.key(&route.chat); let Some(candidate) = rank(&key, &route.value) else { continue; }; @@ -780,8 +853,11 @@ pub struct Gateway { /// [`Principal::Guest`] instead: it owns only what it creates and sees no /// project, so it cannot reach anything of the owner's or a tenant's. operator_chats: BTreeMap, + /// conversation โ†’ its current project ([`FocusScope::Conversation`]: every + /// thread of a conversation works in the same project). current_project: FocusRoutes, - /// chat โ†’ its current/focused session id. Shared (`Arc` inside + /// chat โ†’ its current/focused session id, per THREAD on a channel that + /// threads sessions ([`FocusScope::Thread`]). Shared (`Arc` inside /// [`FocusRoutes`]) so the detached event pumps can read it to label /// *out-of-band* answers/errors โ€” i.e. async events from a session that is /// no longer the chat's focus, which otherwise masquerade as the current @@ -2188,6 +2264,10 @@ pub struct HitlPromptContext { pub channel: String, /// Platform chat/recipient id within `channel`. pub chat_id: String, + /// The session's own platform thread (Slack `thread_ts`), so the prompt + /// lands in the thread the session lives in. `None` on a single-stream + /// channel. + pub thread_ts: Option, /// The session's role (persona), for the "session sX (role) wants to /// run โ€ฆ" label. pub role: String, @@ -2197,6 +2277,31 @@ pub struct HitlPromptContext { pub progress_path: Option, } +/// A live session's delivery route ([`Gateway::reply_target_for`]): the +/// frontend chat its output goes to, plus โ€” on a channel that gives every +/// session its own thread โ€” that session's thread, so a file, a question or an +/// approval prompt the session raises lands in the thread it lives in. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReplyTarget { + /// IM/web channel name. + pub channel: String, + /// Platform chat/recipient id within `channel`. + pub chat_id: String, + /// The session's platform thread (Slack `thread_ts`); `None` on a + /// single-stream channel. + pub thread_ts: Option, +} + +impl ReplyTarget { + fn from_key(key: &ChatKey) -> Self { + Self { + channel: key.channel.clone(), + chat_id: key.chat_id.clone(), + thread_ts: key.thread.clone(), + } + } +} + /// A live `ccteam-chat-*` process with no matching tracked gateway session โ€” /// a survivor of a prior daemon. The process name carries only slug+sid (not /// the owning chat), so orphans are a global concern and are never attributed @@ -3034,11 +3139,16 @@ impl SpawnClaims { } /// Acquire (waiting if necessary) the single-flight claim for `chat`. + /// + /// Keyed exactly like the `current_session` slot the claim guards + /// ([`ChatKey::thread_focus_key`]): one claim per chat โ€” per THREAD on a + /// channel that threads sessions, so two new threads spawn concurrently + /// while two messages racing into one new thread still spawn once. async fn lock_for(&self, chat: &ChatKey) -> tokio::sync::OwnedMutexGuard<()> { let entry = { let mut map = self.per_chat.lock().unwrap(); Arc::clone( - map.entry(chat.clone()) + map.entry(chat.thread_focus_key()) .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))), ) }; @@ -3317,8 +3427,8 @@ impl Gateway { body_watch_notify: Arc::new(tokio::sync::Notify::new()), projects, operator_chats: BTreeMap::new(), - current_project: FocusRoutes::default(), - current_session: FocusRoutes::default(), + current_project: FocusRoutes::new(FocusScope::Conversation), + current_session: FocusRoutes::new(FocusScope::Thread), sessions: BTreeMap::new(), next_session_generation: 0, rebuild_reservations: BTreeMap::new(), @@ -4361,11 +4471,7 @@ impl Gateway { .sessions .get(sid) .and_then(|s| s.reply_to.lock().ok().map(|c| c.clone())) - .or_else(|| g.tenant_project_owner_reply_target(slug)) - .or_else(|| { - ChatKey::from_identity(&meta.owner).map(|owner| reply_target_for_owner(&owner)) - }) - .unwrap_or_else(web_api_chat); + .unwrap_or_else(|| g.owner_reply_target_for_meta(slug, &meta)); (adapter, reply_to, g.delegation_tx.clone()) }; let recovered = adapter @@ -4498,7 +4604,7 @@ impl Gateway { id: format!("gateway-recovered-{turn_id}"), channel: reply_to.channel.clone(), chat_id: reply_to.chat_id.clone(), - thread_ts: None, + thread_ts: reply_to.thread.clone(), content, kind: GatewayEventKind::Answer, attachments: Vec::new(), @@ -4555,13 +4661,7 @@ impl Gateway { } else { match g.find_meta_for_sid(sid) { Ok((slug, cwd, meta)) => { - let reply_to = g - .tenant_project_owner_reply_target(&slug) - .or_else(|| { - ChatKey::from_identity(&meta.owner) - .map(|owner| reply_target_for_owner(&owner)) - }) - .unwrap_or_else(web_api_chat); + let reply_to = g.owner_reply_target_for_meta(&slug, &meta); match g.plan_session_rebuild(&slug, cwd, &meta, &reply_to) { Ok(plan) => Some((plan, reply_to)), Err(err) if is_body_detached_error(&err) => { @@ -4796,12 +4896,19 @@ impl Gateway { } } - /// True when this chat already has a current gateway session. `user_id` is - /// the caller's sender, not part of the focus identity (see - /// [`ChatKey::focus_key`]) โ€” the chat's session is the chat's, whoever spoke. - pub fn has_current_session(&self, channel: &str, chat_id: &str, user_id: &str) -> bool { + /// True when this chat (this THREAD, on a channel that threads sessions) + /// already has a current gateway session. `user_id` is the caller's + /// sender, not part of the focus identity (see [`ChatKey::focus_key`]) โ€” + /// the chat's session is the chat's, whoever spoke. + pub fn has_current_session( + &self, + channel: &str, + chat_id: &str, + user_id: &str, + thread: Option<&str>, + ) -> bool { self.current_session - .contains(&ChatKey::new(channel, chat_id, user_id)) + .contains(&ChatKey::new(channel, chat_id, user_id).with_thread(thread)) } /// Route one inbound text message and return outbound replies. Thin @@ -4815,7 +4922,7 @@ impl Gateway { user_id: &str, text: &str, ) -> Result> { - self.handle_message(channel, chat_id, user_id, "", text, &[], None) + self.handle_message(channel, chat_id, user_id, None, "", text, &[], None) .await } @@ -4825,6 +4932,13 @@ impl Gateway { /// file's on-disk path, so the agent `Read`s it โ€” the load-bearing /// Read convention is taught by the daemon's MCP server instructions /// (the `initialize` response, on whichever transport the session holds). + /// + /// `thread` is the platform thread the message was posted in, passed ONLY + /// by a channel that gives every session its own thread + /// ([`crate::transport::Channel::session_threads`]); everyone else passes + /// `None` and routes exactly as a single stream. With a thread, session + /// focus (implicit spawn, `/use`, `/new`, `@handle`, nav buttons) is that + /// thread's own, while the project and ownership stay the conversation's. // v0.8.5 D3 added the `selection` arg (inbound option click); the // per-field inbound signature is the established shape (same as the // daemon's `deliver_progress`), so allow the arg count. @@ -4834,12 +4948,13 @@ impl Gateway { channel: &str, chat_id: &str, user_id: &str, + thread: Option<&str>, message_id: &str, text: &str, attachments: &[ChannelAttachment], selection: Option<&ChoiceReply>, ) -> Result> { - let chat = ChatKey::new(channel, chat_id, user_id); + let chat = ChatKey::new(channel, chat_id, user_id).with_thread(thread); // (v0.8.5 D3) An inbound option click (Telegram callback / web chip) // resolves the session's pending choice โ€” never treated as text. if let Some(reply) = selection { @@ -4980,13 +5095,14 @@ impl Gateway { channel: &str, chat_id: &str, user_id: &str, + thread: Option<&str>, text: &str, has_selection: bool, ) -> bool { if has_selection { return false; } - if self.has_current_session(channel, chat_id, user_id) { + if self.has_current_session(channel, chat_id, user_id, thread) { return false; } if Self::is_gateway_command(text) { @@ -5021,12 +5137,13 @@ impl Gateway { channel: &str, chat_id: &str, user_id: &str, + thread: Option<&str>, message_id: &str, text: &str, attachments: &[ChannelAttachment], selection: Option<&ChoiceReply>, ) -> Result> { - let chat = ChatKey::new(channel, chat_id, user_id); + let chat = ChatKey::new(channel, chat_id, user_id).with_thread(thread); // `/stop` before anything else: it is the one gateway command that has // to take a per-child claim, which means it cannot run inside a // `&mut Gateway` (issue #197 E). Handled here, where the lock can be @@ -5046,7 +5163,7 @@ impl Gateway { let candidate = if candidate_shape { !crate::latency::gateway_lock(&gateway, "im.turn.candidate") .await - .has_current_session(channel, chat_id, user_id) + .has_current_session(channel, chat_id, user_id, thread) } else { false }; @@ -5057,8 +5174,8 @@ impl Gateway { .spawn_claims, ); // Hold the per-chat claim across plan+spawn+apply so a second - // concurrent "no session yet" message for this SAME chat waits - // here instead of racing to spawn a duplicate session. + // concurrent "no session yet" message for this SAME chat (thread) + // waits here instead of racing to spawn a duplicate session. let _claim = claims.lock_for(&chat).await; let outcome = { let mut g = crate::latency::gateway_lock(&gateway, "im.turn.spawn_plan").await; @@ -5095,6 +5212,7 @@ impl Gateway { channel, chat_id, user_id, + thread, message_id, text, attachments, @@ -5566,13 +5684,14 @@ impl Gateway { if !acl_ok { return Ok(format!("unknown session for this chat: {id}")); } - let caller_identity = canonical_owner(chat).identity(); + // The caller's canonical identity, placed in the thread that asked + // (focus is per thread; the identity is the conversation's). + let caller = ChatKey::from_identity(&canonical_owner(chat).identity()) + .unwrap_or_else(web_api_chat) + .with_thread(chat.thread.as_deref()); // IM already owner-checked above, so no project-slug binding is // needed here โ€” pass None. - match self - .resume_stopped_session(id, &caller_identity, None) - .await - { + match self.resume_stopped_session_as(id, caller, None).await { Ok(resumed_sid) => { // Move current project to this session's project, and bind // the reply target to THIS chat key. @@ -5713,6 +5832,61 @@ impl Gateway { .map(|owner| reply_target_for_owner(&owner)) } + /// Re-attach `sid`'s own thread to a reply `target` that carries none. + /// + /// A turn nobody typed in this thread (a delegation completion + /// notification, a drained internal turn, a web submit to an IM-owned + /// session) answers to an OWNER-derived target, and an owner is a + /// conversation, never a thread ([`canonical_owner`]). On a channel that + /// threads sessions that answer would land top-level and read as "not this + /// thread's focus" โ€” dropping its IM leg โ€” so the session's thread is put + /// back: the one `current` (the session's present `reply_to`) names while + /// that thread is still bound to `sid`, else the most recent thread bound + /// to `sid` in that conversation. Nothing bound (and every single-stream + /// channel) โ†’ `target` unchanged. The ONE place this rule lives; every + /// owner-derived reset goes through [`Self::owner_reply_target`] / + /// [`Self::owner_reply_target_for_meta`]. + fn with_bound_thread(&self, target: ChatKey, sid: &str, current: Option<&ChatKey>) -> ChatKey { + if target.thread.is_some() { + return target; + } + let bound = self + .current_session + .threads_bound_to(&target.channel, &target.chat_id, sid); + let current_thread = current + .filter(|key| key.channel == target.channel && key.chat_id == target.chat_id) + .and_then(|key| key.thread.clone()) + .filter(|thread| bound.contains(thread)); + match current_thread.or_else(|| bound.last().cloned()) { + Some(thread) => target.with_thread(Some(&thread)), + None => target, + } + } + + /// Where a turn that did not come from a chat answers for a LIVE session: + /// the tenant project's web console for a tenant-owned project, else the + /// session owner's own frontend โ€” with the session's thread re-attached + /// ([`Self::with_bound_thread`]). + fn owner_reply_target(&self, session: &GatewaySession) -> ChatKey { + let target = self + .tenant_project_owner_reply_target(&session.project) + .unwrap_or_else(|| reply_target_for_owner(&session.owner)); + let current = pump_target(session); + self.with_bound_thread(target, &session.id, Some(¤t)) + } + + /// [`Self::owner_reply_target`] for a sid with no live record, resolved + /// from its `meta.json` owner (the admin web console when that is unset). + fn owner_reply_target_for_meta(&self, slug: &str, meta: &SessionMeta) -> ChatKey { + let target = self + .tenant_project_owner_reply_target(slug) + .or_else(|| { + ChatKey::from_identity(&meta.owner).map(|owner| reply_target_for_owner(&owner)) + }) + .unwrap_or_else(web_api_chat); + self.with_bound_thread(target, &meta.sid, None) + } + /// One "switch project" button per project (`nav:cd:`), the current /// one marked `โœ“`. Payloads over Telegram's 64-byte `callback_data` cap are /// dropped (a pathologically long slug still shows in the text list). @@ -5839,7 +6013,7 @@ impl Gateway { id: format!("gateway-picker-{}-{nanos}", chat.chat_id), channel: chat.channel.clone(), chat_id: chat.chat_id.clone(), - thread_ts: None, + thread_ts: chat.thread.clone(), content, kind: GatewayEventKind::Answer, attachments: Vec::new(), @@ -6760,7 +6934,9 @@ impl Gateway { .await?; // Replace the record in place: same sid, new role/handle/thread, fresh // pane counters; replies route back to the owner (the chat that drives - // it), matching `start_session`. + // it), matching `start_session` โ€” in the thread this `/role` was typed + // in, on a channel that threads sessions. + let reply_to = self.with_bound_thread(owner.clone(), &sid, Some(chat)); self.sessions.insert( sid.clone(), GatewaySession { @@ -6779,7 +6955,7 @@ impl Gateway { adapter, visible_events: Arc::new(AtomicU64::new(0)), activity_events: Arc::new(AtomicU64::new(0)), - reply_to: Arc::new(std::sync::Mutex::new(owner)), + reply_to: Arc::new(std::sync::Mutex::new(reply_to)), pending_reaction: Arc::new(std::sync::Mutex::new(None)), turn_started_at: Arc::new(std::sync::Mutex::new(None)), steered_this_turn: Arc::new(AtomicBool::new(false)), @@ -7395,17 +7571,13 @@ impl Gateway { .ok() .and_then(|mut p| p.take()); if let Some(ack_msg_id) = pending_ack { - let ack_target = session - .reply_to - .lock() - .map(|k| k.clone()) - .unwrap_or_else(|_| session.owner.clone()); + let ack_target = pump_target(&session); if !tx.send(GatewayEvent { interim: false, id: format!("gateway-reaction-clear-{session_id}-{ack_msg_id}"), channel: ack_target.channel.clone(), chat_id: ack_target.chat_id.clone(), - thread_ts: None, + thread_ts: ack_target.thread.clone(), content: String::new(), kind: GatewayEventKind::Reaction { message_id: ack_msg_id, @@ -7935,11 +8107,7 @@ impl Gateway { }; turn_last_context_pct = crate::delegation::context_pct(Some(&status)); - let chat_key = session - .reply_to - .lock() - .map(|key| key.clone()) - .unwrap_or_else(|_| session.owner.clone()); + let chat_key = pump_target(&session); // A turn whose text went out mid-turn (GitHub #206) // still owes the chat its CLOSING receipt: the // status line an in-boundary answer carries along. @@ -8028,7 +8196,7 @@ impl Gateway { id: status_id.clone(), channel: chat_key.channel.clone(), chat_id: chat_key.chat_id.clone(), - thread_ts: None, + thread_ts: chat_key.thread.clone(), content: line, kind: GatewayEventKind::Answer, attachments: Vec::new(), @@ -8053,7 +8221,7 @@ impl Gateway { id: status_id, channel: chat_key.channel.clone(), chat_id: chat_key.chat_id.clone(), - thread_ts: None, + thread_ts: chat_key.thread.clone(), content: String::new(), kind: GatewayEventKind::Answer, attachments: Vec::new(), @@ -8385,13 +8553,11 @@ impl Gateway { ); } // Resolve the live reply target ONCE (reply_to โ†’ owner - // fallback, same as pump_target) and reuse it for the - // focus check below. - let chat_key = session - .reply_to - .lock() - .map(|k| k.clone()) - .unwrap_or_else(|_| session.owner.clone()); + // fallback) and reuse it for the focus check below. It + // carries the session's thread, so on a channel that + // threads sessions "focused" means "this thread's + // session" and the answer lands in that thread. + let chat_key = pump_target(&session); if boundary_origin.is_some() { mirror_last_answer = None; } else { @@ -8399,6 +8565,7 @@ impl Gateway { } let channel = chat_key.channel.clone(); let chat_id = chat_key.chat_id.clone(); + let thread_ts = chat_key.thread.clone(); // v0.8.10 routing-isolation โ€” when this session is NOT the // chat's current focus (the user has since /use'd or // messaged a different sid), its async answer/error still @@ -8465,7 +8632,7 @@ impl Gateway { id: format!("gateway-event-{session_id}-{seq}"), channel, chat_id, - thread_ts: None, + thread_ts, content, kind: GatewayEventKind::Answer, attachments: Vec::new(), @@ -9413,12 +9580,19 @@ impl Gateway { if let (Some(channel), Some(chat_id)) = (&entry.item.reply_channel, &entry.item.reply_chat_id) { + // The notice is about this session, so it goes to the thread the + // session lives in (on a channel that threads sessions). + let target = self.with_bound_thread( + ChatKey::new(channel, chat_id, chat_id), + &entry.item.sid, + None, + ); self.emit_user_signal(GatewayEvent { interim: false, id: format!("scheduled-failed-{}", entry.item.id), - channel: channel.clone(), - chat_id: chat_id.clone(), - thread_ts: None, + channel: target.channel, + chat_id: target.chat_id, + thread_ts: target.thread, content: format!( "โฐ {} failed to send to {}: {} (kept in /inbox for 24h)", entry.item.id, entry.item.sid, reason @@ -10151,7 +10325,7 @@ impl Gateway { id: format!("gateway-reaction-add-{session_id}-{message_id}"), channel: chat.channel.clone(), chat_id: chat.chat_id.clone(), - thread_ts: None, + thread_ts: chat.thread.clone(), content: String::new(), kind: GatewayEventKind::Reaction { message_id: message_id.to_string(), @@ -10476,7 +10650,7 @@ impl Gateway { id: format!("gateway-choice-{session_id}-{}", prompt.token), channel: chat.channel.clone(), chat_id: chat.chat_id.clone(), - thread_ts: None, + thread_ts: chat.thread.clone(), content: body, kind: GatewayEventKind::Answer, attachments: Vec::new(), @@ -12558,6 +12732,20 @@ impl Gateway { ) -> Result { let caller = ChatKey::from_identity(caller_identity) .unwrap_or_else(|| ChatKey::new("web", "web-api", "web-api")); + self.resume_stopped_session_as(sid, caller, expected_slug) + .await + } + + /// [`Self::resume_stopped_session`] for a caller already resolved to a + /// concrete chat key โ€” one that may carry a THREAD, which an identity + /// string (`"channel:chat_id"`) cannot, so the resumed session's focus + /// lands in the thread that asked for it rather than at conversation level. + async fn resume_stopped_session_as( + &mut self, + sid: &str, + caller: ChatKey, + expected_slug: Option<&str>, + ) -> Result { // Guard: already live. if self.sessions.contains_key(sid) { return Ok(sid.to_string()); @@ -12595,6 +12783,21 @@ impl Gateway { caller_identity: &str, expected_slug: Option<&str>, deadline: GatewayDeadline, + ) -> Result { + let caller = ChatKey::from_identity(caller_identity) + .unwrap_or_else(|| ChatKey::new("web", "web-api", "web-api")); + Self::resume_stopped_session_shared_as(gateway, sid, caller, expected_slug, deadline).await + } + + /// [`Self::resume_stopped_session_shared`] for a caller already resolved + /// to a concrete chat key โ€” one that may carry the session's THREAD, which + /// an identity string (`"channel:chat_id"`) cannot. + async fn resume_stopped_session_shared_as( + gateway: Arc>, + sid: &str, + caller: ChatKey, + expected_slug: Option<&str>, + deadline: GatewayDeadline, ) -> Result { let claims = { let guard = deadline.lock(&gateway).await?; @@ -12603,8 +12806,6 @@ impl Gateway { let _claim = tokio::time::timeout_at(deadline.expires_at.into(), claims.lock_for_sid(sid)) .await .map_err(|_| GatewayRequestError::QueueDeadline)?; - let caller = ChatKey::from_identity(caller_identity) - .unwrap_or_else(|| ChatKey::new("web", "web-api", "web-api")); let plan = { let mut guard = deadline.lock(&gateway).await?; if guard.sessions.contains_key(sid) { @@ -13088,9 +13289,9 @@ impl Gateway { /// `owner` fallback exactly like the private [`pump_target`] free fn. /// Returns `None` when the sid is not tracked (the caller then falls back to /// the on-disk `resolve_home_chat` registry). Read-only, holds no `.await`. - pub fn reply_target_for(&self, sid: &str) -> Option<(String, String)> { + pub fn reply_target_for(&self, sid: &str) -> Option { let session = self.sessions.get(sid)?; - Some(pump_target(session)) + Some(ReplyTarget::from_key(&pump_target(session))) } /// v0.8.22 P0-2 โ€” everything a HITL approval prompt needs for `sid`, in @@ -13102,7 +13303,11 @@ impl Gateway { /// (no chat to ask) instead of panicking. Read-only, holds no `.await`. pub fn hitl_prompt_context_for(&self, sid: &str) -> Option { let session = self.sessions.get(sid)?; - let (channel, chat_id) = pump_target(session); + let ReplyTarget { + channel, + chat_id, + thread_ts, + } = ReplyTarget::from_key(&pump_target(session)); let progress_path = self .project_paths .as_ref() @@ -13110,6 +13315,7 @@ impl Gateway { Some(HitlPromptContext { channel, chat_id, + thread_ts, role: session.role.clone(), progress_path, }) @@ -15932,29 +16138,24 @@ impl Gateway { ) -> Result { let origin = intent.origin; // Resolve the reply route and cold-resume an absent entry without - // holding the registry over the vendor handshake. - let absent_resume_identity = { + // holding the registry over the vendor handshake. The route is the + // owner-derived one with the session's thread kept, so the resume + // re-points nothing but the thread the session already lives in. + let absent_resume_target = { let guard = deadline.lock(&gateway).await?; if guard.sessions.contains_key(sid) { None } else if let Ok((slug, _, meta)) = guard.find_meta_for_sid(sid) { - let reply = guard - .tenant_project_owner_reply_target(&slug) - .or_else(|| { - ChatKey::from_identity(&meta.owner) - .map(|owner| reply_target_for_owner(&owner)) - }) - .unwrap_or_else(web_api_chat); - Some(reply.identity()) + Some(guard.owner_reply_target_for_meta(&slug, &meta)) } else { return Err(anyhow!("unknown session: {sid}")); } }; - if let Some(identity) = absent_resume_identity { - if let Err(err) = Self::resume_stopped_session_shared( + if let Some(reply_to) = absent_resume_target { + if let Err(err) = Self::resume_stopped_session_shared_as( Arc::clone(&gateway), sid, - &identity, + reply_to.clone(), None, deadline, ) @@ -15965,7 +16166,6 @@ impl Gateway { // tell the caller (receipt over the session's SSE, handle // back to the API/MCP caller). let guard = deadline.lock(&gateway).await?; - let reply_to = ChatKey::from_identity(&identity).unwrap_or_else(web_api_chat); let (receipt, id) = guard.queue_behind_detached_body( sid, &text, &reply_to, false, intent, request_id, )?; @@ -16098,9 +16298,7 @@ impl Gateway { .get(sid) .cloned() .ok_or_else(|| anyhow!("current session missing: {sid}"))?; - let chat = guard - .tenant_project_owner_reply_target(&session.project) - .unwrap_or_else(|| reply_target_for_owner(&session.owner)); + let chat = guard.owner_reply_target(&session); if let Ok(mut target) = session.reply_to.lock() { *target = chat.clone(); } @@ -16209,7 +16407,7 @@ impl Gateway { id: format!("gateway-choice-{sid}-{}", prompt.token), channel: chat.channel, chat_id: chat.chat_id, - thread_ts: None, + thread_ts: chat.thread, content: body, kind: GatewayEventKind::Answer, attachments: Vec::new(), @@ -16238,9 +16436,10 @@ impl Gateway { .get(sid) .cloned() .ok_or_else(|| anyhow!("current session missing: {sid}"))?; - let reply_to = self - .tenant_project_owner_reply_target(&session.project) - .unwrap_or_else(|| reply_target_for_owner(&session.owner)); + // Owner-derived, with the session's thread kept: an internal turn's + // answer (a delegation completion notification) belongs in the thread + // the session lives in, where it is that thread's focus. + let reply_to = self.owner_reply_target(&session); if let Ok(mut target) = session.reply_to.lock() { *target = reply_to; } @@ -16411,14 +16610,9 @@ impl Gateway { // turn. Project ownership selects the concrete reply frontend; an empty // `message_id` and a web target suppress the IM-only ๐Ÿ‘€ ack reaction. let reply_to = if let Some(session) = self.sessions.get(sid) { - self.tenant_project_owner_reply_target(&session.project) - .unwrap_or_else(|| reply_target_for_owner(&session.owner)) + self.owner_reply_target(session) } else if let Ok((slug, _, meta)) = self.find_meta_for_sid(sid) { - self.tenant_project_owner_reply_target(&slug) - .or_else(|| { - ChatKey::from_identity(&meta.owner).map(|owner| reply_target_for_owner(&owner)) - }) - .unwrap_or_else(web_api_chat) + self.owner_reply_target_for_meta(&slug, &meta) } else { web_api_chat() }; @@ -17720,6 +17914,9 @@ fn web_api_chat() -> ChatKey { /// frontend chat, e.g. the web console's channel `"web"`), NOT the owner (see /// `pump_target`). So the owner tag stays clear (`user:`) while web SSE / IM /// delivery is untouched. +/// +/// Never carries a thread: ownership is per CONVERSATION, so a session created +/// in one thread is owned โ€” and reachable โ€” from every thread of it. fn canonical_owner(chat: &ChatKey) -> ChatKey { if let Some(tid) = crate::transport::tenant_of_bot_channel(&chat.channel) { // A per-tenant IM bot โ†’ its tenant identity. @@ -17729,8 +17926,9 @@ fn canonical_owner(chat: &ChatKey) -> ChatKey { // frontend chat itself (channel "web") remains the delivery `reply_to`. ChatKey::new("user", &chat.chat_id, &chat.chat_id) } else { - // The admin/global IM bot, etc. โ€” owns by the chat itself. - chat.clone() + // The admin/global IM bot, etc. โ€” owns by the chat itself (its + // conversation, not one of its threads). + chat.conversation() } } @@ -17981,10 +18179,7 @@ fn emit_turn_stall_warning( // produced NO events for the whole window is FLAGGED, not killed โ€” a // long silent command (a benchmark, a big build) is real work, and an // `esc` here mis-kills it. The user `/stop`s if it is genuinely stuck. - let (channel, chat_id) = match session.reply_to.lock() { - Ok(target) => (target.channel.clone(), target.chat_id.clone()), - Err(_) => (session.owner.channel.clone(), session.owner.chat_id.clone()), - }; + let target = pump_target(session); // Carry the two facts ccteam actually owns about the silence: how long the // turn has been running and what the last thing it did was. Without them // the warning cannot be told apart from a legitimately quiet build โ€” and a @@ -18017,9 +18212,9 @@ fn emit_turn_stall_warning( // A heads-up about a turn that is still running, not its end (#209). interim: true, id: format!("gateway-timeout-{session_id}-{turn_id}"), - channel, - chat_id, - thread_ts: None, + channel: target.channel, + chat_id: target.chat_id, + thread_ts: target.thread, content, kind: GatewayEventKind::Answer, attachments: Vec::new(), @@ -18354,13 +18549,16 @@ enum RespawnModel<'a> { FromRole(Option), } -/// Resolve a session pump's live reply target `(channel, chat_id)`, -/// honoring a `/cd`-updated `reply_to` and falling back to the owner. -fn pump_target(session: &GatewaySession) -> (String, String) { - match session.reply_to.lock() { - Ok(target) => (target.channel.clone(), target.chat_id.clone()), - Err(_) => (session.owner.channel.clone(), session.owner.chat_id.clone()), - } +/// Resolve a session pump's live reply target, honoring a `/cd`-updated +/// `reply_to` and falling back to the owner. The key carries the session's +/// thread (on a channel that threads sessions), so every event built from it +/// stamps `thread_ts` from it and lands in that thread. +fn pump_target(session: &GatewaySession) -> ChatKey { + session + .reply_to + .lock() + .map(|target| target.clone()) + .unwrap_or_else(|_| session.owner.clone()) } fn take_turn_origin(session: &GatewaySession, turn_id: Option<&str>) -> TurnOrigin { @@ -18468,16 +18666,16 @@ fn emit_progress( content: &str, done: bool, ) -> bool { - let (channel, chat_id) = pump_target(session); + let target = pump_target(session); let status_key = format!("{session_id}-{epoch}"); // `send` returns false only when the mpsc consumer is gone; surface that as // emit_progress's "sink closed โ†’ pump should stop" signal. tx.send(GatewayEvent { interim: false, id: format!("gateway-progress-{status_key}"), - channel, - chat_id, - thread_ts: None, + channel: target.channel, + chat_id: target.chat_id, + thread_ts: target.thread, content: content.to_string(), sid: Some(session_id.to_string()), slug: Some(session.project.clone()), @@ -18500,15 +18698,15 @@ fn emit_activity( epoch: u64, activity: SessionActivity, ) -> bool { - let (channel, chat_id) = pump_target(session); + let target = pump_target(session); let status_key = format!("{session_id}-{epoch}"); let content = activity.summary.clone(); tx.send(GatewayEvent { interim: false, id: format!("gateway-activity-{status_key}-{}", activity.item_id), - channel, - chat_id, - thread_ts: None, + channel: target.channel, + chat_id: target.chat_id, + thread_ts: target.thread, content, sid: Some(session_id.to_string()), kind: GatewayEventKind::Activity { @@ -20729,6 +20927,7 @@ mod tests { "mock", "chat-1", "alice", + None, "", "/stop s1", &[], @@ -25483,6 +25682,7 @@ mod tests { "telegram", "chat-7", "alice", + None, "tg-555", "do a thing", &[], @@ -26317,7 +26517,7 @@ mod tests { /// recover through the poison rather than degrade. #[tokio::test] async fn a_poisoned_focus_lock_still_records_the_chats_session() { - let routes = FocusRoutes::default(); + let routes = FocusRoutes::new(FocusScope::Thread); let chat = ChatKey::new("telegram", "339498819", "cryptorobsu"); // Poison the lock the way a panic under it would. @@ -28371,6 +28571,7 @@ mod tests { "telegram", "chat-a", "alice", + None, "a-1", "hi there", &[], @@ -28403,6 +28604,7 @@ mod tests { "telegram", "chat-b", "bob", + None, "b-1", "still there?", &[], @@ -28458,6 +28660,7 @@ mod tests { "telegram", "chat-new", "carol", + None, &format!("m-{i}"), &format!("msg {i}"), &[], @@ -28847,7 +29050,11 @@ mod tests { // The live target is the driving chat โ€” resolved purely from memory. assert_eq!( gateway.reply_target_for(&sid), - Some(("telegram".to_string(), "chat-99".to_string())) + Some(ReplyTarget { + channel: "telegram".to_string(), + chat_id: "chat-99".to_string(), + thread_ts: None, + }) ); // An untracked sid โ†’ None (caller falls back to registry). assert_eq!(gateway.reply_target_for("s99"), None); @@ -29392,6 +29599,7 @@ mod tests { "telegram", "chat-1", "alice", + None, "", "", &[], @@ -29416,6 +29624,7 @@ mod tests { "telegram", "chat-1", "alice", + None, "", "", &[], @@ -29435,6 +29644,7 @@ mod tests { "telegram", "chat-1", "alice", + None, "", "", &[], @@ -31793,6 +32003,7 @@ mod tests { "mock", "chat-1", "alice", + None, "", "", &[], @@ -33437,6 +33648,7 @@ mod tests { "telegram", "chat-9", "bob", + None, "", "", &[], @@ -33485,6 +33697,7 @@ mod tests { "telegram", "chat-9", "bob", + None, "", "", &[], @@ -33717,6 +33930,7 @@ mod tests { "telegram", "chat-1", "bob", + None, "", "", &[], @@ -37560,6 +37774,7 @@ mod tests { "mock", "chat-1", "alice", + None, "", "/stop s1", &[], @@ -38489,4 +38704,396 @@ mod tests { .failed_at = Some(chrono::Utc::now() - chrono::Duration::hours(25)); assert!(gateway.scheduled_items_for_sid(&sid).unwrap().is_empty()); } + + // ---- threaded channels (Slack): one thread = one session (#19) ---------- + + /// One inbound message posted in `thread` of the conversation `conv-1` โ€” + /// the shape the daemon hands over for a channel whose + /// `session_threads()` is true. + async fn say_in_thread(gateway: &mut Gateway, thread: &str, text: &str) -> Vec { + gateway + .handle_message("mock", "conv-1", "alice", Some(thread), "", text, &[], None) + .await + .unwrap() + } + + fn thread_key(thread: &str) -> ChatKey { + ChatKey::new("mock", "conv-1", "alice").with_thread(Some(thread)) + } + + /// The next sink (IM leg) `Answer` whose content contains `needle`. + async fn recv_sink_answer_containing( + sink: &mut tokio::sync::mpsc::UnboundedReceiver, + needle: &str, + ) -> GatewayEvent { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let ev = sink.recv().await.expect("gateway sink remains open"); + if matches!(ev.kind, GatewayEventKind::Answer) && ev.content.contains(needle) { + return ev; + } + } + }) + .await + .unwrap_or_else(|_| panic!("an IM-leg answer containing {needle:?} arrives")) + } + + /// A gateway whose every spawn gets its own fake (own event queue) that + /// closes each turn, wired to an IM sink โ€” the setup the answer-path + /// tests below share. + fn threaded_answer_gateway( + dir: &Path, + ) -> (Gateway, tokio::sync::mpsc::UnboundedReceiver) { + let factory: crate::daemon::AdapterFactory = Arc::new(|vendor, _protocol| { + Arc::new(FakeAdapter::new(vendor).with_turn_boundary()) + as Arc + }); + let mut gateway = Gateway::new_with_factory(factory, "alpha", dir); + gateway.register_project("alpha", dir); + let (tx, sink) = tokio::sync::mpsc::unbounded_channel::(); + gateway.set_event_sink(tx); + (gateway, sink) + } + + /// A thread is a session's tab: a new thread's first plain message spawns + /// a session of its own and binds the thread to it, each thread keeps + /// talking to its own session, and the project is the CONVERSATION's โ€” a + /// thread that never typed `/cd` works where another thread `/cd`'d. + #[tokio::test] + async fn each_thread_of_a_conversation_spawns_and_keeps_its_own_session() { + let fake = Arc::new(FakeAdapter::default()); + let alpha = tempfile::TempDir::new().unwrap(); + let beta = tempfile::TempDir::new().unwrap(); + let mut gateway = Gateway::new(fake.clone(), "alpha", alpha.path()); + gateway.register_project("beta", beta.path()); + + say_in_thread(&mut gateway, "100.1", "/cd beta").await; + say_in_thread(&mut gateway, "100.1", "hello from A").await; + say_in_thread(&mut gateway, "200.2", "hello from B").await; + + assert_eq!( + fake.starts.load(Ordering::SeqCst), + 2, + "a new thread spawns its own session" + ); + assert_eq!( + gateway.current_session.get(&thread_key("100.1")), + Some("s1".to_string()) + ); + assert_eq!( + gateway.current_session.get(&thread_key("200.2")), + Some("s2".to_string()) + ); + for sid in ["s1", "s2"] { + assert_eq!( + gateway.sessions[sid].project, "beta", + "{sid} works in the conversation's project" + ); + } + + // B's spawn re-pointed only B: A's next message still reaches s1. + say_in_thread(&mut gateway, "100.1", "again from A").await; + assert_eq!(fake.starts.load(Ordering::SeqCst), 2); + let last = fake.submissions.lock().await.last().cloned().unwrap(); + assert!( + last.0.ends_with("-s1") && last.1.contains("again from A"), + "A's follow-up goes to A's session: {last:?}" + ); + + // The conversation itself is nobody's tab, and the ACL re-check keeps + // every thread route (ownership is the conversation's, not a thread's). + assert_eq!( + gateway + .current_session + .get(&ChatKey::new("mock", "conv-1", "alice")), + None + ); + gateway.drop_dead_session_routes(); + assert_eq!( + gateway + .current_session + .threads_bound_to("mock", "conv-1", "s1"), + vec!["100.1".to_string()] + ); + assert_eq!( + gateway.current_project.get(&thread_key("300.3")), + Some("beta".to_string()), + "a brand-new thread already works in the conversation's project" + ); + } + + /// Two first messages racing into ONE new thread spawn once (the claim is + /// per thread, like the focus slot it guards); first messages in two + /// different new threads each get their own session. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn racing_first_messages_spawn_once_per_thread() { + let tmp = tempfile::TempDir::new().unwrap(); + let fake = Arc::new( + FakeAdapter::new(AgentVendor::Claude) + .with_start_delay(std::time::Duration::from_millis(80)), + ); + let mut gw = Gateway::new(fake.clone(), "alpha", tmp.path()); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::(); + gw.set_event_sink(tx); + let gateway = Arc::new(tokio::sync::Mutex::new(gw)); + + let mut tasks = Vec::new(); + for (i, thread) in ["100.1", "100.1", "200.2", "200.2"].into_iter().enumerate() { + let gw = Arc::clone(&gateway); + tasks.push(tokio::spawn(async move { + Gateway::handle_message_shared( + gw, + "mock", + "conv-1", + "alice", + Some(thread), + &format!("m-{i}"), + &format!("msg {i}"), + &[], + None, + ) + .await + })); + } + for t in tasks { + let r = t.await.unwrap(); + assert!(r.is_ok(), "every racing first message succeeds: {r:?}"); + } + + assert_eq!( + fake.starts.load(Ordering::SeqCst), + 2, + "one spawn per new thread, never two for the same thread" + ); + assert_eq!(fake.submissions.lock().await.len(), 4, "no message dropped"); + let g = gateway.lock().await; + let a = g.current_session.get(&thread_key("100.1")).unwrap(); + let b = g.current_session.get(&thread_key("200.2")).unwrap(); + assert_ne!(a, b, "each thread is bound to its own session"); + } + + /// The answer of a thread's own session lands IN that thread and needs no + /// `[sid โ€ฆ]` label โ€” the thread is its tab, so it is "focused" there even + /// though another thread of the same conversation spawned more recently. + #[tokio::test(flavor = "multi_thread", worker_threads = 3)] + async fn a_threads_answer_lands_in_its_thread_unlabelled() { + let tmp = tempfile::TempDir::new().unwrap(); + let (mut gateway, mut sink) = threaded_answer_gateway(tmp.path()); + + say_in_thread(&mut gateway, "100.1", "first in A").await; + recv_sink_answer_containing(&mut sink, "first in A").await; + say_in_thread(&mut gateway, "200.2", "first in B").await; + recv_sink_answer_containing(&mut sink, "first in B").await; + + say_in_thread(&mut gateway, "100.1", "ping from A").await; + let answer = recv_sink_answer_containing(&mut sink, "ping from A").await; + assert_eq!(answer.sid.as_deref(), Some("s1")); + assert_eq!(answer.thread_ts.as_deref(), Some("100.1")); + assert!( + !answer.content.starts_with("[s1 "), + "a thread's own session is not a second speaker there: {}", + answer.content + ); + } + + /// `docs-local/issues/#14โ‘ ` on a threaded channel: an INTERNAL turn (a + /// delegation completion notification) resets `reply_to` to the owner, + /// and an owner is a conversation, never a thread. Without re-attaching the + /// session's thread the answer read as "not focused" (no thread route + /// points at the bare conversation) and lost its IM leg; with it, it lands + /// in the thread the session lives in. Both the lock-narrowed notifier path + /// and the inline submit path. + #[tokio::test(flavor = "multi_thread", worker_threads = 3)] + async fn an_internal_turns_answer_reaches_the_sessions_thread() { + let tmp = tempfile::TempDir::new().unwrap(); + let (mut gateway, mut sink) = threaded_answer_gateway(tmp.path()); + say_in_thread(&mut gateway, "100.1", "first in A").await; + recv_sink_answer_containing(&mut sink, "first in A").await; + say_in_thread(&mut gateway, "200.2", "first in B").await; + recv_sink_answer_containing(&mut sink, "first in B").await; + + gateway + .submit_to_sid("s2", "s8 done ยท turn 1\nanswer".into()) + .await + .unwrap(); + let inline = recv_sink_answer_containing(&mut sink, "s8 done").await; + assert_eq!(inline.sid.as_deref(), Some("s2")); + assert_eq!(inline.thread_ts.as_deref(), Some("200.2")); + + let gateway = Arc::new(tokio::sync::Mutex::new(gateway)); + Gateway::submit_to_sid_shared( + Arc::clone(&gateway), + "s1", + "s9 done ยท turn 1\nanswer".into(), + GatewayDeadline::start(), + ) + .await + .unwrap(); + let delivered = recv_sink_answer_containing(&mut sink, "s9 done").await; + assert_eq!(delivered.sid.as_deref(), Some("s1")); + assert_eq!(delivered.thread_ts.as_deref(), Some("100.1")); + assert!( + !delivered.content.starts_with("[s1 "), + "the thread's own session is not labelled: {}", + delivered.content + ); + } + + /// The reattach rule itself: prefer the thread the session is answering in + /// right now while it is still that session's tab; otherwise its most + /// recent tab; no tab (and every single-stream chat) leaves the target as + /// it was. + #[tokio::test] + async fn an_owner_target_gets_the_sessions_own_thread_back() { + let fake = Arc::new(FakeAdapter::default()); + let tmp = tempfile::TempDir::new().unwrap(); + let gateway = Gateway::new(fake, "alpha", tmp.path()); + let owner = ChatKey::new("mock", "conv-1", "alice"); + gateway.current_session.set(&thread_key("100.1"), "s1"); + gateway.current_session.set(&thread_key("050.5"), "s1"); + gateway.current_session.set(&thread_key("300.3"), "s2"); + + let current = thread_key("050.5"); + assert_eq!( + gateway + .with_bound_thread(owner.clone(), "s1", Some(¤t)) + .thread + .as_deref(), + Some("050.5"), + "the thread it is answering in wins while it is still its tab" + ); + gateway.current_session.set(&thread_key("050.5"), "s2"); + assert_eq!( + gateway + .with_bound_thread(owner.clone(), "s1", Some(¤t)) + .thread + .as_deref(), + Some("100.1"), + "a thread re-pointed elsewhere is not its tab any more" + ); + assert_eq!( + gateway + .with_bound_thread(owner.clone(), "s2", None) + .thread + .as_deref(), + Some("300.3"), + "the most recent tab (greatest ts) when nothing is current" + ); + assert_eq!(gateway.with_bound_thread(owner.clone(), "s7", None), owner); + let telegram = ChatKey::new("telegram", "339", "339"); + gateway.current_session.set(&telegram, "s1"); + assert_eq!( + gateway.with_bound_thread(telegram.clone(), "s1", Some(&telegram)), + telegram, + "a single-stream chat is untouched" + ); + } + + /// Ownership is the conversation's: a session started in thread A is + /// listed in thread B, `@handle`-addressable from it (no spawn), and + /// `/use` there binds B to it as well โ€” without touching A. + #[tokio::test] + async fn a_session_from_one_thread_is_listed_and_usable_from_another() { + let fake = Arc::new(FakeAdapter::default()); + let tmp = tempfile::TempDir::new().unwrap(); + let mut gateway = Gateway::new(fake.clone(), "alpha", tmp.path()); + + say_in_thread(&mut gateway, "100.1", "hello from A").await; + assert_eq!(fake.starts.load(Ordering::SeqCst), 1); + + let listed = say_in_thread(&mut gateway, "200.2", "/sessions").await; + assert!( + listed.join("\n").contains("s1"), + "thread B lists the conversation's session: {listed:?}" + ); + say_in_thread(&mut gateway, "200.2", "@s1 over here").await; + assert_eq!( + fake.starts.load(Ordering::SeqCst), + 1, + "@handle from another thread reaches the conversation's session" + ); + let last = fake.submissions.lock().await.last().cloned().unwrap(); + assert!(last.0.ends_with("-s1") && last.1.contains("over here")); + assert_eq!( + gateway.current_session.get(&thread_key("200.2")), + Some("s1".to_string()) + ); + + say_in_thread(&mut gateway, "300.3", "/use s1").await; + assert_eq!( + gateway.current_session.get(&thread_key("300.3")), + Some("s1".to_string()) + ); + assert_eq!( + gateway.current_session.get(&thread_key("100.1")), + Some("s1".to_string()) + ); + assert_eq!(fake.starts.load(Ordering::SeqCst), 1); + } + + /// `routing.json` round-trips both scopes: session rows keep their thread, + /// the project row is the conversation's and carries none โ€” and a + /// single-stream key serializes exactly as before (no `thread` field). + #[tokio::test] + async fn routing_json_keeps_thread_session_routes_and_the_conversation_project() { + let tmp = tempfile::tempdir().unwrap(); + let alpha = tmp.path().join("alpha"); + let beta = tmp.path().join("beta"); + std::fs::create_dir_all(&alpha).unwrap(); + std::fs::create_dir_all(&beta).unwrap(); + let fake = Arc::new(FakeAdapter::default()); + { + let mut gateway = Gateway::new(fake.clone(), "alpha", alpha.clone()); + gateway.register_project("beta", beta.clone()); + gateway.enable_persistence(tmp.path()).unwrap(); + say_in_thread(&mut gateway, "100.1", "/cd beta").await; + say_in_thread(&mut gateway, "100.1", "hello from A").await; + say_in_thread(&mut gateway, "200.2", "hello from B").await; + } + + let raw = std::fs::read_to_string(crate::routing_state_path_in(tmp.path())).unwrap(); + let saved: RoutingState = serde_json::from_str(&raw).unwrap(); + let sessions: BTreeMap, String> = saved + .current_session + .iter() + .map(|route| (route.chat.thread.clone(), route.value.clone())) + .collect(); + assert_eq!( + sessions, + BTreeMap::from([ + (Some("100.1".to_string()), "s1".to_string()), + (Some("200.2".to_string()), "s2".to_string()), + ]) + ); + assert!( + saved + .current_project + .iter() + .all(|route| route.chat.thread.is_none() && route.value == "beta"), + "the project route is the conversation's" + ); + assert!( + serde_json::to_value(ChatKey::new("telegram", "339", "339")) + .unwrap() + .get("thread") + .is_none(), + "a single-stream key keeps its old on-disk shape" + ); + + let mut restored = Gateway::new(fake, "alpha", alpha); + restored.register_project("beta", beta); + restored.enable_persistence(tmp.path()).unwrap(); + assert_eq!( + restored.current_session.get(&thread_key("100.1")), + Some("s1".to_string()) + ); + assert_eq!( + restored.current_session.get(&thread_key("200.2")), + Some("s2".to_string()) + ); + assert_eq!( + restored.current_project.get(&thread_key("300.3")), + Some("beta".to_string()) + ); + } } diff --git a/crates/ccteam-im/src/hitl.rs b/crates/ccteam-im/src/hitl.rs index 2cde0c11..106ad4fa 100644 --- a/crates/ccteam-im/src/hitl.rs +++ b/crates/ccteam-im/src/hitl.rs @@ -399,7 +399,7 @@ async fn ask_external_choice( id: format!("permission-{token}"), channel: ctx.channel.clone(), chat_id: ctx.chat_id.clone(), - thread_ts: None, + thread_ts: ctx.thread_ts.clone(), content: prompt.title, kind: GatewayEventKind::Answer, attachments: Vec::new(), @@ -963,6 +963,7 @@ mod tests { HitlPromptContext { channel: "mock".to_string(), chat_id: "chat-1".to_string(), + thread_ts: None, role: "alice".to_string(), progress_path: None, } @@ -995,6 +996,7 @@ mod tests { assert_eq!(evt.options.len(), 2); assert!(evt.content.contains("session s1 (alice)")); assert!(evt.content.contains("Bash ls")); + assert_eq!(evt.thread_ts, None, "a single-stream chat gets no thread"); // Simulate the click: take the pending by token and deliver "allow" // over its oneshot โ€” exactly what `Gateway::resolve_web_selection` / @@ -1073,6 +1075,24 @@ mod tests { drop(rx); } + /// On a channel that gives every session its own thread (Slack) the + /// approval prompt lands in the thread the asking session lives in, not + /// at the top of the conversation where it would read as nobody's. + #[tokio::test(flavor = "current_thread")] + async fn ask_permission_prompt_lands_in_the_sessions_thread() { + let pending = Arc::new(Mutex::new(PendingInteractions::new())); + let (tx, mut rx) = mpsc::unbounded_channel::(); + let threaded = HitlPromptContext { + thread_ts: Some("1700000000.000100".to_string()), + ..ctx() + }; + let answer = + ask_permission_with_ttl(&tx, &pending, &threaded, "s1", "Bash", &json!({}), 1).await; + assert_eq!(answer, PermissionAnswer::Timeout); + let evt = rx.recv().await.expect("prompt event sent"); + assert_eq!(evt.thread_ts.as_deref(), Some("1700000000.000100")); + } + /// A closed sink (event consumer gone) fails safe to `Unavailable` /// instead of hanging or panicking. #[tokio::test(flavor = "current_thread")] diff --git a/crates/ccteam-im/src/mcp/dispatch.rs b/crates/ccteam-im/src/mcp/dispatch.rs index 46ccccb5..b1fdedde 100644 --- a/crates/ccteam-im/src/mcp/dispatch.rs +++ b/crates/ccteam-im/src/mcp/dispatch.rs @@ -528,6 +528,10 @@ fn is_chat_send_file_call(req: &serde_json::Value) -> bool { struct ChatSendFileTarget { channel: String, chat_id: String, + /// The firing session's own platform thread (Slack), so the file lands + /// where that session lives. `None` on a single-stream channel and for a + /// per-user delivery target (no session behind it). + thread_ts: Option, /// Present for a live ccteam session. The server-resolved project path is /// the only authority the web staging/persistence path trusts. session: Option, @@ -538,6 +542,7 @@ impl ChatSendFileTarget { Self { channel, chat_id, + thread_ts: None, session: None, } } @@ -662,7 +667,11 @@ async fn resolve_live_reply_target( return None; } let guard = gw.lock().await; - let (channel, chat_id) = guard.reply_target_for(sid)?; + let crate::gateway::ReplyTarget { + channel, + chat_id, + thread_ts, + } = guard.reply_target_for(sid)?; // IM delivery historically needs only the live reply binding. Project // metadata is an additional requirement solely for the web copy/read // path, so do not make Telegram/Lark depend on it. @@ -674,6 +683,7 @@ async fn resolve_live_reply_target( Some(ChatSendFileTarget { channel, chat_id, + thread_ts, session, }) } @@ -691,6 +701,9 @@ async fn run_chat_send_file( .as_ref() .map(|target| (target.channel.clone(), target.chat_id.clone())); let mut event = build_send_file_event(args, seq, event_target)?; + event.thread_ts = live_target + .as_ref() + .and_then(|target| target.thread_ts.clone()); // A file the session sends from inside its running turn is part of that // turn, not its end (#209): a reader that took it for the boundary // dropped Stop for the rest of the turn. @@ -1052,7 +1065,12 @@ async fn execute_interaction_ask( } _ => None, }; - let Some((channel, chat_id)) = live_target else { + let Some(crate::gateway::ReplyTarget { + channel, + chat_id, + thread_ts, + }) = live_target + else { return err_resp(format!( "interaction/ask: no IM chat bound to firing session sid={session_sid:?} ({slug}/{role}); owner unset at spawn/bind โ€” not falling back to the registry" )); @@ -1113,7 +1131,7 @@ async fn execute_interaction_ask( id: format!("interaction-{token}"), channel, chat_id, - thread_ts: None, + thread_ts, content: question.clone(), kind: GatewayEventKind::Answer, attachments: Vec::new(), @@ -1257,7 +1275,12 @@ async fn execute_permission_ask( } _ => (None, None), }; - let Some((channel, chat_id)) = dest else { + let Some(crate::gateway::ReplyTarget { + channel, + chat_id, + thread_ts, + }) = dest + else { return err_resp(format!( "permission/ask: no IM chat bound to firing session sid={session_sid:?} ({slug}/{role}); owner unset at spawn/bind โ€” not falling back to the registry" )); @@ -1344,7 +1367,7 @@ async fn execute_permission_ask( id: format!("permission-{token}"), channel, chat_id, - thread_ts: None, + thread_ts, content: title, kind: GatewayEventKind::Answer, attachments: Vec::new(), diff --git a/crates/ccteam-im/src/transport/mod.rs b/crates/ccteam-im/src/transport/mod.rs index 7d294b2a..71de9043 100644 --- a/crates/ccteam-im/src/transport/mod.rs +++ b/crates/ccteam-im/src/transport/mod.rs @@ -583,6 +583,17 @@ pub trait Channel: Send + Sync { None } + /// Whether this channel gives every agent session its own platform thread + /// (Slack). When `true`, the daemon hands each inbound message's + /// [`ChannelMessage::thread_ts`] to the gateway, which then scopes session + /// focus to the thread (one thread = one session) while ownership, ACL and + /// the current project stay per conversation. **Default `false`**: a + /// channel without that contract (Telegram, Lark, web) never passes a + /// thread, so its routing is exactly the single-stream behaviour. + fn session_threads(&self) -> bool { + false + } + /// Edit a previously-sent message in place (V0.8.4 P1 โ€” live progress /// status). Returns the platform message id (usually `message_id` /// unchanged). The **default degrades gracefully** to appending a new diff --git a/crates/ccteam-im/src/transport/providers/mock.rs b/crates/ccteam-im/src/transport/providers/mock.rs index 5f8b0cfc..eef052cc 100644 --- a/crates/ccteam-im/src/transport/providers/mock.rs +++ b/crates/ccteam-im/src/transport/providers/mock.rs @@ -53,6 +53,10 @@ pub struct MockChannel { /// and `handle` is the handle passed to remove (always `None` on add). Lets /// a test assert the daemon egress addโ†’remove handle round-trip. reactions: Arc>>, + /// Report [`Channel::session_threads`] โ€” `false` by default (the + /// single-stream shape); a test flips it to drive the threaded routing a + /// Slack channel gets. + session_threads: bool, } impl MockChannel { @@ -68,9 +72,18 @@ impl MockChannel { registered_commands: Arc::default(), reaction_handle: None, reactions: Arc::default(), + session_threads: false, } } + /// Make this channel report [`Channel::session_threads`] `== true` (one + /// thread = one session, the Slack contract), so a test can drive the + /// daemon's threaded inbound path. Builder-style; default stays `false`. + pub fn with_session_threads(mut self) -> Self { + self.session_threads = true; + self + } + /// Make [`Channel::add_reaction`] return `Some(handle)` (the stateful Lark /// shape) so a test can assert the daemon egress stores + replays it on /// remove. Builder-style; default keeps the stateless `Ok(None)`. @@ -170,6 +183,10 @@ impl Channel for MockChannel { self.max_len } + fn session_threads(&self) -> bool { + self.session_threads + } + async fn edit_message( &self, _recipient: &str, diff --git a/crates/ccteam-im/tests/inbound_wiring_test.rs b/crates/ccteam-im/tests/inbound_wiring_test.rs index 83115e95..5ff61567 100644 --- a/crates/ccteam-im/tests/inbound_wiring_test.rs +++ b/crates/ccteam-im/tests/inbound_wiring_test.rs @@ -700,6 +700,118 @@ async fn daemon_routes_gateway_inbound_to_submit_turn_and_outbound() { })); } +/// One threaded-inbound daemon run (#19): two messages posted in two threads +/// of ONE conversation, through a `MockChannel` that does (or does not) give +/// every session its own thread. Returns `(spawns, answers)` where `answers` +/// are the outbound echo sends as `(content, thread_ts)`. +async fn run_two_thread_conversation(threaded: bool) -> (usize, Vec<(String, Option)>) { + let home = isolate_home(); + let projects_root = home.path().join("projects"); + std::fs::create_dir_all(&projects_root).unwrap(); + + let mock = Arc::new(if threaded { + MockChannel::new().with_session_threads() + } else { + MockChannel::new() + }); + for (id, thread, text) in [("t-1", "100.1", "hello A"), ("t-2", "200.2", "hello B")] { + mock.push(ChannelMessage { + id: id.into(), + sender: "alice".into(), + reply_target: "conv-1".into(), + content: text.into(), + channel: "telegram".into(), + timestamp: 0, + thread_ts: Some(thread.into()), + attachments: Vec::new(), + selection: None, + }) + .await; + } + let mut channels: ChannelMap = std::collections::HashMap::new(); + channels.insert( + "telegram".to_string(), + mock.clone() as Arc, + ); + + // A fresh adapter per spawn, so each session drains only its own scripted + // events and an answer is attributed to the session that produced it. + let spawned: Arc>>> = Arc::default(); + let adapter_factory: AdapterFactory = { + let spawned = Arc::clone(&spawned); + Arc::new(move |_, _| { + let adapter = Arc::new(GatewayAdapter::default()); + spawned.lock().unwrap().push(Arc::clone(&adapter)); + adapter as Arc + }) + }; + + let args = DaemonArgs { + credentials: None, + registry: Some(projects_root), + max_runtime: Some(Duration::from_millis(1200)), + adapter_factory: Some(adapter_factory), + channels_override: Some(channels), + extra_channels: None, + ..Default::default() + }; + run_daemon_with_shutdown(args, async { + futures::future::pending::<()>().await; + }) + .await + .unwrap(); + + let spawns = spawned + .lock() + .unwrap() + .iter() + .map(|adapter| adapter.starts.load(Ordering::SeqCst)) + .sum(); + let answers = mock + .outbox() + .await + .into_iter() + .filter(|m| m.content.starts_with("gateway echo: ")) + .map(|m| (m.content, m.thread_ts)) + .collect(); + (spawns, answers) +} + +/// #19 โ€” the daemon hands the gateway an inbound thread ONLY for a channel +/// that gives every session its own thread. There, two threads of one +/// conversation are two sessions and each answer goes back into its own +/// thread; the very same messages on a single-stream channel stay one +/// conversation talking to one session, answered top-level as before. +#[allow(clippy::await_holding_lock)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn daemon_scopes_sessions_to_threads_only_on_a_threading_channel() { + let _g = env_lock(); + + let (spawns, answers) = run_two_thread_conversation(false).await; + assert_eq!(spawns, 1, "a single-stream channel never passes the thread"); + assert_eq!(answers.len(), 2, "both messages answered: {answers:?}"); + assert!( + answers.iter().all(|(_, thread)| thread.is_none()), + "single-stream answers stay top-level: {answers:?}" + ); + + let (spawns, answers) = run_two_thread_conversation(true).await; + assert_eq!(spawns, 2, "one session per thread"); + for (text, thread) in [("hello A", "100.1"), ("hello B", "200.2")] { + let echo = format!("gateway echo: {text}"); + let landed: Vec<_> = answers + .iter() + .filter(|(content, _)| content.starts_with(&echo)) + .map(|(_, thread)| thread.as_deref()) + .collect(); + assert_eq!( + landed, + vec![Some(thread)], + "{text:?} is answered in its own thread: {answers:?}" + ); + } +} + /// V0.8.4 P0 โ€” a gateway reply that overflows the channel's /// `max_message_len` is split into ordered durable sub-messages. Built on /// `daemon_routes_gateway_inbound_to_submit_turn_and_outbound`, but with a From 772833f0c73a36938d39e96904b0085aec20e73b Mon Sep 17 00:00:00 2001 From: firstintent <239836187+firstintent@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:45:41 +0800 Subject: [PATCH 15/37] build: bump async-trait to 0.1.92 for rustc 1.99 clippy rustc/clippy 1.99 reports `clippy::double_must_use` inside the `#[async_trait]` expansion of 0.1.89 (34 errors in ccteam-harness), so `make check` and CI's `clippy -D warnings` on the current stable fail before reaching any workspace code. 0.1.92 no longer emits the doubled attribute. Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com> --- Cargo.lock | 85 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 48 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a2573a4..1ca75738 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,7 +145,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn", + "syn 2.0.117", ] [[package]] @@ -194,18 +194,18 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -718,7 +718,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -868,7 +868,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -892,7 +892,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -903,7 +903,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -939,7 +939,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -949,7 +949,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.117", ] [[package]] @@ -992,7 +992,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1192,7 +1192,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1322,7 +1322,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2180,7 +2180,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -2202,7 +2202,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2234,7 +2234,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2728,7 +2728,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn", + "syn 2.0.117", "walkdir", ] @@ -2880,7 +2880,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3006,7 +3006,7 @@ checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3140,7 +3140,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3160,6 +3160,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3177,7 +3188,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3239,7 +3250,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3250,7 +3261,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3343,7 +3354,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3520,7 +3531,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3720,7 +3731,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn", + "syn 2.0.117", ] [[package]] @@ -3876,7 +3887,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -4026,7 +4037,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4037,7 +4048,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4274,7 +4285,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -4290,7 +4301,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4379,7 +4390,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4400,7 +4411,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4420,7 +4431,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4441,7 +4452,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4474,7 +4485,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] From b99a3738d7371fba0ce0cb39a5f4becce0eef19a Mon Sep 17 00:00:00 2001 From: firstintent <239836187+firstintent@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:47:11 +0800 Subject: [PATCH 16/37] fix(im): /cd in a thread never adopts another thread's session (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a single-stream chat `/cd` adopts the project's existing session so the switch never mints a duplicate beside the one being talked to. A thread is one session's tab, so adopting there pulled a session that already lives in another thread into this one. In a thread `/cd` now keeps the thread's own session when it is in the target project and otherwise frees the thread, so its next message starts a session in the project โ€” the same thing a new thread does. Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com> --- crates/ccteam-im/src/gateway.rs | 75 +++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index 1b43fbc4..2c76f081 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -11288,6 +11288,27 @@ impl Gateway { /// id. When none exists, clear the active session so the next message spawns /// one on demand in `project`. Backs `/cd` so the project switch is real. fn adopt_session_in_project(&mut self, chat: &ChatKey, project: &str) -> Option { + // A thread is one session's own tab: adopting the project's session + // here would pull a session that already lives in another thread into + // this one. The thread keeps its session only if that session is in + // `project`; otherwise it is freed, so its next message starts a + // session there โ€” what a new thread does anyway. + if chat.thread.is_some() { + let kept = self.current_session.get(chat).filter(|sid| { + self.sessions + .get(sid) + .map(|s| s.project == project) + .unwrap_or_else(|| { + self.owned_released_metas(chat) + .iter() + .any(|(slug, meta)| slug == project && &meta.sid == sid) + }) + }); + if kept.is_none() { + self.current_session.remove(chat); + } + return kept; + } // Own = owned by the chat's CANONICAL identity (`user:` for web / // tenant bots, the chat itself for the admin/global IM bot). Owner is the // synthetic `user:` channel, never the querier's delivery channel, so we @@ -38822,6 +38843,60 @@ mod tests { ); } + /// `/cd` in a thread never pulls a session that lives in another thread + /// into this one: a fresh thread stays free (its next message spawns), a + /// thread whose session is already in the project keeps it, and a thread + /// whose session is elsewhere is freed for a new session in the project. + #[tokio::test] + async fn cd_in_a_thread_never_adopts_another_threads_session() { + let fake = Arc::new(FakeAdapter::default()); + let alpha = tempfile::TempDir::new().unwrap(); + let beta = tempfile::TempDir::new().unwrap(); + let mut gateway = Gateway::new(fake.clone(), "alpha", alpha.path()); + gateway.register_project("alpha", alpha.path()); + gateway.register_project("beta", beta.path()); + + say_in_thread(&mut gateway, "100.1", "hello from A").await; + assert_eq!( + gateway.current_session.get(&thread_key("100.1")), + Some("s1".to_string()) + ); + + // A fresh thread `/cd`s into s1's project: s1 stays A's alone. + let reply = say_in_thread(&mut gateway, "200.2", "/cd alpha").await; + assert!( + reply[0].contains("next message starts a session there"), + "{reply:?}" + ); + assert_eq!(gateway.current_session.get(&thread_key("200.2")), None); + say_in_thread(&mut gateway, "200.2", "hello from B").await; + assert_eq!( + gateway.current_session.get(&thread_key("200.2")), + Some("s2".to_string()), + "the fresh thread gets a session of its own" + ); + + // A thread whose session is already in the project keeps it. + say_in_thread(&mut gateway, "100.1", "/cd alpha").await; + assert_eq!( + gateway.current_session.get(&thread_key("100.1")), + Some("s1".to_string()) + ); + + // A thread whose session is elsewhere is freed, not re-pointed. + say_in_thread(&mut gateway, "100.1", "/cd beta").await; + assert_eq!(gateway.current_session.get(&thread_key("100.1")), None); + assert_eq!( + gateway.current_session.get(&thread_key("200.2")), + Some("s2".to_string()), + "another thread's session is untouched" + ); + say_in_thread(&mut gateway, "100.1", "now in beta").await; + let sid = gateway.current_session.get(&thread_key("100.1")).unwrap(); + assert_eq!(gateway.sessions[&sid].project, "beta"); + assert_eq!(fake.starts.load(Ordering::SeqCst), 3); + } + /// Two first messages racing into ONE new thread spawn once (the claim is /// per thread, like the focus slot it guards); first messages in two /// different new threads each get their own session. From 8f2a261d8b86e3673cd230c2791b6e566ad4880b Mon Sep 17 00:00:00 2001 From: firstintent <239836187+firstintent@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:48:07 +0800 Subject: [PATCH 17/37] docs: Slack as an IM, one thread per session (#19) Fold Slack into the current-capability description next to Telegram and Lark: the Socket Mode app manifest, the credentials block, the member-id allowlist that doubles as the owner roster, and how threads map to sessions (top-level message = new session, `/ccteam` for gateway commands, project per channel, focus per thread). Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com> --- README.md | 6 ++-- docs/usage-cn.md | 81 ++++++++++++++++++++++++++++++++++++++++++------ docs/usage.md | 81 ++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 147 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index ea51096b..a1b9f420 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
ccteam mascot โ€” a juggler bot keeping codex, grok and kimi in the air

ccteam

-

ccteam turns the coding agents you already run (Claude Code, Codex, Grok, Kimi, Deepseek Harness) into one team โ€”
any session can spawn, dispatch, and collect work from any vendor on any machine,
while you steer it all from Telegram, Lark, or a browser tab.

+

ccteam turns the coding agents you already run (Claude Code, Codex, Grok, Kimi, Deepseek Harness) into one team โ€”
any session can spawn, dispatch, and collect work from any vendor on any machine,
while you steer it all from Telegram, Lark, Slack, or a browser tab.

CI Made with Rust @@ -33,9 +33,9 @@ ccteam is the connective tissue they lack โ€” identity, routing, delivery guaran ## Usage -**1 ยท Remote control from Telegram / Lark** +**1 ยท Remote control from Telegram / Lark / Slack** -Paste a bot token once (Settings โ†’ Access) and the chat becomes a full console โ€” completion notifications, HITL `[approve] [deny]` buttons, and shipped files all land in the same thread. Dispatch at midnight, close the laptop, find the result at breakfast: +Paste a bot token once (Settings โ†’ Access) and the chat becomes a full console โ€” completion notifications, HITL `[approve] [deny]` buttons, and shipped files all land in the same thread. Dispatch at midnight, close the laptop, find the result at breakfast. On Slack every session gets **its own thread**: a top-level message starts a session, a reply in its thread talks only to it, so parallel sessions never interleave (commands go through the `/ccteam` slash command): ```text /cd demo # pick a project; your next message talks to it diff --git a/docs/usage-cn.md b/docs/usage-cn.md index 16aba81c..1f6ee5f1 100644 --- a/docs/usage-cn.md +++ b/docs/usage-cn.md @@ -1,20 +1,20 @@ # ccteam ไฝฟ็”จๆ‰‹ๅ†Œ -**ccteam ๆŠŠไฝ ๅทฒ็ปๅœจ็”จ็š„็ผ–็จ‹ agent(Claude Codeใ€Codexใ€Grokใ€Kimiใ€DSH ็ญ‰)็ผ–ๆˆไธ€ๆ”ฏๅ›ข้˜Ÿโ€”โ€”ไปปไฝ•ไผš่ฏ้ƒฝ่ƒฝ่ทจๅŽ‚ๅ•†ใ€่ทจๆœบๅ™จ spawnใ€ๆดพๆดปใ€ๆ”ถ็ป“ๆžœ,่€Œไฝ ไปŽ Telegramใ€้ฃžไนฆๆˆ–ๆต่งˆๅ™จ้‡Œ็ปŸไธ€ๆŒ‡ๆŒฅใ€‚** +**ccteam ๆŠŠไฝ ๅทฒ็ปๅœจ็”จ็š„็ผ–็จ‹ agent(Claude Codeใ€Codexใ€Grokใ€Kimiใ€DSH ็ญ‰)็ผ–ๆˆไธ€ๆ”ฏๅ›ข้˜Ÿโ€”โ€”ไปปไฝ•ไผš่ฏ้ƒฝ่ƒฝ่ทจๅŽ‚ๅ•†ใ€่ทจๆœบๅ™จ spawnใ€ๆดพๆดปใ€ๆ”ถ็ป“ๆžœ,่€Œไฝ ไปŽ Telegramใ€้ฃžไนฆใ€Slack ๆˆ–ๆต่งˆๅ™จ้‡Œ็ปŸไธ€ๆŒ‡ๆŒฅใ€‚** ไฝ ่ฃ…ไธ€ๆฌกใ€่ตทไธ€ไธชๅธธ้ฉป่ฟ›็จ‹,ไน‹ๅŽๆ‰€ๆœ‰ๆ—ฅๅธธๆ“ไฝœ้ƒฝๅœจไธ‰ไธชๅ…ฅๅฃ้‡ŒๅฎŒๆˆ,**ๆŽจ่็จ‹ๅบฆไปŽ้ซ˜ๅˆฐไฝŽ**: | ๅ…ฅๅฃ | ้€‚ๅˆ | ็ซ ่Š‚ | |---|---|---| | ๐Ÿ–ฅ๏ธ **Web ๆŽงๅˆถๅฐ** | ๅˆ›ๅปบ้กน็›ฎใ€ๅผ€ไผš่ฏใ€่ฃ…ๆ’ไปถใ€้… IMใ€็œ‹็Šถๆ€ โ€”โ€” ็‚น็‚นๅฐฑ่ƒฝ็”จ,**้ฆ–้€‰** | [ไธ€ใ€Web ๆŽงๅˆถๅฐ](#ไธ€web-ๆŽงๅˆถๅฐๆŽจ่) | -| ๐Ÿ’ฌ **Telegram / ้ฃžไนฆ** | ๆ‰‹ๆœบไธŠ้šๆ—ถๆ”ถๅ‘ใ€้ฉฑๅŠจไผš่ฏใ€ๅฎกๆ‰นๅทฅๅ…ท | [ไบŒใ€Telegram / ้ฃžไนฆ](#ไบŒtelegram--้ฃžไนฆ) | +| ๐Ÿ’ฌ **Telegram / ้ฃžไนฆ / Slack** | ๆ‰‹ๆœบไธŠ้šๆ—ถๆ”ถๅ‘ใ€้ฉฑๅŠจไผš่ฏใ€ๅฎกๆ‰นๅทฅๅ…ท;Slack ไธŠๆฏไธชไผš่ฏไธ€ไธช็‹ฌ็ซ‹็บฟ็จ‹ | [ไบŒใ€Telegram / ้ฃžไนฆ / Slack](#ไบŒtelegram--้ฃžไนฆ--slack) | | โŒจ๏ธ **ๅ‘ฝไปค่กŒ(CLI)** | ่„šๆœฌใ€่ฟ็ปดใ€ๆ— ๅ›พๅฝข็•Œ้ข็š„้ซ˜็บงๅœบๆ™ฏ | [ไธ‰ใ€ๅ‘ฝไปค่กŒ](#ไธ‰ๅ‘ฝไปค่กŒ้ซ˜็บง) | --- ## ๆ ธๅฟƒๆฆ‚ๅฟต -- **chat** = ไธ€ไธชๅฏน่ฏๅ…ฅๅฃ(ไธ€ไธช็ฝ‘้กตๆŽงๅˆถๅฐๆ ‡็ญพใ€ไธ€ไธช Telegram/้ฃžไนฆ็ง่Šๆˆ–็พค)ใ€‚ๆฏไธช chat ๆœ‰่‡ชๅทฑ็š„ๅฝ“ๅ‰้กน็›ฎใ€ๅฝ“ๅ‰ไผš่ฏๅ’Œไผš่ฏๅˆ—่กจ,ไบ’็›ธ้š”็ฆปใ€‚ +- **chat** = ไธ€ไธชๅฏน่ฏๅ…ฅๅฃ(ไธ€ไธช็ฝ‘้กตๆŽงๅˆถๅฐๆ ‡็ญพใ€ไธ€ไธช Telegram/้ฃžไนฆ็ง่Šๆˆ–็พคใ€ไธ€ไธช Slack ้ข‘้“ๆˆ–็งไฟก)ใ€‚ๆฏไธช chat ๆœ‰่‡ชๅทฑ็š„ๅฝ“ๅ‰้กน็›ฎใ€ๅฝ“ๅ‰ไผš่ฏๅ’Œไผš่ฏๅˆ—่กจ,ไบ’็›ธ้š”็ฆปใ€‚Slack ไธŠใ€Œๅฝ“ๅ‰ไผš่ฏใ€ๆŒ‰**็บฟ็จ‹**็ฎ—โ€”โ€”ๆฏไธชไผš่ฏไธ€ไธช่‡ชๅทฑ็š„็บฟ็จ‹(่ง [Slack:ไธ€ไธชไผš่ฏ = ไธ€ไธช็บฟ็จ‹](#slackไธ€ไธชไผš่ฏ--ไธ€ไธช็บฟ็จ‹))ใ€‚ - **project** = ไธ€ไธชๆœฌๅœฐไปฃ็ ็›ฎๅฝ•,็”จ slug(็Ÿญๅ)ๆ ‡่ฏ†ใ€‚ - **session** = ไธ€ไธช็‹ฌ็ซ‹็š„ agent ไผš่ฏ(ๅƒ Claude Code ๅŽŸ็”Ÿไผš่ฏไธ€ๆ ท่‡ชๅธฆไธŠไธ‹ๆ–‡),ๅฑžไบŽๆŸไธช้กน็›ฎใ€‚ไธ€ไธช้กน็›ฎๅฏๅŒๆ—ถๅผ€ๅคšไธชไผš่ฏใ€ไบ’ไธไธฒๅฐ,ๆฏไธชๆœ‰ๆŒไน…ๅฅๆŸ„ `s`(ๆ‰›้‡ๅฏใ€ไธๅค็”จ)ใ€‚ - **role** = ไผš่ฏๅฏๅŠจๆ—ถ**ๅฏ้€‰**็ป‘ๅฎš็š„่ง’่‰ฒ(`.claude/agents/.md` ้‡Œ็š„ persona + ๅทฅๅ…ท)ใ€‚้ป˜่ฎค **roleless**:่ฃธ vendor ่‡ช่ฏป้กน็›ฎ็š„ `CLAUDE.md`/`AGENTS.md`ใ€‚persona ไปŽๆ’ไปถๅธ‚ๅœบ่ฃ…ๆˆ–่‡ชๅปบ,ccteam ไธๅ†…็ฝฎไปปไฝ•่ง’่‰ฒใ€‚ @@ -188,14 +188,15 @@ web url: http://<ไฝ ็š„ๅฑ€ๅŸŸ็ฝ‘IP>:7331/?token=ccteam:<ไปค็‰Œ> **ๆ’ไปถๅธ‚ๅœบ** ้กต(ๅœจ**ๅทฅไฝœๆต**ไธ‹;้ป˜่ฎคๆ‰“ๅผ€ Skills ๅˆ†็ฑป,้กน็›ฎ้€‰ๆ‹ฉๅ™จๅชๅœจ่ฃ…่ฟ›้กน็›ฎ็š„็ฑปๅž‹(agent/plugin)ๅ‡บ็Žฐ)ๆต่งˆ [ccteam-hub](https://github.com/firstintent/ccteam-hub) ็š„็ฒพ้€‰ๆ’ไปถ(ๅฎ˜ๆ–นๆ’ไปถ็ฝฎ้กถ,ๅ…ถไฝ™ๅฆ‚ [agency-agents](https://github.com/wshobson/agents)ใ€[mattpocock/skills](https://github.com/mattpocock/skills) ็ญ‰ๅผ€ๆบๅบ“ไพๆฌก)ใ€‚็‚นๅผ€็œ‹ๆญฃๆ–‡้ข„่งˆๅŽไธ€้”ฎๅฎ‰่ฃ…(ไธ‹่ฝฝๆ—ถๆ ก้ชŒ sha256,ๅธฆ็Šถๆ€ๆ ‡่ฎฐ):**่ง’่‰ฒ่ฃ…่ฟ›ๅฝ“ๅ‰้กน็›ฎ** `.claude/agents/`,่ฃ…ๅฎŒไปปๆ„ๅ…ฅๅฃ `/role <่ง’่‰ฒ>` ๅˆ‡ๆข;**ๆŠ€่ƒฝ่ฃ…่ฟ›็”จๆˆท็บงๅ…จๅฑ€ๅบ“** `~/.ccteam/skills`(**ไธ่ฟ›้กน็›ฎ**),ๅœจไผš่ฏ่พ“ๅ…ฅๆก†็š„ ๏ผ‹ ่œๅ•ๆŒ‰ๆกๆถˆๆฏๅผ•็”จโ€”โ€”ๆŠ€่ƒฝ่œๅ•ๅˆ†ไธคๆฎต:้กน็›ฎ่‡ชๆœ‰ๆŠ€่ƒฝ(`.agents/skills/`,ๅ…ผๅฎน่ฏปๆ—ง `.claude/skills/` ๅฎžไฝ“)ไธŽๅ…จๅฑ€ๅบ“;ๅ…จๅฑ€ๅบ“ไธŽ้กน็›ฎไน‹้—ดไธ่ฝฏ้“พใ€ไธๅคๅˆถใ€‚ -### ้…็ฝฎ Telegram / ้ฃžไนฆ +### ้…็ฝฎ Telegram / ้ฃžไนฆ / Slack ่ฟ› **Settings** ้กตๅกซ IM ๅ‡ญ่ฏ: - **Telegram**:็ฒ˜่ดด bot token,ไฟๅญ˜ๅŽ็ป™ bot ๅ‘ไธ€ๆกๆถˆๆฏ,้กต้ขไผš่‡ชๅŠจ่ฝฎ่ฏขๆŠ“ๅˆฐไฝ ็š„ chat_idใ€‚ - **้ฃžไนฆ / Lark**:ๅกซ App ID / App Secret / ๅŒบๅŸŸ(้ฃžไนฆๅ›ฝๅ†… / Lark ๅ›ฝ้™…)/ ๅ…่ฎธ็š„็”จๆˆทใ€‚ +- **Slack**:ๅกซ bot token(`xoxb-โ€ฆ`)ใ€app-level token(`xapp-โ€ฆ`)ๅ’Œๅ…่ฎธ็š„ๆˆๅ‘˜ ID(`Uโ€ฆ`)ใ€‚ -็ง˜ๅฏ†ๅชๆ˜พ็คบๆŽฉ็ (`โ€ฆๆœซๅ››ไฝ`),ๆฐธไธๅ›žๆ˜พๆ˜Žๆ–‡ใ€‚**ๆ”นๅฎŒ้œ€้‡ๅฏ daemon ๆ‰็”Ÿๆ•ˆ**(ๅ‡ญ่ฏไป…ๅœจๅฏๅŠจๆ—ถๅŠ ่ฝฝ),้กต้ขไผšๆ็คบ `restart required` โ€”โ€” ็…ง [่ฟ็ปด](#่ฟ็ปด) ้‡ๅฏๅณๅฏใ€‚่ฏฆ็ป†็š„ bot ๅˆ›ๅปบๆญฅ้ชค่ง [ไบŒใ€Telegram / ้ฃžไนฆ](#ๆŽฅๅ…ฅ)ใ€‚ +็ง˜ๅฏ†ๅชๆ˜พ็คบๆŽฉ็ (`โ€ฆๆœซๅ››ไฝ`),ๆฐธไธๅ›žๆ˜พๆ˜Žๆ–‡ใ€‚**ๆ”นๅฎŒ้œ€้‡ๅฏ daemon ๆ‰็”Ÿๆ•ˆ**(ๅ‡ญ่ฏไป…ๅœจๅฏๅŠจๆ—ถๅŠ ่ฝฝ),้กต้ขไผšๆ็คบ `restart required` โ€”โ€” ็…ง [่ฟ็ปด](#่ฟ็ปด) ้‡ๅฏๅณๅฏใ€‚่ฏฆ็ป†็š„ bot ๅˆ›ๅปบๆญฅ้ชค่ง [ไบŒใ€Telegram / ้ฃžไนฆ / Slack](#ๆŽฅๅ…ฅ)ใ€‚ ### ๅคš็”จๆˆท @@ -234,9 +235,9 @@ Mcp-Session-Id: --- -## ไบŒใ€Telegram / ้ฃžไนฆ +## ไบŒใ€Telegram / ้ฃžไนฆ / Slack -ๆŠŠ ccteam ๆŽฅๅˆฐ IM ๅŽ,ไฝ ๅฐฑ่ƒฝๅœจๆ‰‹ๆœบไธŠ้šๆ—ถ้ฉฑๅŠจไผš่ฏใ€ๆ”ถๅ‘ๆ–‡ไปถใ€ๅฎกๆ‰นๅทฅๅ…ทใ€‚ๆœ€็œไบ‹ๆ˜ฏๅœจ [Web ๆŽงๅˆถๅฐ Settings](#้…็ฝฎ-telegram--้ฃžไนฆ) ้‡Œ้…;ไนŸๅฏไปฅ็”จ `ccteam config` ่œๅ•,ๆˆ–ๆ‰‹ๅ†™ๅ‡ญ่ฏๆ–‡ไปถใ€‚ +ๆŠŠ ccteam ๆŽฅๅˆฐ IM ๅŽ,ไฝ ๅฐฑ่ƒฝๅœจๆ‰‹ๆœบไธŠ้šๆ—ถ้ฉฑๅŠจไผš่ฏใ€ๆ”ถๅ‘ๆ–‡ไปถใ€ๅฎกๆ‰นๅทฅๅ…ทใ€‚ๆœ€็œไบ‹ๆ˜ฏๅœจ [Web ๆŽงๅˆถๅฐ Settings](#้…็ฝฎ-telegram--้ฃžไนฆ--slack) ้‡Œ้…;ไนŸๅฏไปฅ็”จ `ccteam config` ่œๅ•,ๆˆ–ๆ‰‹ๅ†™ๅ‡ญ่ฏๆ–‡ไปถใ€‚ ### ๆŽฅๅ…ฅ @@ -273,11 +274,73 @@ Mcp-Session-Id: - `use_feishu`:`true` = ้ฃžไนฆ(ๅ›ฝๅ†…),`false` = Lark(ๅ›ฝ้™…)ใ€‚ - `allowed_user_ids` ๆ˜ฏ open_id(`ou_โ€ฆ`)็™ฝๅๅ•,**็•™็ฉบ = ๆ‹’็ปๆ‰€ๆœ‰ไบบ**(fail-closed,ๆฏ” Telegram ๆ›ดๅฎ‰ๅ…จ)ใ€‚ๆ‹ฟ่‡ชๅทฑ็š„ open_id:ๅ…ˆ็•™็ฉบๅฏๅŠจ,็ป™ bot ๅ‘ๆกๆถˆๆฏ,ๅœจๆ—ฅๅฟ—้‡Œๆ‰พ `ignoring ou_xxxx (not in allowed_users)`,ๆŠŠ `ou_xxxx` ๅกซๅ›žๅŽปใ€‚ -> **ๆ‰‹ๅ†™ๅ‡ญ่ฏๆ–‡ไปถๅŽๅฟ…้กป้‡ๅฏ daemon ๆ‰็”Ÿๆ•ˆ**(Web Settings ้…็š„ๅŒ็†)ใ€‚้ฃžไนฆ/Lark ไธŽ Telegram ๅฏน็ญ‰:ๆ–‡ๆœฌใ€ๅฏŒๆ–‡ๆœฌใ€ๅ›พ็‰‡/ๆ–‡ไปถๆ”ถๅ‘้ƒฝๆ”ฏๆŒใ€‚ +**Slack** ็ป™**ๆฏไธชไผš่ฏไธ€ไธช็‹ฌ็ซ‹็บฟ็จ‹**,ๅนถ่กŒไผš่ฏไธๅ†ๆŒคๅœจๅŒไธ€ๆกๆถˆๆฏๆต้‡Œใ€‚่ตฐ **Socket Mode**(ๅ‡บ็ซ™ WebSocket,ไธ้œ€่ฆๅ…ฌ็ฝ‘ๅ›ž่ฐƒๅœฐๅ€)ใ€‚ๅœจ [api.slack.com/apps](https://api.slack.com/apps) ้€‰ **From a manifest** ๆ–ฐๅปบๅบ”็”จ,็ฒ˜่ดด: + +```yaml +display_information: + name: ccteam +features: + bot_user: + display_name: ccteam + always_online: true + slash_commands: + - command: /ccteam + description: ccteam gateway command (sessions, new, cd, status, โ€ฆ) + usage_hint: "sessions | new codex | cd | status" + should_escape: false +oauth_config: + scopes: + bot: + - app_mentions:read + - chat:write + - channels:history + - groups:history + - im:history + - mpim:history + - reactions:write + - files:read + - files:write + - commands +settings: + event_subscriptions: + bot_events: + - message.channels + - message.groups + - message.im + - message.mpim + interactivity: + is_enabled: true + socket_mode_enabled: true +``` + +ๆŠŠๅบ”็”จ่ฃ…่ฟ›ๅทฅไฝœๅŒบ,ๅคๅˆถ **Bot User OAuth Token**(`xoxb-โ€ฆ`);ๅœจ **Basic Information โ†’ App-Level Tokens** ๆ–ฐๅปบไธ€ไธชๅธฆ `connections:write` ็š„ token(`xapp-โ€ฆ`)ใ€‚็”จ Web ่ฎพ็ฝฎ / `ccteam config` ้…็ฝฎ,ๆˆ–ๅœจๅ‡ญๆฎๆ–‡ไปถ้‡ŒๅŠ  `slack` ๆฎต: + +```json +{ + "slack": { + "bot_token": "xoxb-replace_me", + "app_token": "xapp-replace_me", + "allowed_user_ids": ["U0123ABCD"] + } +} +``` + +- `allowed_user_ids` ๆ˜ฏ Slack ๆˆๅ‘˜ ID(`Uโ€ฆ`)็™ฝๅๅ•,**ๅŒๆ—ถๆ˜ฏ owner ๅๅ†Œ**:ๅๅ•้‡Œ็š„ๅ‘้€่€…ๆŒ‰ๆœฌๆœบ owner ๆœๅŠกใ€‚**็•™็ฉบ = ๆ‹’็ปๆ‰€ๆœ‰ไบบ**(fail closed)ใ€‚ๆˆๅ‘˜ ID ๅœจ Slack ไธชไบบ่ต„ๆ–™ โ†’ โ‹ฎ โ†’ *Copy member ID*;่ขซๆ‹’็š„็งไฟกไนŸไผšๆ”ถๅˆฐไธ€ๆกๅ†™็€่ฏฅ ID ็š„ๅ›žๅคใ€‚ +- ๆŠŠ bot ้‚€่ฟ›้ข‘้“(`/invite @ccteam`)ๆˆ–็›ดๆŽฅ็งไฟกๅฎƒใ€‚ๅฎƒไผšๅ›ž็ญ”ๅ…่ฎธๅๅ•้‡Œ็š„ไบบๅœจๅฎƒๆ‰€ๅœจ้ข‘้“้‡Œ็š„ๆฏๆกๆถˆๆฏ,ๆ‰€ไปฅๆœ€ๅฅฝ็”จไธ€ไธชไธ“็”จ้ข‘้“(ๆˆ–็งไฟก)ใ€‚ + +> **ๆ‰‹ๅ†™ๅ‡ญ่ฏๆ–‡ไปถๅŽๅฟ…้กป้‡ๅฏ daemon ๆ‰็”Ÿๆ•ˆ**(Web Settings ้…็š„ๅŒ็†)ใ€‚้ฃžไนฆ/Larkใ€Telegram ไธŽ Slack ๅฏน็ญ‰:ๆ–‡ๆœฌใ€ๅฏŒๆ–‡ๆœฌใ€ๅ›พ็‰‡/ๆ–‡ไปถๆ”ถๅ‘้ƒฝๆ”ฏๆŒใ€‚ + +### Slack:ไธ€ไธชไผš่ฏ = ไธ€ไธช็บฟ็จ‹ + +- **้กถๅฑ‚ๆถˆๆฏ**ๅผ€ไธ€ไธชๆ–ฐ็บฟ็จ‹;ๆฒกๆœ‰ไผš่ฏ็š„็บฟ็จ‹้‡Œ็ฌฌไธ€ๆกๆ™ฎ้€šๆถˆๆฏไผšๅœจๆœฌ้ข‘้“ๅฝ“ๅ‰้กน็›ฎ้‡Œๆ–ฐๅปบไธ€ไธช roleless ไผš่ฏ(ไธŽ Telegram ๆ–ฐ chat ้ฆ–ๆกๆถˆๆฏๅŒไธ€ๆก่ทฏ)ใ€‚ๅ†ๅ‘ไธ€ๆก้กถๅฑ‚ๆถˆๆฏ,็ฌฌไบŒไธชไผš่ฏๅฐฑไธŽ็ฌฌไธ€ไธชๅนถ่กŒใ€‚ +- **็บฟ็จ‹ๅ†…ๅ›žๅค**ๅชๅ‘็ป™่ฏฅ็บฟ็จ‹็š„ไผš่ฏใ€‚่ฏฅไผš่ฏไบง็”Ÿ็š„ไธ€ๅˆ‡โ€”โ€”ๅ›ž็ญ”ใ€ๅฎžๆ—ถ่ฟ›ๅบฆๅกใ€ๅฎกๆ‰นๆŒ‰้’ฎใ€ๆ–‡ไปถใ€ๅง”ๆดพๅ›žๆŠฅๅŽ็š„ๅ›ž็ญ”โ€”โ€”้ƒฝ่ฟ›ๅฎƒ่‡ชๅทฑ็š„็บฟ็จ‹ใ€‚ +- ๅฝ’ๅฑžไธŽๅฝ“ๅ‰้กน็›ฎไปฅ**้ข‘้“**ไธบๅ•ไฝ:ๅœจไปปไธ€็บฟ็จ‹้‡Œ `/cd` ไผšๅˆ‡ๆขๆ•ดไธช้ข‘้“็š„้กน็›ฎ(่ฏฅ็บฟ็จ‹็š„ไผš่ฏ่‹ฅไธๅœจๆ–ฐ้กน็›ฎ้‡Œ,็บฟ็จ‹ๅณ้‡Šๆ”พ,ไธ‹ไธ€ๆกๆถˆๆฏๅœจๆ–ฐ้กน็›ฎ้‡Œๆ–ฐๅปบไผš่ฏ),`/sessions` ๅˆ—ๅ‡บ้ข‘้“ๆ‹ฅๆœ‰็š„ๅ…จ้ƒจไผš่ฏใ€‚็บฟ็จ‹้‡Œ็š„ `/use ` / `/new โ€ฆ` ๅชๆ”น**่ฟ™ไธช็บฟ็จ‹**ๆŒ‡ๅ‘็š„ไผš่ฏใ€‚ +- Slack ไผšๆ‹ฆๆˆชไปฅ `/` ๅผ€ๅคด็š„ๆถˆๆฏ,ๆ‰€ไปฅ็ฝ‘ๅ…ณๅ‘ฝไปค่ตฐ **`/ccteam`** ๆ–œๆ ๅ‘ฝไปค:`/ccteam new codex`ใ€`/ccteam sessions`ใ€`/ccteam cd demo`ใ€‚ccteam ไผšๅ…ˆๅ‘ไธ€ๆก้”š็‚นๆถˆๆฏๅนถๅœจๅฎƒ็š„็บฟ็จ‹้‡Œๅ›žๅคโ€”โ€”`/ccteam new codex` ๅฐฑๆŠŠๆ–ฐ codex ไผš่ฏๅปบๅœจ่ฟ™ไธชๆ–ฐ็บฟ็จ‹้‡Œ,`/ccteam use s12` ๅˆ™ไธบๅทฒๆœ‰ไผš่ฏๅผ€ไธ€ไธช็บฟ็จ‹ใ€‚ๅœจ็บฟ็จ‹้‡Œ็ป™่ฏฅไผš่ฏๅ‘ๅ‘ฝไปค,ๅ‰้ขๅŠ ไธ€ไธช็ฉบๆ ผ(` /status`ใ€` /compact`)ใ€‚ +- ็”ฑๅ…ถไป– agent ้›‡ๆฅ็š„ไผš่ฏๆฒกๆœ‰่‡ชๅทฑ็š„็บฟ็จ‹;ๆƒณ่ทŸๅฎƒ่ฏด่ฏๅฐฑ `/ccteam use ` ็ป™ๅฎƒๅผ€ไธ€ไธชใ€‚ ### ็ฝ‘ๅ…ณๅ‘ฝไปค -่Šๅคฉๆก†้‡Œๅ‘่ฟ™ไบ›ๅ‘ฝไปค,็”ฑ็ฝ‘ๅ…ณ็›ดๆŽฅๅค„็†ใ€‚้šๆ—ถ `/help` ็œ‹ๆธ…ๅ•(Telegram ้‡Œๆ•ฒ `/` ไนŸไผšๅผนๅ€™้€‰)ใ€‚ +่Šๅคฉๆก†้‡Œๅ‘่ฟ™ไบ›ๅ‘ฝไปค,็”ฑ็ฝ‘ๅ…ณ็›ดๆŽฅๅค„็†ใ€‚้šๆ—ถ `/help` ็œ‹ๆธ…ๅ•(Telegram ้‡Œๆ•ฒ `/` ไนŸไผšๅผนๅ€™้€‰)ใ€‚Slack ไธŠ็”จ `/ccteam <ๅ‘ฝไปค>` ๅ‘(็บฟ็จ‹้‡Œๅˆ™ๅœจๅ‘ฝไปคๅ‰ๅŠ ไธ€ไธช็ฉบๆ ผ),่ง [Slack:ไธ€ไธชไผš่ฏ = ไธ€ไธช็บฟ็จ‹](#slackไธ€ไธชไผš่ฏ--ไธ€ไธช็บฟ็จ‹)ใ€‚ ```text # ้กน็›ฎ diff --git a/docs/usage.md b/docs/usage.md index 512d938a..7acd0b75 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,20 +1,20 @@ # ccteam User Manual -**ccteam turns the coding agents you already run (Claude Code, Codex, Grok, Kimi, DSHโ€ฆ) into one team โ€” any session can spawn, dispatch, and collect work from any vendor on any machine, while you steer it all from Telegram, Lark, or a browser tab.** +**ccteam turns the coding agents you already run (Claude Code, Codex, Grok, Kimi, DSHโ€ฆ) into one team โ€” any session can spawn, dispatch, and collect work from any vendor on any machine, while you steer it all from Telegram, Lark, Slack, or a browser tab.** Install once, start one resident process, then do daily work from three surfaces, listed in recommended order: | Surface | Best for | Section | |---|---|---| | **Web console** | Create projects, start sessions, install plugins, configure IM, inspect status - the easiest default path | [1. Web console](#1-web-console-recommended) | -| **Telegram / Lark** | Mobile control, file exchange, tool approvals | [2. Telegram / Lark](#2-telegram--lark) | +| **Telegram / Lark / Slack** | Mobile control, file exchange, tool approvals โ€” on Slack every session gets its own thread | [2. Telegram / Lark / Slack](#2-telegram--lark--slack) | | **CLI** | Scripts, ops, advanced headless use | [3. CLI](#3-cli-advanced) | --- ## Core Concepts -- **chat** = one conversation surface: one web console tab, Telegram/Feishu DM, or group. Each chat has its own current project, current session, and session list. Chats are isolated from each other. +- **chat** = one conversation surface: one web console tab, Telegram/Feishu DM, or group, or one Slack channel/DM. Each chat has its own current project, current session, and session list. Chats are isolated from each other. On Slack the current session is per **thread** โ€” every session gets a thread of its own ([Slack: One Thread per Session](#slack-one-thread-per-session)). - **project** = a local code directory registered with a short slug. - **session** = one independent agent conversation with its own context, like a native Claude Code session. A project can have many sessions running side by side. Each session has a durable handle `s` that survives restarts and is never reused. - **role** = an optional persona bound at session start, loaded from `.claude/agents/.md`. The default is **roleless**: the bare vendor reads the project's own `CLAUDE.md`/`AGENTS.md`. Personas are installed from the marketplace or written by you; ccteam seeds none. @@ -192,16 +192,17 @@ The **Team** page is the multi-vendor cockpit, three tabs: The **Marketplace** page (under **Workflow**; the Skills tab opens first, and the project picker appears only for project-scoped types like agents) browses curated plugins from [ccteam-hub](https://github.com/firstintent/ccteam-hub). Official ccteam plugins are shown first, followed by tracked open-source sources such as [agency-agents](https://github.com/wshobson/agents) and [mattpocock/skills](https://github.com/mattpocock/skills). Open an item to preview its body, then install it. Agents (roles) install into the current project's `.claude/agents/`; **skills install into the user-level global library** `~/.ccteam/skills` โ€” never into the project โ€” and are attached per message from the composer. Installs verify sha256 and show status (skill status is computed against the library). After installing a role, switch to it from any surface with `/role `. -### Configure Telegram / Lark +### Configure Telegram / Lark / Slack Open **Settings** and enter IM credentials: - **Telegram:** paste the bot token, save it, then send the bot a message. The page polls and captures your chat id. - **Lark/Feishu:** enter App ID, App Secret, region (Feishu China / Lark international), and allowed users. +- **Slack:** enter the bot token (`xoxb-โ€ฆ`), the app-level token (`xapp-โ€ฆ`), and the allowed member ids (`Uโ€ฆ`). Secrets are masked (`...last4`) and never returned in plaintext. **Restart the daemon after changing global IM credentials** because they are loaded at startup. The page will show `restart required`. Per-user IM bots are hot-reloaded; see [Multi-User](#multi-user). -Detailed bot setup is in [2. Telegram / Lark](#setup). +Detailed bot setup is in [2. Telegram / Lark / Slack](#setup). ### Multi-User @@ -241,9 +242,9 @@ Mcp-Session-Id: --- -## 2. Telegram / Lark +## 2. Telegram / Lark / Slack -After connecting IM, you can drive sessions, send files, and approve tools from your phone. The easiest setup is [Web console Settings](#configure-telegram--lark). You can also use the `ccteam config` menu or write the credentials file manually. +After connecting IM, you can drive sessions, send files, and approve tools from your phone. The easiest setup is [Web console Settings](#configure-telegram--lark--slack). You can also use the `ccteam config` menu or write the credentials file manually. ### Setup @@ -281,11 +282,73 @@ After connecting IM, you can drive sessions, send files, and approve tools from - `allowed_user_ids` is an open_id allowlist (`ou_...`) **and the owner roster**: a listed sender is served as the box owner. **Empty means reject everyone** (fail closed). To get your open_id, start with an empty list, message the bot, find `ignoring ou_xxxx (not in allowed_users)` in logs, and add that `ou_xxxx`. - The `"*"` wildcard lets **anyone** message the bot. It names nobody, so nobody is served as the owner through it: every sender is a guest who owns only what it creates and sees no project. The daemon warns about this at startup โ€” put your own `ou_...` in the list to take the bot back. -> Manual credentials file changes require daemon restart. The same applies to global credentials configured in Web Settings. Lark/Feishu and Telegram are peers: text, rich text, images, and files are supported. +**Slack** gives **every session its own thread**, so parallel sessions never interleave in one stream. It connects over **Socket Mode** (an outbound WebSocket โ€” no public callback URL). At [api.slack.com/apps](https://api.slack.com/apps) create an app **from a manifest** and paste: + +```yaml +display_information: + name: ccteam +features: + bot_user: + display_name: ccteam + always_online: true + slash_commands: + - command: /ccteam + description: ccteam gateway command (sessions, new, cd, status, โ€ฆ) + usage_hint: "sessions | new codex | cd | status" + should_escape: false +oauth_config: + scopes: + bot: + - app_mentions:read + - chat:write + - channels:history + - groups:history + - im:history + - mpim:history + - reactions:write + - files:read + - files:write + - commands +settings: + event_subscriptions: + bot_events: + - message.channels + - message.groups + - message.im + - message.mpim + interactivity: + is_enabled: true + socket_mode_enabled: true +``` + +Install the app to your workspace, copy the **Bot User OAuth Token** (`xoxb-โ€ฆ`), and under **Basic Information โ†’ App-Level Tokens** create a token with the `connections:write` scope (`xapp-โ€ฆ`). Configure through Web Settings / `ccteam config`, or add a `slack` block: + +```json +{ + "slack": { + "bot_token": "xoxb-replace_me", + "app_token": "xapp-replace_me", + "allowed_user_ids": ["U0123ABCD"] + } +} +``` + +- `allowed_user_ids` is a Slack member-id allowlist (`Uโ€ฆ`) **and the owner roster**: a listed sender is served as the box owner. **Empty means reject everyone** (fail closed). Your member id is under your Slack profile โ†’ โ‹ฎ โ†’ *Copy member ID*; a rejected DM also gets a reply naming the id to add. +- Invite the bot to a channel (`/invite @ccteam`) or open its DM. It answers every message from allowed users in the channels it is in, so a dedicated channel (or the DM) works best. + +> Manual credentials file changes require daemon restart. The same applies to global credentials configured in Web Settings. Lark/Feishu, Telegram, and Slack are peers: text, rich text, images, and files are supported. + +### Slack: One Thread per Session + +- A **top-level message** starts a new thread; the first ordinary message in a thread with no session starts a roleless session in the channel's current project (exactly like a fresh Telegram chat). Post another top-level message and a second session runs beside the first. +- A **reply in a thread** goes to that thread's session. Everything that session produces โ€” answers, the live progress card, approval buttons, files, the answer it gives after a delegate reports back โ€” lands in its own thread. +- The **channel** is the chat for ownership and the current project: `/cd` in any thread switches the project for the whole channel (that thread keeps its session only if the session is in the new project; otherwise its next message starts one there), and `/sessions` lists every session the channel owns. `/use ` or `/new โ€ฆ` inside a thread re-points **that thread** only. +- Slack treats a leading `/` as its own command, so gateway commands go through the **`/ccteam`** slash command: `/ccteam new codex`, `/ccteam sessions`, `/ccteam cd demo`. ccteam posts an anchor message for the command and answers in its thread โ€” so `/ccteam new codex` creates the codex session right in that new thread, and `/ccteam use s12` opens a thread for an existing session. Inside a thread, type the command with a leading space (` /status`, ` /compact`) to send it to that thread's session. +- Sessions hired by other agents get no thread of their own; open one with `/ccteam use ` when you want to talk to it. ### Gateway Commands -Send these commands in chat. The gateway handles them directly. Use `/help` anytime; Telegram also shows command candidates when you type `/`. +Send these commands in chat. The gateway handles them directly. Use `/help` anytime; Telegram also shows command candidates when you type `/`. On Slack send them as `/ccteam ` (or, inside a thread, with a leading space) โ€” see [Slack: One Thread per Session](#slack-one-thread-per-session). ```text # Projects From 5db1785a30b4e1573766692fb6cd0555b601f643 Mon Sep 17 00:00:00 2001 From: firstintent <239836187+firstintent@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:52:36 +0800 Subject: [PATCH 18/37] feat(im): Slack over Socket Mode, one thread per session (#19) On Telegram several agent sessions share one bot stream and their replies interleave. Slack has native threads, so the Slack provider gives every session its own thread: it advertises the new `Channel::session_threads()` contract and stamps every inbound ChannelMessage with the thread it belongs to (top-level message -> its own ts, reply -> parent thread_ts, `/ccteam` -> a fresh anchor message, button click -> the clicked message's thread). The old polling provider is replaced outright (Pre-v1, no migration): - Inbound is Socket Mode (apps.connections.open + WSS, no public endpoint). Every envelope is ACKed before any slow work and handed to an ordered processor; refresh/warning disconnects reconnect at once, drops reconnect with capped backoff, link_disabled and auth failures stop the listener; re-deliveries are deduped. - The allowlist (Slack user ids) is fail-closed like Lark. Rejected senders are always probed, but only get the one-time binding notice when they addressed the bot (DM, mention, slash command, click). - Outbound posts a `markdown` block (+ option buttons), retrying once as plain text if Slack rejects the blocks; edits use chat.update, the ack is the :eyes: reaction, files use the external-upload flow, inbound files are downloaded with the bot token and staged. - SlackCreds become {bot_token, app_token, allowed_user_ids}; onboarding validates both tokens; the daemon builds the channel with the rejected-sender probe and binds the allowlist as the operator roster. The dead signing-secret stub goes with the webhook design. Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com> --- crates/ccteam-im/src/credentials.rs | 54 +- crates/ccteam-im/src/daemon.rs | 51 +- crates/ccteam-im/src/onboarding.rs | 155 +- crates/ccteam-im/src/three_layer_sec.rs | 77 +- crates/ccteam-im/src/transport/mod.rs | 8 +- .../src/transport/providers/slack.rs | 1546 +++++++++++++++-- .../src/transport/providers/slack_tests.rs | 1411 +++++++++++++++ crates/ccteam-im/tests/credentials_test.rs | 4 +- .../ccteam-im/tests/slack_onboarding_test.rs | 193 ++ 9 files changed, 3207 insertions(+), 292 deletions(-) create mode 100644 crates/ccteam-im/src/transport/providers/slack_tests.rs create mode 100644 crates/ccteam-im/tests/slack_onboarding_test.rs diff --git a/crates/ccteam-im/src/credentials.rs b/crates/ccteam-im/src/credentials.rs index edec078c..e2b9d319 100644 --- a/crates/ccteam-im/src/credentials.rs +++ b/crates/ccteam-im/src/credentials.rs @@ -5,7 +5,7 @@ //! ```json //! { //! "telegram": { "bot_token": "...", "allowed_chat_ids": ["12345"] }, -//! "slack": { "bot_token": "xoxb-...", "signing_secret": "..." }, +//! "slack": { "bot_token": "xoxb-...", "app_token": "xapp-...", "allowed_user_ids": ["U..."] }, //! "discord": { "bot_token": "...", "authorized_user_ids": ["..."] }, //! "lark": { "app_id": "cli_...", "app_secret": "...", "allowed_user_ids": ["ou_..."] } //! } @@ -26,8 +26,8 @@ pub struct Credentials { /// Telegram bot credentials (long-polling getUpdates). #[serde(default, skip_serializing_if = "Option::is_none")] pub telegram: Option, - /// Slack bot credentials (HTTP chat.postMessage + signing-secret - /// HMAC verify on incoming events). + /// Slack app credentials (Socket Mode inbound + Web API outbound; one + /// Slack thread per agent session). #[serde(default, skip_serializing_if = "Option::is_none")] pub slack: Option, /// Discord bot credentials (REST messages API). @@ -51,19 +51,21 @@ pub struct TelegramCreds { pub allowed_chat_ids: Vec, } -/// Slack bot credentials. +/// Slack app credentials. +/// +/// Inbound arrives over Socket Mode (an outbound WSS long-connection opened +/// with the app-level token), so there is no public endpoint and no signing +/// secret to verify; outbound uses the bot token against the Web API. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct SlackCreds { - /// `xoxb-...` bot token. + /// `xoxb-...` bot token (Web API: post, update, react, files). pub bot_token: String, - /// Used to verify the `X-Slack-Signature` header on incoming - /// webhook events. Required when running the inbound HTTP - /// receiver; optional when running pure HTTP polling. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub signing_secret: Option, - /// Channels the daemon should poll. Empty = polling disabled. + /// `xapp-...` app-level token with `connections:write` (Socket Mode). + pub app_token: String, + /// Slack user ids (`U...`) allowed to drive the bot. Empty = closed + /// (deny all), `"*"` = open โ€” the same fail-closed semantics as Lark. #[serde(default)] - pub poll_channels: Vec, + pub allowed_user_ids: Vec, } /// Discord bot credentials. @@ -235,6 +237,34 @@ mod tests { assert_eq!(back, original); } + #[test] + fn round_trip_slack_only() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("c.json"); + let original = Credentials { + slack: Some(SlackCreds { + bot_token: "xoxb-1-abc".into(), + app_token: "xapp-1-def".into(), + allowed_user_ids: vec!["U0ALICE".into(), "U0BOB".into()], + }), + ..Default::default() + }; + save(&path, &original).unwrap(); + let back = load(Some(&path)).unwrap(); + assert_eq!(back, original); + } + + #[test] + fn slack_allowlist_defaults_empty_fail_closed() { + let json = r#"{"slack":{"bot_token":"xoxb-1","app_token":"xapp-1"}}"#; + let creds: Credentials = serde_json::from_str(json).unwrap(); + let slack = creds.slack.expect("slack block parsed"); + assert!( + slack.allowed_user_ids.is_empty(), + "allowed_user_ids defaults empty (channel layer = closed)" + ); + } + #[test] fn lark_defaults_use_feishu() { let json = r#"{"lark":{"app_id":"a","app_secret":"s"}}"#; diff --git a/crates/ccteam-im/src/daemon.rs b/crates/ccteam-im/src/daemon.rs index 6a3b0582..d5628262 100644 --- a/crates/ccteam-im/src/daemon.rs +++ b/crates/ccteam-im/src/daemon.rs @@ -749,6 +749,11 @@ fn bind_operator_rosters(gateway: &mut Gateway, creds: &Credentials) { if let Some(lark) = creds.lark.as_ref() { rosters.push(("lark", lark.allowed_user_ids.clone())); } + // Slack names its owners by user id (`Uโ€ฆ`), which the gateway matches + // against `ChatKey::user_id` (the inbound sender). + if let Some(slack) = creds.slack.as_ref() { + rosters.push(("slack", slack.allowed_user_ids.clone())); + } if let Some(discord) = creds.discord.as_ref() { rosters.push(("discord", discord.authorized_user_ids.clone())); } @@ -970,22 +975,27 @@ fn build_telegram_channel( ))) } -/// Slack: HTTP `chat.postMessage` + channel polling. Discharges the old -/// `TODO(V0.7-im-providers)` โ€” the row was dark only because no creds -/// block existed, not because the provider was missing. +/// Slack: Socket Mode inbound + Web API outbound, one thread per session. +/// +/// The allowlist is the operator's `SlackCreds.allowed_user_ids` alone (Slack +/// user ids, fail-closed). Unlike Lark's parity-only union, registered bots' +/// `im_chat_id`s (channel ids โ€” a different namespace) are not mixed in. #[cfg(feature = "slack")] fn build_slack_channel( creds: &Credentials, _bots: &[BotRegistration], - _probe_path: Option<&Path>, + probe_path: Option<&Path>, ) -> Option> { let slack = creds.slack.as_ref()?; - Some(Arc::new( - crate::transport::providers::slack::SlackChannel::new( - slack.bot_token.clone(), - slack.poll_channels.clone(), - ), - )) + let mut ch = crate::transport::providers::slack::SlackChannel::new( + slack.bot_token.clone(), + slack.app_token.clone(), + slack.allowed_user_ids.clone(), + ); + if let Some(path) = probe_path { + ch = ch.with_probe_path(path.to_path_buf()); + } + Some(Arc::new(ch)) } /// Discord: REST messages API + per-channel polling. `DiscordCreds` @@ -2196,6 +2206,27 @@ mod tests { // route back through this bot (not a colliding shared `"telegram"`). assert_eq!(chans[0].1.name(), format!("telegram@{}", a.id).as_str()); } + + /// #19 โ€” a Slack credentials block yields the Socket Mode channel under the + /// `"slack"` key, and that channel carries the one-thread-per-session + /// contract the inbound consumer keys on. No block โ†’ no channel. + #[cfg(feature = "slack")] + #[test] + fn build_slack_channel_from_creds_threads_sessions() { + assert!(build_slack_channel(&Credentials::default(), &[], None).is_none()); + let creds = Credentials { + slack: Some(crate::credentials::SlackCreds { + bot_token: "xoxb-1".into(), + app_token: "xapp-1".into(), + allowed_user_ids: vec!["U1".into()], + }), + ..Default::default() + }; + let ch = build_slack_channel(&creds, &[], None).expect("slack block โ†’ channel"); + assert_eq!(ch.name(), "slack"); + assert!(ch.session_threads()); + assert!(ch.max_message_len().is_some()); + } use tempfile::TempDir; /// (v0.8.5 B1 / B1b) The security gate must let a non-text message through diff --git a/crates/ccteam-im/src/onboarding.rs b/crates/ccteam-im/src/onboarding.rs index 7a1dbcc3..4831cd7e 100644 --- a/crates/ccteam-im/src/onboarding.rs +++ b/crates/ccteam-im/src/onboarding.rs @@ -18,9 +18,11 @@ use serde::Deserialize; use thiserror::Error; -use crate::credentials::{LarkCreds, TelegramCreds}; +use crate::credentials::{LarkCreds, SlackCreds, TelegramCreds}; -fn client_for_api_base( +/// HTTP client for one IM platform's API base. A loopback base (a test mock) +/// bypasses any configured HTTP proxy so `cargo test` never leaves the box. +pub(crate) fn client_for_api_base( api_base: &str, timeout: std::time::Duration, ) -> Result { @@ -72,9 +74,11 @@ pub enum OnboardingError { /// DNS, TLS, connect, or read timeout. #[error("HTTP request failed: {0}")] Http(#[from] reqwest::Error), - /// Telegram returned a `200` with `ok: false`; the `String` names the - /// API method that was rejected (e.g. an invalid bot token on `getMe`). - #[error("Telegram API returned `ok: false`: {0}")] + /// The platform API answered but refused the call (Telegram/Slack + /// `ok: false`, a non-zero Feishu `code`); the `String` names the + /// platform + method and carries the upstream reason (e.g. an invalid + /// bot token on `getMe` / `auth.test`). + #[error("IM platform API rejected the call: {0}")] ApiNotOk(String), /// The long-poll window elapsed without the owner sending a message, /// so no `chat_id` could be captured. @@ -83,9 +87,10 @@ pub enum OnboardingError { /// The poll budget (seconds) that was exhausted. seconds: u64, }, - /// A Telegram response decoded but was missing a field the flow needs - /// (e.g. `getMe.result`); the `String` describes what was absent. - #[error("malformed Telegram response: {0}")] + /// A platform response decoded but was missing a field the flow needs + /// (e.g. `getMe.result`, `auth.test.user_id`); the `String` describes + /// what was absent. + #[error("malformed IM platform response: {0}")] BadResponse(String), } @@ -151,7 +156,7 @@ pub async fn telegram_validate_token_with_base( .json() .await?; if !me.ok { - return Err(OnboardingError::ApiNotOk("getMe".into())); + return Err(OnboardingError::ApiNotOk("Telegram getMe".into())); } let bot_user = me .result @@ -198,7 +203,7 @@ async fn poll_first_chat_id( let resp: GetUpdatesResponse = client.get(&url).send().await?.json().await?; if !resp.ok { - return Err(OnboardingError::ApiNotOk("getUpdates".into())); + return Err(OnboardingError::ApiNotOk("Telegram getUpdates".into())); } for upd in resp.result.iter() { last_update_id = Some(upd.update_id); @@ -328,7 +333,7 @@ pub async fn lark_setup_with_base( if resp.code != 0 { let msg = resp.msg.unwrap_or_else(|| "unknown error".into()); return Err(OnboardingError::ApiNotOk(format!( - "tenant_access_token (code={}): {msg}", + "Lark tenant_access_token (code={}): {msg}", resp.code ))); } @@ -357,3 +362,131 @@ struct TenantTokenResponse { #[serde(default)] tenant_access_token: Option, } + +// --- Slack onboarding ------------------------------------------------ +// +// Like Lark there is nothing to long-poll: the provider keys its allowlist on +// operator-supplied Slack user ids (fail-closed) and opens an *outbound* +// Socket Mode connection. Setup proves both tokens with the exact calls the +// live channel makes (`transport::providers::slack::SlackChannel`): +// `auth.test` with the `xoxb-` bot token (Web API) and +// `apps.connections.open` with the `xapp-` app-level token (Socket Mode). The +// WSS URL the latter returns is discarded unused โ€” no socket is kept. + +/// Default Slack Web API root (also the live channel's default base). +pub const SLACK_API_BASE: &str = "https://slack.com/api"; + +/// What the Slack app itself must have for the provider to work โ€” printed by +/// the setup surfaces after a successful save. The one home of this list. +pub const SLACK_APP_CHECKLIST: &str = "\ +Slack app checklist (api.slack.com/apps โ†’ your app): + - Socket Mode: ON (the xapp- app-level token needs connections:write) + - Slash command: /ccteam + - Interactivity & Shortcuts: ON (option buttons) + - App Home โ†’ Messages tab: ON, allow users to message the app (DMs) + - Bot token scopes: app_mentions:read chat:write channels:history groups:history + im:history mpim:history reactions:write files:read files:write commands + - Event subscriptions โ†’ bot events: message.channels message.groups message.im message.mpim + - Reinstall the app after scope changes, then invite the bot to a channel + (/invite @) or DM it +"; + +/// Result of a successful Slack credential check: the on-disk +/// [`SlackCreds`] record plus the workspace/bot names for the setup UX. +#[derive(Debug, Clone, PartialEq)] +pub struct SlackSetupResult { + /// Validated tokens + the provider allowlist. + pub creds: SlackCreds, + /// Workspace name from `auth.test` (`team`). + pub team: String, + /// The bot user's handle from `auth.test` (`user`). + pub bot_user: String, + /// The bot user's id (`Uโ€ฆ`) from `auth.test` (`user_id`). + pub bot_user_id: String, +} + +/// Validate Slack credentials against the real Web API and return the +/// on-disk record. `allowed_user_ids` is the provider allowlist of Slack user +/// ids (`Uโ€ฆ`) โ€” **fail-closed**: empty means the bot answers no one. +pub async fn slack_setup( + bot_token: &str, + app_token: &str, + allowed_user_ids: Vec, +) -> Result { + slack_setup_with_base(bot_token, app_token, allowed_user_ids, SLACK_API_BASE).await +} + +/// Test-friendly variant of [`slack_setup`] with an overridable API base +/// (point a deterministic local mock at it โ€” `cargo test` never calls Slack). +pub async fn slack_setup_with_base( + bot_token: &str, + app_token: &str, + allowed_user_ids: Vec, + api_base: &str, +) -> Result { + let client = client_for_api_base(api_base, std::time::Duration::from_secs(30))?; + + let auth: SlackOkResponse = client + .post(format!("{api_base}/auth.test")) + .bearer_auth(bot_token) + .send() + .await? + .json() + .await?; + if !auth.ok { + return Err(OnboardingError::ApiNotOk(format!( + "Slack auth.test (bot token xoxb-โ€ฆ): {}", + auth.error.unwrap_or_else(|| "unknown_error".into()) + ))); + } + let bot_user_id = auth + .user_id + .filter(|id| !id.is_empty()) + .ok_or_else(|| OnboardingError::BadResponse("Slack auth.test: user_id missing".into()))?; + + let socket: SlackOkResponse = client + .post(format!("{api_base}/apps.connections.open")) + .bearer_auth(app_token) + .send() + .await? + .json() + .await?; + if !socket.ok { + return Err(OnboardingError::ApiNotOk(format!( + "Slack apps.connections.open (app-level token xapp-โ€ฆ, Socket Mode): {}", + socket.error.unwrap_or_else(|| "unknown_error".into()) + ))); + } + if socket.url.unwrap_or_default().is_empty() { + return Err(OnboardingError::BadResponse( + "Slack apps.connections.open: url missing".into(), + )); + } + + Ok(SlackSetupResult { + creds: SlackCreds { + bot_token: bot_token.into(), + app_token: app_token.into(), + allowed_user_ids, + }, + team: auth.team.unwrap_or_default(), + bot_user: auth.user.unwrap_or_default(), + bot_user_id, + }) +} + +/// The fields of `auth.test` / `apps.connections.open` setup reads. +#[derive(Debug, Deserialize)] +struct SlackOkResponse { + ok: bool, + #[serde(default)] + error: Option, + #[serde(default)] + team: Option, + #[serde(default)] + user: Option, + #[serde(default)] + user_id: Option, + #[serde(default)] + url: Option, +} diff --git a/crates/ccteam-im/src/three_layer_sec.rs b/crates/ccteam-im/src/three_layer_sec.rs index bd7b4faa..e02ef602 100644 --- a/crates/ccteam-im/src/three_layer_sec.rs +++ b/crates/ccteam-im/src/three_layer_sec.rs @@ -1,11 +1,10 @@ //! Three-layer security composition. //! //! Mirrors `references/oh-my-claudecode/src/notifications/reply-listener.ts` -//! (slack-socket.ts `verifySlackSignature`, the shared `RateLimiter`, -//! and `sanitizeReplyInput`). All three layers must pass before the -//! daemon will forward an IM turn to a tmux session. - -use std::time::{SystemTime, UNIX_EPOCH}; +//! (the shared `RateLimiter` and `sanitizeReplyInput`). All three layers +//! must pass before the daemon will forward an IM turn to a session. Slack +//! needs no request-signature layer: its inbound is a Socket Mode connection +//! the daemon opens itself, never a signed webhook. use crate::acl::AclPolicy; use crate::rate_limit::RateLimiter; @@ -30,10 +29,6 @@ pub enum SecOutcome { EmptyAfterSanitize, } -/// Maximum age (seconds) of a Slack signed-request timestamp before it -/// counts as replay-attack territory. OMC uses 5 minutes. -pub const SLACK_TIMESTAMP_MAX_AGE_SECS: u64 = 300; - /// Stateless evaluator. The daemon holds one [`ThreeLayerSec`] per /// bot โ€” the [`RateLimiter`] inside lives across IM events but is /// owned by the caller (so tests can inject a deterministic clock by @@ -56,9 +51,9 @@ impl ThreeLayerSec { /// Layer 1 + 2 + 3 in order: ACL โ†’ rate limit โ†’ sanitize. /// Signature verification is **not** included here because it's - /// platform-specific (Slack HMAC vs Telegram chat-id binding vs - /// Discord allowed-user check); call the verify helper for the - /// matching platform before calling [`Self::evaluate`]. + /// platform-specific (Telegram chat-id binding vs Discord allowed-user + /// check vs the providers' own allowlists); call the verify helper for + /// the matching platform before calling [`Self::evaluate`]. pub fn evaluate(&mut self, platform: &str, sender_id: &str, raw_text: &str) -> SecOutcome { if !self.acl.allow(platform, sender_id) { return SecOutcome::AclDenied; @@ -74,56 +69,6 @@ impl ThreeLayerSec { } } -/// Slack `v0::` HMAC-SHA256 signature verification โ€” drop-in -/// port of the OMC `verifySlackSignature` (TS source quoted in the -/// reply-listener Explore report). -/// -/// V0.6 implements without the `hmac`/`sha2`/`subtle` deps to avoid -/// adding crates for one method โ€” a future PR can swap in -/// `subtle::ConstantTimeEq` + `hmac` if Slack inbound HTTP receiver -/// lands. For now this returns `false` when no crypto backend is -/// available, forcing platform setups to either: -/// -/// 1. Use long-polling (the V0.6 default โ€” no signed request to -/// verify), OR -/// 2. Provide their own verification path before calling -/// [`ThreeLayerSec::evaluate`]. -pub fn verify_slack_signature_stub( - _signing_secret: &str, - _signature: &str, - timestamp: &str, - _body: &str, -) -> bool { - // Replay-window check (we can do this without crypto). - let ts: u64 = match timestamp.parse() { - Ok(n) => n, - Err(_) => return false, - }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let age = now.saturating_sub(ts); - if age > SLACK_TIMESTAMP_MAX_AGE_SECS { - return false; - } - // No HMAC backend wired yet; conservative deny by default. - // TODO(V0.7-slack-inbound): wire `hmac` + `sha2` + `subtle` - // crates to verify the `v0=` signature against - // `v0::` keyed by `creds.slack.signing_secret`, - // returning `true` only on constant-time match. - // Reason deferred: Slack inbound HTTP receiver (the only caller - // that would consume `true`) is V0.7 scope per - // `docs/versions/v0-6-0/wave-2-decisions.md ยง5`; V0.6.x Slack - // uses polling via `SlackChannel` which carries no signed - // request, so wiring HMAC in isolation would add 3 deps for - // zero production caller and risk drift before the inbound - // receiver lands. - // Tracking: docs/versions/v0-6-6/prd.md ยงF168 (decision row #8) + - // docs/dev-coupling-audit.md V0.6.6 segment. - false -} - /// Telegram chat-id binding check โ€” the bot only accepts updates from /// `allowed_chat_ids` configured in `credentials.json`. pub fn verify_telegram_chat_binding(allowed: &[String], inbound_chat_id: &str) -> bool { @@ -202,14 +147,6 @@ mod tests { ); } - #[test] - fn slack_signature_stub_rejects_old_timestamp() { - let old = "100"; // epoch=100s โ€” far past window. - assert!(!verify_slack_signature_stub( - "secret", "v0=abc", old, "body" - )); - } - #[test] fn telegram_chat_binding_open_when_empty() { assert!(verify_telegram_chat_binding(&[], "12345")); diff --git a/crates/ccteam-im/src/transport/mod.rs b/crates/ccteam-im/src/transport/mod.rs index 71de9043..f6f9763c 100644 --- a/crates/ccteam-im/src/transport/mod.rs +++ b/crates/ccteam-im/src/transport/mod.rs @@ -9,10 +9,10 @@ //! - **No event_bus / security / config coupling.** ccteam-im has //! its own credentials + ACL + sanitize layers; providers stay //! plain reqwest clients. -//! - **No Socket Mode / gateway WebSockets.** Slack + Discord both -//! use HTTP polling. Telegram uses `getUpdates` long-polling. None -//! of the V0.6 scope needs a public HTTPS endpoint, which keeps -//! ops surface to "edit credentials.json, run daemon". +//! - **No public endpoint.** Every inbound path is outbound-initiated: +//! Telegram `getUpdates` long-polling, Lark and Slack (Socket Mode) +//! WebSocket long-connections, Discord HTTP polling โ€” which keeps ops +//! surface to "edit credentials.json, run daemon". //! //! See `providers/mock.rs` for the in-memory test channel. diff --git a/crates/ccteam-im/src/transport/providers/slack.rs b/crates/ccteam-im/src/transport/providers/slack.rs index 629ad7ba..c3501f00 100644 --- a/crates/ccteam-im/src/transport/providers/slack.rs +++ b/crates/ccteam-im/src/transport/providers/slack.rs @@ -1,242 +1,1422 @@ -//! Slack channel โ€” HTTP `chat.postMessage` for outbound, polling -//! `conversations.history` for inbound. +//! Slack channel โ€” Socket Mode inbound + Web API outbound, one Slack thread +//! per agent session. //! -//! No Socket Mode in V0.6 (avoids `tokio-tungstenite`); polling is -//! enough for the host-probe scope. +//! **Why Slack exists here (#19).** On a single-stream IM (Telegram) several +//! agent sessions share one bot conversation and their replies interleave. +//! Slack gives every conversation native threads, so this channel advertises +//! [`Channel::session_threads`] = `true`: every inbound [`ChannelMessage`] +//! carries the Slack thread it belongs to, the gateway scopes session focus +//! per thread, and every outbound reply for a session is posted back into that +//! session's thread. //! -// TODO(V0.7-slack-socket-mode): switch to Slack Socket Mode (WSS via -// `tokio-tungstenite`) for inbound โ€” replaces `conversations.history` -// polling with `apps.connections.open` + persistent WS frame loop. -// Reason deferred: V0.6.x host probe only exercises one Slack channel -// with `POLL_INTERVAL_SECS = 4`, well under Slack rate limits; -// Socket Mode adds a new dep + reconnect / backoff state machine -// that benefits primarily from sub-second latency multi-channel -// workloads landing with V0.7 Epic C. Decision recorded in -// `docs/versions/v0-6-0/wave-2-decisions.md ยง5`. -// Tracking: docs/versions/v0-6-6/prd.md ยงF168 (decision row #9) + -// docs/dev-coupling-audit.md V0.6.6 segment. - -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; +//! **Inbound = Socket Mode** (no public endpoint, same posture as Lark's WSS +//! long-connection): `apps.connections.open` with the app-level `xapp-` token +//! returns a one-shot `wss://` URL; the frame loop ACKs every envelope +//! *immediately* (Slack's 3 s budget) and hands it to an ordered processor, so +//! slow work (posting a slash-command anchor, downloading a file) never delays +//! an ACK and never reorders messages. Three envelope kinds are consumed: +//! +//! - `events_api` โ†’ `message` events (channels, private channels, DMs, group +//! DMs). `app_mention` is deliberately ignored: it duplicates `message`. +//! - `slash_commands` โ†’ `/ccteam `: the provider first posts a top-level +//! ANCHOR message echoing the command, and the command runs inside the +//! anchor's thread โ€” so `/ccteam new codex` opens a fresh thread (session). +//! - `interactive` / `block_actions` โ†’ option-button clicks +//! ([`ChoiceReply`]), threaded on the clicked message's thread. +//! +//! **Inbound invariant** (load-bearing for the gateway's per-thread focus): +//! every emitted [`ChannelMessage`] has `thread_ts = Some(..)` โ€” a top-level +//! message threads on its own `ts`, a reply on its parent `thread_ts`, a slash +//! command on its anchor, a click on the clicked message's thread. +//! +//! **Allowlist** (`allowed_user_ids`, Slack `Uโ€ฆ` ids) is fail-closed like +//! Lark: EMPTY = deny all, `"*"` = anyone. A rejected sender always leaves a +//! [`RejectedSenderProbe`] (the web binding flow reads it) but only gets the +//! one-time binding notice when the bot was addressed directly โ€” a DM, an +//! @-mention, a slash command or a button click โ€” never for ambient chatter +//! in a shared channel. +//! +//! **Outbound** posts Markdown through Slack's `markdown` block (standard +//! Markdown, unlike the legacy `mrkdwn` text dialect), with options rendered as +//! `actions` buttons; a block rejection retries once as plain `text`. + +use std::collections::{HashSet, VecDeque}; +use std::path::PathBuf; +use std::time::{Duration, Instant}; +use anyhow::Context as _; use async_trait::async_trait; -use serde::Deserialize; -use tokio::sync::Mutex; +use futures::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use tokio::sync::{mpsc, Mutex, RwLock}; +use tokio_tungstenite::tungstenite::Message as WsMsg; + +use crate::onboarding::{client_for_api_base, SLACK_API_BASE}; +use crate::transport::{ + inbound_staging_dir, sanitize_attachment_name, AttachmentKind, Channel, ChannelAttachment, + ChannelMessage, ChoiceReply, MessageOption, OutboundFile, RejectedSenderNotifier, + RejectedSenderProbe, SendMessage, +}; + +/// The slash command this provider answers. It must match the command +/// declared in the Slack app manifest (slash commands live there, not in an +/// API call โ€” so [`Channel::register_commands`] stays the default no-op). +pub const SLACK_SLASH_COMMAND: &str = "/ccteam"; + +/// Per-message ceiling in **UTF-16 code units**. Slack caps the cumulative +/// text of `markdown` blocks in one payload at 12 000 characters; a UTF-16 +/// count is never smaller than the character count, so 10 000 units keeps a +/// split part safely under that cap with headroom for re-opened code fences. +/// The only home of the Slack length constant โ€” the daemon reads it through +/// [`Channel::max_message_len`]. +const SLACK_MAX_MESSAGE_UTF16: usize = 10_000; + +/// Inbound attachment ceiling in bytes โ€” mirrors Lark's ceiling so every +/// provider stages the same worst case to the shared filesystem. +const SLACK_MAX_ATTACHMENT_BYTES: u64 = 30 * 1024 * 1024; + +/// Reaction name of the ๐Ÿ‘€ "received, processing" ack. +const SLACK_ACK_REACTION: &str = "eyes"; + +/// Slack allows at most 25 elements in one `actions` block. +const SLACK_ACTIONS_PER_BLOCK: usize = 25; + +/// Slack rejects a button whose `plain_text` label exceeds 75 characters. +const SLACK_BUTTON_LABEL_MAX_CHARS: usize = 75; + +/// Re-delivery dedupe memory (event ids + `(channel, ts)` + trigger ids). +/// Slack retries an un-ACKed event a handful of times within minutes, so a +/// few thousand recent keys is ample and keeps memory flat. +const SLACK_DEDUP_CAPACITY: usize = 2048; + +/// Ordered envelope queue between the frame reader (which only ACKs and +/// enqueues) and the processor (which may post, download and backpressure). +const SLACK_ENVELOPE_QUEUE: usize = 256; + +const RECONNECT_BACKOFF_MIN: Duration = Duration::from_secs(1); +const RECONNECT_BACKOFF_MAX: Duration = Duration::from_secs(60); + +/// The client pings this often; any inbound frame refreshes liveness. A +/// half-open socket that stays silent past [`WS_IDLE_TIMEOUT`] is reopened. +const WS_PING_INTERVAL: Duration = Duration::from_secs(30); +const WS_IDLE_TIMEOUT: Duration = Duration::from_secs(120); + +/// Upper bound on one `Retry-After` wait; a longer ask fails the call +/// instead of stalling the delivery path for minutes. +const RATE_LIMIT_MAX_WAIT: Duration = Duration::from_secs(30); + +/// Web API error codes after which reconnecting cannot help: the credentials +/// (or Socket Mode itself) must be fixed by the operator, and a credentials +/// change rebuilds the channel anyway. +const FATAL_API_ERRORS: &[&str] = &[ + "invalid_auth", + "not_authed", + "account_inactive", + "token_revoked", + "token_expired", + "not_allowed_token_type", + "link_disabled", +]; + +/// A Slack Web API call that returned `ok: false` (or a Socket Mode +/// `link_disabled`). Kept typed so callers can branch on the error code. +#[derive(Debug)] +struct SlackApiError { + method: String, + code: String, +} + +impl std::fmt::Display for SlackApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "slack {}: {}", self.method, self.code) + } +} + +impl std::error::Error for SlackApiError {} + +/// The Slack error code carried by `err`, if it is a [`SlackApiError`]. +fn api_error_code(err: &anyhow::Error) -> Option<&str> { + err.downcast_ref::().map(|e| e.code.as_str()) +} + +/// Whether Slack refused the message's blocks (`invalid_blocks`, +/// `invalid_blocks_format`, `msg_blocks_too_long`, โ€ฆ) โ€” the one failure a +/// plain-`text` resend can fix. +fn is_blocks_rejection(err: &anyhow::Error) -> bool { + api_error_code(err).is_some_and(|code| code.contains("blocks")) +} + +/// The bot's own identity from `auth.test`: its user id, used to drop its +/// own messages and to strip `<@BOT>` mentions. (Its `bot_id` needs no +/// keeping โ€” every message carrying any `bot_id` is dropped, ours included.) +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct BotIdentity { + user_id: String, +} + +/// One Socket Mode frame. +#[derive(Debug, PartialEq)] +enum Frame { + Hello, + Disconnect(String), + Envelope(Envelope), + Other, +} + +/// An envelope that must be ACKed by echoing its `envelope_id`. +#[derive(Debug, Clone, PartialEq)] +struct Envelope { + envelope_id: String, + kind: String, + payload: Value, +} + +fn parse_frame(text: &str) -> Frame { + let Ok(v) = serde_json::from_str::(text) else { + return Frame::Other; + }; + let kind = v.get("type").and_then(Value::as_str).unwrap_or(""); + if let Some(id) = v + .get("envelope_id") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + return Frame::Envelope(Envelope { + envelope_id: id.to_string(), + kind: kind.to_string(), + payload: v.get("payload").cloned().unwrap_or(Value::Null), + }); + } + match kind { + "hello" => Frame::Hello, + "disconnect" => Frame::Disconnect( + v.get("reason") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + ), + _ => Frame::Other, + } +} + +/// How one socket session ended. +#[derive(Debug, PartialEq, Eq)] +enum SocketEnd { + /// The gateway receiver is gone โ€” stop listening. + Closed, + /// Slack asked us to move (`refresh_requested` / `warning`): reopen now. + Refresh, + /// The socket dropped. `healthy` = it got as far as `hello`, so the + /// reconnect backoff restarts from its minimum. + Dropped { healthy: bool }, +} + +/// A file attached to an inbound message, still to be downloaded. +#[derive(Debug, Clone, PartialEq)] +struct PendingFile { + id: String, + name: String, + mimetype: Option, + size: Option, + url: String, +} + +/// One inbound Slack `message` event after decode, before the allowlist, +/// dedupe and file download (which need `&self`). +#[derive(Debug, Clone, PartialEq)] +struct DecodedMessage { + user: String, + channel: String, + channel_type: String, + ts: String, + /// Resolved thread: the event's `thread_ts`, else its own `ts`. + thread_ts: String, + text: String, + mentions_bot: bool, + files: Vec, +} + +impl DecodedMessage { + fn into_channel_message(self, channel_name: &str) -> ChannelMessage { + ChannelMessage { + timestamp: ts_secs(&self.ts), + id: self.ts, + sender: self.user, + reply_target: self.channel, + content: self.text, + channel: channel_name.to_string(), + thread_ts: Some(self.thread_ts), + attachments: Vec::new(), + selection: None, + } + } +} + +fn str_at<'a>(v: &'a Value, pointer: &str) -> Option<&'a str> { + v.pointer(pointer) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) +} + +/// Decode a Socket Mode `events_api` `message` event. Returns `None` for +/// anything the bot must not act on: other event types, edits/joins/other +/// subtypes, bot traffic (its own or another app's), empty bodies. +fn decode_message_event(event: &Value, bot: &BotIdentity) -> Option { + if event.get("type").and_then(Value::as_str) != Some("message") { + return None; + } + let channel_type = event + .get("channel_type") + .and_then(Value::as_str) + .unwrap_or(""); + if !matches!(channel_type, "" | "channel" | "group" | "im" | "mpim") { + return None; + } + match event.get("subtype").and_then(Value::as_str) { + None | Some("file_share") | Some("thread_broadcast") => {} + Some(_) => return None, + } + if str_at(event, "/bot_id").is_some() { + return None; + } + let user = str_at(event, "/user")?; + if !bot.user_id.is_empty() && user == bot.user_id { + return None; + } + let channel = str_at(event, "/channel")?; + let ts = str_at(event, "/ts")?; + let thread_ts = str_at(event, "/thread_ts").unwrap_or(ts); + + let raw = event.get("text").and_then(Value::as_str).unwrap_or(""); + let (stripped, mentions_bot) = strip_bot_mention(raw, &bot.user_id); + // Strip the mention BEFORE unescaping, so a literal `<@BOT>` the + // user typed stays text. `trim` (not just `trim_end`) is what lets + // ` /status` โ€” typed with a leading space to get past Slack's own slash + // interception โ€” reach the gateway as the `/status` command. + let text = unescape_entities(&stripped).trim().to_string(); + + let files: Vec = event + .get("files") + .and_then(Value::as_array) + .map(|files| files.iter().filter_map(decode_file).collect()) + .unwrap_or_default(); + if text.is_empty() && files.is_empty() { + return None; + } + Some(DecodedMessage { + user: user.to_string(), + channel: channel.to_string(), + channel_type: channel_type.to_string(), + ts: ts.to_string(), + thread_ts: thread_ts.to_string(), + text, + mentions_bot, + files, + }) +} + +/// A downloadable file from `event.files[]`; tombstoned / external files +/// without a private download URL are skipped. +fn decode_file(file: &Value) -> Option { + let url = str_at(file, "/url_private_download").or_else(|| str_at(file, "/url_private"))?; + let id = str_at(file, "/id").unwrap_or("file"); + let name = str_at(file, "/name") + .or_else(|| str_at(file, "/title")) + .unwrap_or("file"); + Some(PendingFile { + id: id.to_string(), + name: name.to_string(), + mimetype: str_at(file, "/mimetype").map(str::to_string), + size: file.get("size").and_then(Value::as_u64), + url: url.to_string(), + }) +} + +/// Remove every `<@BOT>` / `<@BOT|label>` token. Returns the remaining text +/// and whether the bot was mentioned. +fn strip_bot_mention(text: &str, bot_user_id: &str) -> (String, bool) { + if bot_user_id.is_empty() { + return (text.to_string(), false); + } + let needle = format!("<@{bot_user_id}"); + let mut out = String::with_capacity(text.len()); + let mut rest = text; + let mut found = false; + while let Some(pos) = rest.find(&needle) { + let after = &rest[pos + needle.len()..]; + let end = match after.chars().next() { + Some('>') => Some(1), + Some('|') => after.find('>').map(|i| i + 1), + _ => None, + }; + match end { + Some(end) => { + out.push_str(&rest[..pos]); + rest = &after[end..]; + found = true; + } + None => { + out.push_str(&rest[..pos + needle.len()]); + rest = after; + } + } + } + out.push_str(rest); + (out, found) +} + +/// Undo Slack's three control-character escapes (`&` last, so an +/// escaped `&lt;` decodes to the literal `<` the user typed). +fn unescape_entities(text: &str) -> String { + text.replace("<", "<") + .replace(">", ">") + .replace("&", "&") +} + +/// Escape user text for Slack's `text` field. +fn escape_entities(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +/// A `slash_commands` envelope payload. +#[derive(Debug, Clone, PartialEq)] +struct SlashCommand { + user_id: String, + channel_id: String, + command: String, + text: String, + response_url: String, + trigger_id: String, +} + +fn decode_slash(payload: &Value) -> Option { + Some(SlashCommand { + user_id: str_at(payload, "/user_id")?.to_string(), + channel_id: str_at(payload, "/channel_id")?.to_string(), + command: str_at(payload, "/command")?.to_string(), + text: payload + .get("text") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + response_url: str_at(payload, "/response_url").unwrap_or("").to_string(), + trigger_id: str_at(payload, "/trigger_id").unwrap_or("").to_string(), + }) +} + +/// The gateway command a `/ccteam ` invocation stands for: +/// `new codex` โ†’ `/new codex`, empty โ†’ `/help`. +fn slash_content(text: &str) -> String { + let text = unescape_entities(text).trim().to_string(); + if text.is_empty() { + "/help".to_string() + } else if text.starts_with('/') { + text + } else { + format!("/{text}") + } +} + +/// Text of the top-level anchor message a slash command threads under โ€” +/// it echoes the command so the thread reads as "who asked for what". +fn anchor_text(command: &str, text: &str, user_id: &str) -> String { + let text = unescape_entities(text).trim().to_string(); + let invocation = if text.is_empty() { + command.to_string() + } else { + format!("{command} {text}") + }; + format!("`{}` ยท <@{user_id}>", escape_entities(&invocation)) +} + +/// A `block_actions` click on one of our option buttons. +#[derive(Debug, Clone, PartialEq)] +struct ButtonClick { + user_id: String, + channel_id: String, + message_ts: String, + thread_ts: String, + value: String, + trigger_id: String, +} + +fn decode_block_actions(payload: &Value) -> Option { + if payload.get("type").and_then(Value::as_str) != Some("block_actions") { + return None; + } + let user_id = str_at(payload, "/user/id")?; + let value = str_at(payload, "/actions/0/value")?; + let channel_id = + str_at(payload, "/channel/id").or_else(|| str_at(payload, "/container/channel_id"))?; + let message_ts = + str_at(payload, "/container/message_ts").or_else(|| str_at(payload, "/message/ts"))?; + let thread_ts = str_at(payload, "/container/thread_ts") + .or_else(|| str_at(payload, "/message/thread_ts")) + .unwrap_or(message_ts); + Some(ButtonClick { + user_id: user_id.to_string(), + channel_id: channel_id.to_string(), + message_ts: message_ts.to_string(), + thread_ts: thread_ts.to_string(), + value: value.to_string(), + trigger_id: str_at(payload, "/trigger_id").unwrap_or("").to_string(), + }) +} + +/// Blocks for one outbound message: a `markdown` block carrying `content`, +/// then the options as buttons, chunked into `actions` blocks of โ‰ค 25. Each +/// button's `value` is the option's opaque `data` (returned verbatim on +/// click); `action_id`s are unique within the message as Slack requires. +fn message_blocks(content: &str, options: &[MessageOption]) -> Vec { + let mut blocks = Vec::new(); + if !content.is_empty() { + blocks.push(json!({ "type": "markdown", "text": content })); + } + for (chunk_index, chunk) in options.chunks(SLACK_ACTIONS_PER_BLOCK).enumerate() { + let elements: Vec = chunk + .iter() + .enumerate() + .map(|(offset, option)| { + let index = chunk_index * SLACK_ACTIONS_PER_BLOCK + offset; + let label: String = option + .label + .chars() + .take(SLACK_BUTTON_LABEL_MAX_CHARS) + .collect(); + let label = if label.trim().is_empty() { + format!("{}", index + 1) + } else { + label + }; + json!({ + "type": "button", + "text": { "type": "plain_text", "text": label, "emoji": true }, + "value": option.data, + "action_id": format!("ccteam_opt_{index}"), + }) + }) + .collect(); + blocks.push(json!({ "type": "actions", "elements": elements })); + } + blocks +} + +/// `chat.postMessage` body. `rich` = Markdown block + buttons; plain = the +/// `text` field only (the fallback when Slack rejects the blocks โ€” the +/// gateway already puts a numbered option list in `content`). +fn post_body( + channel: &str, + thread_ts: Option<&str>, + content: &str, + options: &[MessageOption], + rich: bool, +) -> Value { + let mut body = json!({ "channel": channel, "text": content, "unfurl_links": false }); + if let Some(thread_ts) = thread_ts.filter(|t| !t.is_empty()) { + body["thread_ts"] = json!(thread_ts); + } + if rich { + let blocks = message_blocks(content, options); + if !blocks.is_empty() { + body["blocks"] = Value::Array(blocks); + } + } + body +} + +/// `chat.update` body. The plain variant clears the blocks explicitly โ€” +/// omitting `blocks` would keep the message's previous ones. +fn update_body(channel: &str, ts: &str, content: &str, rich: bool) -> Value { + let blocks = if rich { + message_blocks(content, &[]) + } else { + Vec::new() + }; + json!({ "channel": channel, "ts": ts, "text": content, "blocks": blocks }) +} + +/// Seconds part of a Slack `ts` (`"1712345678.000100"`), else wall clock. +fn ts_secs(ts: &str) -> u64 { + ts.split('.') + .next() + .and_then(|secs| secs.parse::().ok()) + .unwrap_or_else(now_secs) +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// `Retry-After` (seconds) of a 429, capped at [`RATE_LIMIT_MAX_WAIT`]. +fn retry_after(headers: &reqwest::header::HeaderMap) -> Duration { + headers + .get(reqwest::header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse::().ok()) + .map(Duration::from_secs) + .unwrap_or(Duration::from_secs(1)) + .min(RATE_LIMIT_MAX_WAIT) +} + +/// Bounded FIFO memory of seen delivery keys. +#[derive(Debug, Default)] +struct SeenKeys { + order: VecDeque, + set: HashSet, +} + +impl SeenKeys { + /// `true` when none of `keys` was seen before; records all of them. + fn first_sighting(&mut self, keys: &[String]) -> bool { + if keys.iter().any(|key| self.set.contains(key)) { + return false; + } + for key in keys { + if self.set.insert(key.clone()) { + self.order.push_back(key.clone()); + } + } + while self.order.len() > SLACK_DEDUP_CAPACITY { + if let Some(oldest) = self.order.pop_front() { + self.set.remove(&oldest); + } + } + true + } +} -use crate::transport::{Channel, ChannelMessage, SendMessage}; +/// Request body of one Web API call. +enum ApiBody<'a> { + Empty, + Json(&'a Value), + Form(&'a [(&'a str, String)]), +} -const SLACK_API: &str = "https://slack.com/api"; -const POLL_INTERVAL_SECS: u64 = 4; +/// Explanation posted (ephemerally) when a slash command cannot open its +/// anchor thread โ€” typically a channel or DM the bot is not a member of. +fn anchor_failure_notice(err: &anyhow::Error) -> String { + let code = api_error_code(err).unwrap_or("request_failed"); + format!( + "ccteam ๆ— ๆณ•ๅœจ่ฟ™้‡Œๅผ€็บฟ็จ‹({code})ใ€‚่ฏทๅ…ˆๆŠŠ bot ้‚€่ฏท่ฟ›ๆญค้ข‘้“(`/invite @`),\ + ๆˆ–ๅœจไธŽ bot ็š„็ง่Š้‡Œไฝฟ็”จ `{SLACK_SLASH_COMMAND}`ใ€‚" + ) +} -/// Slack channel. +/// Slack channel (Socket Mode inbound, Web API outbound). pub struct SlackChannel { bot_token: String, - poll_channels: Vec, + app_token: String, + allowed_users: Vec, + api_base: String, http: reqwest::Client, - /// channel_id โ†’ last `ts` we forwarded; used to skip already-seen - /// messages on the next poll tick. - last_ts: Arc>>, + /// `auth.test` result, fetched once per listener (re-fetched only if a + /// previous attempt failed). + bot: RwLock>, + seen: Mutex, + /// Where downloaded inbound files are staged. + staging_dir: PathBuf, + /// Probe path for rejected senders that are recorded but NOT notified + /// (ambient channel chatter); the notified path goes through + /// [`RejectedSenderNotifier`], which appends the same probe itself. + probe_path: Option, + rejected_senders: RejectedSenderNotifier, name: String, } impl SlackChannel { - /// Build with the `xoxb-...` bot token and list of channel IDs - /// to poll. - pub fn new(bot_token: String, poll_channels: Vec) -> Self { + /// Build with the `xoxb-` bot token (Web API), the `xapp-` app-level + /// token (Socket Mode, `connections:write`) and the provider allowlist of + /// Slack user ids (`Uโ€ฆ`; empty = deny all, `"*"` = anyone). + pub fn new(bot_token: String, app_token: String, allowed_user_ids: Vec) -> Self { Self { bot_token, - poll_channels, - http: reqwest::Client::builder() - .timeout(Duration::from_secs(20)) - .build() - .expect("reqwest client"), - last_ts: Arc::new(Mutex::new(HashMap::new())), + app_token, + allowed_users: allowed_user_ids, + api_base: SLACK_API_BASE.to_string(), + http: Self::http_for(SLACK_API_BASE), + bot: RwLock::new(None), + seen: Mutex::new(SeenKeys::default()), + staging_dir: inbound_staging_dir(), + probe_path: None, + rejected_senders: RejectedSenderNotifier::default(), name: "slack".to_string(), } } - fn auth_header(&self) -> String { - format!("Bearer {}", self.bot_token) + fn http_for(api_base: &str) -> reqwest::Client { + client_for_api_base(api_base, Duration::from_secs(30)).expect("reqwest client") } -} -#[derive(Debug, Deserialize)] -struct HistoryResp { - ok: bool, - #[serde(default)] - messages: Vec, - #[serde(default)] - error: Option, -} + /// Override the channel-map key (a per-tenant bot's `"slack@"`). + pub fn with_name(mut self, name: String) -> Self { + self.name = name; + self + } -#[derive(Debug, Deserialize)] -struct SlackMessage { - #[serde(default)] - user: Option, - #[serde(default)] - text: Option, - ts: String, - #[serde(default, rename = "thread_ts")] - thread_ts: Option, - #[serde(default, rename = "bot_id")] - bot_id: Option, -} + /// Record rejected senders to this JSONL path (the web binding flow + /// reads it). Unset in tests / standalone use. + pub fn with_probe_path(mut self, path: PathBuf) -> Self { + self.rejected_senders = RejectedSenderNotifier::with_probe_path(path.clone()); + self.probe_path = Some(path); + self + } -#[derive(Debug, Deserialize)] -struct PostMessageResp { - ok: bool, - #[serde(default)] - ts: Option, - #[serde(default)] - error: Option, -} + /// Point the Web API (and therefore the Socket Mode URL, which + /// `apps.connections.open` hands out) at another base โ€” a local mock in + /// tests. A loopback base also bypasses any configured HTTP proxy. + pub fn with_api_base(mut self, api_base: impl Into) -> Self { + self.api_base = api_base.into().trim_end_matches('/').to_string(); + self.http = Self::http_for(&self.api_base); + self + } -#[async_trait] -impl Channel for SlackChannel { - fn name(&self) -> &str { - &self.name + /// Stage inbound files under `dir` instead of the daemon's shared + /// inbound staging directory. + pub fn with_staging_dir(mut self, dir: PathBuf) -> Self { + self.staging_dir = dir; + self } - async fn send(&self, message: &SendMessage) -> anyhow::Result> { - let url = format!("{SLACK_API}/chat.postMessage"); - let body = serde_json::json!({ - "channel": message.recipient, - "text": message.content, - "thread_ts": message.thread_ts, - }); + /// Provider-layer allowlist: empty = deny all, `"*"` = anyone. + fn is_user_allowed(&self, user_id: &str) -> bool { + self.allowed_users + .iter() + .any(|allowed| allowed == "*" || allowed == user_id) + } + + // โ”€โ”€ Web API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /// Send a request, honouring one HTTP 429 `Retry-After` and retrying + /// exactly once. + async fn send_rate_limited( + &self, + build: impl Fn() -> reqwest::RequestBuilder, + ) -> anyhow::Result { + let resp = build().send().await?; + if resp.status() != reqwest::StatusCode::TOO_MANY_REQUESTS { + return Ok(resp); + } + let wait = retry_after(resp.headers()); + tracing::warn!( + wait_ms = wait.as_millis() as u64, + "slack: rate limited; retrying once" + ); + tokio::time::sleep(wait).await; + Ok(build().send().await?) + } + + /// Call one Web API method with `token`; `ok: false` becomes a typed + /// [`SlackApiError`]. + async fn call(&self, method: &str, token: &str, body: ApiBody<'_>) -> anyhow::Result { + let url = format!("{}/{method}", self.api_base); + let build = || { + let req = self.http.post(&url).bearer_auth(token); + match &body { + ApiBody::Empty => req, + ApiBody::Json(value) => req + .header( + reqwest::header::CONTENT_TYPE, + "application/json; charset=utf-8", + ) + .body(value.to_string()), + ApiBody::Form(fields) => req.form(fields), + } + }; + let resp = self.send_rate_limited(build).await?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + let v: Value = serde_json::from_str(&text) + .with_context(|| format!("slack {method}: HTTP {status} with a non-JSON body"))?; + if v.get("ok").and_then(Value::as_bool) == Some(true) { + return Ok(v); + } + Err(SlackApiError { + method: method.to_string(), + code: v + .get("error") + .and_then(Value::as_str) + .unwrap_or("unknown_error") + .to_string(), + } + .into()) + } + + async fn call_bot(&self, method: &str, body: ApiBody<'_>) -> anyhow::Result { + self.call(method, &self.bot_token, body).await + } + + /// `auth.test` โ†’ the bot identity, cached after the first success. + async fn ensure_identity(&self) -> anyhow::Result { + if let Some(identity) = self.bot.read().await.clone() { + return Ok(identity); + } + let v = self.call_bot("auth.test", ApiBody::Empty).await?; + let identity = BotIdentity { + user_id: str_at(&v, "/user_id").unwrap_or("").to_string(), + }; + *self.bot.write().await = Some(identity.clone()); + Ok(identity) + } + + /// Post one message; on a block rejection retry once as plain text. + async fn post_message( + &self, + channel: &str, + thread_ts: Option<&str>, + content: &str, + options: &[MessageOption], + ) -> anyhow::Result> { + let rich = post_body(channel, thread_ts, content, options, true); + let v = match self + .call_bot("chat.postMessage", ApiBody::Json(&rich)) + .await + { + Ok(v) => v, + Err(err) if is_blocks_rejection(&err) => { + tracing::warn!(error = %err, "slack: blocks rejected; resending as plain text"); + let plain = post_body(channel, thread_ts, content, options, false); + self.call_bot("chat.postMessage", ApiBody::Json(&plain)) + .await? + } + Err(err) => return Err(err), + }; + Ok(str_at(&v, "/ts").map(str::to_string)) + } + + /// Post plain `text` (Slack's own `mrkdwn`, where `<@Uโ€ฆ>` renders as a + /// mention) โ€” used for the slash-command anchor. + async fn post_plain(&self, channel: &str, text: &str) -> anyhow::Result> { + let body = post_body(channel, None, text, &[], false); + let v = self + .call_bot("chat.postMessage", ApiBody::Json(&body)) + .await?; + Ok(str_at(&v, "/ts").map(str::to_string)) + } + + /// Files go through Slack's external-upload flow + /// (`files.getUploadURLExternal` โ†’ POST bytes โ†’ `files.completeUploadExternal`). + /// + /// Choice: a non-empty `content` (or any options) is posted FIRST as its + /// own Markdown message in the same thread, then every file is shared + /// with only its own caption as `initial_comment`. Keeping the text out + /// of `initial_comment` keeps Markdown rendering and buttons, and avoids + /// that field's tighter limits. Returns the text message's `ts` (an + /// upload has no message `ts` until Slack finishes sharing it). + async fn send_with_attachments(&self, message: &SendMessage) -> anyhow::Result> { + let thread_ts = message.thread_ts.as_deref(); + let mut first = None; + if !message.content.is_empty() || !message.options.is_empty() { + first = self + .post_message( + &message.recipient, + thread_ts, + &message.content, + &message.options, + ) + .await?; + } + for attachment in &message.attachments { + self.upload_file(&message.recipient, thread_ts, attachment) + .await?; + } + Ok(first) + } + + async fn upload_file( + &self, + channel: &str, + thread_ts: Option<&str>, + attachment: &OutboundFile, + ) -> anyhow::Result<()> { + let bytes = tokio::fs::read(&attachment.path) + .await + .with_context(|| format!("read outbound file {}", attachment.path))?; + let file_name = std::path::Path::new(&attachment.path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + let ticket = self + .call_bot( + "files.getUploadURLExternal", + ApiBody::Form(&[ + ("filename", file_name.clone()), + ("length", bytes.len().to_string()), + ]), + ) + .await?; + let upload_url = str_at(&ticket, "/upload_url") + .context("slack files.getUploadURLExternal: no upload_url")?; + let file_id = str_at(&ticket, "/file_id") + .context("slack files.getUploadURLExternal: no file_id")? + .to_string(); + // The upload URL is pre-signed: it takes the raw bytes, no token. let resp = self - .http - .post(&url) - .header("Authorization", self.auth_header()) - .header("Content-Type", "application/json; charset=utf-8") - .json(&body) - .send() + .send_rate_limited(|| { + self.http + .post(upload_url) + .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") + .body(bytes.clone()) + }) .await?; - let parsed: PostMessageResp = resp.json().await?; - if !parsed.ok { + if !resp.status().is_success() { + anyhow::bail!("slack file upload {file_name} โ†’ HTTP {}", resp.status()); + } + let mut form = vec![ + ( + "files", + json!([{ "id": file_id, "title": file_name }]).to_string(), + ), + ("channel_id", channel.to_string()), + ]; + if let Some(thread_ts) = thread_ts.filter(|t| !t.is_empty()) { + form.push(("thread_ts", thread_ts.to_string())); + } + if let Some(caption) = attachment.caption.as_ref().filter(|c| !c.is_empty()) { + form.push(("initial_comment", caption.clone())); + } + self.call_bot("files.completeUploadExternal", ApiBody::Form(&form)) + .await?; + Ok(()) + } + + /// Download one inbound file with the bot token and stage it. `Ok(None)` + /// = over the size ceiling (rejected, not an error). + async fn stage_file( + &self, + ts: &str, + file: &PendingFile, + ) -> anyhow::Result> { + if file + .size + .is_some_and(|size| size > SLACK_MAX_ATTACHMENT_BYTES) + { + return Ok(None); + } + let resp = self + .send_rate_limited(|| self.http.get(&file.url).bearer_auth(&self.bot_token)) + .await?; + let status = resp.status(); + if !status.is_success() { + anyhow::bail!("slack file download {} โ†’ HTTP {status}", file.id); + } + // Without `files:read` Slack answers 200 with its HTML sign-in page; + // staging that as the user's file would mislead the agent. + let served_html = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|ct| ct.starts_with("text/html")); + let expects_html = file + .mimetype + .as_deref() + .is_some_and(|m| m.starts_with("text/html")); + if served_html && !expects_html { anyhow::bail!( - "slack chat.postMessage {} failed: {}", - message.recipient, - parsed.error.unwrap_or_default() + "slack file download {} returned an HTML page (is the files:read scope granted?)", + file.id ); } - Ok(parsed.ts) + let bytes = resp.bytes().await?; + if bytes.len() as u64 > SLACK_MAX_ATTACHMENT_BYTES { + return Ok(None); + } + let safe_name = sanitize_attachment_name(&file.name); + tokio::fs::create_dir_all(&self.staging_dir).await?; + let dest = self.staging_dir.join(sanitize_attachment_name(&format!( + "slack-{ts}-{}-{safe_name}", + file.id + ))); + tokio::fs::write(&dest, &bytes).await?; + let is_image = file + .mimetype + .as_deref() + .is_some_and(|m| m.starts_with("image/")); + Ok(Some(ChannelAttachment { + kind: if is_image { + AttachmentKind::Image + } else { + AttachmentKind::File + }, + file_name: safe_name, + local_path: dest.to_string_lossy().into_owned(), + mime: file.mimetype.clone(), + size: Some(bytes.len() as u64), + })) + } + + /// Answer a slash command privately through its `response_url`. + async fn respond_ephemeral(&self, response_url: &str, text: &str) { + if response_url.is_empty() { + return; + } + let body = json!({ "response_type": "ephemeral", "text": text }); + if let Err(err) = self + .send_rate_limited(|| self.http.post(response_url).json(&body)) + .await + { + tracing::warn!(error = %err, "slack: ephemeral slash-command reply failed"); + } + } + + // โ”€โ”€ Inbound โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /// Fail-closed rejection. Every rejected event leaves a probe; only a + /// sender who addressed the bot (`notify`) gets the one-time notice. + async fn reject_sender( + &self, + sender_id: &str, + chat_id: &str, + message_id: &str, + timestamp: u64, + notify: bool, + ) { + let probe = RejectedSenderProbe { + channel: self.name.clone(), + sender_id: sender_id.to_string(), + chat_id: chat_id.to_string(), + message_id: message_id.to_string(), + timestamp, + }; + if notify { + self.rejected_senders.record_and_notify(self, probe).await; + return; + } + tracing::debug!( + channel = %self.name, + sender_id = %sender_id, + "slack: dropping ambient message from a sender outside the allowlist" + ); + if let Some(path) = self.probe_path.as_ref() { + probe.append_to(path).await; + } + } + + async fn first_sighting(&self, keys: &[String]) -> bool { + self.seen.lock().await.first_sighting(keys) + } + + /// Turn one ACKed envelope into the message the gateway should see. + async fn handle_envelope(&self, envelope: &Envelope) -> Option { + match envelope.kind.as_str() { + "events_api" => self.handle_event(&envelope.payload).await, + "slash_commands" => self.handle_slash(&envelope.payload).await, + "interactive" => self.handle_interactive(&envelope.payload).await, + other => { + tracing::debug!(kind = %other, "slack: ignoring envelope kind"); + None + } + } + } + + async fn handle_event(&self, payload: &Value) -> Option { + let event = payload.get("event")?; + if event.get("type").and_then(Value::as_str) != Some("message") { + return None; + } + let bot = match self.ensure_identity().await { + Ok(bot) => bot, + Err(err) => { + tracing::warn!(error = %err, "slack: auth.test failed; dropping event"); + return None; + } + }; + let decoded = decode_message_event(event, &bot)?; + let mut keys = vec![format!("msg:{}:{}", decoded.channel, decoded.ts)]; + if let Some(event_id) = str_at(payload, "/event_id") { + keys.push(format!("event:{event_id}")); + } + if !self.first_sighting(&keys).await { + tracing::debug!(ts = %decoded.ts, "slack: duplicate delivery dropped"); + return None; + } + if !self.is_user_allowed(&decoded.user) { + let notify = decoded.channel_type == "im" || decoded.mentions_bot; + self.reject_sender( + &decoded.user, + &decoded.channel, + &decoded.ts, + ts_secs(&decoded.ts), + notify, + ) + .await; + return None; + } + let files = decoded.files.clone(); + let mut message = decoded.into_channel_message(&self.name); + for file in &files { + match self.stage_file(&message.id, file).await { + Ok(Some(attachment)) => message.attachments.push(attachment), + Ok(None) => { + let mb = SLACK_MAX_ATTACHMENT_BYTES / (1024 * 1024); + self.notify_in_thread( + &message, + format!("โš ๏ธ ้™„ไปถ {} ่ถ…่ฟ‡ {mb}MB ไธŠ้™,ๅทฒๆ‹’ๆ”ถ", file.name), + ) + .await; + } + Err(err) => { + tracing::warn!(file = %file.id, error = %err, "slack: attachment download failed"); + self.notify_in_thread(&message, format!("โš ๏ธ ้™„ไปถ {} ไธ‹่ฝฝๅคฑ่ดฅ", file.name)) + .await; + } + } + } + if message.content.is_empty() && message.attachments.is_empty() { + return None; + } + Some(message) + } + + async fn notify_in_thread(&self, message: &ChannelMessage, text: String) { + let notice = SendMessage::new(text, message.reply_target.clone()) + .in_thread(message.thread_ts.clone()); + if let Err(err) = self.send(¬ice).await { + tracing::warn!(error = %err, "slack: attachment notice failed"); + } + } + + async fn handle_slash(&self, payload: &Value) -> Option { + let command = decode_slash(payload)?; + if command.command != SLACK_SLASH_COMMAND { + tracing::debug!(command = %command.command, "slack: ignoring foreign slash command"); + return None; + } + if !command.trigger_id.is_empty() + && !self + .first_sighting(&[format!("trigger:{}", command.trigger_id)]) + .await + { + return None; + } + if !self.is_user_allowed(&command.user_id) { + self.reject_sender( + &command.user_id, + &command.channel_id, + &command.trigger_id, + now_secs(), + true, + ) + .await; + return None; + } + let anchor = anchor_text(&command.command, &command.text, &command.user_id); + let anchor_ts = match self.post_plain(&command.channel_id, &anchor).await { + Ok(Some(ts)) => ts, + Ok(None) => { + let err = anyhow::anyhow!("chat.postMessage returned no ts"); + self.respond_ephemeral(&command.response_url, &anchor_failure_notice(&err)) + .await; + return None; + } + Err(err) => { + tracing::warn!( + channel = %command.channel_id, + error = %err, + "slack: slash-command anchor post failed" + ); + self.respond_ephemeral(&command.response_url, &anchor_failure_notice(&err)) + .await; + return None; + } + }; + Some(ChannelMessage { + timestamp: ts_secs(&anchor_ts), + id: anchor_ts.clone(), + sender: command.user_id, + reply_target: command.channel_id, + content: slash_content(&command.text), + channel: self.name.clone(), + thread_ts: Some(anchor_ts), + attachments: Vec::new(), + selection: None, + }) + } + + async fn handle_interactive(&self, payload: &Value) -> Option { + let click = decode_block_actions(payload)?; + if !click.trigger_id.is_empty() + && !self + .first_sighting(&[format!("trigger:{}", click.trigger_id)]) + .await + { + return None; + } + if !self.is_user_allowed(&click.user_id) { + self.reject_sender( + &click.user_id, + &click.channel_id, + &click.message_ts, + now_secs(), + true, + ) + .await; + return None; + } + Some(ChannelMessage { + id: click.message_ts, + sender: click.user_id, + reply_target: click.channel_id, + content: String::new(), + channel: self.name.clone(), + timestamp: now_secs(), + thread_ts: Some(click.thread_ts), + attachments: Vec::new(), + selection: Some(ChoiceReply { data: click.value }), + }) } - async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> { - if self.poll_channels.is_empty() { - tracing::info!("slack: no poll_channels configured; listener idle"); - return Ok(()); + /// Ordered consumer of ACKed envelopes. Returns when the gateway's + /// receiver is gone. + async fn process_envelopes( + &self, + mut work: mpsc::Receiver, + tx: &mpsc::Sender, + ) { + while let Some(envelope) = work.recv().await { + if let Some(message) = self.handle_envelope(&envelope).await { + if tx.send(message).await.is_err() { + return; + } + } } + } + + /// Reconnect loop around [`Self::run_socket`] with capped exponential + /// backoff. `Ok(())` once the gateway's receiver is gone; `Err` only for + /// failures reconnecting cannot fix ([`FATAL_API_ERRORS`]). + async fn connect_loop( + &self, + work: mpsc::Sender, + tx: &mpsc::Sender, + ) -> anyhow::Result<()> { + let mut backoff = RECONNECT_BACKOFF_MIN; loop { if tx.is_closed() { return Ok(()); } - for chan in &self.poll_channels { - let oldest = { - let map = self.last_ts.lock().await; - map.get(chan).cloned() - }; - let url = format!("{SLACK_API}/conversations.history"); - let mut q = vec![("channel", chan.clone()), ("limit", "30".into())]; - if let Some(o) = oldest.clone() { - q.push(("oldest", o)); + match self.run_socket(&work, tx).await { + Ok(SocketEnd::Closed) => return Ok(()), + Ok(SocketEnd::Refresh) => { + tracing::info!("slack: Socket Mode asked for a reconnect"); + backoff = RECONNECT_BACKOFF_MIN; + continue; } - let resp = self - .http - .get(&url) - .header("Authorization", self.auth_header()) - .query(&q) - .send() - .await; - let parsed: HistoryResp = match resp { - Ok(r) => match r.json().await { - Ok(b) => b, - Err(err) => { - tracing::warn!(error = %err, "slack history parse failed"); - continue; - } - }, - Err(err) => { - tracing::warn!(error = %err, "slack history request failed"); - continue; + Ok(SocketEnd::Dropped { healthy }) => { + if healthy { + backoff = RECONNECT_BACKOFF_MIN; } - }; - if !parsed.ok { - tracing::warn!(error = ?parsed.error, "slack history ok=false"); - continue; + tracing::warn!( + backoff_ms = backoff.as_millis() as u64, + "slack: socket dropped; reconnecting" + ); } - // Slack returns newest-first; reverse for chrono order. - let mut msgs = parsed.messages; - msgs.reverse(); - for m in msgs { - if Some(&m.ts) == oldest.as_ref() { - continue; + Err(err) => { + if api_error_code(&err).is_some_and(|code| FATAL_API_ERRORS.contains(&code)) { + tracing::error!(error = %err, "slack: listener stopped โ€” fix the Slack app/credentials"); + return Err(err); } - if m.bot_id.is_some() { - // Skip bot echoes (including our own posts). - continue; + tracing::warn!( + error = %err, + backoff_ms = backoff.as_millis() as u64, + "slack: socket setup failed; retrying" + ); + } + } + tokio::select! { + _ = tokio::time::sleep(backoff) => {} + _ = tx.closed() => return Ok(()), + } + backoff = (backoff * 2).min(RECONNECT_BACKOFF_MAX); + } + } + + /// One Socket Mode connection: open, then read frames โ€” ACK each envelope + /// first, then enqueue it for the ordered processor. + async fn run_socket( + &self, + work: &mpsc::Sender, + tx: &mpsc::Sender, + ) -> anyhow::Result { + self.ensure_identity().await?; + let opened = self + .call("apps.connections.open", &self.app_token, ApiBody::Empty) + .await?; + let url = str_at(&opened, "/url").context("slack apps.connections.open: no url")?; + let (socket, _) = tokio_tungstenite::connect_async(url) + .await + .context("slack: Socket Mode connect")?; + let (mut write, mut read) = socket.split(); + tracing::info!(channel = %self.name, "slack: Socket Mode connected"); + + let mut healthy = false; + let mut last_recv = Instant::now(); + let mut ping = tokio::time::interval(WS_PING_INTERVAL); + ping.tick().await; + loop { + tokio::select! { + _ = tx.closed() => return Ok(SocketEnd::Closed), + _ = ping.tick() => { + if last_recv.elapsed() > WS_IDLE_TIMEOUT { + tracing::warn!("slack: socket silent too long"); + return Ok(SocketEnd::Dropped { healthy }); } - let payload = ChannelMessage { - id: format!("slack-{}", m.ts), - sender: m.user.unwrap_or_else(|| "anonymous".to_string()), - reply_target: chan.clone(), - content: m.text.unwrap_or_default(), - channel: "slack".into(), - timestamp: m - .ts - .split('.') - .next() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0), - thread_ts: m.thread_ts.clone(), - attachments: Vec::new(), - selection: None, - }; - { - let mut map = self.last_ts.lock().await; - map.insert(chan.clone(), m.ts.clone()); + if write.send(WsMsg::Ping(Vec::new())).await.is_err() { + return Ok(SocketEnd::Dropped { healthy }); } - if tx.send(payload).await.is_err() { - return Ok(()); + } + frame = read.next() => { + let frame = match frame { + Some(Ok(frame)) => frame, + Some(Err(err)) => { + tracing::warn!(error = %err, "slack: socket read error"); + return Ok(SocketEnd::Dropped { healthy }); + } + None => return Ok(SocketEnd::Dropped { healthy }), + }; + last_recv = Instant::now(); + let text = match frame { + WsMsg::Text(text) => text, + WsMsg::Ping(data) => { + let _ = write.send(WsMsg::Pong(data)).await; + continue; + } + WsMsg::Close(_) => return Ok(SocketEnd::Dropped { healthy }), + _ => continue, + }; + match parse_frame(&text) { + Frame::Hello => { + healthy = true; + tracing::debug!("slack: Socket Mode hello"); + } + Frame::Disconnect(reason) if reason == "link_disabled" => { + return Err(SlackApiError { + method: "socket_mode".to_string(), + code: reason, + } + .into()); + } + Frame::Disconnect(reason) => { + tracing::info!(reason = %reason, "slack: Socket Mode disconnect"); + return Ok(SocketEnd::Refresh); + } + Frame::Envelope(envelope) => { + // ACK before any slow work: Slack re-delivers an + // envelope that is not ACKed within 3 s. + let ack = json!({ "envelope_id": envelope.envelope_id }).to_string(); + if write.send(WsMsg::Text(ack)).await.is_err() { + return Ok(SocketEnd::Dropped { healthy }); + } + if work.send(envelope).await.is_err() { + return Ok(SocketEnd::Closed); + } + } + Frame::Other => {} } } } - tokio::time::sleep(Duration::from_secs(POLL_INTERVAL_SECS)).await; } } +} - async fn health_check(&self) -> bool { - let url = format!("{SLACK_API}/auth.test"); - let resp = self - .http - .post(&url) - .header("Authorization", self.auth_header()) - .send() - .await; - match resp { - Ok(r) => r.status().is_success(), - Err(_) => false, +#[async_trait] +impl Channel for SlackChannel { + fn name(&self) -> &str { + &self.name + } + + async fn send(&self, message: &SendMessage) -> anyhow::Result> { + if !message.attachments.is_empty() { + return self.send_with_attachments(message).await; } + self.post_message( + &message.recipient, + message.thread_ts.as_deref(), + &message.content, + &message.options, + ) + .await } -} -#[cfg(test)] -mod tests { - use super::*; + async fn listen(&self, tx: mpsc::Sender) -> anyhow::Result<()> { + // The reader ACKs and enqueues; the processor handles envelopes in + // order. Both run on this task, so a reconnect never loses queued + // (already ACKed) envelopes, and either side ending ends the listener. + let (work_tx, work_rx) = mpsc::channel::(SLACK_ENVELOPE_QUEUE); + tokio::select! { + res = self.connect_loop(work_tx, &tx) => res, + () = self.process_envelopes(work_rx, &tx) => Ok(()), + } + } + + async fn health_check(&self) -> bool { + self.call_bot("auth.test", ApiBody::Empty).await.is_ok() + } - #[test] - fn auth_header_format() { - let ch = SlackChannel::new("xoxb-abc".into(), vec![]); - assert_eq!(ch.auth_header(), "Bearer xoxb-abc"); + fn max_message_len(&self) -> Option { + Some(SLACK_MAX_MESSAGE_UTF16) } - #[tokio::test] - async fn listen_returns_when_no_channels() { - let ch = SlackChannel::new("xoxb-x".into(), vec![]); - let (tx, _rx) = tokio::sync::mpsc::channel(1); - // Should return Ok(()) immediately because poll_channels is empty. - ch.listen(tx).await.unwrap(); + fn session_threads(&self) -> bool { + true + } + + async fn edit_message( + &self, + recipient: &str, + message_id: &str, + content: &str, + ) -> anyhow::Result> { + let rich = update_body(recipient, message_id, content, true); + match self.call_bot("chat.update", ApiBody::Json(&rich)).await { + Ok(_) => {} + Err(err) if is_blocks_rejection(&err) => { + let plain = update_body(recipient, message_id, content, false); + self.call_bot("chat.update", ApiBody::Json(&plain)).await?; + } + Err(err) => return Err(err), + } + Ok(Some(message_id.to_string())) + } + + async fn add_reaction( + &self, + chat_id: &str, + message_id: &str, + ) -> anyhow::Result> { + let body = + json!({ "channel": chat_id, "timestamp": message_id, "name": SLACK_ACK_REACTION }); + match self.call_bot("reactions.add", ApiBody::Json(&body)).await { + Ok(_) => Ok(None), + Err(err) if api_error_code(&err) == Some("already_reacted") => Ok(None), + Err(err) => Err(err), + } + } + + async fn remove_reaction( + &self, + chat_id: &str, + message_id: &str, + _handle: Option<&str>, + ) -> anyhow::Result<()> { + let body = + json!({ "channel": chat_id, "timestamp": message_id, "name": SLACK_ACK_REACTION }); + match self + .call_bot("reactions.remove", ApiBody::Json(&body)) + .await + { + Ok(_) => Ok(()), + Err(err) if api_error_code(&err) == Some("no_reaction") => Ok(()), + Err(err) => Err(err), + } } } + +#[cfg(test)] +#[path = "slack_tests.rs"] +mod tests; diff --git a/crates/ccteam-im/src/transport/providers/slack_tests.rs b/crates/ccteam-im/src/transport/providers/slack_tests.rs new file mode 100644 index 00000000..7364c39b --- /dev/null +++ b/crates/ccteam-im/src/transport/providers/slack_tests.rs @@ -0,0 +1,1411 @@ +//! Slack provider tests. Every network peer is local: an in-test HTTP/1.1 +//! responder stands in for the Web API (`with_api_base`) and a plain-TCP +//! WebSocket server stands in for Socket Mode (its `ws://` URL is what the +//! mock `apps.connections.open` hands out). No test touches real Slack, the +//! real `~/.ccteam`, or the process env (staging + probes go to tempdirs). + +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use futures::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio_tungstenite::tungstenite::Message as WsMsg; +use tokio_tungstenite::WebSocketStream; + +use super::*; +use crate::transport::OutboundFileKind; + +const WAIT: Duration = Duration::from_secs(10); + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Local Web API mock +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[derive(Debug, Clone)] +struct Req { + path: String, + headers: Vec<(String, String)>, + body: Vec, +} + +impl Req { + /// Web API method name (`/chat.postMessage` โ†’ `chat.postMessage`). + fn method(&self) -> &str { + self.path.trim_start_matches('/') + } + + fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) + } + + fn json(&self) -> Value { + serde_json::from_slice(&self.body).unwrap_or(Value::Null) + } + + fn form(&self) -> HashMap { + String::from_utf8_lossy(&self.body) + .split('&') + .filter(|pair| !pair.is_empty()) + .filter_map(|pair| { + let (k, v) = pair.split_once('=')?; + Some((url_decode(k), url_decode(v))) + }) + .collect() + } +} + +fn url_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'+' => { + out.push(b' '); + i += 1; + } + b'%' if i + 2 < bytes.len() => { + let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""); + match u8::from_str_radix(hex, 16) { + Ok(b) => { + out.push(b); + i += 3; + } + Err(_) => { + out.push(b'%'); + i += 1; + } + } + } + b => { + out.push(b); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +struct Resp { + status: u16, + headers: Vec<(String, String)>, + body: Vec, +} + +fn json_resp(v: Value) -> Resp { + Resp { + status: 200, + headers: vec![("Content-Type".into(), "application/json".into())], + body: v.to_string().into_bytes(), + } +} + +fn ok(extra: Value) -> Resp { + let mut v = json!({ "ok": true }); + if let (Some(map), Some(extra)) = (v.as_object_mut(), extra.as_object()) { + for (k, val) in extra { + map.insert(k.clone(), val.clone()); + } + } + json_resp(v) +} + +fn api_err(code: &str) -> Resp { + json_resp(json!({ "ok": false, "error": code })) +} + +type BoxFut = Pin + Send>>; +type Handler = Arc BoxFut + Send + Sync>; + +struct MockHttp { + base: String, + log: Arc>>, +} + +impl MockHttp { + async fn start(handler: F) -> Self + where + F: Fn(Req) -> BoxFut + Send + Sync + 'static, + { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handler: Handler = Arc::new(handler); + let log: Arc>> = Arc::default(); + let accept_log = log.clone(); + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + tokio::spawn(serve_conn(stream, handler.clone(), accept_log.clone())); + } + }); + Self { + base: format!("http://{addr}"), + log, + } + } + + async fn start_sync(f: F) -> Self + where + F: Fn(&Req) -> Resp + Send + Sync + 'static, + { + let f = Arc::new(f); + Self::start(move |req| { + let f = f.clone(); + Box::pin(async move { f(&req) }) + }) + .await + } + + fn requests(&self) -> Vec { + self.log.lock().unwrap().clone() + } + + fn calls(&self, method: &str) -> Vec { + self.requests() + .into_iter() + .filter(|r| r.method() == method) + .collect() + } +} + +async fn serve_conn(mut stream: TcpStream, handler: Handler, log: Arc>>) { + let mut buf = Vec::new(); + let mut chunk = [0u8; 4096]; + let header_end = loop { + match stream.read(&mut chunk).await { + Ok(0) | Err(_) => return, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + } + if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + break pos + 4; + } + }; + let head = String::from_utf8_lossy(&buf[..header_end]).into_owned(); + let mut lines = head.lines(); + let request_line = lines.next().unwrap_or(""); + let path = request_line + .split_whitespace() + .nth(1) + .unwrap_or("/") + .split('?') + .next() + .unwrap_or("/") + .to_string(); + let headers: Vec<(String, String)> = lines + .filter_map(|line| { + let (k, v) = line.split_once(':')?; + Some((k.trim().to_string(), v.trim().to_string())) + }) + .collect(); + let content_length = headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("content-length")) + .and_then(|(_, v)| v.parse::().ok()) + .unwrap_or(0); + let mut body = buf[header_end..].to_vec(); + while body.len() < content_length { + match stream.read(&mut chunk).await { + Ok(0) | Err(_) => break, + Ok(n) => body.extend_from_slice(&chunk[..n]), + } + } + let req = Req { + path, + headers, + body, + }; + log.lock().unwrap().push(req.clone()); + let resp = handler(req).await; + let mut out = format!( + "HTTP/1.1 {} X\r\nContent-Length: {}\r\nConnection: close\r\n", + resp.status, + resp.body.len() + ); + for (k, v) in &resp.headers { + out.push_str(&format!("{k}: {v}\r\n")); + } + out.push_str("\r\n"); + let _ = stream.write_all(out.as_bytes()).await; + let _ = stream.write_all(&resp.body).await; + let _ = stream.flush().await; + let _ = stream.shutdown().await; +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Fixtures +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const BOT: &str = "UBOT"; +const ALLOWED: &str = "UALLOWED"; + +fn bot() -> BotIdentity { + BotIdentity { + user_id: BOT.to_string(), + } +} + +fn channel(base: &str, allowed: &[&str]) -> SlackChannel { + SlackChannel::new( + "xoxb-test".into(), + "xapp-test".into(), + allowed.iter().map(|s| s.to_string()).collect(), + ) + .with_api_base(base) +} + +fn envelope_frame(id: &str, kind: &str, payload: Value) -> String { + json!({ + "envelope_id": id, + "type": kind, + "payload": payload, + "accepts_response_payload": false, + "retry_attempt": 0, + }) + .to_string() +} + +fn envelope(kind: &str, payload: Value) -> Envelope { + Envelope { + envelope_id: "env".into(), + kind: kind.into(), + payload, + } +} + +fn event_payload(event_id: &str, event: Value) -> Value { + json!({ "type": "event_callback", "event_id": event_id, "event": event }) +} + +fn msg_event(user: &str, channel: &str, channel_type: &str, ts: &str, text: &str) -> Value { + json!({ + "type": "message", + "user": user, + "channel": channel, + "channel_type": channel_type, + "ts": ts, + "text": text, + }) +} + +fn slash_payload(user: &str, channel: &str, text: &str, response_url: &str) -> Value { + json!({ + "command": "/ccteam", + "text": text, + "user_id": user, + "channel_id": channel, + "response_url": response_url, + "trigger_id": format!("trig-{user}-{text}"), + }) +} + +fn click_payload(user: &str, channel: &str, message_ts: &str, thread_ts: Option<&str>) -> Value { + let mut container = + json!({ "type": "message", "message_ts": message_ts, "channel_id": channel }); + if let Some(t) = thread_ts { + container["thread_ts"] = json!(t); + } + json!({ + "type": "block_actions", + "user": { "id": user }, + "channel": { "id": channel }, + "container": container, + "message": { "ts": message_ts }, + "trigger_id": format!("click-{message_ts}"), + "actions": [{ "action_id": "ccteam_opt_1", "value": "tok:1", "type": "button" }], + }) +} + +fn default_api(req: &Req) -> Resp { + match req.method() { + "auth.test" => { + ok(json!({ "user_id": BOT, "bot_id": "BBOT", "team": "Acme", "user": "ccteam" })) + } + "chat.postMessage" => ok(json!({ "ts": "1700000999.000100" })), + "chat.update" | "reactions.add" | "reactions.remove" => ok(json!({})), + _ => api_err("unknown_method"), + } +} + +async fn recv(rx: &mut mpsc::Receiver) -> ChannelMessage { + tokio::time::timeout(WAIT, rx.recv()) + .await + .expect("timed out waiting for an inbound ChannelMessage") + .expect("listener closed the inbound stream") +} + +async fn ws_server() -> (TcpListener, String) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}/link", listener.local_addr().unwrap()); + (listener, url) +} + +async fn accept_ws(listener: &TcpListener) -> WebSocketStream { + let (stream, _) = tokio::time::timeout(WAIT, listener.accept()) + .await + .expect("client never connected") + .unwrap(); + tokio_tungstenite::accept_async(stream).await.unwrap() +} + +async fn send_text(ws: &mut WebSocketStream, text: String) { + ws.send(WsMsg::Text(text)).await.unwrap(); +} + +fn ack_id(text: &str) -> Option { + serde_json::from_str::(text) + .ok()? + .get("envelope_id")? + .as_str() + .map(str::to_string) +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Pure decode โ€” the inbound thread invariant +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[test] +fn top_level_message_threads_on_its_own_ts() { + let event = msg_event(ALLOWED, "C1", "channel", "1700000001.000100", "hello"); + let msg = decode_message_event(&event, &bot()) + .unwrap() + .into_channel_message("slack"); + assert_eq!(msg.thread_ts.as_deref(), Some("1700000001.000100")); + assert_eq!(msg.id, "1700000001.000100", "id is the raw ts (๐Ÿ‘€ target)"); + assert_eq!(msg.reply_target, "C1"); + assert_eq!(msg.sender, ALLOWED); + assert_eq!(msg.channel, "slack"); + assert_eq!(msg.timestamp, 1_700_000_001); +} + +#[test] +fn thread_reply_and_broadcast_thread_on_the_parent_ts() { + let mut reply = msg_event(ALLOWED, "C1", "channel", "1700000002.000200", "more"); + reply["thread_ts"] = json!("1700000001.000100"); + let decoded = decode_message_event(&reply, &bot()).unwrap(); + assert_eq!(decoded.thread_ts, "1700000001.000100"); + assert_eq!( + decoded.into_channel_message("slack").id, + "1700000002.000200" + ); + + let mut broadcast = reply.clone(); + broadcast["subtype"] = json!("thread_broadcast"); + assert_eq!( + decode_message_event(&broadcast, &bot()).unwrap().thread_ts, + "1700000001.000100" + ); +} + +#[test] +fn own_and_other_bot_messages_and_foreign_subtypes_are_skipped() { + let mine = msg_event(BOT, "C1", "channel", "1.1", "echo"); + assert!(decode_message_event(&mine, &bot()).is_none()); + + let mut other_bot = msg_event("UOTHER", "C1", "channel", "1.2", "beep"); + other_bot["bot_id"] = json!("BOTHER"); + assert!(decode_message_event(&other_bot, &bot()).is_none()); + + for subtype in [ + "message_changed", + "message_deleted", + "channel_join", + "bot_message", + ] { + let mut e = msg_event(ALLOWED, "C1", "channel", "1.3", "x"); + e["subtype"] = json!(subtype); + assert!( + decode_message_event(&e, &bot()).is_none(), + "{subtype} must be skipped" + ); + } + let mut share = msg_event(ALLOWED, "C1", "channel", "1.4", "see"); + share["subtype"] = json!("file_share"); + assert!(decode_message_event(&share, &bot()).is_some()); + + let app_mention = json!({ "type": "app_mention", "user": ALLOWED, "channel": "C1", "ts": "1.5", "text": "<@UBOT> hi" }); + assert!( + decode_message_event(&app_mention, &bot()).is_none(), + "app_mention duplicates `message` and is never consumed" + ); + let shared_channel = msg_event(ALLOWED, "C1", "app_home", "1.6", "x"); + assert!(decode_message_event(&shared_channel, &bot()).is_none()); +} + +#[test] +fn bot_mention_is_stripped_entities_unescaped_and_leading_space_trimmed() { + let e = msg_event( + ALLOWED, + "C1", + "channel", + "1.1", + "<@UBOT> ship it & tell <team> <@UOTHER>", + ); + let d = decode_message_event(&e, &bot()).unwrap(); + assert!(d.mentions_bot); + assert_eq!(d.text, "ship it & tell <@UOTHER>"); + + // A leading space is how a user gets ` /status` past Slack's own slash + // interception; the gateway must see the bare command. + let e = msg_event(ALLOWED, "D1", "im", "1.2", " /status"); + let d = decode_message_event(&e, &bot()).unwrap(); + assert_eq!(d.text, "/status"); + assert!(!d.mentions_bot); + + let labelled = msg_event(ALLOWED, "C1", "channel", "1.3", "<@UBOT|ccteam> /help"); + assert_eq!( + decode_message_event(&labelled, &bot()).unwrap().text, + "/help" + ); + + // An escaped mention the user typed literally is text, not a mention. + let literal = msg_event(ALLOWED, "C1", "channel", "1.4", "<@UBOT> hi"); + let d = decode_message_event(&literal, &bot()).unwrap(); + assert!(!d.mentions_bot); + assert_eq!(d.text, "<@UBOT> hi"); + + let only_mention = msg_event(ALLOWED, "C1", "channel", "1.5", "<@UBOT>"); + assert!(decode_message_event(&only_mention, &bot()).is_none()); +} + +#[test] +fn files_are_collected_and_unreachable_ones_skipped() { + let mut e = msg_event(ALLOWED, "C1", "channel", "1.1", ""); + e["subtype"] = json!("file_share"); + e["files"] = json!([ + { "id": "F1", "name": "a.png", "mimetype": "image/png", "size": 3, + "url_private_download": "http://x/F1/a.png" }, + { "id": "F2", "mode": "tombstone" }, + ]); + let d = decode_message_event(&e, &bot()).unwrap(); + assert_eq!(d.text, ""); + assert_eq!( + d.files, + vec![PendingFile { + id: "F1".into(), + name: "a.png".into(), + mimetype: Some("image/png".into()), + size: Some(3), + url: "http://x/F1/a.png".into(), + }] + ); +} + +#[test] +fn slash_text_maps_to_a_gateway_command_and_anchor_echoes_it() { + assert_eq!(slash_content(""), "/help"); + assert_eq!(slash_content(" "), "/help"); + assert_eq!(slash_content("new codex"), "/new codex"); + assert_eq!(slash_content(" /status "), "/status"); + assert_eq!(slash_content("say a & b"), "/say a & b"); + assert_eq!( + anchor_text("/ccteam", "new codex", "U1"), + "`/ccteam new codex` ยท <@U1>" + ); + assert_eq!(anchor_text("/ccteam", "", "U1"), "`/ccteam` ยท <@U1>"); + assert_eq!( + anchor_text("/ccteam", "a <b>", "U1"), + "`/ccteam a <b>` ยท <@U1>", + "echo stays Slack-escaped" + ); +} + +#[test] +fn button_click_threads_on_the_clicked_messages_thread() { + let in_thread = + decode_block_actions(&click_payload(ALLOWED, "C1", "3.3", Some("1.1"))).unwrap(); + assert_eq!(in_thread.thread_ts, "1.1"); + assert_eq!(in_thread.message_ts, "3.3"); + assert_eq!(in_thread.value, "tok:1"); + + let mut via_message = click_payload(ALLOWED, "C1", "3.3", None); + via_message["message"]["thread_ts"] = json!("2.2"); + assert_eq!(decode_block_actions(&via_message).unwrap().thread_ts, "2.2"); + + let top_level = decode_block_actions(&click_payload(ALLOWED, "C1", "3.3", None)).unwrap(); + assert_eq!(top_level.thread_ts, "3.3", "falls back to the message ts"); + + let mut not_actions = click_payload(ALLOWED, "C1", "3.3", None); + not_actions["type"] = json!("view_submission"); + assert!(decode_block_actions(¬_actions).is_none()); +} + +#[test] +fn blocks_render_markdown_and_chunk_buttons_with_unique_action_ids() { + let options: Vec = (0..30) + .map(|i| MessageOption { + data: format!("tok:{i}"), + label: if i == 0 { + "x".repeat(200) + } else { + format!("opt {i}") + }, + id: format!("o{i}"), + }) + .collect(); + let blocks = message_blocks("**bold** text", &options); + assert_eq!(blocks.len(), 3, "markdown + 25 + 5 buttons"); + assert_eq!( + blocks[0], + json!({ "type": "markdown", "text": "**bold** text" }) + ); + assert_eq!(blocks[1]["elements"].as_array().unwrap().len(), 25); + assert_eq!(blocks[2]["elements"].as_array().unwrap().len(), 5); + let first = &blocks[1]["elements"][0]; + assert_eq!(first["value"], "tok:0"); + assert_eq!( + first["text"]["text"].as_str().unwrap().chars().count(), + SLACK_BUTTON_LABEL_MAX_CHARS + ); + let ids: HashSet<&str> = blocks[1..] + .iter() + .flat_map(|b| b["elements"].as_array().unwrap()) + .map(|e| e["action_id"].as_str().unwrap()) + .collect(); + assert_eq!(ids.len(), 30, "action_ids are unique within the message"); + assert!(ids.contains("ccteam_opt_29")); + + assert!(message_blocks("", &[]).is_empty()); +} + +#[test] +fn socket_frames_parse() { + assert_eq!( + parse_frame(r#"{"type":"hello","num_connections":1}"#), + Frame::Hello + ); + assert_eq!( + parse_frame(r#"{"type":"disconnect","reason":"refresh_requested"}"#), + Frame::Disconnect("refresh_requested".into()) + ); + match parse_frame(&envelope_frame("e1", "events_api", json!({"a": 1}))) { + Frame::Envelope(env) => { + assert_eq!(env.envelope_id, "e1"); + assert_eq!(env.kind, "events_api"); + assert_eq!(env.payload, json!({"a": 1})); + } + other => panic!("expected envelope, got {other:?}"), + } + assert_eq!(parse_frame("not json"), Frame::Other); +} + +#[test] +fn seen_keys_dedupes_and_stays_bounded() { + let mut seen = SeenKeys::default(); + assert!(seen.first_sighting(&["a".into(), "b".into()])); + assert!( + !seen.first_sighting(&["b".into()]), + "any seen key = duplicate" + ); + assert!(!seen.first_sighting(&["c".into(), "a".into()])); + for i in 0..(SLACK_DEDUP_CAPACITY * 2) { + seen.first_sighting(&[format!("k{i}")]); + } + assert!(seen.order.len() <= SLACK_DEDUP_CAPACITY); + assert_eq!(seen.order.len(), seen.set.len()); + assert!( + seen.first_sighting(&["a".into()]), + "evicted keys are forgotten" + ); +} + +#[test] +fn allowlist_is_fail_closed_with_wildcard() { + let closed = channel("http://127.0.0.1:9", &[]); + assert!(!closed.is_user_allowed(ALLOWED), "empty = deny all"); + let named = channel("http://127.0.0.1:9", &[ALLOWED]); + assert!(named.is_user_allowed(ALLOWED)); + assert!(!named.is_user_allowed("USTRANGER")); + let open = channel("http://127.0.0.1:9", &["*"]); + assert!(open.is_user_allowed("USTRANGER")); +} + +#[test] +fn channel_contract_threads_sessions_and_caps_length() { + let ch = channel("http://127.0.0.1:9", &[]); + assert!( + ch.session_threads(), + "Slack gives each session its own thread" + ); + assert_eq!(ch.max_message_len(), Some(SLACK_MAX_MESSAGE_UTF16)); + assert_eq!(ch.name(), "slack"); + assert_eq!( + ch.with_name("slack@u1".into()).name(), + "slack@u1", + "per-tenant key override" + ); +} + +#[test] +fn retry_after_header_is_parsed_and_capped() { + let mut headers = reqwest::header::HeaderMap::new(); + assert_eq!(retry_after(&headers), Duration::from_secs(1)); + headers.insert(reqwest::header::RETRY_AFTER, "3".parse().unwrap()); + assert_eq!(retry_after(&headers), Duration::from_secs(3)); + headers.insert(reqwest::header::RETRY_AFTER, "9999".parse().unwrap()); + assert_eq!(retry_after(&headers), RATE_LIMIT_MAX_WAIT); +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Socket Mode end to end +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/// The load-bearing inbound contract over a real (local) socket: every +/// envelope is ACKed โ€” the slash command's ACK provably lands BEFORE its +/// anchor post (the mock refuses the post until the ACK arrived) โ€” a +/// re-delivery is deduped, Ping is answered, and every emitted message +/// carries `thread_ts`. +#[tokio::test] +async fn socket_mode_acks_first_dedupes_and_every_message_carries_thread_ts() { + let (ws_listener, ws_url) = ws_server().await; + let slash_acked = Arc::new(tokio::sync::Notify::new()); + let gate = slash_acked.clone(); + let url = ws_url.clone(); + let api = MockHttp::start(move |req| { + let gate = gate.clone(); + let url = url.clone(); + Box::pin(async move { + match req.method() { + "apps.connections.open" => ok(json!({ "url": url })), + "chat.postMessage" => { + if tokio::time::timeout(WAIT, gate.notified()).await.is_err() { + return api_err("anchor_posted_before_ack"); + } + ok(json!({ "ts": "1700000100.000200" })) + } + "response" => ok(json!({})), + _ => default_api(&req), + } + }) + }) + .await; + + let response_url = format!("{}/response", api.base); + let frames = vec![ + envelope_frame( + "e1", + "events_api", + event_payload( + "Ev1", + msg_event( + ALLOWED, + "C1", + "channel", + "1700000001.000100", + "<@UBOT> hello & welcome", + ), + ), + ), + envelope_frame( + "e2", + "events_api", + event_payload("Ev2", { + let mut e = msg_event(ALLOWED, "C1", "channel", "1700000002.000200", "follow up"); + e["thread_ts"] = json!("1700000001.000100"); + e + }), + ), + // Slack re-delivers Ev1 (new envelope, same event_id): ACKed, dropped. + envelope_frame( + "e3", + "events_api", + event_payload( + "Ev1", + msg_event( + ALLOWED, + "C1", + "channel", + "1700000001.000100", + "<@UBOT> hello & welcome", + ), + ), + ), + envelope_frame( + "e4", + "slash_commands", + slash_payload(ALLOWED, "C1", "new codex", &response_url), + ), + envelope_frame( + "e5", + "interactive", + click_payload( + ALLOWED, + "C1", + "1700000003.000300", + Some("1700000001.000100"), + ), + ), + ]; + let server = tokio::spawn(async move { + let mut ws = accept_ws(&ws_listener).await; + send_text(&mut ws, json!({ "type": "hello" }).to_string()).await; + for frame in frames { + send_text(&mut ws, frame).await; + } + ws.send(WsMsg::Ping(b"hb".to_vec())).await.unwrap(); + let mut acks = Vec::new(); + let mut pong = false; + while acks.len() < 5 || !pong { + match tokio::time::timeout(WAIT, ws.next()).await { + Ok(Some(Ok(WsMsg::Text(text)))) => { + let id = ack_id(&text).expect("client frames are envelope ACKs"); + if id == "e4" { + slash_acked.notify_one(); + } + acks.push(id); + } + Ok(Some(Ok(WsMsg::Pong(data)))) => { + assert_eq!(data, b"hb".to_vec()); + pong = true; + } + Ok(Some(Ok(_))) => {} + other => panic!("socket ended before all ACKs: {other:?} acks={acks:?}"), + } + } + (acks, ws) + }); + + let ch = Arc::new(channel(&api.base, &[ALLOWED])); + let (tx, mut rx) = mpsc::channel(16); + let listener = tokio::spawn({ + let ch = ch.clone(); + async move { ch.listen(tx).await } + }); + + let top = recv(&mut rx).await; + let reply = recv(&mut rx).await; + let slash = recv(&mut rx).await; + let click = recv(&mut rx).await; + let (acks, _ws) = server.await.unwrap(); + assert_eq!(acks, vec!["e1", "e2", "e3", "e4", "e5"]); + + for m in [&top, &reply, &slash, &click] { + assert!(m.thread_ts.is_some(), "inbound invariant: {m:?}"); + assert_eq!(m.channel, "slack"); + assert_eq!(m.reply_target, "C1"); + assert_eq!(m.sender, ALLOWED); + } + assert_eq!(top.id, "1700000001.000100"); + assert_eq!(top.thread_ts.as_deref(), Some("1700000001.000100")); + assert_eq!(top.content, "hello & welcome"); + assert_eq!(reply.id, "1700000002.000200"); + assert_eq!(reply.thread_ts.as_deref(), Some("1700000001.000100")); + assert_eq!(slash.content, "/new codex"); + assert_eq!(slash.id, "1700000100.000200"); + assert_eq!(slash.thread_ts.as_deref(), Some("1700000100.000200")); + assert_eq!( + click.selection, + Some(ChoiceReply { + data: "tok:1".into() + }) + ); + assert!(click.content.is_empty()); + assert_eq!(click.thread_ts.as_deref(), Some("1700000001.000100")); + + let anchor = api.calls("chat.postMessage"); + assert_eq!(anchor.len(), 1); + let body = anchor[0].json(); + assert_eq!(body["channel"], "C1"); + assert_eq!(body["text"], "`/ccteam new codex` ยท <@UALLOWED>"); + assert!(body.get("thread_ts").is_none(), "the anchor is top-level"); + assert_eq!( + api.calls("auth.test")[0].header("authorization"), + Some("Bearer xoxb-test") + ); + assert_eq!( + api.calls("apps.connections.open")[0].header("authorization"), + Some("Bearer xapp-test") + ); + + drop(rx); + let ended = tokio::time::timeout(WAIT, listener) + .await + .expect("listen must return once the receiver is gone") + .unwrap(); + assert!(ended.is_ok(), "{ended:?}"); +} + +#[tokio::test] +async fn refresh_requested_reopens_the_socket() { + let (ws_listener, ws_url) = ws_server().await; + let api = MockHttp::start_sync(move |req| match req.method() { + "apps.connections.open" => ok(json!({ "url": ws_url })), + _ => default_api(req), + }) + .await; + let server = tokio::spawn(async move { + let mut first = accept_ws(&ws_listener).await; + send_text(&mut first, json!({ "type": "hello" }).to_string()).await; + send_text( + &mut first, + json!({ "type": "disconnect", "reason": "refresh_requested" }).to_string(), + ) + .await; + let mut second = accept_ws(&ws_listener).await; + send_text(&mut second, json!({ "type": "hello" }).to_string()).await; + send_text( + &mut second, + envelope_frame( + "e1", + "events_api", + event_payload( + "Ev9", + msg_event(ALLOWED, "D1", "im", "5.5", "after refresh"), + ), + ), + ) + .await; + (first, second) + }); + + let ch = Arc::new(channel(&api.base, &[ALLOWED])); + let (tx, mut rx) = mpsc::channel(4); + let listener = tokio::spawn({ + let ch = ch.clone(); + async move { ch.listen(tx).await } + }); + let msg = recv(&mut rx).await; + assert_eq!(msg.content, "after refresh"); + assert_eq!(msg.thread_ts.as_deref(), Some("5.5")); + assert_eq!(api.calls("apps.connections.open").len(), 2); + assert_eq!(api.calls("auth.test").len(), 1, "identity is cached"); + let _sockets = server.await.unwrap(); + drop(rx); + assert!(tokio::time::timeout(WAIT, listener) + .await + .unwrap() + .unwrap() + .is_ok()); +} + +#[tokio::test] +async fn link_disabled_stops_the_listener_with_an_error() { + let (ws_listener, ws_url) = ws_server().await; + let api = MockHttp::start_sync(move |req| match req.method() { + "apps.connections.open" => ok(json!({ "url": ws_url })), + _ => default_api(req), + }) + .await; + let server = tokio::spawn(async move { + let mut ws = accept_ws(&ws_listener).await; + send_text(&mut ws, json!({ "type": "hello" }).to_string()).await; + send_text( + &mut ws, + json!({ "type": "disconnect", "reason": "link_disabled" }).to_string(), + ) + .await; + ws + }); + let ch = channel(&api.base, &[ALLOWED]); + let (tx, _rx) = mpsc::channel(4); + let err = tokio::time::timeout(WAIT, ch.listen(tx)) + .await + .expect("link_disabled must end the listener") + .expect_err("link_disabled is fatal"); + assert!(err.to_string().contains("link_disabled"), "{err}"); + let _ws = server.await.unwrap(); +} + +#[tokio::test] +async fn invalid_app_token_stops_the_listener_without_retry_spin() { + let api = MockHttp::start_sync(|req| match req.method() { + "apps.connections.open" => api_err("invalid_auth"), + _ => default_api(req), + }) + .await; + let ch = channel(&api.base, &[ALLOWED]); + let (tx, _rx) = mpsc::channel(4); + let err = tokio::time::timeout(WAIT, ch.listen(tx)) + .await + .expect("a fatal auth error must end the listener") + .expect_err("invalid_auth is fatal"); + assert!(err.to_string().contains("invalid_auth"), "{err}"); + assert_eq!(api.calls("apps.connections.open").len(), 1); +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Allowlist + rejected senders (envelope handler, no socket) +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[tokio::test] +async fn rejected_senders_are_always_probed_but_only_notified_when_addressing_the_bot() { + let api = MockHttp::start_sync(default_api).await; + let tmp = tempfile::tempdir().unwrap(); + let probe_path = tmp.path().join("rejected.jsonl"); + let ch = channel(&api.base, &[ALLOWED]).with_probe_path(probe_path.clone()); + let response_url = format!("{}/response", api.base); + + let ambient = envelope( + "events_api", + event_payload("Ev1", msg_event("UX", "C1", "channel", "1.1", "chatting")), + ); + assert!(ch.handle_envelope(&ambient).await.is_none()); + assert!( + api.calls("chat.postMessage").is_empty(), + "ambient channel chatter must never get a notice" + ); + + let mention = envelope( + "events_api", + event_payload( + "Ev2", + msg_event("UY", "C1", "channel", "1.2", "<@UBOT> let me in"), + ), + ); + assert!(ch.handle_envelope(&mention).await.is_none()); + + let dm = envelope( + "events_api", + event_payload("Ev3", msg_event("UX", "D1", "im", "1.3", "hello?")), + ); + assert!(ch.handle_envelope(&dm).await.is_none()); + let dm_again = envelope( + "events_api", + event_payload("Ev4", msg_event("UX", "D1", "im", "1.4", "anyone?")), + ); + assert!(ch.handle_envelope(&dm_again).await.is_none()); + + let slash = envelope( + "slash_commands", + slash_payload("UZ", "C1", "status", &response_url), + ); + assert!(ch.handle_envelope(&slash).await.is_none()); + let click = envelope("interactive", click_payload("UW", "C1", "9.9", None)); + assert!(ch.handle_envelope(&click).await.is_none()); + + let notices: Vec = api + .calls("chat.postMessage") + .iter() + .map(Req::json) + .collect(); + let targets: Vec<(&str, bool)> = notices + .iter() + .map(|n| { + ( + n["channel"].as_str().unwrap(), + n["text"].as_str().unwrap().contains("็ป‘ๅฎš ID"), + ) + }) + .collect(); + assert_eq!( + targets, + vec![("C1", true), ("D1", true), ("C1", true), ("C1", true)], + "mention, first DM, slash and click notify once each; the repeat DM does not" + ); + assert!(notices[1]["text"].as_str().unwrap().contains("UX")); + + let probes: Vec = std::fs::read_to_string(&probe_path) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + let senders: Vec<&str> = probes.iter().map(|p| p.sender_id.as_str()).collect(); + assert_eq!( + senders, + vec!["UX", "UY", "UX", "UX", "UZ", "UW"], + "every reject is probed" + ); + assert!(probes.iter().all(|p| p.channel == "slack")); + assert_eq!(probes[0].chat_id, "C1"); + assert_eq!(probes[0].message_id, "1.1"); +} + +#[tokio::test] +async fn empty_allowlist_admits_no_one() { + let api = MockHttp::start_sync(default_api).await; + let ch = channel(&api.base, &[]); + let dm = envelope( + "events_api", + event_payload("Ev1", msg_event(ALLOWED, "D1", "im", "1.1", "hi")), + ); + assert!(ch.handle_envelope(&dm).await.is_none()); + let click = envelope("interactive", click_payload(ALLOWED, "C1", "2.2", None)); + assert!(ch.handle_envelope(&click).await.is_none()); +} + +#[tokio::test] +async fn slash_command_without_a_postable_anchor_explains_ephemerally_and_drops() { + let api = MockHttp::start_sync(|req| match req.method() { + "chat.postMessage" => api_err("channel_not_found"), + "response" => ok(json!({})), + _ => default_api(req), + }) + .await; + let ch = channel(&api.base, &[ALLOWED]); + let slash = envelope( + "slash_commands", + slash_payload( + ALLOWED, + "D0OTHER", + "new codex", + &format!("{}/response", api.base), + ), + ); + assert!(ch.handle_envelope(&slash).await.is_none()); + let replies = api.calls("response"); + assert_eq!(replies.len(), 1); + let body = replies[0].json(); + assert_eq!(body["response_type"], "ephemeral"); + assert!(body["text"].as_str().unwrap().contains("channel_not_found")); +} + +#[tokio::test] +async fn foreign_slash_command_is_ignored() { + let api = MockHttp::start_sync(default_api).await; + let ch = channel(&api.base, &[ALLOWED]); + let mut payload = slash_payload(ALLOWED, "C1", "x", ""); + payload["command"] = json!("/other"); + assert!(ch + .handle_envelope(&envelope("slash_commands", payload)) + .await + .is_none()); + assert!(api.calls("chat.postMessage").is_empty()); +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Inbound files +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[tokio::test] +async fn inbound_files_download_with_the_bot_token_and_stage_by_kind() { + let api = MockHttp::start_sync(|req| match req.path.as_str() { + "/files/F1/shot.png" => Resp { + status: 200, + headers: vec![("Content-Type".into(), "image/png".into())], + body: b"\x89PNG".to_vec(), + }, + "/files/F2/notes.txt" => Resp { + status: 200, + headers: vec![("Content-Type".into(), "text/plain".into())], + body: b"notes".to_vec(), + }, + _ => default_api(req), + }) + .await; + let tmp = tempfile::tempdir().unwrap(); + let ch = channel(&api.base, &[ALLOWED]).with_staging_dir(tmp.path().to_path_buf()); + let mut event = msg_event(ALLOWED, "C1", "channel", "7.7", "look"); + event["subtype"] = json!("file_share"); + event["files"] = json!([ + { "id": "F1", "name": "shot.png", "mimetype": "image/png", "size": 4, + "url_private_download": format!("{}/files/F1/shot.png", api.base) }, + { "id": "F2", "name": "../notes.txt", "mimetype": "text/plain", + "url_private_download": format!("{}/files/F2/notes.txt", api.base) }, + ]); + let msg = ch + .handle_envelope(&envelope("events_api", event_payload("Ev1", event))) + .await + .expect("allowed message with files"); + assert_eq!(msg.content, "look"); + assert_eq!(msg.thread_ts.as_deref(), Some("7.7")); + assert_eq!(msg.attachments.len(), 2); + let image = &msg.attachments[0]; + assert_eq!(image.kind, AttachmentKind::Image); + assert_eq!(image.file_name, "shot.png"); + assert_eq!(std::fs::read(&image.local_path).unwrap(), b"\x89PNG"); + assert!(image.local_path.starts_with(tmp.path().to_str().unwrap())); + let file = &msg.attachments[1]; + assert_eq!(file.kind, AttachmentKind::File); + assert_eq!(file.file_name, "notes.txt", "names are sanitized"); + assert_eq!(std::fs::read(&file.local_path).unwrap(), b"notes"); + for download in api + .requests() + .iter() + .filter(|r| r.path.starts_with("/files/")) + { + assert_eq!(download.header("authorization"), Some("Bearer xoxb-test")); + } +} + +#[tokio::test] +async fn oversize_or_html_files_are_refused_with_a_thread_notice() { + let api = MockHttp::start_sync(|req| match req.path.as_str() { + "/files/F2/x.pdf" => Resp { + status: 200, + headers: vec![("Content-Type".into(), "text/html; charset=utf-8".into())], + body: b"sign in".to_vec(), + }, + _ => default_api(req), + }) + .await; + let tmp = tempfile::tempdir().unwrap(); + let ch = channel(&api.base, &[ALLOWED]).with_staging_dir(tmp.path().to_path_buf()); + let mut event = msg_event(ALLOWED, "C1", "channel", "8.8", ""); + event["thread_ts"] = json!("1.1"); + event["subtype"] = json!("file_share"); + event["files"] = json!([ + { "id": "F1", "name": "huge.bin", "size": SLACK_MAX_ATTACHMENT_BYTES + 1, + "url_private_download": format!("{}/files/F1/huge.bin", api.base) }, + { "id": "F2", "name": "x.pdf", "mimetype": "application/pdf", + "url_private_download": format!("{}/files/F2/x.pdf", api.base) }, + ]); + assert!( + ch.handle_envelope(&envelope("events_api", event_payload("Ev1", event))) + .await + .is_none(), + "nothing usable left โ†’ no turn" + ); + assert!( + !api.requests() + .iter() + .any(|r| r.path == "/files/F1/huge.bin"), + "an oversize file is never downloaded" + ); + let notices: Vec = api + .calls("chat.postMessage") + .iter() + .map(Req::json) + .collect(); + assert_eq!(notices.len(), 2); + assert!(notices[0]["text"].as_str().unwrap().contains("่ถ…่ฟ‡")); + assert!(notices[1]["text"].as_str().unwrap().contains("ไธ‹่ฝฝๅคฑ่ดฅ")); + assert!(notices.iter().all(|n| n["thread_ts"] == "1.1")); + assert!( + std::fs::read_dir(tmp.path()) + .map(|d| d.count()) + .unwrap_or(0) + == 0 + ); +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Outbound +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +fn options(n: usize) -> Vec { + (0..n) + .map(|i| MessageOption { + data: format!("tok:{i}"), + label: format!("Option {i}"), + id: format!("o{i}"), + }) + .collect() +} + +#[tokio::test] +async fn send_posts_a_markdown_block_with_buttons_into_the_thread() { + let api = MockHttp::start_sync(default_api).await; + let ch = channel(&api.base, &[]); + let ts = ch + .send( + &SendMessage::new("**done**\n1. a\n2. b", "C1") + .in_thread(Some("1.1".into())) + .with_options(options(2)), + ) + .await + .unwrap(); + assert_eq!(ts.as_deref(), Some("1700000999.000100")); + let calls = api.calls("chat.postMessage"); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].header("authorization"), Some("Bearer xoxb-test")); + let body = calls[0].json(); + assert_eq!(body["channel"], "C1"); + assert_eq!(body["thread_ts"], "1.1"); + assert_eq!(body["unfurl_links"], false); + assert_eq!(body["text"], "**done**\n1. a\n2. b"); + assert_eq!( + body["blocks"][0], + json!({ "type": "markdown", "text": "**done**\n1. a\n2. b" }) + ); + assert_eq!(body["blocks"][1]["type"], "actions"); + assert_eq!(body["blocks"][1]["elements"][1]["value"], "tok:1"); +} + +#[tokio::test] +async fn rejected_blocks_retry_once_as_plain_text() { + let api = MockHttp::start_sync(|req| match req.method() { + "chat.postMessage" if req.json().get("blocks").is_some() => api_err("invalid_blocks"), + "chat.postMessage" => ok(json!({ "ts": "2.2" })), + "chat.update" if !req.json()["blocks"].as_array().unwrap().is_empty() => { + api_err("msg_blocks_too_long") + } + _ => default_api(req), + }) + .await; + let ch = channel(&api.base, &[]); + let ts = ch + .send(&SendMessage::new("hi", "C1").with_options(options(1))) + .await + .unwrap(); + assert_eq!(ts.as_deref(), Some("2.2")); + let calls = api.calls("chat.postMessage"); + assert_eq!(calls.len(), 2); + assert!(calls[1].json().get("blocks").is_none()); + assert_eq!(calls[1].json()["text"], "hi"); + + let edited = ch.edit_message("C1", "2.2", "updated").await.unwrap(); + assert_eq!(edited.as_deref(), Some("2.2")); + let updates = api.calls("chat.update"); + assert_eq!(updates.len(), 2); + assert_eq!( + updates[1].json()["blocks"], + json!([]), + "plain update clears blocks" + ); +} + +#[tokio::test] +async fn other_api_errors_are_not_retried() { + let api = MockHttp::start_sync(|req| match req.method() { + "chat.postMessage" => api_err("channel_not_found"), + _ => default_api(req), + }) + .await; + let ch = channel(&api.base, &[]); + let err = ch.send(&SendMessage::new("hi", "C9")).await.unwrap_err(); + assert_eq!(api_error_code(&err), Some("channel_not_found")); + assert_eq!(api.calls("chat.postMessage").len(), 1); +} + +#[tokio::test] +async fn edit_message_updates_in_place_with_a_markdown_block() { + let api = MockHttp::start_sync(default_api).await; + let ch = channel(&api.base, &[]); + let id = ch.edit_message("C1", "3.3", "progress 50%").await.unwrap(); + assert_eq!(id.as_deref(), Some("3.3")); + let body = api.calls("chat.update")[0].json(); + assert_eq!(body["channel"], "C1"); + assert_eq!(body["ts"], "3.3"); + assert_eq!(body["text"], "progress 50%"); + assert_eq!(body["blocks"][0]["type"], "markdown"); +} + +#[tokio::test] +async fn eyes_reaction_tolerates_already_reacted_and_no_reaction() { + let api = MockHttp::start_sync(|req| match (req.method(), req.json()["channel"].as_str()) { + ("reactions.add", Some("C1")) => api_err("already_reacted"), + ("reactions.remove", Some("C1")) => api_err("no_reaction"), + ("reactions.add", _) => api_err("channel_not_found"), + _ => default_api(req), + }) + .await; + let ch = channel(&api.base, &[]); + assert_eq!(ch.add_reaction("C1", "4.4").await.unwrap(), None); + ch.remove_reaction("C1", "4.4", None).await.unwrap(); + assert!(ch.add_reaction("C2", "4.4").await.is_err()); + let add = api.calls("reactions.add")[0].json(); + assert_eq!( + add, + json!({ "channel": "C1", "timestamp": "4.4", "name": "eyes" }) + ); + assert_eq!(api.calls("reactions.remove")[0].json()["name"], "eyes"); +} + +#[tokio::test] +async fn rate_limit_honours_retry_after_and_retries_once() { + let hits = Arc::new(AtomicUsize::new(0)); + let counter = hits.clone(); + let api = MockHttp::start_sync(move |req| match req.method() { + "chat.postMessage" => { + if counter.fetch_add(1, Ordering::SeqCst) == 0 { + Resp { + status: 429, + headers: vec![ + ("Retry-After".into(), "0".into()), + ("Content-Type".into(), "application/json".into()), + ], + body: br#"{"ok":false,"error":"ratelimited"}"#.to_vec(), + } + } else { + ok(json!({ "ts": "6.6" })) + } + } + "chat.update" => Resp { + status: 429, + headers: vec![("Retry-After".into(), "0".into())], + body: br#"{"ok":false,"error":"ratelimited"}"#.to_vec(), + }, + _ => default_api(req), + }) + .await; + let ch = channel(&api.base, &[]); + let ts = ch.send(&SendMessage::new("hi", "C1")).await.unwrap(); + assert_eq!(ts.as_deref(), Some("6.6")); + assert_eq!(hits.load(Ordering::SeqCst), 2); + + let err = ch.edit_message("C1", "6.6", "x").await.unwrap_err(); + assert_eq!(api_error_code(&err), Some("ratelimited")); + assert_eq!(api.calls("chat.update").len(), 2, "exactly one retry"); +} + +#[tokio::test] +async fn attachments_upload_through_the_external_flow_after_the_text() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("report.txt"); + std::fs::write(&path, b"hello").unwrap(); + let base_cell: Arc> = Arc::default(); + let base_for_handler = base_cell.clone(); + let api = MockHttp::start_sync(move |req| match req.method() { + "files.getUploadURLExternal" => ok(json!({ + "upload_url": format!("{}/upload/F1", base_for_handler.get().unwrap()), + "file_id": "F1", + })), + "upload/F1" => Resp { + status: 200, + headers: vec![], + body: b"OK - 5".to_vec(), + }, + "files.completeUploadExternal" => ok(json!({ "files": [{ "id": "F1" }] })), + _ => default_api(req), + }) + .await; + base_cell.set(api.base.clone()).unwrap(); + let ch = channel(&api.base, &[]); + let message = SendMessage::new("see the report", "C1") + .in_thread(Some("5.5".into())) + .with_attachments(vec![OutboundFile { + id: String::new(), + size: 0, + path: path.to_string_lossy().into_owned(), + caption: Some("Q3 numbers".into()), + kind: OutboundFileKind::Document, + }]); + let ts = ch.send(&message).await.unwrap(); + assert_eq!(ts.as_deref(), Some("1700000999.000100")); + + let order: Vec = api + .requests() + .iter() + .map(|r| r.method().to_string()) + .collect(); + assert_eq!( + order, + vec![ + "chat.postMessage", + "files.getUploadURLExternal", + "upload/F1", + "files.completeUploadExternal" + ] + ); + let requests = api.requests(); + assert_eq!(requests[0].json()["thread_ts"], "5.5"); + let ticket = requests[1].form(); + assert_eq!(ticket["filename"], "report.txt"); + assert_eq!(ticket["length"], "5"); + assert_eq!(requests[2].body, b"hello"); + assert_eq!( + requests[2].header("authorization"), + None, + "the pre-signed upload URL takes no token" + ); + let complete = requests[3].form(); + assert_eq!(complete["channel_id"], "C1"); + assert_eq!(complete["thread_ts"], "5.5"); + assert_eq!(complete["initial_comment"], "Q3 numbers"); + let files: Value = serde_json::from_str(&complete["files"]).unwrap(); + assert_eq!(files, json!([{ "id": "F1", "title": "report.txt" }])); +} + +#[tokio::test] +async fn health_check_follows_auth_test() { + let good = MockHttp::start_sync(default_api).await; + assert!(channel(&good.base, &[]).health_check().await); + let bad = MockHttp::start_sync(|_| api_err("invalid_auth")).await; + assert!(!channel(&bad.base, &[]).health_check().await); +} diff --git a/crates/ccteam-im/tests/credentials_test.rs b/crates/ccteam-im/tests/credentials_test.rs index e79b65a4..53e1c41e 100644 --- a/crates/ccteam-im/tests/credentials_test.rs +++ b/crates/ccteam-im/tests/credentials_test.rs @@ -25,8 +25,8 @@ fn round_trip_all_platforms() { }), slack: Some(SlackCreds { bot_token: "xoxb-x".into(), - signing_secret: Some("sig".into()), - poll_channels: vec!["C123".into()], + app_token: "xapp-x".into(), + allowed_user_ids: vec!["U123".into()], }), discord: None, lark: Some(LarkCreds { diff --git a/crates/ccteam-im/tests/slack_onboarding_test.rs b/crates/ccteam-im/tests/slack_onboarding_test.rs new file mode 100644 index 00000000..577020f0 --- /dev/null +++ b/crates/ccteam-im/tests/slack_onboarding_test.rs @@ -0,0 +1,193 @@ +//! Slack onboarding (`slack_setup_with_base`) โ€” deterministic credential +//! validation tests. +//! +//! Setup proves the bot token with `auth.test` and the app-level token with +//! `apps.connections.open` (the two calls the live Socket Mode channel makes). +//! A small std `TcpListener` responder routed by request path stands in for +//! the Slack Web API, so each round-trip is real HTTP but never leaves the +//! box โ€” mirrors `lark_onboarding_test.rs`. + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::sync::{Arc, Mutex}; + +use ccteam_im::onboarding::{slack_setup_with_base, OnboardingError}; + +/// Requests seen by the mock: `(path, authorization header)`. +type Seen = Arc>>; + +/// Serve `routes` (path โ†’ JSON body) on `127.0.0.1:0` for every connection +/// until the test ends. Returns the `api_base` and the request log. +fn spawn_slack_mock(routes: &[(&str, &'static str)]) -> (String, Seen) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let routes: HashMap = + routes.iter().map(|(p, b)| (p.to_string(), *b)).collect(); + let seen: Seen = Arc::default(); + let log = seen.clone(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { return }; + let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(2))); + let mut req = Vec::new(); + let mut buf = [0u8; 1024]; + let mut header_end = None; + let mut content_length = 0usize; + loop { + match stream.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + req.extend_from_slice(&buf[..n]); + if header_end.is_none() { + if let Some(pos) = req.windows(4).position(|w| w == b"\r\n\r\n") { + header_end = Some(pos + 4); + let headers = String::from_utf8_lossy(&req[..pos]); + content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + } + } + if let Some(end) = header_end { + if req.len().saturating_sub(end) >= content_length { + break; + } + } + } + Err(_) => break, + } + } + let head = String::from_utf8_lossy(&req).into_owned(); + let path = head + .lines() + .next() + .and_then(|l| l.split_whitespace().nth(1)) + .unwrap_or("/") + .to_string(); + let auth = head + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("authorization") + .then(|| value.trim().to_string()) + }) + .unwrap_or_default(); + log.lock().unwrap().push((path.clone(), auth)); + let body = routes + .get(&path) + .copied() + .unwrap_or(r#"{"ok":false,"error":"unknown_method"}"#); + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.flush(); + } + }); + (format!("http://{addr}"), seen) +} + +const AUTH_OK: &str = + r#"{"ok":true,"team":"Acme","user":"ccteam","user_id":"UBOT","bot_id":"BBOT"}"#; +const SOCKET_OK: &str = r#"{"ok":true,"url":"wss://wss-primary.slack.com/link/?ticket=t"}"#; + +#[tokio::test] +async fn slack_setup_ok_validates_both_tokens_and_returns_creds() { + let (base, seen) = spawn_slack_mock(&[ + ("/auth.test", AUTH_OK), + ("/apps.connections.open", SOCKET_OK), + ]); + + let result = slack_setup_with_base( + "xoxb-1-bot", + "xapp-1-app", + vec!["U0ALICE".into(), "U0BOB".into()], + &base, + ) + .await + .expect("valid tokens must validate"); + + assert_eq!(result.creds.bot_token, "xoxb-1-bot"); + assert_eq!(result.creds.app_token, "xapp-1-app"); + assert_eq!(result.creds.allowed_user_ids, vec!["U0ALICE", "U0BOB"]); + assert_eq!(result.team, "Acme"); + assert_eq!(result.bot_user, "ccteam"); + assert_eq!(result.bot_user_id, "UBOT"); + + let seen = seen.lock().unwrap().clone(); + assert_eq!( + seen, + vec![ + ("/auth.test".to_string(), "Bearer xoxb-1-bot".to_string()), + ( + "/apps.connections.open".to_string(), + "Bearer xapp-1-app".to_string() + ), + ], + "the bot token proves the Web API, the app token proves Socket Mode" + ); +} + +#[tokio::test] +async fn slack_setup_bad_bot_token_is_api_not_ok() { + let (base, seen) = spawn_slack_mock(&[ + ("/auth.test", r#"{"ok":false,"error":"invalid_auth"}"#), + ("/apps.connections.open", SOCKET_OK), + ]); + let err = slack_setup_with_base("xoxb-bad", "xapp-1", vec![], &base) + .await + .expect_err("ok:false on auth.test must fail"); + match err { + OnboardingError::ApiNotOk(msg) => { + assert!( + msg.contains("auth.test") && msg.contains("invalid_auth"), + "{msg}" + ); + } + other => panic!("expected ApiNotOk, got {other:?}"), + } + assert_eq!(seen.lock().unwrap().len(), 1, "stops at the first failure"); +} + +#[tokio::test] +async fn slack_setup_bad_app_token_is_api_not_ok() { + let (base, _) = spawn_slack_mock(&[ + ("/auth.test", AUTH_OK), + ( + "/apps.connections.open", + r#"{"ok":false,"error":"not_allowed_token_type"}"#, + ), + ]); + let err = slack_setup_with_base("xoxb-1", "xoxb-wrong-kind", vec![], &base) + .await + .expect_err("a bot token where the app token belongs must fail"); + match err { + OnboardingError::ApiNotOk(msg) => { + assert!( + msg.contains("apps.connections.open") && msg.contains("not_allowed_token_type"), + "{msg}" + ); + } + other => panic!("expected ApiNotOk, got {other:?}"), + } +} + +#[tokio::test] +async fn slack_setup_missing_bot_user_is_bad_response() { + let (base, _) = spawn_slack_mock(&[ + ("/auth.test", r#"{"ok":true,"team":"Acme"}"#), + ("/apps.connections.open", SOCKET_OK), + ]); + let err = slack_setup_with_base("xoxb-1", "xapp-1", vec![], &base) + .await + .expect_err("auth.test without user_id is malformed"); + assert!(matches!(err, OnboardingError::BadResponse(_)), "{err:?}"); +} From 68501f8d5a05cbeaff60d35e92c7b712265fd8e8 Mon Sep 17 00:00:00 2001 From: firstintent <239836187+firstintent@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:52:46 +0800 Subject: [PATCH 19/37] feat(cli,web): configure the Slack app from `ccteam config` and the API (#19) Slack needs the same validate-before-persist setup Lark has, from both operator surfaces: - `ccteam config` gains a Slack item (`run_config_set_slack_creds` + `_with_base` test seam): validate the bot token (auth.test) and the app-level token (apps.connections.open), merge into the credentials file without touching other platforms, nudge a live reload, and print the Slack app checklist (Socket Mode, /ccteam, interactivity, scopes, events) so a half-configured app is not a silent dead bot. - `GET /api/v1/config/im` reports a masked `slack` block (token tails only, plus the non-secret member-id allowlist); `PUT /api/v1/config/im/slack` validates, saves and hot-applies like the Lark handler, admin-only behind the same gate. Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com> --- crates/ccteam-cli/src/commands.rs | 372 +++++++++++++++++++-- crates/ccteam-web/src/routes/im_config.rs | 160 ++++++++- crates/ccteam-web/src/routes/openapi.rs | 1 + crates/ccteam-web/tests/im_config_test.rs | 161 ++++++++- crates/ccteam-web/tests/openapi_test.rs | 1 + crates/ccteam-web/tests/tenant_acl_test.rs | 13 + 6 files changed, 671 insertions(+), 37 deletions(-) diff --git a/crates/ccteam-cli/src/commands.rs b/crates/ccteam-cli/src/commands.rs index 0c94d0ee..c56847d8 100644 --- a/crates/ccteam-cli/src/commands.rs +++ b/crates/ccteam-cli/src/commands.rs @@ -2140,6 +2140,95 @@ pub fn run_config_set_lark_creds_with_base( )) } +/// `config` action โ€” validate Slack app credentials (bot token via +/// `auth.test`, app-level token via `apps.connections.open`) and persist them +/// to `~/.ccteam/secrets/im-credentials.json` (mode 0600). Mirrors +/// [`run_config_set_lark_creds`]: Socket Mode needs no public endpoint and +/// there is nothing to long-poll โ€” the allowlist is operator-supplied Slack +/// user ids (`Uโ€ฆ`), **fail-closed** (empty = the bot answers no one). +pub fn run_config_set_slack_creds( + bot_token: &str, + app_token: &str, + allowed_user_ids: Vec, +) -> Result { + run_config_set_slack_creds_with_base( + bot_token, + app_token, + allowed_user_ids, + ccteam_im::onboarding::SLACK_API_BASE, + None, + ) +} + +/// Test seam for [`run_config_set_slack_creds`]: overrides the Slack API +/// base (a deterministic local mock) and the credentials-file path (a +/// tempdir). Same `_with_base` convention as the Lark seam. +pub fn run_config_set_slack_creds_with_base( + bot_token: &str, + app_token: &str, + allowed_user_ids: Vec, + api_base: &str, + creds_path_override: Option<&std::path::Path>, +) -> Result { + let bot_token = bot_token.trim(); + let app_token = app_token.trim(); + if bot_token.is_empty() || app_token.is_empty() { + bail!("config: Slack bot token (xoxb-โ€ฆ) and app-level token (xapp-โ€ฆ) are both required"); + } + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("build tokio runtime for Slack credential onboarding")?; + let result = runtime + .block_on(ccteam_im::onboarding::slack_setup_with_base( + bot_token, + app_token, + allowed_user_ids, + api_base, + )) + .context("Slack onboarding (auth.test + apps.connections.open)")?; + + // Persist: merge so a prior Telegram / Lark / Discord entry survives. + let creds_path = match creds_path_override { + Some(p) => p.to_path_buf(), + None => ccteam_im::credentials::default_path(), + }; + let mut creds = ccteam_im::credentials::load(Some(&creds_path)) + .context("load existing IM credentials before merge")?; + let allow_count = result.creds.allowed_user_ids.len(); + creds.slack = Some(result.creds); + ccteam_im::credentials::save(&creds_path, &creds).context("persist IM credentials")?; + + // Best-effort live reload; skipped on the test seam (see the Lark twin). + if creds_path_override.is_none() { + notify_daemon_im_reload(); + } + + let allow_note = if allow_count == 0 { + " allowlist EMPTY โ€” fail-closed: the bot answers NO ONE.\n \ + add Slack user ids (Uโ€ฆ) to allowed_user_ids to let users in.\n" + .to_string() + } else { + format!(" allowlist {allow_count} user id(s) allowed\n") + }; + + Ok(format!( + "ccteam config: Slack credentials saved\n\n \ + workspace {}\n \ + bot @{} ({})\n\ + {} \ + credentials {}\n\n\ + {}\n\ + `ccteam start` will bring the IM gateway up with these credentials.\n", + result.team, + result.bot_user, + result.bot_user_id, + allow_note, + creds_path.display(), + ccteam_im::onboarding::SLACK_APP_CHECKLIST, + )) +} + /// Bare `ccteam config` โ€” thin numbered-choice interactive menu. Reads a /// single digit from stdin and dispatches to the same action fn the /// non-interactive path uses (so all real work stays in testable fns). @@ -2164,9 +2253,10 @@ pub fn run_config_menu(paths: &CcteamPaths) -> Result { println!(" 1) register / refresh the ccteam MCP server (~/.claude.json)"); println!(" 2) set the IM (Telegram) bot token"); println!(" 3) set Lark/Feishu app credentials"); - println!(" 4) show preferences"); + println!(" 4) set Slack app credentials (one thread per session)"); + println!(" 5) show preferences"); println!(" q) quit"); - print!("\nchoose [1-4/q]: "); + print!("\nchoose [1-5/q]: "); std::io::stdout().flush().ok(); let mut line = String::new(); @@ -2189,12 +2279,56 @@ pub fn run_config_menu(paths: &CcteamPaths) -> Result { run_config_set_im_token(&token) } "3" => run_config_lark_menu(), - "4" => run_prefs_show(paths), + "4" => run_config_slack_menu(), + "5" => run_prefs_show(paths), "q" | "Q" | "" => Ok("ccteam config: nothing changed.\n".to_string()), - other => bail!("ccteam config: unrecognized choice {other:?} (expected 1-4 or q)"), + other => bail!("ccteam config: unrecognized choice {other:?} (expected 1-5 or q)"), } } +/// One trimmed line from stdin after printing `label` (the menu prompts). +fn prompt_line(label: &str) -> Result { + use std::io::Write; + print!("{label}"); + std::io::stdout().flush().ok(); + let mut buf = String::new(); + std::io::stdin() + .read_line(&mut buf) + .with_context(|| format!("read {label:?} from stdin"))?; + Ok(buf.trim().to_string()) +} + +/// Split a pasted id list on commas / whitespace. +fn split_id_list(raw: &str) -> Vec { + raw.split([',', ' ', '\t']) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + +/// Interactive prompt for the menu's Slack item: collect the bot token, the +/// app-level token and the user-id allowlist, then hand off to +/// [`run_config_set_slack_creds`] (validate + persist). +fn run_config_slack_menu() -> Result { + println!( + "\nSlack app credentials (api.slack.com/apps โ†’ your app). The bot token is under\n\ + OAuth & Permissions; the app-level token (scope connections:write) under Basic Information." + ); + let bot_token = prompt_line("bot token (xoxb-โ€ฆ): ")?; + let app_token = prompt_line("app-level token (xapp-โ€ฆ): ")?; + println!( + "\nallowed_user_ids = the Slack member ids (Uโ€ฆ) allowed to drive the bot\n \ + (profile โ†’ โ‹ฏ โ†’ Copy member ID). FAIL-CLOSED: leaving this EMPTY means the\n \ + bot answers NO ONE. Use `*` to allow everyone in the workspace." + ); + let allowed_user_ids = split_id_list(&prompt_line( + "allowed user ids (comma/space separated, or blank): ", + )?); + println!("validating tokens (auth.test + apps.connections.open)โ€ฆ"); + run_config_set_slack_creds(&bot_token, &app_token, allowed_user_ids) +} + /// Interactive prompt for the menu's Lark/Feishu item: collect /// `app_id` / `app_secret`, the region (Feishu/CN default vs Lark intl), /// and the optional `open_id` allowlist, then hand off to @@ -2202,18 +2336,6 @@ pub fn run_config_menu(paths: &CcteamPaths) -> Result { /// separate from [`run_config_menu`] so the stdin reads stay linear and /// the persistence logic remains unit-testable without a TTY. fn run_config_lark_menu() -> Result { - use std::io::Write; - - fn prompt_line(label: &str) -> Result { - print!("{label}"); - std::io::stdout().flush().ok(); - let mut buf = String::new(); - std::io::stdin() - .read_line(&mut buf) - .with_context(|| format!("read {label:?} from stdin"))?; - Ok(buf.trim().to_string()) - } - println!( "\nLark/Feishu app credentials (from the developer console โ†’ app โ†’ Credentials & Basic Info)." ); @@ -2230,13 +2352,9 @@ fn run_config_lark_menu() -> Result { FAIL-CLOSED: leaving this EMPTY means the bot answers NO ONE\n \ (the opposite of Telegram, where empty = open). Use `*` to allow everyone." ); - let allow_raw = prompt_line("allowed open_ids (comma/space separated, or blank): ")?; - let allowed_user_ids: Vec = allow_raw - .split([',', ' ', '\t']) - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect(); + let allowed_user_ids = split_id_list(&prompt_line( + "allowed open_ids (comma/space separated, or blank): ", + )?); println!("validating app credentials (fetching a tenant_access_token)โ€ฆ"); run_config_set_lark_creds(&app_id, &app_secret, allowed_user_ids, use_feishu) @@ -5100,4 +5218,212 @@ mod tests { "must name the missing-field guard; got: {err}" ); } + + // --- Slack config (run_config_set_slack_creds) ---------------------- + // + // Setup makes two calls (`auth.test`, `apps.connections.open`), so the + // mock is a std TCP responder routed by request path that serves every + // connection until the test ends. No env mutation; the creds path is a + // tempdir โ€” safe in this lib module like the Lark tests above. + + /// Serve `routes` (path โ†’ JSON body) on `127.0.0.1:0`; unknown paths get + /// `{"ok":false,"error":"unknown_method"}`. Returns the `api_base`. + fn spawn_slack_http(routes: &[(&'static str, &'static str)]) -> String { + use std::io::{Read, Write}; + let routes: Vec<(&'static str, &'static str)> = routes.to_vec(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { return }; + let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(2))); + let mut req = Vec::new(); + let mut buf = [0u8; 1024]; + let mut header_end = None; + let mut content_length = 0usize; + loop { + match stream.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + req.extend_from_slice(&buf[..n]); + if header_end.is_none() { + if let Some(pos) = req.windows(4).position(|w| w == b"\r\n\r\n") { + header_end = Some(pos + 4); + content_length = String::from_utf8_lossy(&req[..pos]) + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + } + } + if let Some(end) = header_end { + if req.len().saturating_sub(end) >= content_length { + break; + } + } + } + Err(_) => break, + } + } + let head = String::from_utf8_lossy(&req).into_owned(); + let path = head + .lines() + .next() + .and_then(|l| l.split_whitespace().nth(1)) + .unwrap_or("/") + .to_string(); + let body = routes + .iter() + .find(|(p, _)| *p == path) + .map(|(_, b)| *b) + .unwrap_or(r#"{"ok":false,"error":"unknown_method"}"#); + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.flush(); + } + }); + format!("http://{addr}") + } + + const SLACK_AUTH_OK: &str = + r#"{"ok":true,"team":"Acme","user":"ccteam","user_id":"UBOT","bot_id":"BBOT"}"#; + const SLACK_SOCKET_OK: &str = r#"{"ok":true,"url":"wss://wss-primary.slack.com/link/?t=1"}"#; + + #[test] + fn slack_creds_persist_list_the_app_checklist_and_preserve_lark() { + let tmp = TempDir::new().unwrap(); + let creds_path = tmp.path().join("im/credentials.json"); + let seed = ccteam_im::credentials::Credentials { + lark: Some(ccteam_im::credentials::LarkCreds { + app_id: "cli_seed".into(), + app_secret: "seed_secret".into(), + allowed_user_ids: vec!["ou_seed".into()], + use_feishu: true, + }), + ..Default::default() + }; + ccteam_im::credentials::save(&creds_path, &seed).unwrap(); + let base = spawn_slack_http(&[ + ("/auth.test", SLACK_AUTH_OK), + ("/apps.connections.open", SLACK_SOCKET_OK), + ]); + + let out = run_config_set_slack_creds_with_base( + " xoxb-1-bot ", + "xapp-1-app", + vec!["U0ALICE".into(), "U0BOB".into()], + &base, + Some(&creds_path), + ) + .expect("slack creds validate + persist must succeed against the mock"); + assert!( + out.contains("Slack credentials saved") + && out.contains("2 user id(s) allowed") + && out.contains("@ccteam (UBOT)") + && out.contains("Acme"), + "summary must confirm the save; got: {out}" + ); + for item in [ + "Socket Mode", + "/ccteam", + "Interactivity", + "app_mentions:read chat:write channels:history groups:history", + "im:history mpim:history reactions:write files:read files:write commands", + "message.channels message.groups message.im message.mpim", + "invite the bot", + ] { + assert!( + out.contains(item), + "checklist must mention {item:?}; got: {out}" + ); + } + + let reloaded = ccteam_im::credentials::load(Some(&creds_path)).unwrap(); + let slack = reloaded.slack.expect("slack creds must be persisted"); + assert_eq!(slack.bot_token, "xoxb-1-bot", "tokens are trimmed"); + assert_eq!(slack.app_token, "xapp-1-app"); + assert_eq!(slack.allowed_user_ids, vec!["U0ALICE", "U0BOB"]); + let lark = reloaded.lark.expect("lark must survive the merge"); + assert_eq!(lark.app_id, "cli_seed"); + } + + #[test] + fn slack_creds_empty_allowlist_warns_fail_closed() { + let tmp = TempDir::new().unwrap(); + let creds_path = tmp.path().join("im/credentials.json"); + let base = spawn_slack_http(&[ + ("/auth.test", SLACK_AUTH_OK), + ("/apps.connections.open", SLACK_SOCKET_OK), + ]); + let out = run_config_set_slack_creds_with_base( + "xoxb-1", + "xapp-1", + vec![], + &base, + Some(&creds_path), + ) + .expect("an empty allowlist is a valid, locked-down config"); + assert!( + out.contains("fail-closed") && out.contains("NO ONE"), + "empty allowlist must surface the fail-closed warning; got: {out}" + ); + let reloaded = ccteam_im::credentials::load(Some(&creds_path)).unwrap(); + assert!(reloaded.slack.unwrap().allowed_user_ids.is_empty()); + } + + #[test] + fn slack_creds_rejected_app_token_errors_without_persisting() { + let tmp = TempDir::new().unwrap(); + let creds_path = tmp.path().join("im/credentials.json"); + let base = spawn_slack_http(&[ + ("/auth.test", SLACK_AUTH_OK), + ( + "/apps.connections.open", + r#"{"ok":false,"error":"invalid_auth"}"#, + ), + ]); + let err = run_config_set_slack_creds_with_base( + "xoxb-1", + "xapp-bad", + vec!["U1".into()], + &base, + Some(&creds_path), + ) + .expect_err("a rejected app-level token must not be saved"); + let msg = format!("{err:#}"); + assert!( + msg.contains("apps.connections.open") && msg.contains("invalid_auth"), + "error must carry the upstream Slack reason; got: {msg}" + ); + assert!( + !creds_path.exists(), + "credentials file must not be created when validation fails" + ); + } + + #[test] + fn slack_creds_blank_token_rejected_before_network() { + let tmp = TempDir::new().unwrap(); + let creds_path = tmp.path().join("im/credentials.json"); + let err = run_config_set_slack_creds_with_base( + "xoxb-1", + " ", + vec![], + "http://127.0.0.1:1", // would refuse-connect if reached + Some(&creds_path), + ) + .expect_err("a blank app-level token must be rejected up front"); + assert!( + err.to_string().contains("are both required"), + "must name the missing-field guard; got: {err}" + ); + } } diff --git a/crates/ccteam-web/src/routes/im_config.rs b/crates/ccteam-web/src/routes/im_config.rs index cf667a37..bac5a465 100644 --- a/crates/ccteam-web/src/routes/im_config.rs +++ b/crates/ccteam-web/src/routes/im_config.rs @@ -1,4 +1,5 @@ -//! v0.8.8 F4 โ€” web IM credential configuration (Telegram + Lark/Feishu). +//! v0.8.8 F4 โ€” web IM credential configuration (Telegram + Lark/Feishu + +//! Slack). //! //! Mounted into the `/api/v1` `OpenApiRouter` (see [`super::openapi`]), so //! every route here sits behind the same web-token gate as the rest of the @@ -20,11 +21,12 @@ //! `true` only on the standalone web path with no daemon gateway, where the //! new creds take effect on the next `ccteam start`). Mirrors the per-tenant //! path in [`super::users`]`::apply_tenant_im`. -//! - **Validate before persist.** A bad Telegram token / Lark app secret is -//! rejected (`400` + the [`OnboardingError`] `Display` reason) *before* it -//! lands on disk โ€” reusing the CLI validators -//! (`onboarding::{telegram_validate_token_with_base, lark_setup_with_base}`) -//! directly in the async handler (no nested runtime). +//! - **Validate before persist.** A bad Telegram token / Lark app secret / +//! Slack token is rejected (`400` + the [`OnboardingError`] `Display` +//! reason) *before* it lands on disk โ€” reusing the CLI validators +//! (`onboarding::{telegram_validate_token_with_base, lark_setup_with_base, +//! slack_setup_with_base}`) directly in the async handler (no nested +//! runtime). //! - **No TLS.** The daemon serves plain HTTP; configuring secrets over a //! LAN link is plaintext on the wire. The GET response carries a //! `transport_warning` so the SPA can surface it. @@ -51,8 +53,9 @@ use axum::{ }; use ccteam_im::credentials::{self, Credentials, LarkCreds, TelegramCreds}; use ccteam_im::onboarding::{ - lark_setup_with_base, telegram_poll_chat_id_with_base, telegram_validate_token_with_base, - FEISHU_API_BASE, LARK_API_BASE, TELEGRAM_API_BASE, + lark_setup_with_base, slack_setup_with_base, telegram_poll_chat_id_with_base, + telegram_validate_token_with_base, FEISHU_API_BASE, LARK_API_BASE, SLACK_API_BASE, + TELEGRAM_API_BASE, }; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; @@ -92,6 +95,8 @@ fn reload_note(reloaded: bool) -> &'static str { const TELEGRAM_API_BASE_ENV: &str = "CCTEAM_TELEGRAM_API_BASE"; /// Env override for the Lark/Feishu open-platform API base (both regions). const LARK_API_BASE_ENV: &str = "CCTEAM_LARK_API_BASE"; +/// Env override for the Slack Web API base (same test-override pattern). +const SLACK_API_BASE_ENV: &str = "CCTEAM_SLACK_API_BASE"; /// Resolve the Telegram API base: the [`TELEGRAM_API_BASE_ENV`] override if /// set + non-empty, else the production constant. `pub(crate)` so the per-tenant @@ -120,6 +125,15 @@ fn lark_api_base(use_feishu: bool) -> String { } } +/// Resolve the Slack Web API base: the [`SLACK_API_BASE_ENV`] override if set +/// + non-empty, else the production constant. +fn slack_api_base() -> String { + std::env::var(SLACK_API_BASE_ENV) + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| SLACK_API_BASE.to_string()) +} + // -------------------------------------------------------------------------- // Masking // -------------------------------------------------------------------------- @@ -170,6 +184,21 @@ pub struct LarkStatus { pub allowed_user_id_count: usize, } +/// Masked Slack status. Note the absence of any `bot_token` / `app_token` +/// field โ€” only last-4 fingerprints. Slack member ids (`Uโ€ฆ`) are not +/// secrets, so the allowlist is returned as-is. +#[derive(Debug, Serialize, ToSchema)] +pub struct SlackStatus { + /// Always `true` when present. + pub configured: bool, + /// Last-4 fingerprint of the `xoxb-` bot token, never the token. + pub bot_token_last4: String, + /// Last-4 fingerprint of the `xapp-` app-level token, never the token. + pub app_token_last4: String, + /// The Slack user ids allowed to drive the bot (empty = nobody). + pub allowed_user_ids: Vec, +} + /// `GET /config/im` response โ€” masked, secret-free. #[derive(Debug, Serialize, ToSchema)] pub struct ImConfigStatus { @@ -177,6 +206,8 @@ pub struct ImConfigStatus { pub telegram: Option, /// Present iff a Lark block exists on disk. pub lark: Option, + /// Present iff a Slack block exists on disk. + pub slack: Option, /// Cleartext-on-LAN caveat (no TLS) โ€” for the SPA to surface. pub transport_warning: String, } @@ -212,6 +243,20 @@ fn default_use_feishu() -> bool { true } +/// `PUT /config/im/slack` body. +#[derive(Debug, Deserialize, ToSchema)] +pub struct SlackConfigForm { + /// `xoxb-โ€ฆ` bot token (validated via `auth.test`). + pub bot_token: String, + /// `xapp-โ€ฆ` app-level token with `connections:write` (validated via + /// `apps.connections.open`). + pub app_token: String, + /// Slack user ids (`Uโ€ฆ`) allowed to drive the bot. Empty = fail-closed + /// (the bot answers no one) โ€” matches the provider-layer semantics. + #[serde(default)] + pub allowed_user_ids: Vec, +} + // -------------------------------------------------------------------------- // Helpers // -------------------------------------------------------------------------- @@ -274,8 +319,9 @@ fn json_500(msg: String) -> Response { /// `GET /api/v1/config/im` โ€” masked view of the configured IM credentials. /// -/// Never returns a `bot_token` or `app_secret` (the response type has no -/// such field); only last-4 fingerprints + counts + the `use_feishu` flag. +/// Never returns a `bot_token`, `app_token` or `app_secret` (the response +/// type has no such field); only last-4 fingerprints, counts, the +/// `use_feishu` flag and the (non-secret) Slack member-id allowlist. #[utoipa::path( get, path = "/api/v1/config/im", @@ -309,9 +355,16 @@ pub(crate) async fn handle_get_im_config( use_feishu: l.use_feishu, allowed_user_id_count: l.allowed_user_ids.len(), }); + let slack = creds.slack.map(|s| SlackStatus { + configured: true, + bot_token_last4: mask_last4(&s.bot_token), + app_token_last4: mask_last4(&s.app_token), + allowed_user_ids: s.allowed_user_ids, + }); Json(ImConfigStatus { telegram, lark, + slack, transport_warning: TRANSPORT_WARNING.to_string(), }) .into_response() @@ -615,6 +668,79 @@ pub(crate) async fn handle_put_lark( .into_response() } +// -------------------------------------------------------------------------- +// PUT /config/im/slack โ€” validate both tokens + persist +// -------------------------------------------------------------------------- + +/// `PUT /api/v1/config/im/slack` โ€” validate the bot token (`auth.test`) and +/// the app-level token (`apps.connections.open`), then persist. A rejected +/// token is `400` before it touches disk. +#[utoipa::path( + put, + path = "/api/v1/config/im/slack", + tag = "config", + request_body(content = SlackConfigForm, description = "Slack app tokens + member-id allowlist (JSON or x-www-form-urlencoded)"), + responses( + (status = 200, description = "Validated + persisted; `{ok, reloaded, restart_required, team, bot_user, note}`", body = serde_json::Value), + (status = 400, description = "Missing token / token rejected by Slack"), + (status = 500, description = "Credentials file read/write failed"), + ), +)] +pub(crate) async fn handle_put_slack( + State(app): State, + Extension(identity): Extension, + FormOrJson(form, _mode): FormOrJson, +) -> Response { + if let Some(deny) = deny_non_admin(&identity) { + return deny; + } + let bot_token = form.bot_token.trim(); + let app_token = form.app_token.trim(); + if bot_token.is_empty() || app_token.is_empty() { + return json_400("bot_token and app_token must not be empty".to_string()); + } + let allowed_user_ids: Vec = form + .allowed_user_ids + .iter() + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .collect(); + + // Validate before persisting (reuse the CLI validator). + let result = match slack_setup_with_base( + bot_token, + app_token, + allowed_user_ids, + &slack_api_base(), + ) + .await + { + Ok(result) => result, + Err(err) => return json_400(format!("Slack credentials rejected: {err}")), + }; + + // load โ†’ merge slack โ†’ save. + let mut creds = match load_creds(&app) { + Ok(c) => c, + Err(e) => return json_500(e), + }; + creds.slack = Some(result.creds); + if let Err(e) = save_creds(&app, &creds) { + return json_500(e); + } + + let reloaded = nudge_im_reload(&app).await; + Json(serde_json::json!({ + "ok": true, + "reloaded": reloaded, + "restart_required": !reloaded, + "team": result.team, + "bot_user": result.bot_user, + "note": reload_note(reloaded), + })) + .into_response() +} + // -------------------------------------------------------------------------- // Test-only seam: drive the async poll against a mock base // -------------------------------------------------------------------------- @@ -665,4 +791,18 @@ mod tests { assert!(v.get("bot_token").is_none(), "no bot_token key"); assert!(v.get("bot_token_last4").is_some()); } + + #[test] + fn slack_status_serializes_without_token_fields() { + let s = SlackStatus { + configured: true, + bot_token_last4: "โ€ฆabcd".into(), + app_token_last4: "โ€ฆwxyz".into(), + allowed_user_ids: vec!["U1".into()], + }; + let v = serde_json::to_value(&s).unwrap(); + assert!(v.get("bot_token").is_none(), "no bot_token key"); + assert!(v.get("app_token").is_none(), "no app_token key"); + assert_eq!(v["allowed_user_ids"], serde_json::json!(["U1"])); + } } diff --git a/crates/ccteam-web/src/routes/openapi.rs b/crates/ccteam-web/src/routes/openapi.rs index 0ea2bc76..51e4d910 100644 --- a/crates/ccteam-web/src/routes/openapi.rs +++ b/crates/ccteam-web/src/routes/openapi.rs @@ -294,6 +294,7 @@ fn build_api_v1() -> OpenApiRouter { .routes(routes!(super::im_config::handle_telegram_chat_id_start)) .routes(routes!(super::im_config::handle_telegram_chat_id_poll)) .routes(routes!(super::im_config::handle_put_lark)) + .routes(routes!(super::im_config::handle_put_slack)) // v0.8.9 Phase 2 โ€” ccteam-hub plugin marketplace: global catalog + // body preview, plus per-project decorated catalog + install. .routes(routes!(super::marketplace::handle_marketplace)) diff --git a/crates/ccteam-web/tests/im_config_test.rs b/crates/ccteam-web/tests/im_config_test.rs index 1fdf0def..327c8fb3 100644 --- a/crates/ccteam-web/tests/im_config_test.rs +++ b/crates/ccteam-web/tests/im_config_test.rs @@ -10,9 +10,10 @@ //! - **Creds path:** every test points `AppState::with_creds_path` at a //! tempdir file so the real `~/.ccteam/im/credentials.json` is never read //! or written (CLAUDE.md test-isolation rule). -//! - **Telegram/Lark HTTP:** an in-test axum mock stands in for the Bot / -//! Feishu API, injected via the `CCTEAM_TELEGRAM_API_BASE` / -//! `CCTEAM_LARK_API_BASE` env overrides. Those are process-global, so the +//! - **Telegram/Lark/Slack HTTP:** an in-test axum mock stands in for the +//! Bot / Feishu / Slack Web API, injected via the +//! `CCTEAM_TELEGRAM_API_BASE` / `CCTEAM_LARK_API_BASE` / +//! `CCTEAM_SLACK_API_BASE` env overrides. Those are process-global, so the //! env-mutating PUT tests are `#[serial]`. The async `chat_id` capture //! test uses the `spawn_chat_id_poll_for_test` seam (explicit base, no //! env) so it needs no serialization. @@ -22,7 +23,7 @@ use std::time::Duration; use axum::{routing::get, routing::post, Json, Router}; use ccteam_core::CcteamPaths; -use ccteam_im::credentials::{self, Credentials, LarkCreds, TelegramCreds}; +use ccteam_im::credentials::{self, Credentials, LarkCreds, SlackCreds, TelegramCreds}; use ccteam_web::{router_with_state, AppState, AuthState}; use serde_json::Value; use serial_test::serial; @@ -135,6 +136,40 @@ async fn spawn_lark_mock(code: i64) -> String { format!("http://{addr}") } +/// Spawn an axum mock for the two Slack calls setup makes: `auth.test` (bot +/// token) and `apps.connections.open` (app-level token). `bot_ok` / `app_ok` +/// pick success vs `invalid_auth` for each. +async fn spawn_slack_mock(bot_ok: bool, app_ok: bool) -> String { + let app = Router::new() + .route( + "/auth.test", + post(move || async move { + Json(if bot_ok { + serde_json::json!({"ok": true, "team": "Acme", "user": "ccteam", "user_id": "UBOT"}) + } else { + serde_json::json!({"ok": false, "error": "invalid_auth"}) + }) + }), + ) + .route( + "/apps.connections.open", + post(move || async move { + Json(if app_ok { + serde_json::json!({"ok": true, "url": "wss://wss-primary.slack.com/link/?t=1"}) + } else { + serde_json::json!({"ok": false, "error": "invalid_auth"}) + }) + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + tokio::task::yield_now().await; + format!("http://{addr}") +} + // -------------------------------------------------------------------------- // GET /config/im โ€” masked read // -------------------------------------------------------------------------- @@ -155,6 +190,11 @@ async fn get_im_config_masks_secrets() { allowed_user_ids: vec!["ou_a".into(), "ou_b".into()], use_feishu: true, }), + slack: Some(SlackCreds { + bot_token: "xoxb-SLACKBOTSECRETvalue".into(), + app_token: "xapp-SLACKAPPSECRETtail".into(), + allowed_user_ids: vec!["U0ALICE".into()], + }), ..Default::default() }; credentials::save(&creds_path, &creds).unwrap(); @@ -177,6 +217,10 @@ async fn get_im_config_masks_secrets() { !raw.contains("larkAPPSECRETvalue"), "app_secret leaked into GET body: {raw}" ); + assert!( + !raw.contains("SLACKBOTSECRET") && !raw.contains("SLACKAPPSECRET"), + "slack tokens leaked into GET body: {raw}" + ); // And no `bot_token` / `app_secret` key at all. let v: Value = serde_json::from_str(&raw).unwrap(); let tg = v.get("telegram").unwrap(); @@ -193,6 +237,15 @@ async fn get_im_config_masks_secrets() { assert!(lk.get("app_secret").is_none(), "no app_secret key"); assert_eq!(lk.get("use_feishu").unwrap(), true); assert_eq!(lk.get("allowed_user_id_count").unwrap(), 2); + let sl = v.get("slack").unwrap(); + assert!(sl.get("bot_token").is_none() && sl.get("app_token").is_none()); + assert_eq!(sl.get("configured").unwrap(), true); + assert_eq!(sl.get("bot_token_last4").unwrap(), "โ€ฆalue"); + assert_eq!(sl.get("app_token_last4").unwrap(), "โ€ฆtail"); + assert_eq!( + sl.get("allowed_user_ids").unwrap(), + &serde_json::json!(["U0ALICE"]) + ); // transport (no-TLS) warning present. assert!(v.get("transport_warning").unwrap().as_str().unwrap().len() > 10); } @@ -211,6 +264,7 @@ async fn get_im_config_empty_when_no_creds() { let v: Value = resp.json().await.unwrap(); assert!(v.get("telegram").unwrap().is_null()); assert!(v.get("lark").unwrap().is_null()); + assert!(v.get("slack").unwrap().is_null()); } // -------------------------------------------------------------------------- @@ -406,6 +460,105 @@ async fn put_lark_bad_creds_is_400_no_persist() { std::env::remove_var("CCTEAM_LARK_API_BASE"); } +// -------------------------------------------------------------------------- +// PUT /config/im/slack โ€” validate both tokens + persist +// -------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn put_slack_valid_tokens_persist_and_preserve_other_platforms() { + let base = spawn_slack_mock(true, true).await; + std::env::set_var("CCTEAM_SLACK_API_BASE", &base); + + let tmp = TempDir::new().unwrap(); + let (state, creds_path) = state_with_creds(&tmp, AuthState::disabled()); + credentials::save( + &creds_path, + &Credentials { + telegram: Some(TelegramCreds { + bot_token: "tg".into(), + allowed_chat_ids: vec!["42".into()], + }), + ..Default::default() + }, + ) + .unwrap(); + let addr = spawn_app(state).await; + + let resp = client() + .put(format!("http://{addr}/api/v1/config/im/slack")) + .json(&serde_json::json!({ + "bot_token": " xoxb-good ", + "app_token": "xapp-good", + "allowed_user_ids": ["U0ALICE", " ", "U0BOB "], + })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let v: Value = resp.json().await.unwrap(); + assert_eq!(v.get("ok").unwrap(), true); + assert_eq!(v.get("restart_required").unwrap(), true); + assert_eq!(v.get("team").unwrap(), "Acme"); + assert_eq!(v.get("bot_user").unwrap(), "ccteam"); + + let saved = credentials::load(Some(&creds_path)).unwrap(); + let sl = saved.slack.expect("slack block persisted"); + assert_eq!(sl.bot_token, "xoxb-good"); + assert_eq!(sl.app_token, "xapp-good"); + assert_eq!(sl.allowed_user_ids, vec!["U0ALICE", "U0BOB"]); + assert_eq!( + saved.telegram.unwrap().allowed_chat_ids, + vec!["42".to_string()], + "other platforms survive the merge" + ); + + std::env::remove_var("CCTEAM_SLACK_API_BASE"); +} + +#[tokio::test] +#[serial] +async fn put_slack_rejected_app_token_is_400_no_persist() { + let base = spawn_slack_mock(true, false).await; + std::env::set_var("CCTEAM_SLACK_API_BASE", &base); + + let tmp = TempDir::new().unwrap(); + let (state, creds_path) = state_with_creds(&tmp, AuthState::disabled()); + let addr = spawn_app(state).await; + + let resp = client() + .put(format!("http://{addr}/api/v1/config/im/slack")) + .json(&serde_json::json!({"bot_token": "xoxb-good", "app_token": "xapp-bad"})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400); + let v: Value = resp.json().await.unwrap(); + let error = v.get("error").unwrap().as_str().unwrap(); + assert!( + error.contains("Slack credentials rejected") && error.contains("apps.connections.open"), + "{error}" + ); + assert!(!creds_path.exists(), "rejected tokens must not persist"); + + std::env::remove_var("CCTEAM_SLACK_API_BASE"); +} + +#[tokio::test] +async fn put_slack_missing_token_is_400() { + let tmp = TempDir::new().unwrap(); + let (state, creds_path) = state_with_creds(&tmp, AuthState::disabled()); + let addr = spawn_app(state).await; + let resp = client() + .put(format!("http://{addr}/api/v1/config/im/slack")) + .json(&serde_json::json!({"bot_token": "xoxb-1", "app_token": " "})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400); + assert!(!creds_path.exists()); +} + // -------------------------------------------------------------------------- // Async telegram chat_id capture (no env โ€” explicit-base test seam) // -------------------------------------------------------------------------- diff --git a/crates/ccteam-web/tests/openapi_test.rs b/crates/ccteam-web/tests/openapi_test.rs index 44161c23..f47e55e7 100644 --- a/crates/ccteam-web/tests/openapi_test.rs +++ b/crates/ccteam-web/tests/openapi_test.rs @@ -167,6 +167,7 @@ fn expected_operations() -> BTreeSet<(&'static str, &'static str)> { ("POST", "/api/v1/config/im/telegram/chat-id/start"), ("GET", "/api/v1/config/im/telegram/chat-id"), ("PUT", "/api/v1/config/im/lark"), + ("PUT", "/api/v1/config/im/slack"), // v0.8.9 Phase 2 โ€” ccteam-hub plugin marketplace. ("GET", "/api/v1/marketplace"), ("GET", "/api/v1/marketplace/{id}/body"), diff --git a/crates/ccteam-web/tests/tenant_acl_test.rs b/crates/ccteam-web/tests/tenant_acl_test.rs index eb546669..1d48d052 100644 --- a/crates/ccteam-web/tests/tenant_acl_test.rs +++ b/crates/ccteam-web/tests/tenant_acl_test.rs @@ -65,6 +65,19 @@ async fn tenant_token_keeps_only_user_management_and_global_im_admin_only() { .await .unwrap(); assert_eq!(r.status(), 403, "tenant must be 403 on global IM config"); + // โ€ฆincluding the Slack write (denied before any token validation). + let r = c + .put(format!("http://{addr}/api/v1/config/im/slack")) + .header("Authorization", format!("Bearer ccteam:{tenant_tok}")) + .json(&serde_json::json!({"bot_token": "xoxb-1", "app_token": "xapp-1"})) + .send() + .await + .unwrap(); + assert_eq!( + r.status(), + 403, + "tenant must be 403 on the global Slack config" + ); // Shared operational/library surfaces are available to every identity. for path in ["/api/v1/hosts", "/api/v1/status", "/api/v1/skills"] { From 77af6b1d64e38b94be9aa654729a358aa8d3f550 Mon Sep 17 00:00:00 2001 From: firstintent <239836187+firstintent@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:52:46 +0800 Subject: [PATCH 20/37] feat(web): Slack card in the admin access settings (#19) The admin configures the global Slack app next to Telegram and Lark: a SlackSection mirroring the Lark card (bot token, app-level token, allowed Slack member ids; tokens never pre-filled or echoed, overwrite needs the inline confirm, empty allowlist warns fail-closed) backed by `saveSlack()` and the masked `slack` status in `getImConfig()`. Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com> --- .../ccteam-web/web/src/lib/configApi.test.ts | 57 ++++- crates/ccteam-web/web/src/lib/configApi.ts | 51 +++- .../web/src/pages/AccessView.test.tsx | 2 + .../ccteam-web/web/src/pages/AccessView.tsx | 11 +- .../web/src/pages/SettingsPage.test.tsx | 34 +++ .../ccteam-web/web/src/pages/SettingsPage.tsx | 218 +++++++++++++++++- 6 files changed, 364 insertions(+), 9 deletions(-) diff --git a/crates/ccteam-web/web/src/lib/configApi.test.ts b/crates/ccteam-web/web/src/lib/configApi.test.ts index 00ee17f6..f06c6fb1 100644 --- a/crates/ccteam-web/web/src/lib/configApi.test.ts +++ b/crates/ccteam-web/web/src/lib/configApi.test.ts @@ -13,6 +13,7 @@ import { getImConfig, pollTelegramChatId, saveLark, + saveSlack, saveTelegramToken, startTelegramChatId, } from "./configApi"; @@ -44,6 +45,12 @@ describe("configApi", () => { use_feishu: true, allowed_user_id_count: 2, }, + slack: { + configured: true, + bot_token_last4: "โ€ฆbot1", + app_token_last4: "โ€ฆapp1", + allowed_user_ids: ["U0ALICE"], + }, transport_warning: "no TLS", }; const fetchMock = vi.mocked(globalThis.fetch); @@ -70,15 +77,24 @@ describe("configApi", () => { "use_feishu", "allowed_user_id_count", ]); + expect(got.slack).not.toHaveProperty("bot_token"); + expect(got.slack).not.toHaveProperty("app_token"); + expect(Object.keys(got.slack ?? {})).toEqual([ + "configured", + "bot_token_last4", + "app_token_last4", + "allowed_user_ids", + ]); }); it("getImConfig tolerates null provider blocks", async () => { vi.mocked(globalThis.fetch).mockResolvedValueOnce( - jsonResponse(200, { telegram: null, lark: null, transport_warning: "" }), + jsonResponse(200, { telegram: null, lark: null, slack: null, transport_warning: "" }), ); const got = await getImConfig(); expect(got.telegram).toBeNull(); expect(got.lark).toBeNull(); + expect(got.slack).toBeNull(); }); it("saveTelegramToken PUTs {bot_token} to /config/im/telegram", async () => { @@ -213,6 +229,45 @@ describe("configApi", () => { ).rejects.toThrow("Lark credentials rejected: bad app_secret"); }); + it("saveSlack PUTs both tokens + the member-id allowlist to /config/im/slack", async () => { + const fetchMock = vi.mocked(globalThis.fetch); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + ok: true, + restart_required: false, + team: "Acme", + bot_user: "ccteam", + note: "applied live", + }), + ); + const got = await saveSlack({ + bot_token: "xoxb-1", + app_token: "xapp-1", + allowed_user_ids: ["U0ALICE", "U0BOB"], + }); + expect(fetchMock).toHaveBeenCalledWith("/api/v1/config/im/slack", { + method: "PUT", + credentials: "same-origin", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ + bot_token: "xoxb-1", + app_token: "xapp-1", + allowed_user_ids: ["U0ALICE", "U0BOB"], + }), + }); + expect(got.team).toBe("Acme"); + expect(got.bot_user).toBe("ccteam"); + }); + + it("saveSlack surfaces the server {error} on 400 (rejected token)", async () => { + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + jsonResponse(400, { error: "Slack credentials rejected: invalid_auth" }), + ); + await expect( + saveSlack({ bot_token: "xoxb-bad", app_token: "xapp-1", allowed_user_ids: [] }), + ).rejects.toThrow("Slack credentials rejected: invalid_auth"); + }); + it("maps 401 โ†’ UNAUTHENTICATED across read + write helpers", async () => { const fetchMock = vi.mocked(globalThis.fetch); fetchMock.mockResolvedValueOnce(jsonResponse(401, { error: "auth" })); diff --git a/crates/ccteam-web/web/src/lib/configApi.ts b/crates/ccteam-web/web/src/lib/configApi.ts index 0fca0557..c3854940 100644 --- a/crates/ccteam-web/web/src/lib/configApi.ts +++ b/crates/ccteam-web/web/src/lib/configApi.ts @@ -1,11 +1,13 @@ // v0.8.8 F4 โ€” REST client for the IM credential config surface -// (`/api/v1/config/im/...`), driving the Settings page (Telegram + Lark). +// (`/api/v1/config/im/...`), driving the Settings page (Telegram + Lark + +// Slack). // // Backend SoT: `crates/ccteam-web/src/routes/im_config.rs`. Every endpoint // sits behind the same web-token gate as the rest of the resource API. // // ็บข็บฟ(red line): the read shape NEVER carries a plaintext secret โ€” the -// server `ImConfigStatus` has no `bot_token` / `app_secret` field at all, +// server `ImConfigStatus` has no `bot_token` / `app_token` / `app_secret` +// field at all, // only last-4 fingerprints + counts. {@link ImConfigStatus} mirrors that // exactly: omitting the secret fields here is a second (type-level) guard // on top of the server's. @@ -42,12 +44,26 @@ export interface LarkStatus { allowed_user_id_count: number; } +/** Masked Slack status (`im_config::SlackStatus`). No token fields โ€” only + * last-4 fingerprints; Slack member ids are not secrets. */ +export interface SlackStatus { + /** Always `true` when present. */ + configured: boolean; + /** Last-4 fingerprint of the `xoxb-` bot token (`โ€ฆwxyz`). */ + bot_token_last4: string; + /** Last-4 fingerprint of the `xapp-` app-level token (`โ€ฆwxyz`). */ + app_token_last4: string; + /** Slack member ids (`Uโ€ฆ`) allowed to drive the bot โ€” empty = no one. */ + allowed_user_ids: string[]; +} + /** `GET /api/v1/config/im` response โ€” masked, secret-free - * (`im_config::ImConfigStatus`). `telegram`/`lark` are `null` when the - * corresponding block is absent on disk. */ + * (`im_config::ImConfigStatus`). `telegram`/`lark`/`slack` are `null` when + * the corresponding block is absent on disk. */ export interface ImConfigStatus { telegram: TelegramStatus | null; lark: LarkStatus | null; + slack: SlackStatus | null; /** Cleartext-on-LAN caveat (no TLS) โ€” surfaced as a warning banner. */ transport_warning: string; } @@ -108,6 +124,27 @@ export interface LarkSaveResult { note: string; } +/** `PUT /config/im/slack` request body. */ +export interface SlackSaveInput { + /** `xoxb-โ€ฆ` bot token (validated via `auth.test`). */ + bot_token: string; + /** `xapp-โ€ฆ` app-level token (validated via `apps.connections.open`). */ + app_token: string; + /** Slack member ids (`Uโ€ฆ`) โ€” empty = fail-closed (no one). */ + allowed_user_ids: string[]; +} + +/** `PUT /config/im/slack` success body. */ +export interface SlackSaveResult { + ok: boolean; + restart_required: boolean; + /** Workspace name from `auth.test` (validation echo, not a secret). */ + team: string; + /** The bot user's handle from `auth.test`. */ + bot_user: string; + note: string; +} + /** Read a server `{error: "..."}` body, falling back to `HTTP ` * when the response has no parseable error string. Used by the mutating * helpers so a 400 surfaces the validator's human reason. */ @@ -208,3 +245,9 @@ export function pollTelegramChatId(): Promise { export function saveLark(input: LarkSaveInput): Promise { return putJson("/api/v1/config/im/lark", input); } + +/** `PUT /api/v1/config/im/slack` โ€” validate both tokens (`auth.test` + + * `apps.connections.open`) + persist. 400 surfaces the validator's reason. */ +export function saveSlack(input: SlackSaveInput): Promise { + return putJson("/api/v1/config/im/slack", input); +} diff --git a/crates/ccteam-web/web/src/pages/AccessView.test.tsx b/crates/ccteam-web/web/src/pages/AccessView.test.tsx index 121d84e6..6e35e73a 100644 --- a/crates/ccteam-web/web/src/pages/AccessView.test.tsx +++ b/crates/ccteam-web/web/src/pages/AccessView.test.tsx @@ -112,6 +112,7 @@ describe("AccessView", () => { const html = renderToString(); expect(html).toContain('data-testid="settings-telegram"'); expect(html).toContain('data-testid="settings-lark"'); + expect(html).toContain('data-testid="settings-slack"'); expect(html).toContain('data-testid="access-login-links"'); expect(html).toContain('data-testid="access-im"'); expect(html).toContain('data-testid="access-mcp"'); @@ -141,6 +142,7 @@ describe("AccessView", () => { expect(html).toContain('data-testid="access-satellite"'); expect(html).not.toContain('data-testid="settings-telegram"'); expect(html).not.toContain('data-testid="settings-lark"'); + expect(html).not.toContain('data-testid="settings-slack"'); expect(html).not.toContain('data-testid="settings-transport-warning"'); expect(html).not.toContain('data-testid="access-login-links"'); }); diff --git a/crates/ccteam-web/web/src/pages/AccessView.tsx b/crates/ccteam-web/web/src/pages/AccessView.tsx index 09e2f6cf..608be8d9 100644 --- a/crates/ccteam-web/web/src/pages/AccessView.tsx +++ b/crates/ccteam-web/web/src/pages/AccessView.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; -import { Braces, KeyRound, Link2, MessageSquare, Network, Send } from "lucide-react"; +import { Braces, Hash, KeyRound, Link2, MessageSquare, Network, Send } from "lucide-react"; import { Button, Card, @@ -30,7 +30,7 @@ import { getUserLink, listUsers, type TenantView } from "../lib/usersApi"; import { useMe } from "../hooks/useMe"; import { toastBus } from "../lib/toastBus"; import { JoinCard } from "./HostsView"; -import { LarkSection, MyImSection, TelegramSection } from "./SettingsPage"; +import { LarkSection, MyImSection, SlackSection, TelegramSection } from "./SettingsPage"; const CODE_PRE_CLASS = "max-h-80 overflow-auto rounded-lg border border-surface-700 bg-surface-950 p-3 text-[11px] text-text-secondary"; @@ -135,6 +135,7 @@ export default function AccessView({ lang }: { lang: Lang }) { <> + ) : ( <> @@ -150,6 +151,12 @@ export default function AccessView({ lang }: { lang: Lang }) { title="Lark / ้ฃžไนฆ" loadingLabel={t("loading")} /> + } + title="Slack" + loadingLabel={t("loading")} + /> )} diff --git a/crates/ccteam-web/web/src/pages/SettingsPage.test.tsx b/crates/ccteam-web/web/src/pages/SettingsPage.test.tsx index 72d07621..70589067 100644 --- a/crates/ccteam-web/web/src/pages/SettingsPage.test.tsx +++ b/crates/ccteam-web/web/src/pages/SettingsPage.test.tsx @@ -16,6 +16,7 @@ import { renderToString } from "react-dom/server"; import { LarkSection, MyImSection, + SlackSection, TelegramSection, UserManagementSection, } from "./SettingsPage"; @@ -86,6 +87,39 @@ describe("Settings sections", () => { expect(html).toContain("fail-closed"); }); + it("SlackSection (configured) renders masked token tails + allowlist size, no secret field", () => { + const html = renderToString( + {}} + />, + ); + expect(html).toContain('data-testid="settings-slack"'); + expect(html).toContain('data-testid="settings-slack-summary"'); + expect(html).toContain("โ€ฆbot1"); + expect(html).toContain("โ€ฆapp1"); + expect(html).toContain("allowed users"); + expect(html).not.toContain('type="password"'); + }); + + it("SlackSection (unconfigured) shows empty bot + app token fields and the fail-closed note", () => { + const html = renderToString( {}} />); + expect(html).toContain('data-testid="settings-slack"'); + expect(html).toContain("ๆœช้…็ฝฎ"); + expect(html).toContain('id="settings-slack-bot-token"'); + expect(html).toContain('id="settings-slack-app-token"'); + expect(html).toContain('placeholder="xoxb-โ€ฆ"'); + expect(html).toContain('placeholder="xapp-โ€ฆ"'); + expect(html).toContain('id="settings-slack-users"'); + expect(html).not.toMatch(/value="[^"]+"/); + expect(html).toContain("fail-closed"); + }); + it("MyImSection guides Telegram and Lark as two separate stepped cards", () => { const html = renderToString(); expect(html).toContain('data-testid="settings-my-im"'); diff --git a/crates/ccteam-web/web/src/pages/SettingsPage.tsx b/crates/ccteam-web/web/src/pages/SettingsPage.tsx index ca7ad5d6..a79a8b9f 100644 --- a/crates/ccteam-web/web/src/pages/SettingsPage.tsx +++ b/crates/ccteam-web/web/src/pages/SettingsPage.tsx @@ -1,5 +1,5 @@ // v0.8.8 F4 โ€” Settings panels: the admin's GLOBAL IM credentials -// (Telegram + Lark), the tenant's self-serve ใ€Œๆˆ‘็š„ IM botใ€, and the +// (Telegram + Lark + Slack), the tenant's self-serve ใ€Œๆˆ‘็š„ IM botใ€, and the // admin-only user management table. SettingsView places them: credentials // under ่ฎพ็ฝฎโ†’ๆŽฅๅ…ฅ (admin), MyImSection under ่ฎพ็ฝฎโ†’ๆŽฅๅ…ฅ (tenant), and // UserManagementSection on the standalone ็ฎก็†ๅ‘˜ ยท Admin tab. @@ -41,10 +41,11 @@ // re-run (cleanup `clearTimeout`) so navigating away never leaks a timer. import { useCallback, useEffect, useRef, useState } from "react"; -import { Link2, MessageSquare, Send, Users } from "lucide-react"; +import { Hash, Link2, MessageSquare, Send, Users } from "lucide-react"; import { pollTelegramChatId, saveLark, + saveSlack, saveTelegramToken, startTelegramChatId, type ChatIdPollStatus, @@ -657,6 +658,219 @@ export function LarkSection({ ); } +// -------------------------------------------------------------------------- +// Slack โ€” Socket Mode; every agent session lives in its own Slack thread +// -------------------------------------------------------------------------- + +export function SlackSection({ + lang = "zh", + status, + onSaved, +}: { + lang?: Lang; + status: ImConfigStatus["slack"]; + onSaved: () => void; +}) { + const t = makeT(lang); + const configured = status?.configured ?? false; + const [editing, setEditing] = useState(!configured); + const [botToken, setBotToken] = useState(""); + const [appToken, setAppToken] = useState(""); + const [userIdsRaw, setUserIdsRaw] = useState(""); + const [pending, setPending] = useState(false); + const [confirming, setConfirming] = useState(false); + + const userIds = parseUserIds(userIdsRaw); + const canSubmit = !pending && botToken.trim().length > 0 && appToken.trim().length > 0; + + async function persist() { + setPending(true); + setConfirming(false); + try { + const res = await saveSlack({ + bot_token: botToken.trim(), + app_token: appToken.trim(), + allowed_user_ids: userIds, + }); + toastBus.handler?.info(res.note || "Slack saved. Restart to apply."); + setBotToken(""); + setAppToken(""); + setUserIdsRaw(""); + setEditing(false); + onSaved(); + } catch (err) { + if (err instanceof Error && err.message === "UNAUTHENTICATED") return; + const msg = err instanceof Error ? err.message : "save failed"; + toastBus.handler?.error(msg); + } finally { + setPending(false); + } + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!canSubmit) return; + if (configured && !confirming) { + setConfirming(true); + return; + } + void persist(); + } + + function cancelEdit() { + setBotToken(""); + setAppToken(""); + setUserIdsRaw(""); + setConfirming(false); + setEditing(false); + } + + return ( + + + + Slack + {configured ? ( + ๅทฒ่ฟžๆŽฅ + ) : ( + ๆœช้…็ฝฎ + )} + + + {editing ? ( + +

+
+
+ {/* Red line: token fields always start EMPTY. */} + + { + setBotToken(e.target.value); + if (confirming) setConfirming(false); + }} + disabled={pending} + spellCheck={false} + placeholder="xoxb-โ€ฆ" + className="font-mono" + /> +
+ +
+ + { + setAppToken(e.target.value); + if (confirming) setConfirming(false); + }} + disabled={pending} + spellCheck={false} + placeholder="xapp-โ€ฆ" + className="font-mono" + /> +
+
+ +
+ +