diff --git a/AGENTS.md b/AGENTS.md index 8d1402987..2a52658ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ ccteam = 多 harness agent 团队的桥接与治理层:常驻 daemon(IM gateway | **daemon 无自主内容决策循环** | 只响应消息、排程与连接;引擎不产生任务、不选择工作;编排智能永在会话/用户空间 | | **`progress.jsonl` 是 state SoT** | schema 权威 = `harness/progress_bridge`,`core` 只 re-export;对话原文 = `/.ccteam/chat//turns.jsonl`(按 sid;gateway `spawn_event_pump` 是 live daemon 唯一 turns writer) | | **session = 独立一等实体** | 持久 `sid`(`s`,单调、扛重启、不复用)是唯一身份,任何属性都不参与去重;turns/marker/pane 全按 sid;生命周期 = spawn-on-demand + resume-by-sid + 空闲释放/容量挤停,非常驻吊着;chat 复用 context 是 feature | -| **ACL = 一个身份解析器 + 一套归属策略,两个前端共用(fail-closed)** | ①身份:`Gateway::principal` 唯一解析器,三态 Operator(admin web token / 全局 bot 允许列表点名的 chat)/ Tenant(`@` bot 或 per-user web token → `user:`)/ Guest(进门未点名:只拥有自己建的,看不到任何 project);「够得着 bot」≠「是 operator」,`"*"` 通配点名零人(`bind_operator_allowlist`;空表 = 未配置,单人默认 + 启动告警)。②归属:project 是归属单元(`ProjectState.owner`),session 继承;唯一策略 = `ccteam_core::identity::{can_see_owner, can_see_session_owner}`,IM/web/MCP 全走它;session 可见 = 自己建的 ⊕ 自己身份的 web 控制台池;IM chat 互相隔离,web 不看 IM session;「同-current-project 互看」已反转删除,勿加回。③解析永不越界:`current_project_for` 只在本人可见集里落地(`/cd` 选的 → daemon 默认(可见时)→ 本人首个),无可见项目 = 拒绝并给下一步,绝不回落 daemon 默认项目。④门:REST 单一 choke point = `auth::project_acl_layer`(认全部项目寻址族 `/api/v1/projects/{slug}/*` · `/api/{slug}/*` · `/ws/{slug}/*`,新路由自动覆盖);`/sessions/{sid}/*` 按其 project 门(admin 同样过门);集合面与 SSE 按身份过滤;`/ws/chat` 身份取自认证态不认 query;admin 专属只剩 `/users*` + 全局 `/config/im`(tenant 自助面 = `/me/im`),deny→403;SPA 仅管理员 tab 按 `GET /api/v1/me` 显隐。诚实范围:同 OS uid 下是软隔离(UX)非安全边界;web↔IM 同一人复联 deferred | +| **ACL = 一个身份解析器 + 一套归属策略,两个前端共用(fail-closed)** | ①身份:`Gateway::principal` 唯一解析器,三态 Operator(admin web token / 全局 bot 允许列表点名的 chat)/ Tenant(`@` bot 或 per-user web token → `user:`)/ Guest(进门未点名:只拥有自己建的,看不到任何 project);「够得着 bot」≠「是 operator」,`"*"` 通配点名零人(`bind_operator_allowlist`;空表 = 未配置,单人默认 + 启动告警)。②归属:project 是归属单元(`ProjectState.owner`),session 继承;唯一策略 = `ccteam_core::identity::{can_see_owner, can_see_session_owner}`,IM/web/MCP 全走它;session 可见 = 自己建的 ⊕ 自己身份的 web 控制台池 ⊕ owner 的 IM 会话池(owner 2026-10-05:bot 允许列表**点名**的 owner chat 互看全部 IM 会话,开放模式不算,`operator_im_pool_sees`);**可见 ≠ 推送**:会话只推给唯一 reply 目标(最后驱动/显式切过去且仍聚焦的 chat),绝不广播;其余 IM chat 互相隔离,web 不看 IM session;「同-current-project 互看」已反转删除,勿加回。③解析永不越界:`current_project_for` 只在本人可见集里落地(`/cd` 选的 → daemon 默认(可见时)→ 本人首个),无可见项目 = 拒绝并给下一步,绝不回落 daemon 默认项目。④门:REST 单一 choke point = `auth::project_acl_layer`(认全部项目寻址族 `/api/v1/projects/{slug}/*` · `/api/{slug}/*` · `/ws/{slug}/*`,新路由自动覆盖);`/sessions/{sid}/*` 按其 project 门(admin 同样过门);集合面与 SSE 按身份过滤;`/ws/chat` 身份取自认证态不认 query;admin 专属只剩 `/users*` + 全局 `/config/im`(tenant 自助面 = `/me/im`),deny→403;SPA 仅管理员 tab 按 `GET /api/v1/me` 显隐。诚实范围:同 OS uid 下是软隔离(UX)非安全边界;web↔IM 同一人复联 deferred | | **不解析终端输出** | 读 transcript jsonl + 官方 hooks fast event;不 scrape pane(`tmux capture-pane` 仅 dev 调试;web 终端快照走 pane-snapshot 只读)| | **terminal 协议(tmux/rmux)冻结 = 维护-only** | 新 vendor / 新功能不得新增 tmux/rmux/PTY 依赖(新 harness 一律长驻 stdio:stream-json / ACP / app-server);既有 Claude `terminal` 只修不扩;逐字节终端镜像不再是新功能验收条件 | | **永不主动 kill 长 session(kill 的是进程,不是会话)** | 会话 = sid + 账本 + 焦点路由,永不被 daemon 自行终结;daemon 只管常驻(residency):`resident` / `released`(下条消息按 sid 冷 resume)/ `stopped`(用户显式,`meta.stopped_at`)/ `detached`(体外活)。空闲释放 = 主机制(`sessions.idle_release_secs` 默认 3600 = claude prompt-cache TTL;`idle_release_by_vendor.` 覆盖;0 = 永不;资格 = 无在飞 turn ∧ 无 HITL 等待 ∧ 无 outlives-turn 后台任务);容量挤停 = 次级硬上限(`sessions.max_live`,LRU;等待用户决定的会话与新会话父链绝不挤,创建永不因容量失败);两者同一条释放路(`session_evicted{reason:idle\|capacity}` + lifecycle 广播),都不删焦点路由;daemon 重启不重生进程(只探 body)。预算例外:`budgets.*.max_cost_usd_per_24h` 触顶 auto-disable。`project stop` / `/stop` / `rm --force` 是用户显式命令;`/compact /new` 是合法 turn,`/new` 总铸新 sid | diff --git a/Cargo.lock b/Cargo.lock index 7a2573a44..d861b680a 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]] @@ -394,7 +394,7 @@ dependencies = [ [[package]] name = "ccteam-cli" -version = "0.11.2" +version = "0.12.0" dependencies = [ "anyhow", "async-trait", @@ -427,7 +427,7 @@ dependencies = [ [[package]] name = "ccteam-core" -version = "0.11.2" +version = "0.12.0" dependencies = [ "anyhow", "async-trait", @@ -455,7 +455,7 @@ dependencies = [ [[package]] name = "ccteam-cost" -version = "0.11.2" +version = "0.12.0" dependencies = [ "chrono", "serde", @@ -468,7 +468,7 @@ dependencies = [ [[package]] name = "ccteam-flow" -version = "0.11.2" +version = "0.12.0" dependencies = [ "async-trait", "ccteam-harness", @@ -485,7 +485,7 @@ dependencies = [ [[package]] name = "ccteam-harness" -version = "0.11.2" +version = "0.12.0" dependencies = [ "anyhow", "async-trait", @@ -523,7 +523,7 @@ dependencies = [ [[package]] name = "ccteam-hooks" -version = "0.11.2" +version = "0.12.0" dependencies = [ "anyhow", "ccteam-core", @@ -538,7 +538,7 @@ dependencies = [ [[package]] name = "ccteam-im" -version = "0.11.2" +version = "0.12.0" dependencies = [ "anyhow", "async-trait", @@ -569,7 +569,7 @@ dependencies = [ [[package]] name = "ccteam-web" -version = "0.11.2" +version = "0.12.0" dependencies = [ "anyhow", "askama", @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index 71adc0a1a..613597d89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ members = [ ] [workspace.package] -version = "0.11.2" +version = "0.12.0" edition = "2021" rust-version = "1.85" license = "MIT" diff --git a/README.md b/README.md index ea51096b3..9635a872d 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 start with `!` there — `!status`, `!model` — since Slack keeps `/` for itself; Settings → Access creates the Slack app for you in one click): ```text /cd demo # pick a project; your next message talks to it diff --git a/crates/ccteam-cli/src/commands.rs b/crates/ccteam-cli/src/commands.rs index 0c94d0ee5..17465271e 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-core/src/identity.rs b/crates/ccteam-core/src/identity.rs index 356878d33..16a56dcdc 100644 --- a/crates/ccteam-core/src/identity.rs +++ b/crates/ccteam-core/src/identity.rs @@ -92,6 +92,19 @@ pub fn can_see_session_owner( is_web_pool_owned(owner) && can_see_owner(user_id, is_admin, Some(owner)) } +/// The owner's IM chats share ONE session pool (owner decision 2026-10-05): +/// an IM chat that is a NAMED operator — listed in a global bot's allowlist, +/// never the open-mode fallback that treats anyone as the operator — sees +/// every IM-owned session, whichever of the owner's chats (Telegram, Slack, +/// Lark …) created it. Visibility only: a session still delivers to its one +/// reply target, the chat that last drove it or switched to it, so seeing a +/// session never makes it push to another chat. The web-console pools +/// (`user:*`) stay under [`can_see_session_owner`]: a tenant's sessions stay +/// private and the web console still does not list IM sessions. +pub fn operator_im_pool_sees(viewer_identity: &str, named_operator: bool, owner: &str) -> bool { + named_operator && !is_web_pool_owned(viewer_identity) && !is_web_pool_owned(owner) +} + #[cfg(test)] mod tests { use super::*; @@ -192,4 +205,18 @@ mod tests { "user:ualice" )); } + + #[test] + fn a_named_operators_im_chats_share_one_session_pool() { + // Telegram and Slack chats of the owner see each other's sessions… + assert!(operator_im_pool_sees("slack:C1", true, "telegram:339")); + assert!(operator_im_pool_sees("telegram:339", true, "slack:C1")); + // …but only when the viewer is NAMED (never the open-mode fallback), + assert!(!operator_im_pool_sees("slack:C1", false, "telegram:339")); + // never a web-console pool (tenants stay private), + assert!(!operator_im_pool_sees("slack:C1", true, "user:ualice")); + assert!(!operator_im_pool_sees("slack:C1", true, "user:web-api")); + // and the web console itself does not start listing IM sessions. + assert!(!operator_im_pool_sees("user:web-api", true, "telegram:339")); + } } diff --git a/crates/ccteam-harness/src/execution/acp/translate.rs b/crates/ccteam-harness/src/execution/acp/translate.rs index c89fca7f0..f7f72d28a 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" => { @@ -649,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 @@ -657,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. @@ -673,16 +743,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 +817,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 +1234,99 @@ 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" + ); + } + + /// #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() { + 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 77fae1bbb..500210c14 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 9082f5061..055593968 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). @@ -374,11 +379,23 @@ 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); + 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 +471,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 +498,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 +604,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 +749,100 @@ 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 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(); + // 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 +851,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 +874,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 +897,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 +913,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 +942,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 +994,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/dsh_acp/assets/ccteam-ui.tgz b/crates/ccteam-harness/src/execution/dsh_acp/assets/ccteam-ui.tgz index 041b1e270..1fce76cf7 100644 Binary files a/crates/ccteam-harness/src/execution/dsh_acp/assets/ccteam-ui.tgz and b/crates/ccteam-harness/src/execution/dsh_acp/assets/ccteam-ui.tgz differ diff --git a/crates/ccteam-harness/src/execution/pi_rpc/translate.rs b/crates/ccteam-harness/src/execution/pi_rpc/translate.rs index dfcf61c2d..15a5e5c00 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/src/usage_catalog.rs b/crates/ccteam-harness/src/usage_catalog.rs index f162e5b6d..51b4e7aee 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. diff --git a/crates/ccteam-harness/tests/pi_rpc_test.rs b/crates/ccteam-harness/tests/pi_rpc_test.rs index 581873012..fe7944dc3 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/credentials.rs b/crates/ccteam-im/src/credentials.rs index edec078c7..e2b9d319a 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 802c37dd3..297bb1b6a 100644 --- a/crates/ccteam-im/src/daemon.rs +++ b/crates/ccteam-im/src/daemon.rs @@ -463,6 +463,10 @@ where // Privilege is a NAMED chat, never "reached the bot": seed the operator // roster from the same credentials the channels above were built from. bind_operator_rosters(&mut *gateway.lock().await, &creds); + // Which channels render buttons is the provider's fact; hand it over so + // the gateway's pickers never branch on a platform name. + let button_caps = channel_button_caps(&shared_channels.read().unwrap()); + bind_channel_buttons(&mut *gateway.lock().await, &button_caps); // V0.8.4 P2b — use the externally-supplied channel when `ccteam start` // provided one (so the mcp.sock handler shares this sender); else make // our own (standalone `ccteam-im run`). @@ -749,6 +753,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())); } @@ -769,6 +778,22 @@ fn bind_operator_rosters(gateway: &mut Gateway, creds: &Credentials) { } } +/// Each channel's [`Channel::native_buttons`], read out of `channels` so no +/// lock is held while the gateway is updated. +fn channel_button_caps(channels: &ChannelMap) -> Vec<(String, bool)> { + channels + .iter() + .map(|(name, ch)| (name.clone(), ch.native_buttons())) + .collect() +} + +/// Report channel button capabilities to the gateway (startup + IM reload). +fn bind_channel_buttons(gateway: &mut Gateway, caps: &[(String, bool)]) { + for (name, native) in caps { + gateway.bind_channel_buttons(name, *native); + } +} + #[allow(clippy::too_many_arguments)] async fn reload_im_channels( gateway: &Arc>, @@ -817,6 +842,8 @@ async fn reload_im_channels( rebuilt.insert(name, ch); } } + // A rebuilt channel's button capability goes to the gateway with it. + bind_channel_buttons(&mut *gateway.lock().await, &channel_button_caps(&rebuilt)); // Apply: for each rebuilt channel, abort its old listener, spawn a new one, // and (re)publish its command menu. for (name, ch) in rebuilt.iter() { @@ -970,22 +997,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` @@ -1439,6 +1471,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 +1505,7 @@ fn spawn_inbound_consumer( &msg.channel, &msg.reply_target, &msg.sender, + thread.as_deref(), &msg.id, &clean_payload, &msg.attachments, @@ -1524,6 +1563,7 @@ fn spawn_inbound_consumer( &msg.channel, &msg.reply_target, &msg.sender, + thread.as_deref(), &clean_payload, msg.selection.is_some(), ) @@ -1544,6 +1584,7 @@ fn spawn_inbound_consumer( &msg.channel, &msg.reply_target, &msg.sender, + thread.as_deref(), &msg.id, &clean_payload, &msg.attachments, @@ -1570,6 +1611,7 @@ fn spawn_inbound_consumer( &msg.channel, &msg.reply_target, &msg.sender, + thread.as_deref(), &msg.id, &clean_payload, &msg.attachments, @@ -1590,6 +1632,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 @@ -1730,7 +1785,7 @@ fn spawn_gateway_event_consumer( GatewayEventKind::Delegation { .. } => {} GatewayEventKind::SessionLifecycle { .. } => {} GatewayEventKind::ScheduledChanged => {} - // v0.8.19 — the 👀 ack reaction (IM-only; web/discord/slack keep + // v0.8.19 — the 👀 ack reaction (IM-only; web/discord keep // the trait's no-op `add_reaction`/`remove_reaction`). Mirror the // Activity arm's discipline: ALL fire-and-forget — log + swallow, // never propagate, so a reaction can't break/delay the turn. @@ -2123,6 +2178,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")] @@ -2149,6 +2228,58 @@ 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.native_buttons()); + assert!(ch.max_message_len().is_some()); + } + + /// The daemon hands every live channel's button capability to the + /// gateway: a provider that renders buttons is bound, one that does not + /// (the mock) is not. + #[test] + fn channel_button_caps_come_from_the_providers() { + let mut map: ChannelMap = HashMap::new(); + 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() + }; + map.insert( + "slack".into(), + build_slack_channel(&creds, &[], None).expect("slack channel"), + ); + map.insert( + "mock".into(), + Arc::new(crate::transport::providers::mock::MockChannel::new()), + ); + let mut caps = channel_button_caps(&map); + caps.sort(); + assert_eq!( + caps, + vec![("mock".to_string(), false), ("slack".to_string(), true)] + ); + } use tempfile::TempDir; /// (v0.8.5 B1 / B1b) The security gate must let a non-text message through @@ -2217,6 +2348,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 +2416,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 ac66c368d..3c0284238 100644 --- a/crates/ccteam-im/src/delegation.rs +++ b/crates/ccteam-im/src/delegation.rs @@ -462,6 +462,111 @@ 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 — 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, +) -> 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 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() + .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 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() + } +} + /// 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 +796,92 @@ 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"); + + // 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] 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 28b97ff5c..e38882891 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -32,7 +32,7 @@ use futures::StreamExt; use serde::{Deserialize, Serialize}; use crate::pending::InteractionOrigin; -use crate::transport::{ChannelAttachment, ChoiceReply, MessageOption}; +use crate::transport::{ChannelAttachment, ChoiceReply, MessageOption, OptionWeight}; use crate::BotRegistration; /// Resolve a live thread status after the caller has dropped the gateway @@ -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 { @@ -167,19 +230,53 @@ 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()); + /// + /// On the thread scope a session lives in ONE thread of a conversation: + /// binding it to this thread unbinds it from the conversation's other + /// threads, which are returned so the caller can tell them. Otherwise + /// the session's later output would follow whichever thread last drove it + /// and the thread the human knows it by would silently go quiet. Always + /// empty for a chat without a thread and on the conversation scope. + fn set(&self, chat: &ChatKey, value: impl Into) -> Vec { + let key = self.key(chat); + let value = value.into(); + let mut map = self.write(); + let mut displaced = Vec::new(); + if self.scope == FocusScope::Thread && key.thread.is_some() { + map.retain(|other, v| { + let moved = *v == value + && other.thread.is_some() + && other.thread != key.thread + && other.channel == key.channel + && other.chat_id == key.chat_id; + if moved { + displaced.push(other.clone()); + } + !moved + }); + } + map.insert(key, value); + displaced } 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` — at most one, since [`Self::set`] moves a session rather than + /// copying it. 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 +330,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; }; @@ -244,10 +341,30 @@ impl FocusRoutes { } } } - *self.write() = collapsed + let mut routes: BTreeMap = collapsed .into_iter() .map(|(chat, (_, value))| (chat, value)) .collect(); + if self.scope == FocusScope::Thread { + // The restore path holds the same invariant [`Self::set`] does: a + // session lives in ONE thread of a conversation. Keys iterate in + // ascending thread order, so the last one seen per + // `(conversation, session)` is its most recent thread — keep that, + // drop the rest, whatever the file says. + let mut home: BTreeMap<(String, String, String), ChatKey> = BTreeMap::new(); + for (chat, value) in routes.iter().filter(|(chat, _)| chat.thread.is_some()) { + home.insert( + (chat.channel.clone(), chat.chat_id.clone(), value.clone()), + chat.clone(), + ); + } + routes.retain(|chat, value| { + chat.thread.is_none() + || home.get(&(chat.channel.clone(), chat.chat_id.clone(), value.clone())) + == Some(chat) + }); + } + *self.write() = routes; } } @@ -780,8 +897,15 @@ 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, + /// Live channel names whose provider renders option buttons, as the + /// daemon reported them ([`Self::bind_channel_buttons`]). Empty until the + /// daemon binds its channel set — every chat then gets plain-text pickers. + button_channels: BTreeSet, + /// 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 @@ -1395,8 +1519,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 +1546,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), + // 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: last.assistant.clone(), + tail: answer.assistant.clone(), vendor, host: host.to_string(), boundary: true, @@ -1446,14 +1575,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: last.conclusion.clone(), + conclusion: answer.conclusion.clone(), }); } pending @@ -1890,6 +2019,7 @@ impl DelegationProgressEmitter { .strip_prefix("delegation_") .unwrap_or(&record.event); let event = GatewayEvent { + interim: false, id: format!( "delegation-{relation}-{}-{}-{}", record.parent_sid, @@ -2014,7 +2144,7 @@ pub enum GatewayEventKind { /// dispatched (filling the silent time-to-first-token gap); `on: false` /// removes it the moment the turn's first event appears. **IM-only**: the /// daemon egress maps it to the channel's `add_reaction`/`remove_reaction` - /// (default no-op for web/discord/slack), and the web SSE drops it (a + /// (default no-op for web/discord), and the web SSE drops it (a /// reaction has no web representation — web has its own UI). The /// `GatewayEvent` already carries `channel`/`chat_id`/`sid`; this only adds /// the inbound `message_id` to react to. @@ -2082,6 +2212,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 @@ -2175,6 +2312,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, @@ -2184,6 +2325,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 @@ -3021,11 +3187,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(()))), ) }; @@ -3115,7 +3286,8 @@ pub const GATEWAY_COMMANDS: &[GatewayCommandSpec] = &[ }, GatewayCommandSpec { name: "/use", - arg_hint: Some(""), + // No `|` inside `<…>`: Slack reads `` as its own link syntax. + arg_hint: Some(" | @"), // v0.8.23 review §3.2-5 — `@role` is a shorthand for "the most // recently active session with that role" (silent recency tie-break; // an unmatched role lists what IS available). @@ -3221,14 +3393,72 @@ fn command_menu_description(c: &GatewayCommandSpec) -> String { const NEXT_HINT_STATUS: &str = "↓ 查看状态 → /status"; const NEXT_HINT_SESSIONS: &str = "↓ 本项目会话 → /sessions"; -fn command_next_hint(cmd: &str) -> Option<&'static str> { +/// What to offer after a command's reply. On a channel that renders buttons +/// it becomes a row of buttons under the reply (see +/// [`Gateway::reply_with_next_step`]); elsewhere the text footer +/// ([`NextStep::hint`]), when there is one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NextStep { + /// The chat's session was just created / switched / steered: its controls. + Session, + /// The chat's session set changed: the session list. + Sessions, + /// `/status` already shows the session: just its controls, no footer. + SessionControls, + /// `/help`: the main menu, so nothing has to be typed. + Menu, +} + +impl NextStep { + fn hint(self) -> Option<&'static str> { + match self { + Self::Session => Some(NEXT_HINT_STATUS), + Self::Sessions => Some(NEXT_HINT_SESSIONS), + Self::SessionControls | Self::Menu => None, + } + } +} + +fn command_next_step(cmd: &str) -> Option { Some(match cmd { - "/new" | "/use" | "/role" | "/interrupt" => NEXT_HINT_STATUS, - "/stop" | "/rename" | "/cd" | "/newproject" => NEXT_HINT_SESSIONS, + "/new" | "/use" | "/role" | "/interrupt" => NextStep::Session, + "/stop" | "/rename" | "/cd" | "/newproject" => NextStep::Sessions, + "/status" => NextStep::SessionControls, + "/help" => NextStep::Menu, _ => return None, }) } +/// A button click that stands for a typed command (`act:`): the +/// whitelist of commands a button may run, as the text a user would type. +/// Anything else is refused — a button can do nothing typing could not. +fn button_action_command(action: &str) -> Option { + let command = match action { + "status" => "/status", + "sessions" => "/sessions", + "projects" => "/projects", + "help" => "/help", + "model" => "/model", + "interrupt" => "/interrupt", + _ => { + let vendor = action.strip_prefix("new:")?; + parse_vendor(vendor).ok()?; + return Some(format!("/new {vendor}")); + } + }; + Some(command.to_string()) +} + +/// One `act:` button. +fn action_option(data: &str, label: &str, weight: OptionWeight) -> MessageOption { + MessageOption { + data: format!("act:{data}"), + label: label.to_string(), + id: data.to_string(), + weight, + } +} + fn append_next_hint(reply: &mut String, hint: &str) { if !reply.is_empty() { reply.push('\n'); @@ -3304,8 +3534,9 @@ impl Gateway { body_watch_notify: Arc::new(tokio::sync::Notify::new()), projects, operator_chats: BTreeMap::new(), - current_project: FocusRoutes::default(), - current_session: FocusRoutes::default(), + button_channels: BTreeSet::new(), + current_project: FocusRoutes::new(FocusScope::Conversation), + current_session: FocusRoutes::new(FocusScope::Thread), sessions: BTreeMap::new(), next_session_generation: 0, rebuild_reservations: BTreeMap::new(), @@ -4058,6 +4289,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() @@ -4347,11 +4579,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 @@ -4480,10 +4708,11 @@ 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(), - thread_ts: None, + thread_ts: reply_to.thread.clone(), content, kind: GatewayEventKind::Answer, attachments: Vec::new(), @@ -4540,13 +4769,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) => { @@ -4781,12 +5004,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 @@ -4800,7 +5030,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 } @@ -4810,6 +5040,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. @@ -4819,12 +5056,37 @@ 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); + // An `act:` button stands for a command the user could have typed + // (`button_action_command`): run it exactly as typed, in this chat. + if let Some(action) = selection.and_then(|reply| reply.data.strip_prefix("act:")) { + let Some(command) = button_action_command(action) else { + return Ok(vec![format!("unknown action: {action}")]); + }; + let needs_session = matches!(command.as_str(), "/model" | "/interrupt"); + if needs_session && !self.current_session.contains(&chat) { + return Ok(vec![ + "这里现在没有会话 —— 发一条消息就会新建一个".to_string() + ]); + } + return Box::pin(self.handle_message( + channel, + chat_id, + user_id, + thread, + message_id, + &command, + &[], + None, + )) + .await; + } // (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 { @@ -4857,18 +5119,13 @@ impl Gateway { "/inbox scheduled messages do not support files or skills" )); } - if let Some(mut reply) = self.handle_command(&chat, text).await? { - // Owner req — teach the next step: append a recommended-command - // footer as the reply's last line (see `command_next_hint`). IM only - // — the web console navigates by GUI and reaches commands via - // `submit_web_sid` in production, so it never gets the text footer. - if chat.channel != "web" { - if let Some(hint) = command_next_hint(text.split_whitespace().next().unwrap_or("")) - { - append_next_hint(&mut reply, hint); - } - } - return Ok(vec![reply]); + if let Some(reply) = self.handle_command(&chat, text).await? { + // Owner req — teach the next step (`command_next_step`): buttons + // where the channel renders them, else a footer line. IM only — the + // web console navigates by GUI and reaches commands via + // `submit_web_sid` in production, so it never gets either. + let step = command_next_step(text.split_whitespace().next().unwrap_or("")); + return Ok(self.reply_with_next_step(&chat, reply, step)); } // A gateway command may handle itself ENTIRELY via the event sink and // return no inline reply — a project / session picker delivered as @@ -4880,7 +5137,7 @@ impl Gateway { } if let Some((handle, payload)) = crate::router::parse_first_mention(text) { if let Some(session_id) = self.session_by_handle(&chat, &handle) { - self.current_session.set(&chat, session_id); + self.focus_session(&chat, session_id); if payload.is_empty() && attachments.is_empty() { return Ok(vec![format!("using @{handle}")]); } @@ -4890,7 +5147,7 @@ impl Gateway { } if let Some(template) = self.template_by_handle(&chat, &handle) { let session_id = self.start_template_session(chat.clone(), template).await?; - self.current_session.set(&chat, session_id); + self.focus_session(&chat, session_id); if payload.is_empty() && attachments.is_empty() { return Ok(vec![format!("using @{handle}")]); } @@ -4928,7 +5185,10 @@ impl Gateway { } let turn = wrap_inbound(channel, chat_id, user_id, message_id, text, attachments); let mut replies = self.submit_to_current(&chat, message_id, turn).await?; - if chat.channel != "web" && text.split_whitespace().next() == Some("/model") { + if chat.channel != "web" + && !self.channel_supports_buttons(&chat.channel) + && text.split_whitespace().next() == Some("/model") + { if let Some(last) = replies.last_mut() { append_next_hint(last, NEXT_HINT_STATUS); } @@ -4965,13 +5225,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) { @@ -5006,12 +5267,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 @@ -5031,7 +5293,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 }; @@ -5042,8 +5304,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; @@ -5066,6 +5328,7 @@ impl Gateway { .apply_new_session(*plan, thread, None, false, None) .await?; let sid = outcome.id.clone(); + g.announce_thread_session(&chat, &sid); g.drain_and_dispatch_pending_turns(&sid).await; sid }; @@ -5080,6 +5343,7 @@ impl Gateway { channel, chat_id, user_id, + thread, message_id, text, attachments, @@ -5139,15 +5403,11 @@ impl Gateway { Some(match Self::stop_session_shared(gateway, &sid).await { Err(error) => Err(error), Ok(outcome) => { - let mut reply = stop_command_receipt(&sid, was_detached, &outcome); - // The same next-step footer every other IM command gets: this - // leg answers ahead of `handle_command`, so it appends its own. - if chat.channel != "web" { - if let Some(hint) = command_next_hint("/stop") { - append_next_hint(&mut reply, hint); - } - } - Ok(vec![reply]) + let reply = stop_command_receipt(&sid, was_detached, &outcome); + // The same next step every other IM command gets: this leg + // answers ahead of `handle_command`, so it adds its own. + let g = crate::latency::gateway_lock(gateway, "im.stop.next_step").await; + Ok(g.reply_with_next_step(chat, reply, command_next_step("/stop"))) } }) } @@ -5352,7 +5612,7 @@ impl Gateway { // command then returns no inline reply. Every other channel // (web's structured session frame, Lark's text-only send, the // test mock) keeps the plain-text reply unchanged. - if Self::channel_supports_buttons(&chat.channel) { + if self.channel_supports_buttons(&chat.channel) { let options = self.session_switch_options(chat, all); self.emit_list_options(chat, text, options); Ok(None) @@ -5367,7 +5627,7 @@ impl Gateway { // "switch" button per project (`nav:cd:` tap → `/cd`), // delivered via the event sink. Others keep the bare // newline-separated slug list as an inline reply. - if Self::channel_supports_buttons(&chat.channel) { + if self.channel_supports_buttons(&chat.channel) { let options = self.project_switch_options(chat); let cur = self.current_project_label(chat); self.emit_list_options( @@ -5551,13 +5811,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. @@ -5574,7 +5835,7 @@ impl Gateway { } self.current_project.set(chat, proj); } - self.current_session.set(chat, resumed_sid.clone()); + self.focus_session(chat, resumed_sid.clone()); self.persist_routing()?; return Ok(format!("resumed session {resumed_sid}")); } @@ -5591,7 +5852,7 @@ impl Gateway { // session's project, so a following /new (and /cd's default) lands in // the same project you just switched into — not the stale prior one. self.current_project.set(chat, project); - self.current_session.set(chat, sid.clone()); + self.focus_session(chat, sid.clone()); self.persist_routing()?; Ok(format!("using session {sid}")) } @@ -5603,30 +5864,34 @@ impl Gateway { /// or reads as an unknown target. async fn resolve_nav_selection(&mut self, chat: &ChatKey, nav: &str) -> Result> { if let Some(slug) = nav.strip_prefix("cd:") { - let mut reply = self.change_project(chat, slug)?; - if chat.channel != "web" { - append_next_hint(&mut reply, NEXT_HINT_SESSIONS); - } - return Ok(vec![reply]); + let reply = self.change_project(chat, slug)?; + return Ok(self.reply_with_next_step(chat, reply, Some(NextStep::Sessions))); } if let Some(sid) = nav.strip_prefix("use:") { - let mut reply = self.use_session(chat, sid).await?; - if chat.channel != "web" { - append_next_hint(&mut reply, NEXT_HINT_STATUS); - } - return Ok(vec![reply]); + let reply = self.use_session(chat, sid).await?; + return Ok(self.reply_with_next_step(chat, reply, Some(NextStep::Session))); } Ok(vec!["invalid selection".to_string()]) } - /// Whether a channel renders message `options` as tappable inline-keyboard - /// buttons. Only Telegram does today: Lark's `send` ignores options, web - /// turns an options-bearing message into a choice-chip frame (which would - /// REPLACE its structured session list), and the test mock reads the plain - /// text reply. Keyed by platform so per-tenant bots (`"telegram@"`) - /// are covered too. Extend as other providers gain native buttons. - fn channel_supports_buttons(channel: &str) -> bool { - crate::transport::platform_of(channel) == "telegram" + /// Record whether the live channel `name` (`"telegram"`, `"slack"`, + /// `"telegram@"` …) renders option buttons — the provider's own + /// [`crate::transport::Channel::native_buttons`], reported by the daemon + /// whenever it (re)builds a channel. The gateway never names a platform. + pub fn bind_channel_buttons(&mut self, name: &str, native: bool) { + if native { + self.button_channels.insert(name.to_string()); + } else { + self.button_channels.remove(name); + } + } + + /// Whether a chat's channel renders message `options` as tappable + /// buttons ([`Self::bind_channel_buttons`]). A picker there is text + + /// buttons; everywhere else (web's structured session frame, a channel + /// whose clicks are not wired) it stays a plain-text reply. + fn channel_supports_buttons(&self, channel: &str) -> bool { + self.button_channels.contains(channel) } /// Project slugs `chat` may SEE — backs `/projects`, the `/cd` picker, and @@ -5698,6 +5963,69 @@ 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 thread of that conversation bound to `sid` (a session + /// lives in at most one, see [`FocusRoutes::set`]). 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) -> ChatKey { + if target.thread.is_some() { + return target; + } + let bound = self + .current_session + .threads_bound_to(&target.channel, &target.chat_id, sid); + match bound.last() { + 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 = match self.tenant_project_owner_reply_target(&session.project) { + Some(target) => target, + None => { + // A session one of the owner's IM chats switched to (or last + // drove) and still has in focus lives THERE now — its + // follow-ups go to that chat, not back to the one that created + // it. One target, never a broadcast: seeing a session from + // several chats must not make it push to all of them. + let current = pump_target(session); + if current.channel != "web" + && self.current_session.is_focused(¤t, &session.id) + { + return current; + } + reply_target_for_owner(&session.owner) + } + }; + self.with_bound_thread(target, &session.id) + } + + /// [`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) + } + /// 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). @@ -5716,6 +6044,7 @@ impl Gateway { format!("▸ {slug}") }; MessageOption { + weight: Default::default(), data: format!("nav:cd:{slug}"), label, id: slug, @@ -5798,6 +6127,7 @@ impl Gateway { )); } MessageOption { + weight: Default::default(), data: format!("nav:use:{sid}"), label, id: sid.to_string(), @@ -5809,6 +6139,93 @@ impl Gateway { options } + /// Deliver a command's `reply` with its next step. Where the channel + /// renders buttons the step is a row of buttons under the reply, sent + /// through the event sink (so no inline reply comes back); elsewhere it is + /// the footer line [`NextStep::hint`]. The web console gets neither. + fn reply_with_next_step( + &self, + chat: &ChatKey, + mut reply: String, + step: Option, + ) -> Vec { + let Some(step) = step.filter(|_| chat.channel != "web") else { + return vec![reply]; + }; + if self.channel_supports_buttons(&chat.channel) { + let options = self.next_step_options(chat, step); + if !options.is_empty() { + self.emit_list_options(chat, reply, options); + return Vec::new(); + } + } + if let Some(hint) = step.hint() { + append_next_hint(&mut reply, hint); + } + vec![reply] + } + + /// The buttons for a [`NextStep`]: a session's controls (status, model, + /// interrupt) when the chat has one, the session/project lists, or the + /// main menu. + fn next_step_options(&self, chat: &ChatKey, step: NextStep) -> Vec { + use OptionWeight::{Minor, Normal, Primary}; + let lists = |weight| { + vec![ + action_option("sessions", "🧵 会话", weight), + action_option("projects", "📁 项目", weight), + ] + }; + match step { + NextStep::Session | NextStep::SessionControls => { + if self.current_session.contains(chat) { + // Status and model are what a thread reaches for; interrupt + // is easy to regret, so it is the smallest, last button. + let mut controls = vec![ + action_option("status", "📊 状态", Primary), + action_option("model", "🧠 模型", Primary), + ]; + controls.extend(lists(Normal)); + controls.push(action_option("interrupt", "⏹ 中断", Minor)); + controls + } else { + lists(Primary) + } + } + NextStep::Sessions => lists(Primary), + NextStep::Menu => vec![ + action_option("projects", "📁 项目", Primary), + action_option("sessions", "🧵 会话", Primary), + action_option("status", "📊 状态", Normal), + action_option("new:claude", "+ Claude", Normal), + action_option("new:codex", "+ Codex", Normal), + ], + } + } + + /// A thread whose first message just spawned its session opens with a + /// one-line header and that session's controls, so status / model / + /// interrupt are a tap away for the rest of the thread. Threads only: a + /// single-stream chat has no "top of the session" to put them at. + fn announce_thread_session(&self, chat: &ChatKey, sid: &str) { + if chat.thread.is_none() || !self.channel_supports_buttons(&chat.channel) { + return; + } + let Some(session) = self.sessions.get(sid) else { + return; + }; + let header = format!( + "🧵 会话 {sid} · 项目 {} · {}", + session.project, + vendor_str(session.vendor) + ); + self.emit_list_options( + chat, + header, + self.next_step_options(chat, NextStep::Session), + ); + } + /// Emit a picker message (a project/session list) carrying inline `options` /// to a button-capable channel, via the user-signal sink. /// Delivers text + buttons as ONE message @@ -5820,10 +6237,11 @@ 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(), - thread_ts: None, + thread_ts: chat.thread.clone(), content, kind: GatewayEventKind::Answer, attachments: Vec::new(), @@ -5923,6 +6341,7 @@ impl Gateway { let outcome = self .apply_new_session(*plan, thread, None, false, None) .await?; + self.announce_thread_session(chat, &outcome.id); self.drain_and_dispatch_pending_turns(&outcome.id).await; Ok(()) } @@ -6590,7 +7009,7 @@ impl Gateway { delegation_depth, }, ); - self.current_session.set(&reply_to, id.clone()); + self.focus_session(&reply_to, id.clone()); if !capacity_checked { self.persist_routing()?; } @@ -6744,7 +7163,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); self.sessions.insert( sid.clone(), GatewaySession { @@ -6763,7 +7184,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)), @@ -6777,7 +7198,7 @@ impl Gateway { delegation_depth, }, ); - self.current_session.set(chat, sid.clone()); + self.focus_session(chat, sid.clone()); self.persist_routing()?; // v0.8.21 Wave-2 — keep meta.json (the session SoT) in sync: `/role` // changed the role, so a daemon restart must rebuild at the NEW role. @@ -6812,6 +7233,37 @@ impl Gateway { Ok(sid) } + /// Bind `chat` (a thread, on a channel that threads sessions) to `sid`. + /// The ONE writer of `current_session`: a session lives in one thread of + /// a conversation ([`FocusRoutes::set`]), so a thread it leaves is told — + /// otherwise the human keeps writing there and gets a fresh session with + /// no idea why. + fn focus_session(&self, chat: &ChatKey, sid: impl Into) { + let sid = sid.into(); + for left in self.current_session.set(chat, sid.clone()) { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + self.emit_user_signal(GatewayEvent { + interim: false, + id: format!("gateway-moved-{sid}-{nanos}"), + channel: left.channel.clone(), + chat_id: left.chat_id.clone(), + thread_ts: left.thread.clone(), + content: format!( + "↪ {sid} moved to another thread. This thread has no session now — your next message here starts a new one." + ), + kind: GatewayEventKind::Answer, + attachments: Vec::new(), + options: Vec::new(), + status: None, + sid: None, + slug: None, + }); + } + } + fn emit_user_signal(&self, event: GatewayEvent) { if let Some(tx) = self.event_sink.clone() { let _ = tx.send(event); @@ -6963,13 +7415,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 +7446,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 @@ -7125,9 +7575,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 +7680,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, @@ -7286,6 +7755,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 @@ -7359,16 +7831,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, @@ -7720,9 +8189,16 @@ 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() => { + // 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 { @@ -7736,11 +8212,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 +8241,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 +8309,29 @@ 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. + 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(), @@ -7854,11 +8367,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. @@ -7889,26 +8398,106 @@ 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, - thread_ts: None, - content: closing_line.unwrap_or_default(), + // 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 { + interim: false, + id: status_id.clone(), + channel: chat_key.channel.clone(), + chat_id: chat_key.chat_id.clone(), + thread_ts: chat_key.thread.clone(), + 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 { + interim: false, + id: status_id, + channel: chat_key.channel.clone(), + chat_id: chat_key.chat_id.clone(), + thread_ts: chat_key.thread.clone(), + 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 +8505,59 @@ 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; + // 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. 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 + // 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() { - fold.mark_done(); + if is_final_answer + || !structured_turn_open + || 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, @@ -7948,7 +8572,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), @@ -8046,7 +8676,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,16 +8738,18 @@ 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; 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 { @@ -8158,6 +8790,7 @@ impl Gateway { record.turn_id.clone(), text.clone(), )); + turn_said.push(text.clone()); } } Err(err) => { @@ -8180,13 +8813,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 { @@ -8194,6 +8825,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 @@ -8256,10 +8888,11 @@ impl Gateway { || channel == "web" || latest_turn_origin(&session) == TurnOrigin::User; let answer = GatewayEvent { + interim, id: format!("gateway-event-{session_id}-{seq}"), channel, chat_id, - thread_ts: None, + thread_ts, content, kind: GatewayEventKind::Answer, attachments: Vec::new(), @@ -8295,17 +8928,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, @@ -8316,7 +8958,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 { @@ -8336,6 +8978,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; @@ -8345,7 +8991,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 @@ -9064,6 +9710,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(), @@ -9193,11 +9840,16 @@ 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); 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 @@ -9926,10 +10578,11 @@ 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(), - thread_ts: None, + thread_ts: chat.thread.clone(), content: String::new(), kind: GatewayEventKind::Reaction { message_id: message_id.to_string(), @@ -10250,10 +10903,11 @@ 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(), - thread_ts: None, + thread_ts: chat.thread.clone(), content: body, kind: GatewayEventKind::Answer, attachments: Vec::new(), @@ -10416,6 +11070,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(), @@ -10714,12 +11369,13 @@ impl Gateway { // tenant's console), so a `/sessions` list — or a `/use` on a listed // sid, which re-points `reply_to` — crossed users. let (user_id, is_admin) = self.project_acl_identity(chat); - ccteam_core::identity::can_see_session_owner( - &canonical_owner(chat).identity(), - &user_id, - is_admin, - owner_identity, - ) + let viewer = canonical_owner(chat).identity(); + ccteam_core::identity::can_see_session_owner(&viewer, &user_id, is_admin, owner_identity) + || ccteam_core::identity::operator_im_pool_sees( + &viewer, + self.is_named_operator_chat(chat), + owner_identity, + ) } /// Session ownership inherits the project principal. The stored owner is @@ -10828,6 +11484,20 @@ impl Gateway { } } + /// Whether this chat is EXPLICITLY named in its global bot's operator + /// roster — [`Self::is_operator_chat`] minus the open-mode fallback that + /// treats an unconfigured bot's every sender as the operator. What the + /// owner's shared IM session pool keys on: a stranger who reached an + /// unconfigured bot must not see the owner's sessions. + fn is_named_operator_chat(&self, chat: &ChatKey) -> bool { + match self.operator_chats.get(&chat.channel) { + Some(OperatorBinding::Named(named)) => { + named.contains(&chat.chat_id) || named.contains(&chat.user_id) + } + _ => false, + } + } + /// Bind `platform`'s operator roster from that global bot's credential /// allowlist, and report what the binding means so the daemon can warn. pub fn bind_operator_allowlist>( @@ -10890,6 +11560,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 @@ -10916,7 +11607,7 @@ impl Gateway { }); match &adopted { Some(id) => { - self.current_session.set(chat, id.clone()); + self.focus_session(chat, id.clone()); } None => { self.current_session.remove(chat); @@ -12334,6 +13025,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()); @@ -12357,7 +13062,7 @@ impl Gateway { // mints a fresh secret, inserts into the live map, starts the pump. self.rebuild_session_from_meta(&slug, cwd, &meta, caller.clone()) .await?; - self.current_session.set(&caller, sid); + self.focus_session(&caller, sid); self.persist_routing()?; Ok(sid.to_string()) } @@ -12371,6 +13076,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?; @@ -12379,8 +13099,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) { @@ -12426,7 +13144,7 @@ impl Gateway { guard .apply_rebuilt_session(plan, thread, caller.clone(), true) .await?; - guard.current_session.set(&caller, sid); + guard.focus_session(&caller, sid); } Self::persist_latest_routing_shared(&gateway).await?; Ok(sid.to_string()) @@ -12530,7 +13248,7 @@ impl Gateway { self.rebuild_session_from_meta(slug, cwd.clone(), &meta, caller.clone()) .await?; self.persist_session_meta(&cwd, &meta)?; - self.current_session.set(&caller, sid.clone()); + self.focus_session(&caller, sid.clone()); self.persist_routing()?; Ok(sid) } @@ -12659,7 +13377,7 @@ impl Gateway { catalog.write(&cwd, &meta)?; { let guard = gateway.lock().await; - guard.current_session.set(&caller, sid.clone()); + guard.focus_session(&caller, sid.clone()); } Self::persist_latest_routing_shared(&gateway).await?; Ok(sid) @@ -12864,9 +13582,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 @@ -12878,7 +13596,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() @@ -12886,6 +13608,7 @@ impl Gateway { Some(HitlPromptContext { channel, chat_id, + thread_ts, role: session.role.clone(), progress_path, }) @@ -13164,6 +13887,7 @@ impl Gateway { }); } self.emit_user_signal(GatewayEvent { + interim: false, id: format!("session-evicted-{sid}"), channel: String::new(), chat_id: String::new(), @@ -15707,29 +16431,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, ) @@ -15740,7 +16459,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, )?; @@ -15873,9 +16591,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(); } @@ -15980,10 +16696,11 @@ 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, - thread_ts: None, + thread_ts: chat.thread, content: body, kind: GatewayEventKind::Answer, attachments: Vec::new(), @@ -16012,9 +16729,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; } @@ -16185,14 +16903,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() }; @@ -16264,7 +16977,7 @@ impl Gateway { // the IM current-session semantics. if let Some(project) = self.sessions.get(sid).map(|s| s.project.clone()) { self.current_project.set(&chat, project); - self.current_session.set(&chat, sid); + self.focus_session(&chat, sid); } if let Some(reply) = self.handle_command(&chat, &text).await? { self.emit_sid_answer(sid, 0, reply); @@ -16286,6 +16999,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(), @@ -17047,6 +17761,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(), @@ -17492,6 +18207,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. @@ -17501,8 +18219,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() } } @@ -17753,10 +18472,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 @@ -17786,10 +18502,12 @@ fn emit_turn_stall_warning( tune the window via CCTEAM_IM_GATEWAY_TURN_TIMEOUT_MS (0 = off)." ); let _ = tx.send(GatewayEvent { + // 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(), @@ -18124,13 +18842,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 { @@ -18206,6 +18927,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, @@ -18237,15 +18959,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()), @@ -18268,14 +18991,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 { @@ -18365,6 +19089,87 @@ 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 { + /// 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) { + 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), + _ => { + let last = held.last().cloned(); + let text = held + .iter() + .map(|part| part.trim()) + .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. @@ -19225,6 +20030,7 @@ fn to_message_options(prompt: &ChoicePrompt) -> Vec { .iter() .enumerate() .map(|(i, opt)| MessageOption { + weight: Default::default(), data: format!("{}:{}", prompt.token, i), label: opt.label.clone(), id: opt.id.clone(), @@ -19244,15 +20050,19 @@ fn render_choice_text(prompt: &ChoicePrompt) -> String { /// Render the `/help` body from [`GATEWAY_COMMANDS`]. fn render_help() -> String { - let mut s = String::from("Gateway commands:"); - for c in GATEWAY_COMMANDS { - match c.arg_hint { - Some(hint) => s.push_str(&format!("\n{} {} — {}", c.name, hint, c.help)), - None => s.push_str(&format!("\n{} — {}", c.name, c.help)), - } - } - s.push_str("\n\nAny other /command is forwarded to the current session's agent."); - s + // A short, task-grouped list — not the full reference (that is `ccteam`'s + // docs and each command's own usage error). Grouped by what a person + // wants to do; on a channel with buttons `/help` also carries the main + // menu, so most of this need not be typed at all. + [ + "常用命令", + "会话 /new [harness] [model=…] · /use · /sessions · /status", + "控制 /interrupt · /stop · /rename <标题> · /role <角色>", + "项目 /projects · /cd <项目> · /newproject <路径>", + "定时 /inbox", + "其他命令(如 /model /compact)会转给当前会话的 agent。", + ] + .join("\n") } fn parse_inbox_create_args(rest: &str) -> Result<(String, String)> { @@ -20415,6 +21225,7 @@ mod tests { "mock", "chat-1", "alice", + None, "", "/stop s1", &[], @@ -21269,6 +22080,7 @@ mod tests { fn fake_event(sid: Option<&str>) -> GatewayEvent { GatewayEvent { + interim: false, id: "e1".into(), channel: "web".into(), chat_id: "web-api".into(), @@ -25168,6 +25980,7 @@ mod tests { "telegram", "chat-7", "alice", + None, "tg-555", "do a thing", &[], @@ -25363,22 +26176,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())); - 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]; + // #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!( - 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 +26222,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 ); } @@ -26010,7 +26815,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. @@ -26131,6 +26936,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") @@ -26150,6 +26959,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), @@ -26319,7 +27136,7 @@ mod tests { /// ("IM chat 之间互相隔离" — the 档0 rule the v0.8.13 same-project sharing /// was reverted for; do not let project inheritance quietly re-add it). #[tokio::test] - async fn operator_and_unowned_projects_keep_per_im_chat_isolation() { + async fn the_owners_named_chats_share_sessions_a_guest_sees_none() { let tmp = tempfile::TempDir::new().unwrap(); let (paths, _project_dir) = mirror_test_paths(&tmp); seed_owned_project(&paths, "ops", Some("user:web-api")); // operator pool @@ -26354,14 +27171,23 @@ mod tests { gateway.chat_can_access(&rob, gateway.sessions.get(&mine).unwrap()), "{slug}: a chat sees its own session" ); + // Owner 2026-10-05: the owner's NAMED IM chats share one pool. assert!( - !gateway.chat_can_access(&rob, gateway.sessions.get(&theirs).unwrap()), - "{slug}: an admin chat must NOT see another admin chat's session" + gateway.chat_can_access(&rob, gateway.sessions.get(&theirs).unwrap()), + "{slug}: a named operator chat sees the owner's other chat's session" ); assert!( - !gateway.chat_can_access(&eve, gateway.sessions.get(&mine).unwrap()), - "{slug}: isolation is symmetric" + gateway.chat_can_access(&eve, gateway.sessions.get(&mine).unwrap()), + "{slug}: sharing is symmetric" ); + // A chat the allowlist does not name is a guest: it sees neither. + let guest = ChatKey::new("telegram", "333", "mallory"); + for sid in [&mine, &theirs] { + assert!( + !gateway.chat_can_access(&guest, gateway.sessions.get(sid).unwrap()), + "{slug}: a guest must not see the owner's {sid}" + ); + } } } @@ -27505,12 +28331,321 @@ 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")); + // 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, 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. + /// 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!(answer.interim, "and it says so: {answer:?}"); + assert!( + asked.elapsed() >= heartbeat, + "it was held for one heartbeat first, to fold what follows" + ); + 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 + /// 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.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:?}" + ); + + 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 +28665,65 @@ 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)); + assert!( + answer.interim && !closing.interim, + "{answer:?} / {closing:?}" + ); + + // …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. @@ -27715,6 +28878,7 @@ mod tests { "telegram", "chat-a", "alice", + None, "a-1", "hi there", &[], @@ -27747,6 +28911,7 @@ mod tests { "telegram", "chat-b", "bob", + None, "b-1", "still there?", &[], @@ -27802,6 +28967,7 @@ mod tests { "telegram", "chat-new", "carol", + None, &format!("m-{i}"), &format!("msg {i}"), &[], @@ -28191,7 +29357,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); @@ -28479,9 +29649,10 @@ mod tests { assert_eq!(answer.assistant, "LGTM, two nits inline."); } - /// Clickable project picker: on Telegram, `/projects` is delivered as a - /// header + one inline "switch" button per project (`nav:cd:`), so - /// the command returns NO inline reply (the buttons ride the event sink). + /// Clickable project picker: on a channel the daemon reported as + /// button-capable (Telegram here), `/projects` is delivered as a header + + /// one inline "switch" button per project (`nav:cd:`), so the + /// command returns NO inline reply (the buttons ride the event sink). /// Non-button channels (the test mock, web, Lark) keep the plain slug list. #[tokio::test] async fn telegram_projects_delivers_switch_buttons() { @@ -28489,6 +29660,7 @@ mod tests { let proj = tempfile::TempDir::new().unwrap(); let mut gateway = Gateway::new(fake, "alpha", proj.path()); gateway.register_project("beta", proj.path()); + gateway.bind_channel_buttons("telegram", true); let mut events = gateway.subscribe_events(); // Telegram → the reply is empty; the list + buttons arrive as an Answer. @@ -28522,6 +29694,260 @@ mod tests { assert_eq!(mock, vec!["alpha\nbeta"]); } + /// Buttons are the provider's capability, not a platform name: a threaded + /// channel the daemon reported as button-capable (Slack) gets the project + /// picker as buttons IN the thread the command came from, and a tapped + /// `nav:cd:` switches the conversation's project; once the capability is + /// withdrawn the same command is a plain list again. + #[tokio::test] + async fn a_button_capable_threaded_channel_gets_a_clickable_picker() { + let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude)); + let proj = tempfile::TempDir::new().unwrap(); + let mut gateway = Gateway::new(fake, "alpha", proj.path()); + gateway.register_project("beta", proj.path()); + gateway.bind_channel_buttons("slack", true); + let mut events = gateway.subscribe_events(); + + let replies = gateway + .handle_message( + "slack", + "C1", + "U1", + Some("100.1"), + "100.1", + "/projects", + &[], + None, + ) + .await + .unwrap(); + assert!( + replies.is_empty(), + "picker rides the event sink: {replies:?}" + ); + let ev = recv_answer(&mut events).await; + assert_eq!(ev.channel, "slack"); + assert_eq!( + ev.thread_ts.as_deref(), + Some("100.1"), + "in the command's thread" + ); + assert!( + ev.options.iter().any(|o| o.data == "nav:cd:beta"), + "options: {:?}", + ev.options + ); + + let tap = ChoiceReply { + data: "nav:cd:beta".into(), + }; + gateway + .handle_message( + "slack", + "C1", + "U1", + Some("100.1"), + "100.1", + "", + &[], + Some(&tap), + ) + .await + .unwrap(); + let other_thread = ChatKey::new("slack", "C1", "U1").with_thread(Some("200.2")); + assert_eq!( + gateway.current_project.get(&other_thread), + Some("beta".to_string()), + "the tap switched the conversation's project" + ); + + gateway.bind_channel_buttons("slack", false); + let replies = gateway + .handle_message( + "slack", + "C1", + "U1", + Some("300.3"), + "300.3", + "/projects", + &[], + None, + ) + .await + .unwrap(); + assert_eq!(replies, vec!["alpha\nbeta"]); + } + + /// Buttons over typing: on a button-capable channel a command's next step + /// is a row of buttons (a session's controls, the menu) instead of a + /// `→ /status` footer, and an `act:` tap runs the whitelisted command it + /// stands for exactly as if it were typed. + #[tokio::test] + async fn next_steps_are_buttons_and_a_tap_runs_the_command() { + 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.bind_channel_buttons("slack", true); + let mut events = gateway.subscribe_events(); + // `/help` → the main menu, no footer. + let replies = gateway + .handle_message("slack", "C1", "U1", Some("1.1"), "1.1", "/help", &[], None) + .await + .unwrap(); + assert!(replies.is_empty(), "rides the sink: {replies:?}"); + let ev = recv_answer(&mut events).await; + let menu: Vec<&str> = ev.options.iter().map(|o| o.data.as_str()).collect(); + assert_eq!( + menu, + vec![ + "act:projects", + "act:sessions", + "act:status", + "act:new:claude", + "act:new:codex" + ] + ); + + // A tap on `+ Codex` is `/new codex`, in the thread it was tapped in. + let tap = |data: &str| ChoiceReply { data: data.into() }; + gateway + .handle_message( + "slack", + "C1", + "U1", + Some("1.1"), + "1.1", + "", + &[], + Some(&tap("act:new:codex")), + ) + .await + .unwrap(); + let thread = ChatKey::new("slack", "C1", "U1").with_thread(Some("1.1")); + let sid = gateway + .current_session + .get(&thread) + .expect("the tap created a session"); + assert_eq!(gateway.sessions[&sid].vendor, AgentVendor::Codex); + // …and its receipt carries the session's controls instead of a footer. + let ev = recv_answer(&mut events).await; + assert!(ev.content.contains(&sid), "receipt: {}", ev.content); + assert!( + !ev.content.contains("→ /status"), + "no typed footer: {}", + ev.content + ); + let controls: Vec<&str> = ev.options.iter().map(|o| o.data.as_str()).collect(); + assert_eq!( + controls, + vec![ + "act:status", + "act:model", + "act:sessions", + "act:projects", + "act:interrupt" + ] + ); + assert_eq!(ev.thread_ts.as_deref(), Some("1.1")); + + // Only whitelisted actions run; session actions need a session. + let refused = gateway + .handle_message( + "slack", + "C1", + "U1", + Some("1.1"), + "1.1", + "", + &[], + Some(&tap("act:stop s1")), + ) + .await + .unwrap(); + assert!(refused[0].contains("unknown action"), "{refused:?}"); + let starts = fake.starts.load(Ordering::SeqCst); + let empty = gateway + .handle_message( + "slack", + "C1", + "U1", + Some("9.9"), + "9.9", + "", + &[], + Some(&tap("act:model")), + ) + .await + .unwrap(); + assert!(empty[0].contains("没有会话"), "{empty:?}"); + assert_eq!( + fake.starts.load(Ordering::SeqCst), + starts, + "a model tap in a thread without a session spawns nothing" + ); + + // A channel without buttons keeps the typed footer. + let plain = gateway + .handle_text("mock", "chat-9", "bob", "/new claude") + .await + .unwrap(); + assert!(plain[0].contains("→ /status"), "{plain:?}"); + } + + /// A thread whose first plain message spawns its session opens with a + /// header carrying that session's controls — in that thread. + #[tokio::test] + async fn a_new_thread_session_opens_with_its_controls() { + let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude)); + let proj = tempfile::TempDir::new().unwrap(); + let mut gateway = Gateway::new(fake, "alpha", proj.path()); + gateway.bind_channel_buttons("slack", true); + let mut events = gateway.subscribe_events(); + gateway + .handle_message( + "slack", + "C1", + "U1", + Some("7.7"), + "7.7", + "fix the bug", + &[], + None, + ) + .await + .unwrap(); + let ev = recv_answer(&mut events).await; + assert!( + ev.content.starts_with("🧵 会话 s1"), + "header: {}", + ev.content + ); + assert_eq!(ev.thread_ts.as_deref(), Some("7.7")); + let controls: Vec<&str> = ev.options.iter().map(|o| o.data.as_str()).collect(); + assert_eq!( + controls, + vec![ + "act:status", + "act:model", + "act:sessions", + "act:projects", + "act:interrupt" + ] + ); + + // A single-stream chat gets no header. + gateway.bind_channel_buttons("telegram", true); + gateway + .handle_text("telegram", "42", "bob", "hello") + .await + .unwrap(); + assert!( + !std::iter::from_fn(|| events.try_recv().ok()) + .any(|e| e.content.starts_with("🧵 会话")), + "no header outside a thread" + ); + } + /// Clickable session picker: on Telegram, `/sessions` is delivered as the /// usual text list PLUS one inline "switch" button per live session /// (`nav:use:`), so the command returns no inline reply. The mock @@ -28531,6 +29957,7 @@ mod tests { let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude)); let proj = tempfile::TempDir::new().unwrap(); let mut gateway = Gateway::new(fake, "alpha", proj.path()); + gateway.bind_channel_buttons("telegram", true); gateway .handle_text("telegram", "chat-1", "alice", "/new claude reviewer") .await @@ -28695,11 +30122,13 @@ mod tests { // Padding: mixed CJK/Latin rows end up the same display width. let mut opts = vec![ MessageOption { + weight: Default::default(), data: "a".into(), label: "▸ s39 · grok · 「当前是什么模型」".into(), id: "s39".into(), }, MessageOption { + weight: Default::default(), data: "b".into(), label: "▸ s43 · claude · 「Completed the full reques…".into(), id: "s43".into(), @@ -28736,6 +30165,7 @@ mod tests { "telegram", "chat-1", "alice", + None, "", "", &[], @@ -28760,6 +30190,7 @@ mod tests { "telegram", "chat-1", "alice", + None, "", "", &[], @@ -28779,6 +30210,7 @@ mod tests { "telegram", "chat-1", "alice", + None, "", "", &[], @@ -31137,6 +32569,7 @@ mod tests { "mock", "chat-1", "alice", + None, "", "", &[], @@ -32344,12 +33777,22 @@ mod tests { .unwrap(); assert_eq!(used, vec!["using session s1\n↓ 查看状态 → /status"]); - // /help advertises /role. - assert!( - render_help().contains("/role"), - "render_help should list /role: {}", - render_help() - ); + // /help is a short, grouped list that still names the everyday + // commands — and no `` hint, which Slack would turn into a link. + let help = render_help(); + for cmd in [ + "/new", + "/use", + "/status", + "/role", + "/cd", + "/stop", + "/interrupt", + ] { + assert!(help.contains(cmd), "help names {cmd}: {help}"); + } + assert!(help.lines().count() <= 8, "help stays short: {help}"); + assert!(!help.contains(" 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" + ); + } + + /// `/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. + #[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 owner's IM chats share one session POOL but never one push: a + /// session the owner's Telegram chat created is listed and `/use`-able from + /// the owner's Slack, and once Slack switched to it its follow-ups (an + /// internal turn's answer) go to Slack ONLY — until Telegram drives it + /// again. A sender who is not a named operator still sees nothing. + #[tokio::test] + async fn the_owners_chats_share_sessions_but_a_session_pushes_to_one() { + let tmp = tempfile::TempDir::new().unwrap(); + let (mut gateway, mut sink) = threaded_answer_gateway(tmp.path()); + gateway.bind_operator_allowlist("telegram", ["339".to_string()]); + gateway.bind_operator_allowlist("slack", ["U1".to_string()]); + + gateway + .handle_text("telegram", "339", "339", "hello from telegram") + .await + .unwrap(); + recv_sink_answer_containing(&mut sink, "hello from telegram").await; + let tg = ChatKey::new("telegram", "339", "339"); + let sid = gateway + .current_session + .get(&tg) + .expect("telegram spawned a session"); + + // Slack (the owner, named by member id) sees and takes it. + let listed = gateway + .handle_message( + "slack", + "C1", + "U1", + Some("1.1"), + "1.1", + "/sessions", + &[], + None, + ) + .await + .unwrap(); + assert!( + listed.join("\n").contains(&sid), + "listed on Slack: {listed:?}" + ); + gateway + .handle_message( + "slack", + "C1", + "U1", + Some("1.1"), + "1.1", + &format!("/use {sid}"), + &[], + None, + ) + .await + .unwrap(); + + // A follow-up nobody typed goes where the session was switched to. + gateway + .submit_to_sid(&sid, "s9 done · turn 1\nanswer".into()) + .await + .unwrap(); + let follow_up = recv_sink_answer_containing(&mut sink, "s9 done").await; + assert_eq!(follow_up.channel, "slack"); + assert_eq!(follow_up.thread_ts.as_deref(), Some("1.1")); + assert!( + !std::iter::from_fn(|| sink.try_recv().ok()) + .any(|e| e.channel == "telegram" && e.content.contains("s9 done")), + "never a second push to Telegram" + ); + + // Telegram drives it again → its follow-ups come back to Telegram. + gateway + .handle_text("telegram", "339", "339", "back on telegram") + .await + .unwrap(); + recv_sink_answer_containing(&mut sink, "back on telegram").await; + gateway + .submit_to_sid(&sid, "s10 done · turn 1\nanswer".into()) + .await + .unwrap(); + let back = recv_sink_answer_containing(&mut sink, "s10 done").await; + assert_eq!(back.channel, "telegram"); + + // Someone the Slack allowlist does not name sees nothing of it. + let stranger = gateway + .handle_message( + "slack", + "C2", + "U9", + Some("2.2"), + "2.2", + "/sessions", + &[], + None, + ) + .await + .unwrap(); + assert!( + !stranger.join("\n").contains(&sid), + "a guest sees no owner session: {stranger:?}" + ); + } + + /// The reattach rule itself: an owner target gets back the one thread the + /// session lives in; binding the session to another thread MOVES it (the + /// left thread is reported, never kept as a second 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"); + assert!(gateway + .current_session + .set(&thread_key("100.1"), "s1") + .is_empty()); + gateway.current_session.set(&thread_key("300.3"), "s2"); + assert_eq!( + gateway + .with_bound_thread(owner.clone(), "s1") + .thread + .as_deref(), + Some("100.1") + ); + + let left = gateway.current_session.set(&thread_key("050.5"), "s1"); + assert_eq!( + left, + vec![thread_key("100.1").thread_focus_key()], + "binding s1 elsewhere reports the thread it left" + ); + assert_eq!(gateway.current_session.get(&thread_key("100.1")), None); + assert_eq!( + gateway + .with_bound_thread(owner.clone(), "s1") + .thread + .as_deref(), + Some("050.5"), + "the session follows its one thread" + ); + assert_eq!( + gateway + .with_bound_thread(owner.clone(), "s2") + .thread + .as_deref(), + Some("300.3"), + "another session's thread is untouched" + ); + assert_eq!(gateway.with_bound_thread(owner.clone(), "s7"), owner); + + let telegram = ChatKey::new("telegram", "339", "339"); + let other_tg = ChatKey::new("telegram", "440", "440"); + gateway.current_session.set(&telegram, "s1"); + assert!( + gateway.current_session.set(&other_tg, "s1").is_empty(), + "single-stream chats never move each other's focus" + ); + assert_eq!(gateway.current_session.get(&telegram), Some("s1".into())); + assert_eq!( + gateway.with_bound_thread(telegram.clone(), "s1"), + telegram, + "a single-stream chat is untouched" + ); + } + + /// Restoring `routing.json` keeps the one-thread-per-session invariant + /// even when the file names a session in two threads: the most recent + /// thread keeps it, the other is freed; other conversations, other + /// sessions and single-stream chats are untouched. + #[test] + fn restored_routes_keep_one_thread_per_session() { + let routes = FocusRoutes::new(FocusScope::Thread); + let route = |chat: ChatKey, value: &str| SavedGatewayRoute { + chat, + value: value.to_string(), + }; + let other_conv = ChatKey::new("mock", "conv-2", "bob").with_thread(Some("100.1")); + let telegram_a = ChatKey::new("telegram", "339", "339"); + let telegram_b = ChatKey::new("telegram", "440", "440"); + routes.load_saved( + vec![ + route(thread_key("100.1"), "s1"), + route(thread_key("300.3"), "s1"), + route(thread_key("200.2"), "s2"), + route(other_conv.clone(), "s1"), + route(telegram_a.clone(), "s1"), + route(telegram_b.clone(), "s1"), + ], + |_, _| Some(0), + ); + assert_eq!(routes.get(&thread_key("100.1")), None); + assert_eq!(routes.get(&thread_key("300.3")), Some("s1".into())); + assert_eq!(routes.get(&thread_key("200.2")), Some("s2".into())); + assert_eq!(routes.get(&other_conv), Some("s1".into())); + assert_eq!(routes.get(&telegram_a), Some("s1".into())); + assert_eq!(routes.get(&telegram_b), Some("s1".into())); + } + + /// Ownership is the conversation's: a session started in thread A is + /// listed in thread B and `@handle`-addressable from it (no spawn) — and + /// addressing it there MOVES it: it lives in one thread at a time, and the + /// thread it left is told so (its next message starts a fresh session). + #[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()) + ); + + assert_eq!( + gateway.current_session.get(&thread_key("100.1")), + None, + "s1 moved: thread A is no longer its tab" + ); + + let (tx, mut sink) = tokio::sync::mpsc::unbounded_channel::(); + gateway.set_event_sink(tx); + 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("200.2")), None); + let notice = recv_sink_answer_containing(&mut sink, "moved to another thread").await; + assert_eq!( + notice.thread_ts.as_deref(), + Some("200.2"), + "the thread s1 left is told, in that thread" + ); + assert_eq!(fake.starts.load(Ordering::SeqCst), 1); + + // Later output follows s1's one thread, not the thread it left. + let owner = ChatKey::new("mock", "conv-1", "conv-1"); + assert_eq!( + gateway.with_bound_thread(owner, "s1").thread.as_deref(), + Some("300.3") + ); + } + + /// `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 6de41f77a..9361fb08c 100644 --- a/crates/ccteam-im/src/hitl.rs +++ b/crates/ccteam-im/src/hitl.rs @@ -376,6 +376,7 @@ async fn ask_external_choice( .iter() .enumerate() .map(|(index, option)| MessageOption { + weight: Default::default(), data: format!("{token}:{index}"), label: option.label.clone(), id: option.id.clone(), @@ -394,10 +395,12 @@ async fn ask_external_choice( } if sink .send(GatewayEvent { + // 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(), - thread_ts: None, + thread_ts: ctx.thread_ts.clone(), content: prompt.title, kind: GatewayEventKind::Answer, attachments: Vec::new(), @@ -961,6 +964,7 @@ mod tests { HitlPromptContext { channel: "mock".to_string(), chat_id: "chat-1".to_string(), + thread_ts: None, role: "alice".to_string(), progress_path: None, } @@ -993,6 +997,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` / @@ -1071,6 +1076,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 dda7faab6..c1d9e1652 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,17 @@ 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. + 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() @@ -908,6 +929,7 @@ fn build_send_file_event( ) })?; Ok(crate::gateway::GatewayEvent { + interim: false, id: format!("chat-send-file-{slug}-{role}-{seq}"), channel, chat_id, @@ -1043,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" )); @@ -1073,6 +1100,7 @@ async fn execute_interaction_ask( .iter() .enumerate() .map(|(i, opt)| MessageOption { + weight: Default::default(), data: format!("{token}:{i}"), label: opt.label.clone(), // v0.8.7 review-fix (R-H1) — carry the stable option id (e.g. @@ -1100,10 +1128,11 @@ async fn execute_interaction_ask( // Render the buttons in IM. if sink .send(GatewayEvent { + interim: false, id: format!("interaction-{token}"), channel, chat_id, - thread_ts: None, + thread_ts, content: question.clone(), kind: GatewayEventKind::Answer, attachments: Vec::new(), @@ -1247,7 +1276,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" )); @@ -1295,6 +1329,7 @@ async fn execute_permission_ask( .iter() .enumerate() .map(|(i, opt)| MessageOption { + weight: Default::default(), data: format!("{token}:{i}"), label: opt.label.clone(), // v0.8.7 review-fix (R-H1) — carry the stable option id (e.g. @@ -1330,10 +1365,11 @@ 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, - thread_ts: None, + thread_ts, content: title, kind: GatewayEventKind::Answer, attachments: Vec::new(), @@ -3991,13 +4027,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). @@ -4094,8 +4132,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, @@ -12048,13 +12090,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/onboarding.rs b/crates/ccteam-im/src/onboarding.rs index 7a1dbcc3f..bee3c26b0 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,270 @@ 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"; + +/// The app name a setup surface proposes when the operator gives none. +pub const DEFAULT_SLACK_APP_NAME: &str = "ccteam"; + +/// Bot token scopes the Slack provider uses — the manifest and the setup +/// checklist are both built from this list. +pub const SLACK_BOT_SCOPES: &[&str] = &[ + "app_mentions:read", + "chat:write", + "channels:history", + "groups:history", + "im:history", + "mpim:history", + "reactions:write", + "files:read", + "files:write", + "commands", +]; + +/// Bot events the Slack provider consumes over Socket Mode. +pub const SLACK_BOT_EVENTS: &[&str] = &[ + "message.channels", + "message.groups", + "message.im", + "message.mpim", +]; + +/// Slack's limits on an app's display name, its bot user's handle and a +/// slash command (the leading `/` included). +const SLACK_APP_NAME_MAX: usize = 35; +const SLACK_BOT_HANDLE_MAX: usize = 80; +const SLACK_SLASH_COMMAND_MAX: usize = 32; + +/// The app's display name: trimmed, capped, [`DEFAULT_SLACK_APP_NAME`] when +/// blank. +fn slack_app_name(app_name: &str) -> String { + let name: String = app_name.trim().chars().take(SLACK_APP_NAME_MAX).collect(); + if name.is_empty() { + DEFAULT_SLACK_APP_NAME.to_string() + } else { + name + } +} + +/// The bot user's handle for `app_name`: Slack only allows lowercase letters, +/// digits, `.`, `_` and `-` there. +fn slack_bot_handle(app_name: &str) -> String { + let handle: String = slack_app_name(app_name) + .to_lowercase() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { + c + } else { + '-' + } + }) + .take(SLACK_BOT_HANDLE_MAX) + .collect(); + let handle = handle.trim_matches('-'); + if handle.is_empty() { + DEFAULT_SLACK_APP_NAME.to_string() + } else { + handle.to_string() + } +} + +/// The slash command an app called `app_name` declares: `/` + its bot handle +/// (`cct2` → `/cct2`). Every app gets its own, so several ccteam daemons can +/// each have an app in one workspace without fighting over one command. The +/// provider needs no copy of it: Socket Mode only ever delivers the app's own +/// commands. +pub fn slack_slash_command(app_name: &str) -> String { + let handle: String = slack_bot_handle(app_name) + .chars() + .take(SLACK_SLASH_COMMAND_MAX - 1) + .collect(); + format!("/{}", handle.trim_end_matches(['-', '.'])) +} + +/// The Slack app manifest ccteam needs, for an app called `app_name`: Socket +/// Mode on (no public URL), a slash command named after the app +/// ([`slack_slash_command`]), interactivity for +/// option buttons, the App Home messages tab for DMs, and exactly the scopes +/// and events the provider uses. The one home of what "a ccteam Slack app" +/// is — the web setup card and [`slack_app_checklist`] both come from here. +pub fn slack_app_manifest(app_name: &str) -> serde_json::Value { + let name = slack_app_name(app_name); + let handle = slack_bot_handle(app_name); + serde_json::json!({ + "display_information": { + "name": name, + "description": "ccteam — every agent session in its own thread", + }, + "features": { + "bot_user": { "display_name": handle, "always_online": true }, + "app_home": { + "messages_tab_enabled": true, + "messages_tab_read_only_enabled": false, + }, + "slash_commands": [{ + "command": slack_slash_command(app_name), + "description": "ccteam command: projects, cd, sessions, new, status, help", + "usage_hint": "projects | cd | sessions | new codex | status", + "should_escape": false, + }], + }, + "oauth_config": { "scopes": { "bot": SLACK_BOT_SCOPES } }, + "settings": { + "event_subscriptions": { "bot_events": SLACK_BOT_EVENTS }, + "interactivity": { "is_enabled": true }, + "socket_mode_enabled": true, + "org_deploy_enabled": false, + "token_rotation_enabled": false, + }, + }) +} + +/// A link that opens Slack's "create app" flow with [`slack_app_manifest`] +/// already filled in (`new_app=1&manifest_json=…`): one click, pick the +/// workspace, Create — no YAML to copy. +pub fn slack_create_app_url(app_name: &str) -> String { + let manifest = slack_app_manifest(app_name).to_string(); + reqwest::Url::parse_with_params( + "https://api.slack.com/apps", + &[("new_app", "1"), ("manifest_json", manifest.as_str())], + ) + .map(|url| url.to_string()) + .unwrap_or_else(|_| "https://api.slack.com/apps".to_string()) +} + +/// What the Slack app itself must have for the provider to work — printed by +/// the setup surfaces after a successful save. Built from the same constants +/// as [`slack_app_manifest`]. +pub fn slack_app_checklist() -> String { + format!( + "Slack app checklist (api.slack.com/apps → your app): + - Socket Mode: ON (the xapp- app-level token needs connections:write) + - Slash command: / ({} for an app named {}) + - Interactivity & Shortcuts: ON (option buttons) + - App Home → Messages tab: ON, allow users to message the app (DMs) + - Bot token scopes: {} + - Event subscriptions → bot events: {} + - Reinstall the app after scope changes, then invite the bot to a channel + (/invite @) or DM it + Easiest: create the app from {} +", + slack_slash_command(DEFAULT_SLACK_APP_NAME), + DEFAULT_SLACK_APP_NAME, + SLACK_BOT_SCOPES.join(" "), + SLACK_BOT_EVENTS.join(" "), + slack_create_app_url(DEFAULT_SLACK_APP_NAME), + ) +} + +/// 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/progress.rs b/crates/ccteam-im/src/progress.rs index 4232446e4..95889a063 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(); diff --git a/crates/ccteam-im/src/three_layer_sec.rs b/crates/ccteam-im/src/three_layer_sec.rs index bd7b4faab..e02ef602b 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 7d294b2a3..fe327ca6d 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. @@ -465,6 +465,34 @@ pub struct MessageOption { /// the SAME token-keyed pending the IM callback does, never a turn. #[serde(default)] pub id: String, + /// How prominently to present this option ([`OptionWeight`]). Pickers + /// leave it `Normal`; a session's controls mark their main actions and + /// the one that is easy to regret. + #[serde(default, skip_serializing_if = "OptionWeight::is_normal")] + pub weight: OptionWeight, +} + +/// How prominently a channel should present one option. Channel-neutral: a +/// provider maps it to whatever its buttons can do (Telegram: row layout; +/// Slack: button style + a confirm step) and may ignore it. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum OptionWeight { + /// An ordinary option — every picker's. + #[default] + Normal, + /// A main action: as large / prominent as the channel allows. + Primary, + /// Easy to regret on a mis-tap (interrupt): kept small, and asks first + /// where the channel can. + Minor, +} + +impl OptionWeight { + /// `serde` skip predicate: `Normal` is the default and stays off the wire. + pub fn is_normal(&self) -> bool { + *self == Self::Normal + } } /// An inbound option click carried on a [`ChannelMessage`] (v0.8.5 D3). @@ -583,6 +611,29 @@ 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 + } + + /// Whether this channel renders [`SendMessage::options`] as tappable + /// buttons whose click comes back as a [`ChannelMessage::selection`] + /// (Telegram inline keyboard, Slack Block Kit). The daemon reports it to + /// the gateway ([`crate::gateway::Gateway::bind_channel_buttons`]), which + /// then delivers its pickers (`/projects`, `/sessions`) as text + buttons + /// instead of a plain list — so the gateway never names a platform. + /// **Default `false`**: Lark's picker card is not wired for these clicks, + /// web turns options into a choice-chip frame, and the mock reads text. + fn native_buttons(&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 @@ -617,7 +668,7 @@ pub trait Channel: Send + Sync { /// so there is no generic emoji argument). Returns an opaque `handle` the /// provider needs to remove it later (e.g. Feishu's `reaction_id`), or /// `None` when the provider clears a reaction by `(chat, message)` alone - /// (Telegram). **Default no-op `Ok(None)`** — web/discord/slack/mock/ws + /// (Telegram, Slack). **Default no-op `Ok(None)`** — web/discord/mock/ws /// keep it (reactions are an IM-only affordance), so they need no change. /// Fire-and-forget at the call site: a reaction failure must NEVER break or /// delay turn/answer delivery. diff --git a/crates/ccteam-im/src/transport/providers/lark_tests.rs b/crates/ccteam-im/src/transport/providers/lark_tests.rs index 882fa0b2e..0f42838a9 100644 --- a/crates/ccteam-im/src/transport/providers/lark_tests.rs +++ b/crates/ccteam-im/src/transport/providers/lark_tests.rs @@ -956,11 +956,13 @@ fn image_ext_sniffs_magic_bytes() { fn build_option_card_renders_text_and_one_button_per_option() { let opts = vec![ MessageOption { + weight: Default::default(), data: "nav:cd:alpha".into(), label: "✓ alpha".into(), id: "alpha".into(), }, MessageOption { + weight: Default::default(), data: "nav:cd:beta".into(), label: "▸ beta".into(), id: "beta".into(), @@ -987,6 +989,7 @@ fn build_option_card_renders_text_and_one_button_per_option() { #[test] fn build_option_card_omits_empty_text_div() { let opts = vec![MessageOption { + weight: Default::default(), data: "t:0".into(), label: "Yes".into(), id: "yes".into(), diff --git a/crates/ccteam-im/src/transport/providers/mock.rs b/crates/ccteam-im/src/transport/providers/mock.rs index 5f8b0cfc6..eef052cc9 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/src/transport/providers/slack.rs b/crates/ccteam-im/src/transport/providers/slack.rs index 629ad7baf..8d79040ee 100644 --- a/crates/ccteam-im/src/transport/providers/slack.rs +++ b/crates/ccteam-im/src/transport/providers/slack.rs @@ -1,242 +1,1619 @@ -//! 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, CommandSpec, MessageOption, OptionWeight, OutboundFile, + RejectedSenderNotifier, RejectedSenderProbe, SendMessage, +}; + +/// 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"; + +/// A picker longer than this renders as a dropdown instead of a list of +/// buttons (Slack caps a message at 50 blocks; a list this long is better +/// searched than scrolled). +const SLACK_MAX_LIST_BUTTONS: usize = 20; + +/// Slack's cap on the options of one `static_select`. +const SLACK_SELECT_MAX_OPTIONS: usize = 100; + +/// 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, +} -use crate::transport::{Channel, ChannelMessage, SendMessage}; +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, + } +} -const SLACK_API: &str = "https://slack.com/api"; -const POLL_INTERVAL_SECS: u64 = 4; +/// 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 }, +} -/// Slack channel. +/// 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 = command_from_sigil(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}") + } +} + +/// The character a Slack user starts a ccteam command with. Slack keeps `/` +/// for itself — it swallows any message that starts with one, and an app's +/// own slash command cannot run inside a thread at all — so `!status`, +/// `!model`, `!compact` stand in for `/status` & co. everywhere on Slack, in a +/// session's thread included. +const COMMAND_SIGIL: char = '!'; + +/// Agent (vendor) commands ccteam's replies name by example; rewritten to +/// `!name` alongside ccteam's own registered commands. +const AGENT_COMMANDS_NAMED: &[&str] = &["model", "compact", "clear", "effort", "goal"]; + +/// `!word …` → `/word …` (a letter must follow, so `!!!` or `! wow` stay +/// prose); anything else unchanged. +fn command_from_sigil(text: String) -> String { + let mut chars = text.chars(); + if chars.next() == Some(COMMAND_SIGIL) && chars.next().is_some_and(|c| c.is_ascii_alphabetic()) + { + format!("/{}", &text[COMMAND_SIGIL.len_utf8()..]) + } else { + text + } +} + +/// Rewrite references to ccteam's own commands (`names`, without the `/`) +/// from `/name` to `!name`, so a hint like `→ /status` names what a Slack user +/// can actually type. Only a `/` that starts a token is touched, and only when +/// the whole word is one of `names` (`/home/stop`, `/status.json`, `/stopped` +/// stay as they are); fenced code blocks are left verbatim. +fn rewrite_command_sigils(text: &str, names: &[String]) -> String { + let mut out = String::with_capacity(text.len()); + let mut in_fence = false; + for line in text.split_inclusive('\n') { + if line.trim_start().starts_with("```") { + in_fence = !in_fence; + out.push_str(line); + } else if in_fence { + out.push_str(line); + } else { + rewrite_line_sigils(line, names, &mut out); + } + } + out +} + +fn rewrite_line_sigils(line: &str, names: &[String], out: &mut String) { + let chars: Vec = line.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + let starts_token = i == 0 + || chars[i - 1].is_whitespace() + || matches!( + chars[i - 1], + '(' | '[' | '`' | '"' | '\'' | '「' | '“' | ':' | ':' + ); + if c == '/' && starts_token { + let mut j = i + 1; + while j < chars.len() && (chars[j].is_ascii_alphanumeric() || chars[j] == '_') { + j += 1; + } + let word: String = chars[i + 1..j].iter().collect(); + let whole_word = match chars.get(j) { + None => true, + Some('/') | Some('-') => false, + Some('.') => !chars.get(j + 1).is_some_and(|n| n.is_alphanumeric()), + Some(_) => true, + }; + if whole_word && names.contains(&word) { + out.push(COMMAND_SIGIL); + out.push_str(&word); + i = j; + continue; + } + } + out.push(c); + i += 1; + } +} + +/// 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")?; + // A button carries `value`; a dropdown pick carries the chosen option's. + let value = str_at(payload, "/actions/0/value") + .or_else(|| str_at(payload, "/actions/0/selected_option/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. A plain picker (a project / session / model list) is +/// one button per row — like Telegram, so every label (a session's title +/// included) is readable at a glance — and a dropdown past +/// [`SLACK_MAX_LIST_BUTTONS`]. Weighted options (a session's controls, the +/// menu) are one row of buttons, styled by weight. Either way the option's +/// opaque `data` comes back 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 })); + } + if options.is_empty() { + return blocks; + } + let weighted = options.iter().any(|o| o.weight != OptionWeight::Normal); + if !weighted && options.len() > SLACK_MAX_LIST_BUTTONS { + for (index, chunk) in options.chunks(SLACK_SELECT_MAX_OPTIONS).enumerate() { + let choices: Vec = chunk + .iter() + .map(|option| { + json!({ + "text": { "type": "plain_text", "text": slack_label(option), "emoji": true }, + "value": option.data, + }) + }) + .collect(); + blocks.push(json!({ + "type": "actions", + "elements": [{ + "type": "static_select", + "action_id": format!("ccteam_select_{index}"), + "placeholder": { "type": "plain_text", "text": "选择…", "emoji": true }, + "options": choices, + }], + })); + } + return blocks; + } + let buttons: Vec = options + .iter() + .enumerate() + .map(|(index, option)| slack_button(index, option)) + .collect(); + if weighted { + blocks.push(json!({ "type": "actions", "elements": buttons })); + } else { + // One `actions` block per button: Slack wraps buttons side by side + // otherwise, and a list reads top to bottom. + blocks.extend( + buttons + .into_iter() + .map(|button| json!({ "type": "actions", "elements": [button] })), + ); + } + blocks +} + +/// An option's label as Slack shows it: trimmed (including the invisible +/// U+2800 padding the gateway adds to left-align Telegram's buttons), capped +/// at Slack's 75 characters, and never blank. +fn slack_label(option: &MessageOption) -> String { + let label: String = option + .label + .trim_matches(|c: char| c.is_whitespace() || c == '\u{2800}') + .chars() + .take(SLACK_BUTTON_LABEL_MAX_CHARS) + .collect(); + if label.is_empty() { + option + .data + .chars() + .take(SLACK_BUTTON_LABEL_MAX_CHARS) + .collect() + } else { + label + } +} + +/// One option as a Slack button. Slack cannot size a button: a main action +/// gets the highlighted style, one that is easy to regret asks before it +/// acts. +fn slack_button(index: usize, option: &MessageOption) -> Value { + let label = slack_label(option); + let mut button = json!({ + "type": "button", + "text": { "type": "plain_text", "text": label, "emoji": true }, + "value": option.data, + "action_id": format!("ccteam_opt_{index}"), + }); + match option.weight { + OptionWeight::Primary => button["style"] = json!("primary"), + OptionWeight::Minor => { + button["confirm"] = json!({ + "title": { "type": "plain_text", "text": "确认" }, + "text": { "type": "plain_text", "text": format!("{label} — 确定吗?") }, + "confirm": { "type": "plain_text", "text": "确定" }, + "deny": { "type": "plain_text", "text": "取消" }, + }); + } + OptionWeight::Normal => {} + } + button +} + +/// `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 + } +} + +/// Request body of one Web API call. +enum ApiBody<'a> { + Empty, + Json(&'a Value), + Form(&'a [(&'a str, String)]), +} + +/// 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 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, + /// ccteam's own command names (no `/`), as the daemon registers them — + /// what [`rewrite_command_sigils`] turns into `!name` on the way out. + command_names: RwLock>, 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(), + command_names: RwLock::new(Vec::new()), name: "slack".to_string(), } } - fn auth_header(&self) -> String { - format!("Bearer {}", self.bot_token) + /// [`rewrite_command_sigils`] with the registered names; `None` when + /// nothing changes. + async fn localize_commands(&self, text: &str) -> Option { + if !text.contains('/') { + return None; + } + let mut names = self.command_names.read().await.clone(); + if names.is_empty() { + return None; + } + // The agents' own commands a reply most often names (`/help` points + // at `/model` and `/compact`) — typed with `!` on Slack just the same. + names.extend(AGENT_COMMANDS_NAMED.iter().map(|n| n.to_string())); + let out = rewrite_command_sigils(text, &names); + (out != text).then_some(out) } -} -#[derive(Debug, Deserialize)] -struct HistoryResp { - ok: bool, - #[serde(default)] - messages: Vec, - #[serde(default)] - error: Option, -} + 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 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, -} + /// 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 PostMessageResp { - ok: bool, - #[serde(default)] - ts: Option, - #[serde(default)] - error: 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 + } -#[async_trait] -impl Channel for SlackChannel { - fn name(&self) -> &str { - &self.name + /// 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 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, - }); + /// 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 + } + + /// 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?; + 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?; - let parsed: PostMessageResp = resp.json().await?; - if !parsed.ok { + 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 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(()); + 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)?; + // No name check: Socket Mode only delivers this app's own slash + // commands, and each app is named its own (`/ccteam`, `/cct2`, …) — + // see `onboarding::slack_slash_command`. + 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 }), + }) + } + + /// 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> { + let localized; + let message = match self.localize_commands(&message.content).await { + Some(content) => { + localized = SendMessage { + content, + ..message.clone() + }; + &localized + } + None => message, + }; + 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(()), + } + } - #[test] - fn auth_header_format() { - let ch = SlackChannel::new("xoxb-abc".into(), vec![]); - assert_eq!(ch.auth_header(), "Bearer xoxb-abc"); + async fn health_check(&self) -> bool { + self.call_bot("auth.test", ApiBody::Empty).await.is_ok() + } + + fn max_message_len(&self) -> Option { + Some(SLACK_MAX_MESSAGE_UTF16) + } + + fn session_threads(&self) -> bool { + true } - #[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(); + /// Slack has no command menu to fill; it keeps the names so replies can + /// point at `!name` instead of a `/name` Slack would swallow. + async fn register_commands(&self, cmds: &[CommandSpec]) -> anyhow::Result<()> { + *self.command_names.write().await = cmds + .iter() + .map(|c| c.name.trim_start_matches('/').to_string()) + .filter(|n| !n.is_empty()) + .collect(); + Ok(()) + } + + fn native_buttons(&self) -> bool { + true + } + + async fn edit_message( + &self, + recipient: &str, + message_id: &str, + content: &str, + ) -> anyhow::Result> { + let localized = self.localize_commands(content).await; + let content = localized.as_deref().unwrap_or(content); + 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 000000000..34923f0ae --- /dev/null +++ b/crates/ccteam-im/src/transport/providers/slack_tests.rs @@ -0,0 +1,1592 @@ +//! 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 a_plain_picker_is_one_button_per_row_with_readable_labels() { + let mut options = options(3); + // The gateway pads labels with U+2800 to left-align Telegram buttons. + options[1].label = "💤 s676 claude (model)\u{2800}\u{2800}\u{2800}".into(); + let blocks = message_blocks("**bold** text", &options); + assert_eq!(blocks.len(), 4, "markdown + one row per option"); + assert_eq!( + blocks[0], + json!({ "type": "markdown", "text": "**bold** text" }) + ); + for (row, block) in blocks[1..].iter().enumerate() { + let elements = block["elements"].as_array().unwrap(); + assert_eq!(elements.len(), 1, "row {row} holds one button"); + assert_eq!(elements[0]["type"], "button"); + assert_eq!(elements[0]["value"], format!("tok:{row}")); + } + assert_eq!( + blocks[2]["elements"][0]["text"]["text"], "💤 s676 claude (model)", + "title shown, padding gone" + ); + let ids: HashSet<&str> = blocks[1..] + .iter() + .map(|b| b["elements"][0]["action_id"].as_str().unwrap()) + .collect(); + assert_eq!(ids.len(), 3, "action_ids are unique within the message"); + + assert!(message_blocks("", &[]).is_empty()); +} + +/// A list past 20 options is a dropdown, not a wall of buttons; Slack caps +/// one dropdown at 100 options. +#[test] +fn many_options_render_as_a_dropdown() { + let options: Vec = (0..130) + .map(|i| MessageOption { + weight: Default::default(), + data: format!("nav:cd:p{i}"), + label: if i == 0 { + "x".repeat(200) + } else { + format!(" project {i}") + }, + id: format!("p{i}"), + }) + .collect(); + let blocks = message_blocks("📁 项目", &options); + assert_eq!(blocks.len(), 3, "markdown + 100 + 30 in two dropdowns"); + let select = &blocks[1]["elements"][0]; + assert_eq!(select["type"], "static_select"); + let choices = select["options"].as_array().unwrap(); + assert_eq!(choices.len(), 100); + assert_eq!(choices[0]["value"], "nav:cd:p0"); + assert_eq!( + choices[0]["text"]["text"].as_str().unwrap().chars().count(), + SLACK_BUTTON_LABEL_MAX_CHARS + ); + assert_eq!(choices[1]["text"]["text"], "project 1", "padding trimmed"); + assert_ne!( + blocks[1]["elements"][0]["action_id"], + blocks[2]["elements"][0]["action_id"] + ); +} + +#[test] +fn a_dropdown_pick_comes_back_like_a_button_click() { + let mut payload = click_payload(ALLOWED, "C1", "5.5", Some("1.1")); + payload["actions"] = json!([{ + "type": "static_select", + "action_id": "ccteam_select_0", + "selected_option": { "value": "nav:cd:beta", "text": { "type": "plain_text", "text": "beta" } }, + }]); + let click = decode_block_actions(&payload).unwrap(); + assert_eq!(click.value, "nav:cd:beta"); + assert_eq!(click.thread_ts, "1.1"); +} + +#[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")); +} + +/// Each app declares its own command (`/cct2` for an app named cct2) and +/// Socket Mode only routes an app its own commands, so whatever command +/// arrives is this app's: it opens an anchor thread like `/ccteam` does. +#[tokio::test] +async fn an_apps_own_slash_command_works_whatever_it_is_named() { + let api = MockHttp::start_sync(default_api).await; + let ch = channel(&api.base, &[ALLOWED]); + let mut payload = slash_payload(ALLOWED, "C1", "projects", ""); + payload["command"] = json!("/cct2"); + let message = ch + .handle_envelope(&envelope("slash_commands", payload)) + .await + .expect("the app's own command is handled"); + assert_eq!(message.content, "/projects"); + let posts = api.calls("chat.postMessage"); + assert_eq!(posts.len(), 1, "one anchor message"); + assert!(posts[0].json()["text"] + .as_str() + .unwrap() + .contains("/cct2 projects")); + assert_eq!(message.thread_ts.as_deref(), Some("1700000999.000100")); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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 { + weight: Default::default(), + 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"][2]["elements"][0]["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); +} + +// ───────────────────────────────────────────────────────────────────────────── +// `!` stands in for `/` (Slack swallows `/…`, and app slash commands cannot +// run in threads) +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn a_bang_command_reaches_the_gateway_as_a_slash_command() { + let as_text = |text: &str| { + decode_message_event(&msg_event(ALLOWED, "C1", "channel", "5.5", text), &bot()) + .unwrap() + .text + }; + assert_eq!(as_text("!model"), "/model"); + assert_eq!(as_text("!status"), "/status"); + assert_eq!(as_text("!new codex"), "/new codex"); + assert_eq!( + as_text(" /compact"), + "/compact", + "the leading-space form still works" + ); + assert_eq!(as_text("!!! nice"), "!!! nice", "prose stays prose"); + assert_eq!(as_text("! ok"), "! ok"); + assert_eq!( + as_text("hello !status"), + "hello !status", + "only at the start" + ); +} + +#[test] +fn replies_name_ccteam_commands_with_the_bang_sigil() { + let names: Vec = ["status", "sessions", "use", "stop", "cd"] + .iter() + .map(|s| s.to_string()) + .collect(); + let rw = |t: &str| rewrite_command_sigils(t, &names); + assert_eq!( + rw("created s1\n↓ 查看状态 → /status"), + "created s1\n↓ 查看状态 → !status" + ); + assert_eq!( + rw("无当前会话 —— /use 选一个驱动(/sessions 看全部)"), + "无当前会话 —— !use 选一个驱动(!sessions 看全部)" + ); + assert_eq!(rw("run `/stop s3` now"), "run `!stop s3` now"); + assert_eq!( + rw("see /home/stop and /status.json"), + "see /home/stop and /status.json" + ); + assert_eq!( + rw("/stopped /cd-x"), + "/stopped /cd-x", + "only whole command names" + ); + assert_eq!( + rw("/model and /compact"), + "/model and /compact", + "not ccteam commands" + ); + assert_eq!( + rw("```\n/status\n```\nthen /status."), + "```\n/status\n```\nthen !status.", + "fenced code is verbatim" + ); +} + +#[tokio::test] +async fn sends_use_the_registered_command_names() { + let api = MockHttp::start_sync(default_api).await; + let ch = channel(&api.base, &[]); + // Before the daemon registers the gateway's commands nothing is rewritten. + ch.send(&SendMessage::new("→ /status", "C1")).await.unwrap(); + ch.register_commands(&[ + CommandSpec { + name: "/status".into(), + description: "fleet health".into(), + }, + CommandSpec { + name: "/sessions".into(), + description: "list".into(), + }, + ]) + .await + .unwrap(); + ch.send(&SendMessage::new("→ /status · /sessions", "C1")) + .await + .unwrap(); + ch.send(&SendMessage::new("其他命令(如 /model /compact)", "C1")) + .await + .unwrap(); + ch.edit_message("C1", "9.9", "card → /status") + .await + .unwrap(); + let posts = api.calls("chat.postMessage"); + assert_eq!(posts[0].json()["text"], "→ /status"); + assert_eq!(posts[1].json()["text"], "→ !status · !sessions"); + assert_eq!( + posts[2].json()["text"], + "其他命令(如 !model !compact)", + "the agent commands a reply names by example are typed with `!` too" + ); + assert_eq!(api.calls("chat.update")[0].json()["text"], "card → !status"); +} + +/// Slack cannot size a button: main actions get the highlighted style, and a +/// minor one (interrupt) asks for confirmation before it acts. +#[test] +fn button_weight_maps_to_style_and_a_confirm_step() { + let option = |data: &str, weight: OptionWeight| MessageOption { + data: data.into(), + label: format!("{data} label"), + id: data.into(), + weight, + }; + let blocks = message_blocks( + "", + &[ + option("act:status", OptionWeight::Primary), + option("act:sessions", OptionWeight::Normal), + option("act:interrupt", OptionWeight::Minor), + ], + ); + let buttons = blocks[0]["elements"].as_array().unwrap(); + assert_eq!(buttons[0]["style"], "primary"); + assert!(buttons[0].get("confirm").is_none()); + assert!(buttons[1].get("style").is_none() && buttons[1].get("confirm").is_none()); + assert!(buttons[2].get("style").is_none()); + assert_eq!(buttons[2]["confirm"]["confirm"]["text"], "确定"); + assert_eq!(buttons[2]["confirm"]["deny"]["text"], "取消"); +} diff --git a/crates/ccteam-im/src/transport/providers/telegram.rs b/crates/ccteam-im/src/transport/providers/telegram.rs index c14128920..3bf9242f1 100644 --- a/crates/ccteam-im/src/transport/providers/telegram.rs +++ b/crates/ccteam-im/src/transport/providers/telegram.rs @@ -21,8 +21,8 @@ use anyhow::Context as _; use crate::latency::now_unix_ms; use crate::transport::{ inbound_staging_dir, sanitize_attachment_name, AttachmentKind, Channel, ChannelAttachment, - ChannelMessage, ChoiceReply, CommandSpec, OutboundFile, OutboundFileKind, - RejectedSenderNotifier, RejectedSenderProbe, SendMessage, + ChannelMessage, ChoiceReply, CommandSpec, MessageOption, OptionWeight, OutboundFile, + OutboundFileKind, RejectedSenderNotifier, RejectedSenderProbe, SendMessage, }; /// `getUpdates` long-poll seconds. @@ -507,17 +507,13 @@ impl Channel for TelegramChannel { // Telegram supports reply_to_message_id for in-thread replies. "reply_to_message_id": message.thread_ts.as_ref().and_then(|s| s.parse::().ok()), }); - // v0.8.5 D3 — render choice options as an inline keyboard (one button - // per row); the opaque `data` ("{token}:{idx}") rides `callback_data` - // (≤64B). The numbered-text fallback already lives in `content`, so - // button-less clients still work. + // v0.8.5 D3 — render choice options as an inline keyboard + // ([`keyboard_rows`]); the opaque `data` ("{token}:{idx}") rides + // `callback_data` (≤64B). The numbered-text fallback already lives in + // `content`, so button-less clients still work. if !message.options.is_empty() { - let rows: Vec> = message - .options - .iter() - .map(|o| vec![serde_json::json!({ "text": o.label, "callback_data": o.data })]) - .collect(); - body["reply_markup"] = serde_json::json!({ "inline_keyboard": rows }); + body["reply_markup"] = + serde_json::json!({ "inline_keyboard": keyboard_rows(&message.options) }); } let t0 = Instant::now(); let resp = self.http.post(&url).json(&body).send().await?; @@ -710,6 +706,10 @@ impl Channel for TelegramChannel { Some(MAX_MESSAGE_UTF16) } + fn native_buttons(&self) -> bool { + true + } + async fn edit_message( &self, recipient: &str, @@ -859,8 +859,77 @@ fn set_message_reaction_body(chat_id: &str, message_id: i64, add: bool) -> serde }) } +/// Inline-keyboard rows. A plain picker (every option `Normal`) keeps one +/// button per row. Weighted options — a session's controls, the menu — lay +/// out by [`OptionWeight`]: `Primary` two to a row, so they are the widest, +/// then the rest three to a row with `Minor` last, the narrowest spot and the +/// hardest to hit by mistake. Telegram sizes a button only by how many share +/// its row. +fn keyboard_rows(options: &[MessageOption]) -> Vec> { + let button = + |o: &MessageOption| serde_json::json!({ "text": o.label, "callback_data": o.data }); + if options.iter().all(|o| o.weight == OptionWeight::Normal) { + return options.iter().map(|o| vec![button(o)]).collect(); + } + let of = |weight: OptionWeight| options.iter().filter(move |o| o.weight == weight); + let primary: Vec<&MessageOption> = of(OptionWeight::Primary).collect(); + let rest: Vec<&MessageOption> = of(OptionWeight::Normal) + .chain(of(OptionWeight::Minor)) + .collect(); + primary + .chunks(2) + .chain(rest.chunks(3)) + .map(|row| row.iter().map(|o| button(o)).collect()) + .collect() +} + #[cfg(test)] mod tests { + + fn opt(data: &str, weight: OptionWeight) -> MessageOption { + MessageOption { + data: data.into(), + label: data.into(), + id: data.into(), + weight, + } + } + + /// A plain picker stays one button per row; a session's controls put the + /// two main actions side by side (widest) and interrupt last in a row of + /// three (narrowest). + #[test] + fn keyboard_rows_size_buttons_by_weight() { + let labels = |rows: Vec>| -> Vec> { + rows.iter() + .map(|r| { + r.iter() + .map(|b| b["text"].as_str().unwrap().to_string()) + .collect() + }) + .collect() + }; + let picker = [ + opt("a", OptionWeight::Normal), + opt("b", OptionWeight::Normal), + ]; + assert_eq!(labels(keyboard_rows(&picker)), vec![vec!["a"], vec!["b"]]); + + let controls = [ + opt("status", OptionWeight::Primary), + opt("model", OptionWeight::Primary), + opt("interrupt", OptionWeight::Minor), + opt("sessions", OptionWeight::Normal), + opt("projects", OptionWeight::Normal), + ]; + assert_eq!( + labels(keyboard_rows(&controls)), + vec![ + vec!["status", "model"], + vec!["sessions", "projects", "interrupt"] + ] + ); + } use super::*; #[test] diff --git a/crates/ccteam-im/tests/credentials_test.rs b/crates/ccteam-im/tests/credentials_test.rs index e79b65a40..53e1c41e3 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/daemon_test.rs b/crates/ccteam-im/tests/daemon_test.rs index c42ca5046..01fe58e66 100644 --- a/crates/ccteam-im/tests/daemon_test.rs +++ b/crates/ccteam-im/tests/daemon_test.rs @@ -93,7 +93,7 @@ async fn daemon_registers_command_menu_per_channel() { // /help. They're in_menu now, with the arg hint woven into the // description so a menu tap still teaches the argument. for (name, hint) in [ - ("/use", ""), + ("/use", " | @"), ("/cd", ""), ("/role", ""), ("/stop", ""), diff --git a/crates/ccteam-im/tests/inbound_wiring_test.rs b/crates/ccteam-im/tests/inbound_wiring_test.rs index ce40df687..5ff615672 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 @@ -2918,6 +3030,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-im/tests/slack_onboarding_test.rs b/crates/ccteam-im/tests/slack_onboarding_test.rs new file mode 100644 index 000000000..405b5fa99 --- /dev/null +++ b/crates/ccteam-im/tests/slack_onboarding_test.rs @@ -0,0 +1,248 @@ +//! 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:?}"); +} + +/// One home for "what a ccteam Slack app is": the manifest the web card links +/// to and the checklist the CLI prints carry the same scopes, events and slash +/// command, and an odd app name still yields a valid bot handle. +#[test] +fn slack_manifest_and_checklist_share_one_definition() { + use ccteam_im::onboarding::{ + slack_app_checklist, slack_app_manifest, slack_create_app_url, slack_slash_command, + SLACK_BOT_EVENTS, SLACK_BOT_SCOPES, + }; + let manifest = slack_app_manifest("ccteam"); + let scopes: Vec<&str> = manifest["oauth_config"]["scopes"]["bot"] + .as_array() + .unwrap() + .iter() + .map(|s| s.as_str().unwrap()) + .collect(); + assert_eq!(scopes, SLACK_BOT_SCOPES); + assert_eq!( + manifest["features"]["slash_commands"][0]["command"], + "/ccteam" + ); + // Each app is named its own command, so several daemons can share a + // workspace: cct2 → /cct2, and a long name stays within Slack's 32. + assert_eq!( + slack_app_manifest("cct2")["features"]["slash_commands"][0]["command"], + "/cct2" + ); + assert_eq!(slack_slash_command("Dev Box 3"), "/dev-box-3"); + let long = slack_slash_command(&"x".repeat(40)); + assert!(long.len() <= 32 && long.starts_with("/x"), "{long}"); + assert_eq!(manifest["settings"]["socket_mode_enabled"], true); + assert_eq!(manifest["settings"]["interactivity"]["is_enabled"], true); + + let checklist = slack_app_checklist(); + for item in SLACK_BOT_SCOPES.iter().chain(SLACK_BOT_EVENTS) { + assert!(checklist.contains(item), "checklist names {item}"); + } + assert!(checklist.contains(&slack_create_app_url("ccteam"))); + + let blank = slack_app_manifest(" "); + assert_eq!(blank["display_information"]["name"], "ccteam"); + let odd = slack_app_manifest("Dev Team / 机器人!"); + assert_eq!(odd["display_information"]["name"], "Dev Team / 机器人!"); + let handle = odd["features"]["bot_user"]["display_name"] + .as_str() + .unwrap(); + assert!( + handle + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "._-".contains(c)), + "bot handle is Slack-valid: {handle}" + ); + assert!(!handle.is_empty() && !handle.starts_with('-') && !handle.ends_with('-')); +} diff --git a/crates/ccteam-web/src/ring.rs b/crates/ccteam-web/src/ring.rs index f57824dbf..6bba3ccb3 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 ff851323d..a09816f08 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/im_config.rs b/crates/ccteam-web/src/routes/im_config.rs index cf667a370..92a55b110 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,223 @@ 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() +} + +// -------------------------------------------------------------------------- +// Slack guided setup: create the app from a link, capture who to allow +// -------------------------------------------------------------------------- + +/// `GET /config/im/slack/app-manifest` query. +#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)] +pub struct SlackManifestQuery { + /// The Slack app's display name (default `ccteam`). + #[serde(default)] + pub name: Option, +} + +/// `GET /config/im/slack/app-manifest` response. +#[derive(Debug, Serialize, ToSchema)] +pub struct SlackManifestResponse { + /// The app manifest ccteam needs (JSON — Slack's manifest editor takes it + /// as is). + #[schema(value_type = Object)] + pub manifest: serde_json::Value, + /// Opens Slack's "create app" flow with the manifest filled in. + pub create_url: String, +} + +/// `GET /api/v1/config/im/slack/app-manifest` — the manifest for a new Slack +/// app and a one-click link that creates it, so setup never starts from a +/// hand-copied YAML. The manifest's one home is +/// [`ccteam_im::onboarding::slack_app_manifest`]. +#[utoipa::path( + get, + path = "/api/v1/config/im/slack/app-manifest", + tag = "config", + params(SlackManifestQuery), + responses( + (status = 200, description = "Manifest + create-app link", body = SlackManifestResponse), + (status = 403, description = "Not an admin"), + ), +)] +pub(crate) async fn handle_get_slack_manifest( + Extension(identity): Extension, + axum::extract::Query(query): axum::extract::Query, +) -> Response { + if let Some(deny) = deny_non_admin(&identity) { + return deny; + } + let name = query.name.unwrap_or_default(); + Json(SlackManifestResponse { + manifest: ccteam_im::onboarding::slack_app_manifest(&name), + create_url: ccteam_im::onboarding::slack_create_app_url(&name), + }) + .into_response() +} + +/// `GET /api/v1/config/im/slack/user-id-candidates` — Slack member ids the +/// global Slack bot saw and REJECTED (not on its allowlist yet), newest +/// first. The setup card polls it after the tokens are saved: DM the bot, +/// your own `U…` appears, one click allows it. The rejected messages reached +/// no agent. +#[utoipa::path( + get, + path = "/api/v1/config/im/slack/user-id-candidates", + tag = "config", + params(("since" = Option, Query, description = "Only candidates at/after this Unix timestamp")), + responses( + (status = 200, description = "Recent rejected Slack senders", body = super::users::SenderCandidatesResponse), + (status = 403, description = "Not an admin"), + ), +)] +pub(crate) async fn handle_get_slack_user_id_candidates( + State(app): State, + Extension(identity): Extension, + axum::extract::Query(query): axum::extract::Query, +) -> Response { + if let Some(deny) = deny_non_admin(&identity) { + return deny; + } + Json(super::users::SenderCandidatesResponse { + candidates: super::users::read_sender_candidates( + &super::users::probe_path(&app), + "slack", + query.since, + ), + }) + .into_response() +} + +/// `PUT /config/im/slack/allowed-users` body. +#[derive(Debug, Deserialize, ToSchema)] +pub struct SlackAllowedUsersForm { + /// The full desired allowlist of Slack member ids (`U…`). + #[serde(default)] + pub allowed_user_ids: Vec, +} + +/// `PUT /api/v1/config/im/slack/allowed-users` — replace only the Slack +/// allowlist (also the owner roster) of the configured app. The tokens are +/// never echoed, so binding a member id must not require re-entering them. +#[utoipa::path( + put, + path = "/api/v1/config/im/slack/allowed-users", + tag = "config", + request_body(content = SlackAllowedUsersForm, description = "Full desired allowed_user_ids list"), + responses( + (status = 200, description = "Allowlist saved; `{ok, allowed_user_ids, reloaded, note}`", body = serde_json::Value), + (status = 400, description = "No Slack app configured yet"), + (status = 403, description = "Not an admin"), + ), +)] +pub(crate) async fn handle_put_slack_allowed_users( + State(app): State, + Extension(identity): Extension, + Json(form): Json, +) -> Response { + if let Some(deny) = deny_non_admin(&identity) { + return deny; + } + let mut creds = match load_creds(&app) { + Ok(c) => c, + Err(e) => return json_500(e), + }; + let Some(slack) = creds.slack.as_mut() else { + return json_400("no Slack app configured; save the bot and app-level tokens first".into()); + }; + let mut ids: Vec = form + .allowed_user_ids + .iter() + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .collect(); + ids.dedup(); + slack.allowed_user_ids = ids.clone(); + 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, + "allowed_user_ids": ids, + "reloaded": reloaded, + "restart_required": !reloaded, + "note": reload_note(reloaded), + })) + .into_response() +} + // -------------------------------------------------------------------------- // Test-only seam: drive the async poll against a mock base // -------------------------------------------------------------------------- @@ -665,4 +935,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 0ea2bc760..aa9e0ac25 100644 --- a/crates/ccteam-web/src/routes/openapi.rs +++ b/crates/ccteam-web/src/routes/openapi.rs @@ -294,6 +294,10 @@ 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)) + .routes(routes!(super::im_config::handle_get_slack_manifest)) + .routes(routes!(super::im_config::handle_get_slack_user_id_candidates)) + .routes(routes!(super::im_config::handle_put_slack_allowed_users)) // 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/src/routes/sessions_api.rs b/crates/ccteam-web/src/routes/sessions_api.rs index e1d93ad8d..b22f25d96 100644 --- a/crates/ccteam-web/src/routes/sessions_api.rs +++ b/crates/ccteam-web/src/routes/sessions_api.rs @@ -1501,6 +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 { + // 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(), @@ -1513,6 +1515,7 @@ fn synthetic_approval_event(sid: &str, prompt: &ChoicePrompt) -> GatewayEvent { .iter() .enumerate() .map(|(i, opt)| MessageOption { + weight: Default::default(), data: format!("{}:{i}", prompt.token), label: opt.label.clone(), id: opt.id.clone(), @@ -2110,6 +2113,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 +2500,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 +2575,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. @@ -2682,11 +2703,13 @@ mod tests { ev.content = "session s7 (cto) wants to run: Bash rm -rf /".into(); ev.options = vec![ MessageOption { + weight: Default::default(), data: "pcafef00d:0".into(), label: "✅ Approve".into(), id: "allow".into(), }, MessageOption { + weight: Default::default(), data: "pcafef00d:1".into(), label: "⛔ Deny".into(), id: "deny".into(), @@ -2708,6 +2731,7 @@ mod tests { use ccteam_im::transport::MessageOption; let mut ev = gw_event(Some("s1")); ev.options = vec![MessageOption { + weight: Default::default(), data: "ptok:0".into(), label: "x".into(), id: "allow".into(), @@ -3396,6 +3420,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(), diff --git a/crates/ccteam-web/src/routes/users.rs b/crates/ccteam-web/src/routes/users.rs index de2ea2fa7..6ab790152 100644 --- a/crates/ccteam-web/src/routes/users.rs +++ b/crates/ccteam-web/src/routes/users.rs @@ -472,7 +472,7 @@ fn last4(s: &str) -> String { chars[start..].iter().collect() } -fn probe_path(app: &AppState) -> std::path::PathBuf { +pub(crate) fn probe_path(app: &AppState) -> std::path::PathBuf { app.paths.im_state_dir().join("rejected-senders.jsonl") } @@ -480,7 +480,7 @@ fn probe_path(app: &AppState) -> std::path::PathBuf { /// sender and capped. One reader for every platform: the daemon writes a single /// `RejectedSenderProbe` shape, so Lark `open_id` discovery and Telegram /// `chat_id` discovery differ only in the `channel` key passed in. -fn read_sender_candidates( +pub(crate) fn read_sender_candidates( path: &std::path::Path, channel: &str, since: Option, diff --git a/crates/ccteam-web/tests/im_config_test.rs b/crates/ccteam-web/tests/im_config_test.rs index 1fdf0def9..49c13f6d0 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) // -------------------------------------------------------------------------- @@ -499,6 +652,144 @@ async fn chat_id_start_without_token_is_400() { assert_eq!(resp.status(), 400); } +// -------------------------------------------------------------------------- +// Slack guided setup — create link, sender capture, allowlist-only update +// -------------------------------------------------------------------------- + +#[tokio::test] +async fn slack_manifest_comes_with_a_one_click_create_link() { + let tmp = TempDir::new().unwrap(); + let (state, _) = state_with_creds(&tmp, AuthState::disabled()); + let addr = spawn_app(state).await; + + let v: Value = client() + .get(format!( + "http://{addr}/api/v1/config/im/slack/app-manifest?name=My%20Team%20Bot" + )) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let manifest = v.get("manifest").unwrap(); + assert_eq!(manifest["display_information"]["name"], "My Team Bot"); + assert_eq!( + manifest["features"]["bot_user"]["display_name"], + "my-team-bot" + ); + assert_eq!(manifest["settings"]["socket_mode_enabled"], true); + assert_eq!( + manifest["features"]["slash_commands"][0]["command"], "/my-team-bot", + "the slash command follows the app name" + ); + + let create = reqwest::Url::parse(v["create_url"].as_str().unwrap()).unwrap(); + assert_eq!(create.host_str(), Some("api.slack.com")); + let query: std::collections::HashMap = + create.query_pairs().into_owned().collect(); + assert_eq!(query.get("new_app").map(String::as_str), Some("1")); + let embedded: Value = serde_json::from_str(&query["manifest_json"]).unwrap(); + assert_eq!( + &embedded, manifest, + "the link carries exactly the manifest shown" + ); +} + +#[tokio::test] +async fn slack_allowed_users_update_keeps_the_tokens() { + let tmp = TempDir::new().unwrap(); + let (state, creds_path) = state_with_creds(&tmp, AuthState::disabled()); + let addr = spawn_app(state).await; + let url = format!("http://{addr}/api/v1/config/im/slack/allowed-users"); + + // No Slack app yet → nothing to bind to. + let r = client() + .put(&url) + .json(&serde_json::json!({"allowed_user_ids": ["U1"]})) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 400); + + credentials::save( + &creds_path, + &Credentials { + slack: Some(SlackCreds { + bot_token: "xoxb-keep".into(), + app_token: "xapp-keep".into(), + allowed_user_ids: vec![], + }), + ..Default::default() + }, + ) + .unwrap(); + let r = client() + .put(&url) + .json(&serde_json::json!({"allowed_user_ids": [" U0ALICE ", "", "U0BOB"]})) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 200); + let saved = credentials::load(Some(&creds_path)).unwrap().slack.unwrap(); + assert_eq!(saved.allowed_user_ids, vec!["U0ALICE", "U0BOB"]); + assert_eq!(saved.bot_token, "xoxb-keep"); + assert_eq!(saved.app_token, "xapp-keep"); +} + +#[tokio::test] +async fn slack_user_id_candidates_are_the_global_bots_rejected_senders() { + let tmp = TempDir::new().unwrap(); + let paths = fake_paths(tmp.path()); + let probe_dir = paths.im_state_dir(); + std::fs::create_dir_all(&probe_dir).unwrap(); + let probe = |channel: &str, sender: &str, ts: u64| { + serde_json::json!({ + "channel": channel, + "sender_id": sender, + "chat_id": "D0DM", + "message_id": format!("{ts}.000100"), + "timestamp": ts, + }) + .to_string() + }; + std::fs::write( + probe_dir.join("rejected-senders.jsonl"), + [ + probe("slack", "U0OLD", 100), + probe("telegram", "339", 150), + probe("slack@u1", "U0TENANT", 160), + probe("slack", "U0NEW", 200), + ] + .join("\n"), + ) + .unwrap(); + let (state, _) = state_with_creds(&tmp, AuthState::disabled()); + let addr = spawn_app(state).await; + + let v: Value = client() + .get(format!( + "http://{addr}/api/v1/config/im/slack/user-id-candidates?since=150" + )) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let ids: Vec<&str> = v["candidates"] + .as_array() + .unwrap() + .iter() + .map(|c| c["sender_id"].as_str().unwrap()) + .collect(); + assert_eq!( + ids, + vec!["U0NEW"], + "only the global Slack bot, since the cutoff" + ); +} + // -------------------------------------------------------------------------- // Web-token gate // -------------------------------------------------------------------------- diff --git a/crates/ccteam-web/tests/openapi_test.rs b/crates/ccteam-web/tests/openapi_test.rs index 44161c236..7ff9fe489 100644 --- a/crates/ccteam-web/tests/openapi_test.rs +++ b/crates/ccteam-web/tests/openapi_test.rs @@ -167,6 +167,10 @@ 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"), + ("GET", "/api/v1/config/im/slack/app-manifest"), + ("GET", "/api/v1/config/im/slack/user-id-candidates"), + ("PUT", "/api/v1/config/im/slack/allowed-users"), // 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 eb5466696..f93b421f4 100644 --- a/crates/ccteam-web/tests/tenant_acl_test.rs +++ b/crates/ccteam-web/tests/tenant_acl_test.rs @@ -65,6 +65,38 @@ 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" + ); + // …and the Slack setup helpers (manifest link, sender capture, allowlist). + for (method, path) in [ + ("GET", "/api/v1/config/im/slack/app-manifest"), + ("GET", "/api/v1/config/im/slack/user-id-candidates"), + ("PUT", "/api/v1/config/im/slack/allowed-users"), + ] { + let req = if method == "GET" { + c.get(format!("http://{addr}{path}")) + } else { + c.put(format!("http://{addr}{path}")) + .json(&serde_json::json!({"allowed_user_ids": ["U1"]})) + }; + let r = req + .header("Authorization", format!("Bearer ccteam:{tenant_tok}")) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 403, "tenant must be 403 on {method} {path}"); + } // Shared operational/library surfaces are available to every identity. for path in ["/api/v1/hosts", "/api/v1/status", "/api/v1/skills"] { diff --git a/crates/ccteam-web/web/src/hooks/useAgentsEvents.test.ts b/crates/ccteam-web/web/src/hooks/useAgentsEvents.test.ts index dc32bcc91..de9114055 100644 --- a/crates/ccteam-web/web/src/hooks/useAgentsEvents.test.ts +++ b/crates/ccteam-web/web/src/hooks/useAgentsEvents.test.ts @@ -94,6 +94,31 @@ describe("parseAgentsEvent", () => { expect(ev).toMatchObject({ kind: "session_lifecycle", sid: "s4", slug: "demo" }); }); + 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…", 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(ev).toMatchObject({ kind: "scheduled_changed", sid: "s1" }); + }); + 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 37aa385a1..d75cb4209 100644 --- a/crates/ccteam-web/web/src/hooks/useAgentsEvents.ts +++ b/crates/ccteam-web/web/src/hooks/useAgentsEvents.ts @@ -19,8 +19,8 @@ import { useEffect, useRef, useState } from "react"; import { createAuthedEventSource } from "../lib/authedEventSource"; -import { shouldAcceptEventSeq } from "./useSessionEvents"; -import type { SessionActivity } from "./useSessionEvents"; +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 @@ -33,10 +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 (#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; @@ -86,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 : "", @@ -95,6 +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 (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 1e8d2d84a..e2d53212f 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,75 @@ 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 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); + // 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); + 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…", interim: true }; + 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: 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]; + 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 a0f77904f..66bad78c0 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?, 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 … @@ -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,7 +81,53 @@ export interface SessionEvent { * branch (`ccteam-web/src/routes/sessions_api.rs`). */ state?: string; reason?: string; + /** 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 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 + * 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 { @@ -146,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. */ @@ -180,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 @@ -194,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 ba13e4458..d9f91d1fa 100644 --- a/crates/ccteam-web/web/src/lib/agentsReducer.test.ts +++ b/crates/ccteam-web/web/src/lib/agentsReducer.test.ts @@ -70,21 +70,53 @@ describe("sidsActiveWithin", () => { expect(sidsActiveWithin(events, 15_000, now).has("s1")).toBe(false); }); - it("a finalizing progress (done:true) ends the pulse even if recent", () => { + 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 answer ends the pulse", () => { + 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", receivedAt: now - 200 }), + 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: "run it?", + options: [{ label: "Approve", id: "allow" }], + receivedAt: now - 200, + }), + ]; + expect(sidsActiveWithin(events, 15_000, now).has("s1")).toBe(true); + }); + + 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…", interim: true, receivedAt: now - 400 }), + ts({ sid: "s1", kind: "progress", done: true, receivedAt: now - 300 }), + 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 868c943c9..3ba94bf27 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 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( 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/lib/configApi.test.ts b/crates/ccteam-web/web/src/lib/configApi.test.ts index 00ee17f64..ce3b7e410 100644 --- a/crates/ccteam-web/web/src/lib/configApi.test.ts +++ b/crates/ccteam-web/web/src/lib/configApi.test.ts @@ -13,6 +13,10 @@ import { getImConfig, pollTelegramChatId, saveLark, + saveSlack, + getSlackAppManifest, + getSlackUserIdCandidates, + putSlackAllowedUsers, saveTelegramToken, startTelegramChatId, } from "./configApi"; @@ -44,6 +48,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 +80,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 +232,76 @@ 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("getSlackAppManifest asks for the named app's manifest + create link", async () => { + const fetchMock = vi.mocked(globalThis.fetch); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { manifest: { display_information: { name: "cct2" } }, create_url: "https://api.slack.com/apps?new_app=1" }), + ); + const got = await getSlackAppManifest("cct 2"); + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/config/im/slack/app-manifest?name=cct%202"); + expect(got.create_url).toContain("new_app=1"); + }); + + it("putSlackAllowedUsers replaces only the member-id allowlist", async () => { + const fetchMock = vi.mocked(globalThis.fetch); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { ok: true, allowed_user_ids: ["U1"], restart_required: false, note: "ok" }), + ); + await putSlackAllowedUsers(["U1"]); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/v1/config/im/slack/allowed-users"); + expect(init.method).toBe("PUT"); + expect(JSON.parse(init.body as string)).toEqual({ allowed_user_ids: ["U1"] }); + }); + + it("getSlackUserIdCandidates polls the global bot's rejected senders since a cutoff", async () => { + const fetchMock = vi.mocked(globalThis.fetch); + fetchMock.mockResolvedValueOnce(jsonResponse(200, { candidates: [] })); + await getSlackUserIdCandidates(1700); + expect(fetchMock.mock.calls[0][0]).toBe( + "/api/v1/config/im/slack/user-id-candidates?since=1700", + ); + }); + + 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 0fca0557a..f3356b0d8 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. @@ -20,6 +22,8 @@ // (e.g. "Telegram token rejected: ..."), so we surface that text rather // than a bare "HTTP 400". +import type { SenderCandidatesResponse } from "./usersApi"; + /** Masked Telegram status (`im_config::TelegramStatus`). No token field. */ export interface TelegramStatus { /** Always `true` when present (a block exists on disk). */ @@ -42,12 +46,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 +126,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 +247,53 @@ 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); +} + +/** `GET /config/im/slack/app-manifest` response: the manifest a new ccteam + * Slack app needs + a link that opens Slack's create-app flow with it. */ +export interface SlackManifestResult { + manifest: Record; + create_url: string; +} + +/** `GET /api/v1/config/im/slack/app-manifest?name=` — the manifest for an + * app called `name` (its slash command is `/` + the name) and a one-click + * create link, so setup never starts from a hand-copied YAML. */ +export function getSlackAppManifest(name: string): Promise { + return getJson( + `/api/v1/config/im/slack/app-manifest?name=${encodeURIComponent(name)}`, + ); +} + +/** `GET /api/v1/config/im/slack/user-id-candidates` — poll while binding: + * DM the bot and your own `U…` shows up here. Those messages reached no + * agent. Same candidate shape as the tenant capture endpoints. */ +export function getSlackUserIdCandidates( + since?: number, +): Promise { + const qs = since ? `?since=${encodeURIComponent(String(since))}` : ""; + return getJson( + `/api/v1/config/im/slack/user-id-candidates${qs}`, + ); +} + +/** `PUT /config/im/slack/allowed-users` success body. */ +export interface SlackAllowedUsersResult { + ok: boolean; + allowed_user_ids: string[]; + restart_required: boolean; + note: string; +} + +/** `PUT /api/v1/config/im/slack/allowed-users` — replace only the member-id + * allowlist (also the owner roster); the tokens stay as saved. */ +export function putSlackAllowedUsers(ids: string[]): Promise { + return putJson("/api/v1/config/im/slack/allowed-users", { + allowed_user_ids: ids, + }); +} diff --git a/crates/ccteam-web/web/src/pages/AccessView.test.tsx b/crates/ccteam-web/web/src/pages/AccessView.test.tsx index 121d84e6e..6e35e73a9 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 09e2f6cfe..608be8d9b 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/AgentsView.tsx b/crates/ccteam-web/web/src/pages/AgentsView.tsx index 840168d7b..d9b28d79d 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 { emptyFold, foldActivity, 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"; @@ -893,21 +899,19 @@ 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; 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 5f440703e..8d8150c18 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(); + }, + }; + }, }; } @@ -919,6 +934,269 @@ 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 }, + history = vi.fn().mockResolvedValue({ sid: "s9", events: [] }), + ) { + 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: history, + 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; + const view = () => View({ sid: "s9", session: SESSION }); + return Object.assign(() => harness.render(view), { + deferred: () => harness.renderDeferred(view), + }); + } + + 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…", interim: true }, + ); + 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…", interim: true }); + 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(); + } + }); + + 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)", () => { 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 1f6e0e7e2..acf0fc1b6 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 { @@ -42,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, @@ -197,7 +204,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)); @@ -219,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; @@ -247,15 +259,18 @@ 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, nextBefore: h.next_before ?? null, loadingEarlier: false, + closedBy: leadingClosingStatus(h.events), }); setHistoryError(null); }) @@ -289,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(() => { @@ -322,9 +338,20 @@ export default function SessionView({ saveRows(sid, rows); }, [sid, rows]); + // ---- working state of the turn we sent (#209) ------------------------------ + // 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 + // 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 +450,13 @@ export default function SessionView({ .filter(Boolean); const shown = names.length > 0 ? `${content}\n📎 ${names.join(", ")}` : content; pushRow({ kind: "user", content: shown }); - setBusyMark(doneCount); + // 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) => { - setBusyMark(null); + setTurnMark(null); setBusySince(null); const detail = e instanceof Error ? e.message : "unknown"; pushRow({ @@ -435,7 +465,7 @@ export default function SessionView({ }); }); }, - [sid, pushRow, doneCount], + [sid, pushRow, events], ); // ---- resolve a HITL approval prompt (gateway pending machinery) ---------- diff --git a/crates/ccteam-web/web/src/pages/SettingsPage.test.tsx b/crates/ccteam-web/web/src/pages/SettingsPage.test.tsx index 72d076210..8b50b7ae2 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,68 @@ describe("Settings sections", () => { expect(html).toContain("fail-closed"); }); + it("SlackSection (bound) shows masked token tails and the allowed members, no secret field", () => { + const html = renderToString( + {}} + />, + ); + expect(html).toContain('data-testid="settings-slack"'); + expect(html).toContain("已连接"); + expect(html).toContain('data-testid="settings-slack-summary"'); + expect(html).toContain("…bot1"); + expect(html).toContain("…app1"); + expect(html).toContain('data-testid="settings-slack-remove-U0ALICE"'); + expect(html).toContain('data-testid="settings-slack-remove-U0BOB"'); + expect(html).not.toContain('type="password"'); + }); + + it("SlackSection (tokens saved, nobody allowed) asks for step 3 with sender capture", () => { + const html = renderToString( + {}} + />, + ); + expect(html).toContain("待绑定"); + expect(html).toContain('data-testid="settings-slack-capture"'); + expect(html).toContain('id="settings-slack-users"'); + expect(html).toContain("未允许前 bot 谁也不回"); + }); + + it("SlackSection (unconfigured) walks through create → tokens → allow", () => { + const html = renderToString( {}} />); + expect(html).toContain('data-testid="settings-slack"'); + expect(html).toContain("未配置"); + // ① create the app from a link (the name drives its slash command) + expect(html).toContain('data-testid="settings-slack-step-create"'); + expect(html).toContain('id="settings-slack-app-name"'); + expect(html).toContain('data-testid="settings-slack-create"'); + expect(html).toContain('data-testid="settings-slack-copy-manifest"'); + // ② the two tokens start empty, each with where-to-find-it + 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("Bot User OAuth Token"); + expect(html).toContain("connections:write"); + expect(html).not.toMatch(/type="password"[^>]*value="[^"]+"/); + // ③ binding waits for saved tokens + expect(html).toContain('data-testid="settings-slack-step-bind"'); + expect(html).not.toContain('data-testid="settings-slack-capture"'); + }); + 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 ca7ad5d6c..088d73b27 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,14 +41,19 @@ // 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 { + getSlackAppManifest, + getSlackUserIdCandidates, pollTelegramChatId, + putSlackAllowedUsers, saveLark, + saveSlack, saveTelegramToken, startTelegramChatId, type ChatIdPollStatus, type ImConfigStatus, + type SlackManifestResult, } from "../lib/configApi"; import { copyText } from "../lib/clipboard"; import { toastBus } from "../lib/toastBus"; @@ -657,6 +662,399 @@ 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; +}) { + void lang; + const configured = status?.configured ?? false; + const allowed = status?.allowed_user_ids ?? []; + const bound = configured && allowed.length > 0; + + // ① The app: named here, created in Slack by one click (manifest prefilled). + const [appName, setAppName] = useState("ccteam"); + const [manifest, setManifest] = useState(null); + useEffect(() => { + let cancelled = false; + const timer = setTimeout(() => { + getSlackAppManifest(appName.trim()) + .then((res) => { + if (!cancelled) setManifest(res); + }) + .catch(() => { + if (!cancelled) setManifest(null); + }); + }, 250); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [appName]); + const slashCommand = slashCommandOf(manifest); + + // ② The two tokens. + const [editingTokens, setEditingTokens] = useState(!configured); + const [botToken, setBotToken] = useState(""); + const [appToken, setAppToken] = useState(""); + const [confirming, setConfirming] = useState(false); + const [pending, setPending] = useState(false); + + // ③ Who the bot answers: DM it, your member id shows up, one click allows it. + const [captureSince, setCaptureSince] = useState(null); + const candidates = useSenderCapture( + captureSince, + getSlackUserIdCandidates, + "Slack member id capture failed", + ).filter((c) => !allowed.includes(c.sender_id)); + const [manualId, setManualId] = useState(""); + + function startCapture() { + setCaptureSince(Math.floor(Date.now() / 1000) - 2); + } + + async function copyManifest() { + if (!manifest) return; + const ok = await copyText(JSON.stringify(manifest.manifest, null, 2)); + if (ok) toastBus.handler?.info("manifest 已复制 —— 在 Slack「From a manifest」里粘贴(JSON)"); + else toastBus.handler?.error("复制失败,请改用「在 Slack 中创建」"); + } + + async function saveTokens(e: React.FormEvent) { + e.preventDefault(); + const bot = botToken.trim(); + const app = appToken.trim(); + if (pending || !bot || !app) return; + if (configured && !confirming) { + setConfirming(true); + return; + } + setPending(true); + setConfirming(false); + try { + const res = await saveSlack({ bot_token: bot, app_token: app, allowed_user_ids: allowed }); + setBotToken(""); + setAppToken(""); + setEditingTokens(false); + // Fail-closed: an empty allowlist answers nobody — walk straight into ③. + if (allowed.length === 0) startCapture(); + toastBus.handler?.info( + `已连接 ${res.team} · @${res.bot_user}` + + (allowed.length === 0 ? " —— 现在私聊 bot,完成第 3 步" : ""), + ); + onSaved(); + } catch (err) { + if (err instanceof Error && err.message === "UNAUTHENTICATED") return; + toastBus.handler?.error(err instanceof Error ? err.message : "save failed"); + } finally { + setPending(false); + } + } + + async function saveAllowlist(ids: string[]) { + if (pending) return; + const normalized = Array.from(new Set(ids.map((id) => id.trim()).filter(Boolean))); + setPending(true); + try { + const res = await putSlackAllowedUsers(normalized); + setManualId(""); + if (normalized.length > 0) setCaptureSince(null); + toastBus.handler?.info(res.note || "allowlist 已保存"); + onSaved(); + } catch (err) { + if (err instanceof Error && err.message === "UNAUTHENTICATED") return; + toastBus.handler?.error(err instanceof Error ? err.message : "save failed"); + } finally { + setPending(false); + } + } + + return ( + + + + Slack + {bound ? ( + 已连接 + ) : configured ? ( + 待绑定 + ) : ( + 未配置 + )} + + + + {/* ① Create the Slack app */} +

+ +
+
+ + setAppName(e.target.value)} + spellCheck={false} + placeholder="ccteam" + className="font-mono" + /> +
+ + +
+

+ 斜杠命令随 App 名:{slashCommand} + 。每台 ccteam 用自己的 App(如 cct2、cct3;免费版一个工作区最多 10 个 App)。打开后选工作区 → + Create → 左侧 Install App → Install to Workspace → Allow。 +

+
+ + {/* ② The two tokens */} +
+ + {editingTokens ? ( +
+
+
+ {/* Red line: token fields always start EMPTY. */} + + { + setBotToken(e.target.value); + if (confirming) setConfirming(false); + }} + disabled={pending} + spellCheck={false} + placeholder="xoxb-…" + className="font-mono" + /> +

+ OAuth & Permissions → Bot User OAuth Token +

+
+
+ + { + setAppToken(e.target.value); + if (confirming) setConfirming(false); + }} + disabled={pending} + spellCheck={false} + placeholder="xapp-…" + className="font-mono" + /> +

+ Basic Information → App-Level Tokens → Generate,scope 选 connections:write +

+
+
+
+ {confirming ? ( + + 覆盖已配置的 Slack token? + + ) : null} + {configured ? ( + + ) : null} + +
+
+ ) : ( +
+ + + + + +
+ )} +
+ + {/* ③ Who the bot answers */} +
+
+ + {configured && captureSince === null ? ( + + ) : null} +
+

+ 私信这个 bot(或在频道里 @ 它),你的 Slack 成员 ID 会出现在下面,点一下即允许。 + 未允许前 bot 谁也不回。 +

+ {captureSince !== null && candidates.length === 0 ? ( +

等待消息…

+ ) : null} + {candidates.length > 0 ? ( +
+ {candidates.map((c) => ( + + ))} +
+ ) : null} + {allowed.length > 0 ? ( +
+ {allowed.map((id) => ( + + {id} + + + ))} +
+ ) : null} + {configured ? ( +
{ + e.preventDefault(); + if (manualId.trim()) void saveAllowlist([...allowed, manualId]); + }} + > +
+ + setManualId(e.target.value)} + disabled={pending} + spellCheck={false} + placeholder="U0123ABCD" + className="font-mono" + /> +
+ +
+ ) : null} +
+ + + + token 永不回显 · 顶层消息 = 新会话(独立线程)· 命令用 App 的斜杠命令,如 {slashCommand} projects + + + ); +} + +/** The slash command a manifest declares (`/ccteam` until it loads). */ +function slashCommandOf(manifest: SlackManifestResult | null): string { + const features = manifest?.manifest?.features as + | { slash_commands?: { command?: string }[] } + | undefined; + return features?.slash_commands?.[0]?.command ?? "/ccteam"; +} + // -------------------------------------------------------------------------- // v0.8.18 档1 — multi-user management (web-first). The admin/owner mints a // per-user tenant; each gets a one-time personal link (?token=ccteam:) diff --git a/crates/ccteam-web/web/src/pages/chatTranscript.test.ts b/crates/ccteam-web/web/src/pages/chatTranscript.test.ts index baae4f784..723d90649 100644 --- a/crates/ccteam-web/web/src/pages/chatTranscript.test.ts +++ b/crates/ccteam-web/web/src/pages/chatTranscript.test.ts @@ -9,12 +9,15 @@ import { describe, expect, it } from "vitest"; import { appendEvent, appendRow, + currentTurnFold, eventToRow, foldActivity, emptyFold, historyToRows, + leadingClosingStatus, loadRows, mergeHistory, + recentHistoryTurns, renderFold, rowsKeyFor, saveRows, @@ -225,6 +228,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 +326,91 @@ 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…", interim: true }); + rows = appendEvent(rows, step("r1", "Read")); + 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(); + + 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", 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 }); + 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("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([ + 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…", interim: true }), + 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"); + // 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()); + }); +}); + describe("chatTranscript historyToRows", () => { it("expands mirrored turns into user+assistant rows", () => { const events: SessionHistoryEvent[] = [ @@ -337,6 +432,70 @@ 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("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[] = [ { @@ -389,6 +548,88 @@ 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 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")); + 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 f6bf5a4d8..f0e9aadc9 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"; @@ -51,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; @@ -118,9 +125,12 @@ 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 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 +252,65 @@ 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 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; + interim?: boolean; + options?: readonly unknown[]; + }[], + 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 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" || row.standalone) 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) { @@ -281,10 +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 by content, 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. */ + * 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[], @@ -305,28 +368,105 @@ export function mergeHistory( next = appendRow(next, row); } } + const mirrored = mirroredLateReplies(seeded, events, barrier); 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 - } + 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; } +/** 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(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). Used to populate a reopened per-session page - * before the live SSE takes over. */ -export function historyToRows(events: SessionHistoryEvent[]): TranscriptRow[] { + * (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. 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 }); @@ -340,11 +480,34 @@ export function historyToRows(events: SessionHistoryEvent[]): TranscriptRow[] { status: ev.status, attachments: ev.attachments, }); + } else if (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`. */ diff --git a/docs/dsh-plugin-cn.md b/docs/dsh-plugin-cn.md index 1cada4b1b..442a90b95 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 6566c6608..22ade3a52 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 d0080b84d..b59a8ae2d 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 有自己的当前项目、当前会话和会话列表,不同人的 chat 互相隔离;**你自己的** IM chat(bot 允许列表里点名是你的)能互相看到全部会话——但一个会话永远只推送到**一个** 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**:三步卡片——一键链接创建 App(manifest 已填好)、填 bot token 和 app-level token、私信 bot 后点一下自己的成员 ID 完成授权。详见 [接入](#接入)。 -秘密只显示掩码(`…末四位`),永不回显明文。**改完需重启 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,30 @@ 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,不需要公网回调地址)。在 **Settings → 接入 → Slack** 卡片里按三步完成: + +1. **创建 App**:起个名字(比如 `ccteam`,第二台机器用 `cct2`),点 **在 Slack 中创建**——Slack 的建应用页面会带着完整 manifest 打开:Socket Mode、ccteam 需要的权限和事件、选项按钮、私信入口,以及以 App 名命名的斜杠命令(`/ccteam`、`/cct2`)。选工作区 → **Create**,再 **Install App → Install to Workspace → Allow**。 +2. **填两个 token**:*Bot token* = **OAuth & Permissions → Bot User OAuth Token**(`xoxb-…`);*App-level token* = **Basic Information → App-Level Tokens → Generate**,scope 选 `connections:write`(`xapp-…`)。保存时会先用 Slack 校验两者,通过即连接,不用重启。 +3. **允许自己**:私信 bot(或在频道里 @ 它),你的成员 ID(`U…`)会出现在卡片上,点一下即允许。没人被允许前 bot **谁也不回**(fail closed)。允许名单同时是 **owner 名册**:被允许的成员按本机 owner 服务。 + +之后把 bot 邀进频道(`/invite @ccteam`)或直接私信它。它会回答允许名单里的人在它所在频道里的每条消息,所以最好用一个专用频道(或私信)。 + +- **一个工作区里有多台 ccteam**:每个独立的 ccteam 各建一个 App(`cct2`、`cct3`……)——Socket Mode 会把一个 App 的事件分散到它的所有连接上,两个 daemon 不能共用一个 App。每个 App 有自己的斜杠命令。Slack 免费版一个工作区最多 10 个 App。以卫星方式接入同一个 ccteam 的机器不需要单独的 App:那个 ccteam 的 App 已经能管到它们的项目。 + +> **手写凭证文件后必须重启 daemon 才生效**(Web Settings 配的同理)。飞书/Lark、Telegram 与 Slack 对等:文本、富文本、图片/文件收发都支持。 + +### Slack:一个会话 = 一个线程 + +- **顶层消息**开一个新线程;没有会话的线程里第一条普通消息会在本频道当前项目里新建一个 roleless 会话(与 Telegram 新 chat 首条消息同一条路)。再发一条顶层消息,第二个会话就与第一个并行。 +- **线程内回复**只发给该线程的会话。该会话产生的一切——回答、实时进度卡、审批按钮、文件、委派回报后的回答——都进它自己的线程。 +- 归属与当前项目以**频道**为单位:在任一线程里 `/cd` 会切换整个频道的项目(该线程的会话若不在新项目里,线程即释放,下一条消息在新项目里新建会话),`/sessions` 列出频道拥有的全部会话。一个会话**同一时刻只住一个线程**:在线程里 `/use `(或 `@`)会把该会话搬进这个线程,它离开的线程会收到提示(在那里再发消息会新建会话);线程里 `/new …` 在该线程新建会话。 +- **以点按钮为主**。线程里第一条消息新建会话时,会先出一条会话头和这个会话的按钮——**📊 状态 · 🧠 模型 · ⏹ 中断**;`new` / `use` / `status` 的回复也带这排按钮。只发 App 的斜杠命令(`/ccteam`)会出一个菜单:**📁 项目 · 🧵 会话 · 📊 状态 · + Claude · + Codex**。长列表(项目、会话、模型 × effort)用下拉框。 +- **Slack 上命令用 `!` 开头**。Slack 把 `/` 留给自己(以 `/` 开头的消息都会被它拦下,App 的斜杠命令在线程里根本不能用),所以 Telegram 里打 `/` 的地方在 Slack 打 `!`:会话线程里发 `!status`、`!model`、`!compact`、`!interrupt` 作用于该会话;顶层发 `!projects`、`!cd demo`、`!sessions`、`!new codex` 会开一个线程回复——`!new codex` 就把新 codex 会话建在这个线程里,`!use s12` 则把已有会话搬进来。ccteam 自己回复里提到的命令也写成这样(`→ !status`)。App 的斜杠命令(`/ccteam`,名为 cct2 的 App 是 `/cct2`)在频道里同样可用。 +- 由其他 agent 雇来的会话没有自己的线程;想跟它说话就在顶层发 `!use ` 给它开一个。 ### 网关命令 -聊天框里发这些命令,由网关直接处理。随时 `/help` 看清单(Telegram 里敲 `/` 也会弹候选)。 +聊天框里发这些命令,由网关直接处理。随时 `/help` 看清单(Telegram 里敲 `/` 也会弹候选)。Slack 上把 `/` 换成 `!`(`!status`、`!model`),见 [Slack:一个会话 = 一个线程](#slack一个会话--一个线程)。 ```text # 项目 @@ -349,8 +369,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 ee0107c18..9e8207e31 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. Other people's chats are isolated from each other; **your own** IM chats (the ones a bot allowlist names as you) see each other's sessions — but a session only ever pushes to **one** chat: the one that last messaged it or switched to it. 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:** a three-step card — create the app from a one-click link (manifest prefilled), paste the bot and app-level tokens, then DM the bot and click your member id to allow it. Details in [Setup](#setup). 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,30 @@ 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). Set it up in **Settings → Access → Slack**, a guided three-step card: + +1. **Create the app.** Name it (say `ccteam`, or `cct2` on a second machine) and click **Create in Slack**: Slack's create-app page opens with the whole manifest filled in — Socket Mode, the scopes and events ccteam needs, option buttons, the DM tab, and a slash command named after the app (`/ccteam`, `/cct2`). Pick the workspace, **Create**, then **Install App → Install to Workspace → Allow**. +2. **Paste the two tokens.** *Bot token* = **OAuth & Permissions → Bot User OAuth Token** (`xoxb-…`); *App-level token* = **Basic Information → App-Level Tokens → Generate** with the `connections:write` scope (`xapp-…`). Saving checks both with Slack and connects at once — no restart. +3. **Allow yourself.** DM the bot (or @-mention it in a channel): your member id (`U…`) shows up on the card, one click allows it. Until someone is allowed the bot answers **no one** (fail closed). The allowlist is also the **owner roster**: an allowed member is served as the box owner. + +Then invite the bot to a channel (`/invite @ccteam`) or DM it. It answers every message from allowed members in the channels it is in, so a dedicated channel (or the DM) works best. + +- **Several ccteam machines in one workspace:** give each independent ccteam its own app (`cct2`, `cct3`, …) — Socket Mode spreads one app's events across all its connections, so two daemons cannot share an app. Each app gets its own slash command. Slack's free plan allows up to 10 apps per workspace. Machines joined to one ccteam as satellites need no app of their own: that ccteam's app already reaches their projects. + +> 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. A session lives in **one thread at a time**: `/use ` (or `@`) in a thread moves that session into it, and the thread it left says so (a new message there starts a fresh session). `/new …` in a thread starts a new session in that thread. +- **Mostly you tap.** A thread whose first message starts a session opens with a header and the session's buttons — **📊 Status · 🧠 Model · ⏹ Interrupt** — and the replies to `new` / `use` / `status` carry the same row. The app's slash command on its own (`/ccteam`) posts a menu: **📁 Projects · 🧵 Sessions · 📊 Status · + Claude · + Codex**. Long lists (projects, sessions, model × effort) come as a dropdown. +- **Commands start with `!` on Slack.** Slack keeps `/` for itself (it swallows any message starting with one, and an app's slash command cannot run inside a thread at all), so type `!` where Telegram types `/`: `!status`, `!model`, `!compact`, `!interrupt` in a session's thread act on that session; `!projects`, `!cd demo`, `!sessions`, `!new codex` as a top-level message open a thread for the reply — `!new codex` creates the codex session right there, and `!use s12` moves an existing session into it. ccteam's own replies name commands the same way (`→ !status`). The app's slash command (`/ccteam`, or `/cct2` for an app named cct2) also works in the channel. +- Sessions hired by other agents get no thread of their own; give one a thread with a top-level `!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 type `!` instead of `/` (`!status`, `!model`) — see [Slack: One Thread per Session](#slack-one-thread-per-session). ```text # Projects @@ -368,8 +388,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) diff --git a/dsh-plugins/ccteam-ui/package-lock.json b/dsh-plugins/ccteam-ui/package-lock.json index f5ca644ef..e5bc90620 100644 --- a/dsh-plugins/ccteam-ui/package-lock.json +++ b/dsh-plugins/ccteam-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ccteam/ccteam-ui", - "version": "0.11.2", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ccteam/ccteam-ui", - "version": "0.11.2", + "version": "0.12.0", "bundleDependencies": [ "@deepseek-ai/schemastery" ], @@ -41,27 +41,27 @@ "vitest": "^3.2.4" }, "optionalDependencies": { - "@ccteam/engine-darwin-arm64": "0.11.2", - "@ccteam/engine-darwin-x64": "0.11.2", - "@ccteam/engine-linux-arm64": "0.11.2", - "@ccteam/engine-linux-x64": "0.11.2" + "@ccteam/engine-darwin-arm64": "0.12.0", + "@ccteam/engine-darwin-x64": "0.12.0", + "@ccteam/engine-linux-arm64": "0.12.0", + "@ccteam/engine-linux-x64": "0.12.0" } }, "node_modules/@ccteam/engine-darwin-arm64": { "optional": true, - "version": "0.11.2" + "version": "0.12.0" }, "node_modules/@ccteam/engine-darwin-x64": { "optional": true, - "version": "0.11.2" + "version": "0.12.0" }, "node_modules/@ccteam/engine-linux-arm64": { "optional": true, - "version": "0.11.2" + "version": "0.12.0" }, "node_modules/@ccteam/engine-linux-x64": { "optional": true, - "version": "0.11.2" + "version": "0.12.0" }, "node_modules/@deepseek-ai/cordis": { "version": "4.0.1", diff --git a/dsh-plugins/ccteam-ui/package.json b/dsh-plugins/ccteam-ui/package.json index 9a47c26df..67ac05dfd 100644 --- a/dsh-plugins/ccteam-ui/package.json +++ b/dsh-plugins/ccteam-ui/package.json @@ -1,6 +1,6 @@ { "name": "@ccteam/ccteam-ui", - "version": "0.11.2", + "version": "0.12.0", "description": "ccteam inside the DeepSeek Harness: the workbench in DSH web (cross-harness session tree, native-grade chat, one-click spawn), the six ccteam MCP tools for DSH agents, and the ACP transport ccteam hires DSH sessions through", "type": "module", "main": "lib/index.js", @@ -19,7 +19,7 @@ "README.md" ], "ccteam": { - "engine": "0.11.2" + "engine": "0.12.0" }, "dsh": { "bundle": { @@ -49,10 +49,10 @@ "@deepseek-ai/schemastery": "^3.18.1" }, "optionalDependencies": { - "@ccteam/engine-darwin-arm64": "0.11.2", - "@ccteam/engine-darwin-x64": "0.11.2", - "@ccteam/engine-linux-arm64": "0.11.2", - "@ccteam/engine-linux-x64": "0.11.2" + "@ccteam/engine-darwin-arm64": "0.12.0", + "@ccteam/engine-darwin-x64": "0.12.0", + "@ccteam/engine-linux-arm64": "0.12.0", + "@ccteam/engine-linux-x64": "0.12.0" }, "bundledDependencies": [ "@deepseek-ai/schemastery" diff --git a/dsh-plugins/ccteam-ui/src/bff.ts b/dsh-plugins/ccteam-ui/src/bff.ts index de291580e..d2b822c03 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,9 @@ 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 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[] = [] @@ -1003,7 +1007,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 +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 `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 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 [] @@ -1082,12 +1088,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 +1109,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 +1126,54 @@ 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. 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) + return { + kind: 'answer', + id: stringOf(data.id) ?? '', + content: stringOf(data.content) ?? '', + ...defined('ts', stringOf(data.ts)), + ...(data.interim === true ? { interim: true as const } : {}), + ...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; absent / + * `null` / anything but an object is no status. + */ +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 148650760..b32168844 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 5de63bed0..537f98a7c 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,12 +108,22 @@ 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 steps: Step[] startedAt: number + /** + * Item ids an interim answer already settled into its row this turn. Their + * late events belong to that row, not here; scoped to the turn because + * some vendors number tool calls per response, so ids repeat across turns. + */ + settled?: string[] } /** Per-sid chat state. */ @@ -122,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 @@ -337,6 +356,7 @@ const EMPTY_CHAT: ChatState = { live: null, activity: undefined, waiting: false, + ended: null, loading: false, loadingOlder: false, error: null, @@ -397,12 +417,21 @@ export function rowFromTranscript(row: TranscriptRow): ChatRow { } } +/** A row as the daemon has it on disk (neither optimistic nor settled from the stream). */ +function isCanonicalRow(row: ChatRow): boolean { + return (row.kind === 'user' && row.local !== true) || (row.kind === 'assistant' && row.ephemeral !== true) +} + /** * Reconcile a freshly loaded canonical page with what the chat already - * shows: optimistic local user rows whose text arrived drop out; ephemeral - * assistant rows settled from the stream hand their steps to the canonical - * row carrying the same text and drop out; choice/system rows survive after - * the canonical rows. + * shows. Provisional rows — optimistic local user rows and assistant rows + * settled from the stream — give way to the canonical row that says the same + * thing, handing over their steps (live-only knowledge). Every row with no + * canonical counterpart — a steps-only or file-only row, a pending choice, a + * lifecycle note, a message not on disk yet — keeps its PLACE, next to the + * row it followed: a later page must neither sink it under newer turns nor + * give its steps to another turn's answer. Canonical rows outside this page + * (paged in earlier) stay in front of it. */ function reconcile(existing: ChatRow[], canonical: ChatRow[]): ChatRow[] { // Steps are live-only knowledge: a canonical row re-read from disk carries @@ -416,41 +445,65 @@ function reconcile(existing: ChatRow[], canonical: ChatRow[]): ChatRow[] { const steps = rememberedSteps.get(row.id) return steps === undefined ? row : { ...row, steps } }) - const canonicalUserTexts = new Set(rows.filter(r => 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 - })() - const tail: ChatRow[] = [] + 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) { - if (row.kind === 'user') { - if (row.local === true && !canonicalUserTexts.has(row.content)) tail.push(row) - continue + 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]! + 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 !== row.kind || canon.content !== row.content) continue + claimed.add(i) + pairs.set(row, i) + break } - if (row.kind === 'assistant') { - if (row.ephemeral !== true) continue - const target = rows.findIndex(r => r.kind === 'assistant' && r.content === row.content) - 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 - } + } + // 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) { + 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 } } - tail.push(row) + if (pending.length > 0) before.set(at, [...(before.get(at) ?? []), ...pending]) + pending = [] + anchor = at + continue + } + 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. */ @@ -472,29 +525,140 @@ 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 }), + } +} + +/** + * 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) { + const row = rows[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 (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}` + 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'), + settled: [...(chat.live.settled ?? []), ...done.map(s => s.itemId)], + } + return { ...chat, rows: [...chat.rows, row], live, activity: 'working', ended: null } +} + +/** + * 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 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)) + 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 } + // Between turns a card is what a stray pulse folded into, not a turn. + if (chat.live === null && chat.ended !== null) return chat + 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 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': { - const notices = chat.notices.filter(notice => notice.kind !== 'queued') if (event.options !== undefined && event.options.length > 0 && event.token !== undefined) { const row: ChatRow = { kind: 'choice', @@ -504,20 +668,10 @@ function applySessionEvent(chat: ChatState, action: Extract r.id === row.id)) return chat - 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 }), + const notices = chat.notices.filter(notice => notice.kind !== 'queued') + return { ...chat, rows: [...chat.rows, row], notices, activity: 'idle', waiting: true, ended: null } } - 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 +829,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)) @@ -689,13 +843,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 @@ -722,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, } } @@ -763,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/src/index.ts b/dsh-plugins/ccteam-ui/src/index.ts index 2e01a53ab..4a4605f33 100644 --- a/dsh-plugins/ccteam-ui/src/index.ts +++ b/dsh-plugins/ccteam-ui/src/index.ts @@ -102,7 +102,7 @@ export const Config: Schema = Schema.object({ * Rust side gates the handshake on it (`MIN_DSH_CLIENT_VERSION`), so it must * stay in step with package.json — asserted by tests/host-exports.test.ts. */ -export const PACKAGE_VERSION = '0.11.2' +export const PACKAGE_VERSION = '0.12.0' /** * The engine version this plugin is published against — package.json @@ -114,7 +114,7 @@ export const PACKAGE_VERSION = '0.11.2' * repairs — the engine is older, so update the engine; the plugin is older, so * update the plugin. */ -export const ENGINE_VERSION = '0.11.2' +export const ENGINE_VERSION = '0.12.0' export interface ApplyContext extends BffContext { /** diff --git a/dsh-plugins/ccteam-ui/src/shared/contract.ts b/dsh-plugins/ccteam-ui/src/shared/contract.ts index 44d1de288..f52c471aa 100644 --- a/dsh-plugins/ccteam-ui/src/shared/contract.ts +++ b/dsh-plugins/ccteam-ui/src/shared/contract.ts @@ -246,13 +246,29 @@ export interface TurnUsage { outputTokens?: number } +/** + * 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 + 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 + /** The closing snapshot, on the row that closed a structured turn. */ + status?: TurnStatus attachments?: AttachmentRef[] usage?: TurnUsage } @@ -366,10 +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` closes the status line); + * `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`: a delivered assistant message — the turn's result, or a - * human-in-the-loop prompt when `options` are present; + * - `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 = @@ -380,16 +402,34 @@ export type SessionEvent = id: string content: string ts?: string - status?: string + /** Said mid-turn: the turn is still running. Absent on every turn-ending answer. */ + interim?: true + 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 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.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 (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 new file mode 100644 index 000000000..d27da5be8 --- /dev/null +++ b/dsh-plugins/ccteam-ui/tests/client-chat.test.tsx @@ -0,0 +1,118 @@ +/** + * 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.', interim: true }), +] + +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>/) + }) + + 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 5d6913a71..3ce595815 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,16 @@ function run(actions: Action[], start = initialState()): ConsoleState { const NOW = 1_700_000_000_000 +/** 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, 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', () => { 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' }]) @@ -97,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 }, @@ -110,7 +121,8 @@ 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', () => { @@ -210,6 +222,308 @@ describe('chat: live turn', () => { }) }) +describe('chat: mid-turn answers', () => { + 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: '?', 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' }, + 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 card sealed mid-tool finishes nothing: the running tool stays live and completes where it runs', () => { + const state = run([ + activity('t1', 'started'), + // 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).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 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') + 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' }, + 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 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 }, + ]) + 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', () => { + 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: 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' }]) @@ -220,7 +534,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,6 +542,9 @@ 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) + // 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', 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' }, 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 f5dad97ca..7f8a8397b 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 e3dec589c..894c493e4 100644 --- a/dsh-plugins/ccteam-ui/tests/host-sse.test.ts +++ b/dsh-plugins/ccteam-ui/tests/host-sse.test.ts @@ -227,11 +227,77 @@ 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([ - { kind: 'graph' }, - { kind: 'turn_done', sid: 's1' }, - ]) + /** 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' }, + turn: 3, + cost_usd: 0.25, + tokens_total: 9000, + } + + 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(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 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', 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 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 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', + 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', 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: 'a2', content: 'end' }, + }]) }) it('does not mistake a human-in-the-loop prompt for a completed turn', () => {