From 090dfc42c849179593730308272db8f95812e90f Mon Sep 17 00:00:00 2001 From: yen <5915590+antigenius0910@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:15:42 -0500 Subject: [PATCH 1/4] fix(slack): collapse consecutive duplicate tool lines in Full display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When claude-agent-acp calls the same tool several times in a row (e.g. `ToolSearch` three times to look up related MCP tools), `compose_display` in `ToolDisplay::Full` mode rendered one line per invocation, so Slack messages looked like: ✅ `ToolSearch` ✅ `ToolSearch` ✅ `ToolSearch` The tool summary is intentional (see the `compose_display` comment), but identical consecutive entries add no information — they only crowd out the answer. Add `render_grouped()` to collapse consecutive runs with the same `(title, state)` into a single line with a `×N` suffix, giving: ✅ `ToolSearch` (×3) Only consecutive runs are grouped, so `grep → curl → grep` still renders as three lines (order preserved), and `Completed` vs `Failed` runs of the same title never merge (state-aware). Streaming and send-once code paths both go through the same helper. Reproduced on `ghcr.io/openabdev/openab:pre-beta-claude` via a Bash tool invocation from bot1 in Slack. Three new unit tests cover the group, order, and state-boundary cases. --- crates/openab-core/src/adapter.rs | 103 +++++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 8 deletions(-) diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index 1cdf29fd0..8fdb5fdd4 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -1405,6 +1405,43 @@ impl ToolEntry { /// during streaming before collapsing into a summary line. const TOOL_COLLAPSE_THRESHOLD: usize = 3; +/// Render an iterator of tool entries into one line per **consecutive** run of +/// entries with the same `(title, state)`. Repeated invocations of the same +/// tool (e.g. Claude calling `ToolSearch` three times in a row) collapse from +/// three identical `✅ \`ToolSearch\`` lines into one `✅ \`ToolSearch\` (×3)`. +/// +/// Only consecutive runs are grouped — two `curl` calls with an intervening +/// `grep` render as three separate lines, preserving the actual call order. +fn render_grouped<'a>(entries: impl IntoIterator) -> Vec { + let mut out: Vec = Vec::new(); + let mut run: Option<(String, ToolState, usize)> = None; + let flush = |run: &mut Option<(String, ToolState, usize)>, out: &mut Vec| { + if let Some((title, state, count)) = run.take() { + let entry = ToolEntry { + id: String::new(), + title, + state, + }; + let mut line = entry.render(); + if count > 1 { + line.push_str(&format!(" (×{count})")); + } + out.push(line); + } + }; + for e in entries { + match &mut run { + Some((t, s, n)) if *t == e.title && *s == e.state => *n += 1, + _ => { + flush(&mut run, &mut out); + run = Some((e.title.clone(), e.state, 1)); + } + } + } + flush(&mut run, &mut out); + out +} + // --- Empty-turn classification (pure helper, unit-testable) --- /// Message to show the consumer when a silent failure is detected. @@ -1472,8 +1509,10 @@ fn compose_display( .collect(); if finished <= TOOL_COLLAPSE_THRESHOLD { - for entry in tool_lines.iter().filter(|e| e.state != ToolState::Running) { - out.push_str(&entry.render()); + for line in render_grouped( + tool_lines.iter().filter(|e| e.state != ToolState::Running), + ) { + out.push_str(&line); out.push('\n'); } } else { @@ -1488,21 +1527,23 @@ fn compose_display( } if running_entries.len() <= TOOL_COLLAPSE_THRESHOLD { - for entry in &running_entries { - out.push_str(&entry.render()); + for line in render_grouped(running_entries.iter().copied()) { + out.push_str(&line); out.push('\n'); } } else { let hidden = running_entries.len() - TOOL_COLLAPSE_THRESHOLD; out.push_str(&format!("🔧 {hidden} more running\n")); - for entry in running_entries.iter().skip(hidden) { - out.push_str(&entry.render()); + for line in + render_grouped(running_entries.iter().skip(hidden).copied()) + { + out.push_str(&line); out.push('\n'); } } } else { - for entry in tool_lines { - out.push_str(&entry.render()); + for line in render_grouped(tool_lines.iter()) { + out.push_str(&line); out.push('\n'); } } @@ -1908,6 +1949,52 @@ mod tests { assert!(out.contains("🔧 1"), "expected running count: {out}"); } + #[test] + fn compose_display_full_collapses_consecutive_duplicates() { + // Claude often calls the same tool multiple times in a row (e.g. three + // ToolSearch calls to look up related MCP tools). Show one line with a + // ×N suffix instead of three identical lines. + let tools = vec![ + tool("1", "ToolSearch", ToolState::Completed), + tool("2", "ToolSearch", ToolState::Completed), + tool("3", "ToolSearch", ToolState::Completed), + ]; + let out = compose_display(&tools, "done", false, ToolDisplay::Full); + assert!( + out.contains("`ToolSearch` (×3)"), + "expected grouped line: {out}" + ); + // Must render only one tool line, not three + assert_eq!(out.matches("`ToolSearch`").count(), 1, "output: {out}"); + } + + #[test] + fn compose_display_full_preserves_order_across_different_titles() { + // A `curl` between two `grep`s should not merge the grep entries. + let tools = vec![ + tool("1", "grep", ToolState::Completed), + tool("2", "curl", ToolState::Completed), + tool("3", "grep", ToolState::Completed), + ]; + let out = compose_display(&tools, "done", false, ToolDisplay::Full); + assert!(!out.contains("(×"), "should not collapse across order: {out}"); + assert_eq!(out.matches("`grep`").count(), 2, "output: {out}"); + assert_eq!(out.matches("`curl`").count(), 1, "output: {out}"); + } + + #[test] + fn compose_display_full_groups_mixed_state_runs_separately() { + // Same title but different states must NOT merge (completed vs failed). + let tools = vec![ + tool("1", "curl", ToolState::Completed), + tool("2", "curl", ToolState::Failed), + tool("3", "curl", ToolState::Failed), + ]; + let out = compose_display(&tools, "done", false, ToolDisplay::Full); + assert!(out.contains("✅ `curl`"), "output: {out}"); + assert!(out.contains("❌ `curl` (×2)"), "output: {out}"); + } + #[test] fn compose_display_none_hides_tools() { let tools = vec![tool( From bb4244a78ebb352c05c5c5a7ad8446399815eb98 Mon Sep 17 00:00:00 2001 From: yen <5915590+antigenius0910@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:04:46 -0500 Subject: [PATCH 2/4] =?UTF-8?q?fix(slack):=20address=20mob=20review=20?= =?UTF-8?q?=E2=80=94=20group=20first,=20gate=20threshold=20on=20group=20co?= =?UTF-8?q?unt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Response to https://github.com/openabdev/openab/pull/1397#issuecomment mob review by 3 LLM reviewers (Claude / Codex / agy). Four Important findings, all reproducible against the original patch: 1. Filter-then-group broke true call-order adjacency: the streaming finished branch stripped Running entries before grouping, so `A(Completed), B(Running), A(Completed)` collapsed into `A (×2)` even though a different tool ran in between. 2. `TOOL_COLLAPSE_THRESHOLD` gated on raw entry count. Four identical entries triggered the generic "4 tool(s) completed" fallback and never rendered as `(×4)` — the collapse stopped helping precisely where it would have mattered most. 3. All three new tests used `streaming=false`. The streaming branches (finished-over-threshold, running dups, running-hidden tail) had zero coverage — which is why (1) and (2) slipped through the original PR. 4. `render_grouped`'s doc comment nested backslash-escaped backticks inside a single-backtick code span; rustdoc/CommonMark would render this broken. Fixes: - Split `render_grouped` into `group_entries` (data-only pass over the full unfiltered sequence) + `render_group` (per-group formatting). Callers filter the resulting groups by state — the Running-splits-a-run case now Just Works. - `compose_display` computes `groups` once, filters into `finished_groups` and `running_groups`, and compares those vec lengths against `TOOL_COLLAPSE_THRESHOLD`. The threshold constant's doc updated to say "post-grouping" explicitly. - `render_group` for Running places the `(×N)` suffix BEFORE the trailing `...` so the marker keeps its "still running" meaning. - 4 new streaming=true tests covering the boundary + interleaved cases that had no coverage in v1. - Doc comment for `group_entries` explains WHY it's called on the full sequence, and drops the malformed nested backticks. All 32 adapter tests pass. Verified live in Slack against a rebuild of the pre-beta image with this patch. --- crates/openab-core/src/adapter.rs | 213 ++++++++++++++++++++++-------- 1 file changed, 161 insertions(+), 52 deletions(-) diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index 8fdb5fdd4..a23cdc4f4 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -1401,47 +1401,54 @@ impl ToolEntry { } } -/// Maximum number of finished tool entries to show individually -/// during streaming before collapsing into a summary line. +/// Maximum number of **post-grouping** finished/running lines to show +/// individually during streaming before collapsing into a summary line. +/// Compared against grouped-line count so that N identical repeats of a +/// single tool (which collapse to one `(×N)` line) always render as that +/// line, never as a generic "N tool(s) completed" fallback. const TOOL_COLLAPSE_THRESHOLD: usize = 3; -/// Render an iterator of tool entries into one line per **consecutive** run of -/// entries with the same `(title, state)`. Repeated invocations of the same -/// tool (e.g. Claude calling `ToolSearch` three times in a row) collapse from -/// three identical `✅ \`ToolSearch\`` lines into one `✅ \`ToolSearch\` (×3)`. +/// Collapse a sequence of tool entries into one entry per **consecutive** run +/// of same `(title, state)` — the tuple carries the count. /// -/// Only consecutive runs are grouped — two `curl` calls with an intervening -/// `grep` render as three separate lines, preserving the actual call order. -fn render_grouped<'a>(entries: impl IntoIterator) -> Vec { - let mut out: Vec = Vec::new(); - let mut run: Option<(String, ToolState, usize)> = None; - let flush = |run: &mut Option<(String, ToolState, usize)>, out: &mut Vec| { - if let Some((title, state, count)) = run.take() { - let entry = ToolEntry { - id: String::new(), - title, - state, - }; - let mut line = entry.render(); - if count > 1 { - line.push_str(&format!(" (×{count})")); - } - out.push(line); - } - }; +/// Called ONCE over the full unfiltered `tool_lines` slice so adjacency is +/// evaluated in true call order. Callers then filter the resulting groups by +/// state; this prevents `A(Completed), B(Running), A(Completed)` from folding +/// to `A(Completed)×2` after the caller strips Running entries first. +fn group_entries<'a>( + entries: impl IntoIterator, +) -> Vec<(String, ToolState, usize)> { + let mut out: Vec<(String, ToolState, usize)> = Vec::new(); for e in entries { - match &mut run { + match out.last_mut() { Some((t, s, n)) if *t == e.title && *s == e.state => *n += 1, - _ => { - flush(&mut run, &mut out); - run = Some((e.title.clone(), e.state, 1)); - } + _ => out.push((e.title.clone(), e.state, 1)), } } - flush(&mut run, &mut out); out } +/// Render one grouped entry. Repeats append ` (×N)`; for `Running` the count +/// sits BEFORE the trailing `...` so the string reads +/// `🔧 \`curl\` (×3)...` instead of `🔧 \`curl\`... (×3)`. +fn render_group(title: &str, state: ToolState, count: usize) -> String { + let base = ToolEntry { + id: String::new(), + title: title.to_string(), + state, + } + .render(); + if count <= 1 { + return base; + } + if state == ToolState::Running { + let trimmed = base.trim_end_matches("..."); + format!("{trimmed} (×{count})...") + } else { + format!("{base} (×{count})") + } +} + // --- Empty-turn classification (pure helper, unit-testable) --- /// Message to show the consumer when a silent failure is detected. @@ -1482,7 +1489,6 @@ fn compose_display( .iter() .filter(|e| e.state == ToolState::Running) .count(); - let finished = done + failed; match tool_display { ToolDisplay::Compact => { @@ -1502,17 +1508,26 @@ fn compose_display( } } ToolDisplay::Full => { + // Group once over the FULL sequence so adjacency reflects + // true call order (a Running entry between two identical + // Completed entries splits them into two groups, not one). + let groups = group_entries(tool_lines.iter()); + let finished_groups: Vec<&(String, ToolState, usize)> = groups + .iter() + .filter(|(_, s, _)| *s != ToolState::Running) + .collect(); + let running_groups: Vec<&(String, ToolState, usize)> = groups + .iter() + .filter(|(_, s, _)| *s == ToolState::Running) + .collect(); + if streaming { - let running_entries: Vec<_> = tool_lines - .iter() - .filter(|e| e.state == ToolState::Running) - .collect(); - - if finished <= TOOL_COLLAPSE_THRESHOLD { - for line in render_grouped( - tool_lines.iter().filter(|e| e.state != ToolState::Running), - ) { - out.push_str(&line); + // Threshold on GROUPED-line count, not raw entries — so + // 4× the same tool renders as `✅ X (×4)`, never as the + // generic "4 tool(s) completed" fallback. + if finished_groups.len() <= TOOL_COLLAPSE_THRESHOLD { + for (t, s, n) in &finished_groups { + out.push_str(&render_group(t, *s, *n)); out.push('\n'); } } else { @@ -1526,24 +1541,22 @@ fn compose_display( out.push_str(&format!("{} tool(s) completed\n", parts.join(" · "))); } - if running_entries.len() <= TOOL_COLLAPSE_THRESHOLD { - for line in render_grouped(running_entries.iter().copied()) { - out.push_str(&line); + if running_groups.len() <= TOOL_COLLAPSE_THRESHOLD { + for (t, s, n) in &running_groups { + out.push_str(&render_group(t, *s, *n)); out.push('\n'); } } else { - let hidden = running_entries.len() - TOOL_COLLAPSE_THRESHOLD; + let hidden = running_groups.len() - TOOL_COLLAPSE_THRESHOLD; out.push_str(&format!("🔧 {hidden} more running\n")); - for line in - render_grouped(running_entries.iter().skip(hidden).copied()) - { - out.push_str(&line); + for (t, s, n) in running_groups.iter().skip(hidden) { + out.push_str(&render_group(t, *s, *n)); out.push('\n'); } } } else { - for line in render_grouped(tool_lines.iter()) { - out.push_str(&line); + for (t, s, n) in &groups { + out.push_str(&render_group(t, *s, *n)); out.push('\n'); } } @@ -1995,6 +2008,102 @@ mod tests { assert!(out.contains("❌ `curl` (×2)"), "output: {out}"); } + #[test] + fn compose_display_full_streaming_groups_beyond_threshold_dups() { + // 5 identical entries: raw count 5 > TOOL_COLLAPSE_THRESHOLD (3), but + // group count is 1, so the grouped line MUST render — never the + // generic "5 tool(s) completed" fallback. Regression test for the + // reviewer's finding that the threshold used to gate on raw count. + let tools = vec![ + tool("1", "ToolSearch", ToolState::Completed), + tool("2", "ToolSearch", ToolState::Completed), + tool("3", "ToolSearch", ToolState::Completed), + tool("4", "ToolSearch", ToolState::Completed), + tool("5", "ToolSearch", ToolState::Completed), + ]; + let out = compose_display(&tools, "done", true, ToolDisplay::Full); + assert!( + out.contains("`ToolSearch` (×5)"), + "expected grouped ×5 line: {out}" + ); + assert!( + !out.contains("5 tool(s) completed"), + "should not fall back to count summary: {out}" + ); + } + + #[test] + fn compose_display_full_streaming_running_dups_collapse() { + // The streaming Running branch also has to collapse identical + // in-flight tool invocations (rare in claude-agent-acp, common with + // parallel-tool-call backends). (×N) sits BEFORE the `...` so the + // marker keeps its "still working" meaning. + let tools = vec![ + tool("1", "curl", ToolState::Running), + tool("2", "curl", ToolState::Running), + tool("3", "curl", ToolState::Running), + ]; + let out = compose_display(&tools, "", true, ToolDisplay::Full); + assert!( + out.contains("`curl` (×3)..."), + "expected running (×N) before ...: {out}" + ); + assert_eq!(out.matches("`curl`").count(), 1, "output: {out}"); + } + + #[test] + fn compose_display_full_streaming_true_order_preserved_across_state_boundaries() { + // Reviewer finding #1: A(Completed), B(Running), A(Completed) must + // NOT collapse into A(×2). Filtering Running out AFTER grouping (as + // this PR now does) keeps the two A entries as distinct groups so + // the finished-view still shows two lines, matching true call order. + let tools = vec![ + tool("1", "ToolSearch", ToolState::Completed), + tool("2", "Bash", ToolState::Running), + tool("3", "ToolSearch", ToolState::Completed), + ]; + let out = compose_display(&tools, "", true, ToolDisplay::Full); + assert!( + !out.contains("(×2)"), + "must not merge non-adjacent finished entries across a Running: {out}" + ); + assert_eq!( + out.matches("`ToolSearch`").count(), + 2, + "expected two separate ToolSearch lines: {out}" + ); + assert!(out.contains("`Bash`"), "output: {out}"); + } + + #[test] + fn compose_display_full_streaming_running_hidden_summary_uses_group_index() { + // >TOOL_COLLAPSE_THRESHOLD DISTINCT running groups triggers the + // "N more running" tail; the summary counts groups (not raw entries) + // and the visible tail preserves group boundaries. Regression test + // for the reviewer's finding that skipping by raw index could split + // a duplicate run across the hidden/visible boundary. + let tools = vec![ + tool("1", "a", ToolState::Running), + tool("2", "a", ToolState::Running), // grouped with #1 + tool("3", "b", ToolState::Running), + tool("4", "c", ToolState::Running), + tool("5", "d", ToolState::Running), + tool("6", "e", ToolState::Running), + ]; + // 5 distinct groups → 2 hidden, 3 visible (b/c/d skipped, then + // c/d/e? actually skip 2 = c, d, e visible). The important assertion + // is that duplicates never straddle the visible/hidden boundary. + let out = compose_display(&tools, "", true, ToolDisplay::Full); + assert!(out.contains("more running"), "expected tail summary: {out}"); + // `a` was grouped, so it either appears as one `(×2)` line or is + // wholly in the summary — never split. + let a_lines = out.matches("`a`").count(); + assert!( + a_lines == 0 || a_lines == 1, + "grouped `a` must not be split across summary/visible: {out}" + ); + } + #[test] fn compose_display_none_hides_tools() { let tools = vec![tool( From b7977cc541e7fa5c746d1f7a9e8b1d7d89bf700a Mon Sep 17 00:00:00 2001 From: yen <5915590+antigenius0910@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:55:04 -0500 Subject: [PATCH 3/4] =?UTF-8?q?fix(slack):=20address=20round-3=20review=20?= =?UTF-8?q?=E2=80=94=20hidden=20count=20in=20tool=20units=20+=20real=20tes?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Response to https://github.com/openabdev/openab/pull/1397#issuecomment round-3 review (chaodu-agent + howie mob). Three blocking + several important findings against `bb4244a`, all verified reproducible: **F1 (blocking) — `🔧 N more running` reported group count as if it were a tool count.** For `[a, a, b, b, c, d, e]` (5 groups, 4 hidden calls in the a and b runs), the summary read `2 more running` instead of `4 more running`. Reader totals 5 running tools when 7 are actually in flight, and the sibling finished-fallback summary at the same site still reports raw call counts, so units silently diverged inside one function. Fix: keep the group count as the skip index (never split a run across the boundary), but sum the multiplicities of the hidden groups for the displayed number. Same site now reads `🔧 {hidden_calls} more running`. **F2 (blocking) — the hidden-tail regression test was a tautology.** `compose_display_full_streaming_running_hidden_summary_uses_group_index` asserted `a_lines == 0 || a_lines == 1`, which holds for every possible output (grouping always makes `` `a` `` appear at most once). Mutation tests confirmed the entire `>THRESHOLD` running branch was uncovered: `skip(0)`, `skip(99999)`, and grouping-disabled all left the test green. Fix: split into two targeted tests with EXACT-string assertions — - `..._hidden_count_is_tool_calls_not_groups` — `[a,a,b,b,c,d,e]` pins `🔧 4 more running` + exact visible tail (regression guard for F1). - `..._hidden_boundary_preserves_group` — `[a,b,b,c,d]` pins `🔧 1 more running` + `🔧 `b` (×2)...` in the visible tail, catching raw-vs-group indexing bugs and boundary splits. **F3 (blocking) — the rustdoc "fix" from v2 was incomplete.** The `render_group` doc comment still nested backslash-escaped backticks inside a single-backtick code span (`` `🔧 \`curl\` (×3)...` ``), which CommonMark/pulldown-cmark ignores as escapes — the span closes at the first inner backtick and the rest of the line leaks. Fix: use double-backtick delimiters (``` ``🔧 `curl` (×3)...`` ```), which is the CommonMark-sanctioned way to embed a code span containing backticks. **F4 (important) — neither `TOOL_COLLAPSE_THRESHOLD` boundary tested.** Flipping `<=` to `<` on either branch left every existing test green. Added two boundary tests pinning exactly `THRESHOLD` groups as "individual lines shown, no fallback fires" — one for each branch. **F5 (important) — the finished-fallback branch had no test coverage.** Replacing the whole `else` body with a `MUTANT` string still passed every test. Added `..._finished_fallback_reports_raw_counts` — 5 distinct groups (with one duplicate + one failure) → asserts exact string `"✅ 5 · ❌ 1 tool(s) completed"`, individual lines suppressed. **F6/F7 (nits) — doc claim overstatement.** The `TOOL_COLLAPSE_THRESHOLD` comment said N repeats "always render as one line, never as fallback", which is false once the *group* count itself exceeds the threshold. The `compose_display` inline comment had the same overstatement plus a misquoted fallback string. Both scoped. **F8 (nits) — stale draft self-correction in test comment.** The `b/c/d skipped, then c/d/e? actually skip 2 = c, d, e` scratch note is gone (test replaced entirely). **F13 (docs) — `docs/tool-display.md` was stale.** Said Full mode "shows each tool call" and that "more than 3 tools finish" collapses. Updated to describe the consecutive-repeat `(×N)` collapse and the group-count-based threshold. **Not addressed** (kept for future PR / triage): - F9 `trim_end_matches("...")` → `strip_suffix`: reviewers themselves refuted the correctness concern; deferred as pure cleanup. - F10 `render_group` fabricating a throwaway `ToolEntry` to reuse `render()`: real code-smell but a bigger refactor of `ToolEntry`. - F11 caller-contract wording in `group_entries` doc: cosmetic. - F12 empty-tool-list under Full: defended by the outer `!tool_lines.is_empty()` guard. All 15 compose_display tests pass. Verified with a rebuild of `Dockerfile.claude` and a live Slack repro against bot1 — see PR body update. --- crates/openab-core/src/adapter.rs | 179 +++++++++++++++++++++++++----- docs/tool-display.md | 4 +- 2 files changed, 154 insertions(+), 29 deletions(-) diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index a23cdc4f4..844cefbab 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -1403,9 +1403,14 @@ impl ToolEntry { /// Maximum number of **post-grouping** finished/running lines to show /// individually during streaming before collapsing into a summary line. -/// Compared against grouped-line count so that N identical repeats of a -/// single tool (which collapse to one `(×N)` line) always render as that -/// line, never as a generic "N tool(s) completed" fallback. +/// +/// Gates both the streaming finished-branch and the streaming +/// running-branch, and is compared against grouped-line count (a run of +/// N identical repeats counts as ONE line, not N). Once the grouped-line +/// count itself exceeds this threshold the fallback path still fires — +/// the finished branch shows the raw-count summary `✅ N tool(s) completed` +/// and the running branch shows `🔧 N more running` + the trailing few +/// visible groups. const TOOL_COLLAPSE_THRESHOLD: usize = 3; /// Collapse a sequence of tool entries into one entry per **consecutive** run @@ -1430,7 +1435,7 @@ fn group_entries<'a>( /// Render one grouped entry. Repeats append ` (×N)`; for `Running` the count /// sits BEFORE the trailing `...` so the string reads -/// `🔧 \`curl\` (×3)...` instead of `🔧 \`curl\`... (×3)`. +/// ``🔧 `curl` (×3)...`` instead of ``🔧 `curl`... (×3)``. fn render_group(title: &str, state: ToolState, count: usize) -> String { let base = ToolEntry { id: String::new(), @@ -1522,9 +1527,12 @@ fn compose_display( .collect(); if streaming { - // Threshold on GROUPED-line count, not raw entries — so - // 4× the same tool renders as `✅ X (×4)`, never as the - // generic "4 tool(s) completed" fallback. + // Threshold on GROUPED-line count, not raw entries: N + // repeats of a single tool count as 1, so 4× the same + // tool renders as `✅ X (×4)`. The `>THRESHOLD` fallback + // still fires once the number of distinct groups itself + // exceeds the threshold — that summary reports raw call + // counts (`✅ N · ❌ M tool(s) completed`). if finished_groups.len() <= TOOL_COLLAPSE_THRESHOLD { for (t, s, n) in &finished_groups { out.push_str(&render_group(t, *s, *n)); @@ -1547,9 +1555,20 @@ fn compose_display( out.push('\n'); } } else { - let hidden = running_groups.len() - TOOL_COLLAPSE_THRESHOLD; - out.push_str(&format!("🔧 {hidden} more running\n")); - for (t, s, n) in running_groups.iter().skip(hidden) { + // Index by group boundary (never split a run) but + // report the summary in tool-call units so the number + // matches the sibling finished-fallback summary and + // the pre-PR raw-count behaviour that users are used + // to. A hidden group of `a×2` contributes 2, not 1. + let hidden_groups = + running_groups.len() - TOOL_COLLAPSE_THRESHOLD; + let hidden_calls: usize = running_groups + .iter() + .take(hidden_groups) + .map(|(_, _, n)| *n) + .sum(); + out.push_str(&format!("🔧 {hidden_calls} more running\n")); + for (t, s, n) in running_groups.iter().skip(hidden_groups) { out.push_str(&render_group(t, *s, *n)); out.push('\n'); } @@ -2076,31 +2095,137 @@ mod tests { } #[test] - fn compose_display_full_streaming_running_hidden_summary_uses_group_index() { - // >TOOL_COLLAPSE_THRESHOLD DISTINCT running groups triggers the - // "N more running" tail; the summary counts groups (not raw entries) - // and the visible tail preserves group boundaries. Regression test - // for the reviewer's finding that skipping by raw index could split - // a duplicate run across the hidden/visible boundary. + fn compose_display_full_streaming_running_hidden_count_is_tool_calls_not_groups() { + // Fixture: 7 running entries in 5 groups — a(×2), b(×2), c, d, e. + // At `TOOL_COLLAPSE_THRESHOLD = 3`, hidden_groups = 2 (`a` + `b`), + // representing 4 tool calls. The summary must say "4 more running", + // matching the raw-count units used by the sibling finished-branch + // fallback and the pre-PR behaviour. Regression test for reviewer + // finding F1: previously the summary reported hidden group count. + let tools = vec![ + tool("1", "a", ToolState::Running), + tool("2", "a", ToolState::Running), + tool("3", "b", ToolState::Running), + tool("4", "b", ToolState::Running), + tool("5", "c", ToolState::Running), + tool("6", "d", ToolState::Running), + tool("7", "e", ToolState::Running), + ]; + let out = compose_display(&tools, "", true, ToolDisplay::Full); + assert!( + out.contains("🔧 4 more running"), + "hidden summary must count tool calls: {out}" + ); + assert!( + !out.contains("🔧 2 more running"), + "must not report hidden group count: {out}" + ); + // Visible tail = last THRESHOLD groups = c, d, e (each ×1). + // Neither hidden group (a, b) should appear in the visible tail. + assert!(!out.contains("`a`"), "`a` should be hidden: {out}"); + assert!(!out.contains("`b`"), "`b` should be hidden: {out}"); + assert!(out.contains("🔧 `c`..."), "output: {out}"); + assert!(out.contains("🔧 `d`..."), "output: {out}"); + assert!(out.contains("🔧 `e`..."), "output: {out}"); + } + + #[test] + fn compose_display_full_streaming_hidden_boundary_preserves_group() { + // Fixture per reviewer F2: `[a, b, b, c, d]` — 5 entries, 4 groups. + // With correct group-boundary skipping, `a` is hidden and the + // visible tail is `b(×2), c, d`. A regression to raw-entry skipping + // would hide `[a, b]` (leaving a bare `b, c, d` and losing the + // `(×2)` collapse). Pinning the exact strings catches both the raw + // vs group indexing bug AND F1 (hidden count in tool-call units: + // 1 group hidden = 1 tool hidden). let tools = vec![ tool("1", "a", ToolState::Running), - tool("2", "a", ToolState::Running), // grouped with #1 + tool("2", "b", ToolState::Running), tool("3", "b", ToolState::Running), tool("4", "c", ToolState::Running), tool("5", "d", ToolState::Running), - tool("6", "e", ToolState::Running), ]; - // 5 distinct groups → 2 hidden, 3 visible (b/c/d skipped, then - // c/d/e? actually skip 2 = c, d, e visible). The important assertion - // is that duplicates never straddle the visible/hidden boundary. let out = compose_display(&tools, "", true, ToolDisplay::Full); - assert!(out.contains("more running"), "expected tail summary: {out}"); - // `a` was grouped, so it either appears as one `(×2)` line or is - // wholly in the summary — never split. - let a_lines = out.matches("`a`").count(); assert!( - a_lines == 0 || a_lines == 1, - "grouped `a` must not be split across summary/visible: {out}" + out.contains("🔧 1 more running"), + "expected exact hidden count 1: {out}" + ); + assert!( + out.contains("🔧 `b` (×2)..."), + "grouped `b (×2)` must survive in visible tail: {out}" + ); + assert_eq!( + out.matches("`b`").count(), + 1, + "must not split the grouped `b` run across boundary: {out}" + ); + assert!(out.contains("🔧 `c`..."), "output: {out}"); + assert!(out.contains("🔧 `d`..."), "output: {out}"); + assert!(!out.contains("`a`"), "`a` should be hidden: {out}"); + } + + #[test] + fn compose_display_full_streaming_finished_fallback_reports_raw_counts() { + // >TOOL_COLLAPSE_THRESHOLD distinct FINISHED groups triggers the + // fallback branch that was previously untested (reviewer F5). The + // summary must report raw call counts (deliberately different units + // from the group-count threshold gate above it): `a(×2) + b + c + d` + // = 5 successes, plus a failed `e` = 1 failure. String is exactly + // "✅ 5 · ❌ 1 tool(s) completed". + let tools = vec![ + tool("1", "a", ToolState::Completed), + tool("2", "a", ToolState::Completed), + tool("3", "b", ToolState::Completed), + tool("4", "c", ToolState::Completed), + tool("5", "d", ToolState::Completed), + tool("6", "e", ToolState::Failed), + ]; + let out = compose_display(&tools, "answer", true, ToolDisplay::Full); + assert!( + out.contains("✅ 5 · ❌ 1 tool(s) completed"), + "expected raw-count fallback summary: {out}" + ); + // Individual lines must NOT appear (we're in the fallback branch). + assert!(!out.contains("`a`"), "individual lines suppressed: {out}"); + assert!(!out.contains("(×2)"), "grouped line suppressed: {out}"); + } + + #[test] + fn compose_display_full_streaming_finished_at_threshold_shows_lines() { + // Boundary: EXACTLY `TOOL_COLLAPSE_THRESHOLD` distinct groups still + // renders individual lines (gate uses `<=`). Companion to the >3 + // fallback test above — together they pin the boundary against a + // silent `<=` → `<` regression. Reviewer F4. + let tools = vec![ + tool("1", "a", ToolState::Completed), + tool("2", "b", ToolState::Completed), + tool("3", "c", ToolState::Completed), + ]; + let out = compose_display(&tools, "answer", true, ToolDisplay::Full); + assert!(out.contains("✅ `a`"), "output: {out}"); + assert!(out.contains("✅ `b`"), "output: {out}"); + assert!(out.contains("✅ `c`"), "output: {out}"); + assert!( + !out.contains("tool(s) completed"), + "must not fall through to summary at threshold: {out}" + ); + } + + #[test] + fn compose_display_full_streaming_running_at_threshold_shows_lines() { + // Same boundary check for the running branch. + let tools = vec![ + tool("1", "a", ToolState::Running), + tool("2", "b", ToolState::Running), + tool("3", "c", ToolState::Running), + ]; + let out = compose_display(&tools, "", true, ToolDisplay::Full); + assert!(out.contains("🔧 `a`..."), "output: {out}"); + assert!(out.contains("🔧 `b`..."), "output: {out}"); + assert!(out.contains("🔧 `c`..."), "output: {out}"); + assert!( + !out.contains("more running"), + "must not fall through to summary at threshold: {out}" ); } diff --git a/docs/tool-display.md b/docs/tool-display.md index 553b1451d..0f9718e29 100644 --- a/docs/tool-display.md +++ b/docs/tool-display.md @@ -22,11 +22,11 @@ agents: ### `full` (default) -Shows each tool call with its complete title. When more than 3 tools finish, they collapse into a count summary automatically. +Shows each tool call with its complete title. Consecutive repeats of the same tool are collapsed into a single line with an `(×N)` suffix, so a burst like three back-to-back `ToolSearch` calls renders as one line — not three. When more than 3 **distinct** tool groups finish or are still running mid-stream, individual lines collapse into a raw-count summary (`✅ 5 · ❌ 1 tool(s) completed` / `🔧 4 more running` + the trailing few). ``` ✅ `curl -s "https://ghcr.io/v2/openabdev/charts/openab/tags/list"` -✅ `grep -r "pattern" src/` +✅ `grep -r "pattern" src/` (×2) 🔧 `npm install`... Agent response text here... From 0d24cb696a1ffb35132d0f37a760c504cd74c4e2 Mon Sep 17 00:00:00 2001 From: yen <5915590+antigenius0910@users.noreply.github.com> Date: Wed, 15 Jul 2026 05:18:39 -0500 Subject: [PATCH 4/4] =?UTF-8?q?docs(tool-display):=20clarify=20grouping=20?= =?UTF-8?q?semantics=20=E2=80=94=20consecutive=20runs,=20run-count=20thres?= =?UTF-8?q?hold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Response to https://github.com/openabdev/openab/pull/1397#issuecomment round-4 review (chaodu-agent). Only remaining request was doc alignment — runtime + tests carried an LGTM. Two spots in `docs/tool-display.md` were imprecise: - Line 25 said "more than 3 **distinct** tool groups" — misleading. The algorithm counts CONSECUTIVE run groups, not globally distinct titles. `a, b, a, b` produces four run groups, not two. Reworded to "consecutive repeats", "run count", and "non-consecutive repeat … still renders as three separate lines" so the reader can predict the behaviour without reading the code. - Line 71 (Streaming behavior note) didn't acknowledge that Full mode can either group lines OR fall back to a summary depending on the run count. Reworded so it names both the grouped-line rendering AND the raw-count fallback strings that appear once either set exceeds 3 runs. No runtime code touched — this commit is docs-only. PR body updated separately via the GitHub API to match the current implementation (`group_entries` / `render_group` split, 15 `compose_display` tests, v3 image digest) rather than v1 wording. --- docs/tool-display.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tool-display.md b/docs/tool-display.md index 0f9718e29..6bc89ef96 100644 --- a/docs/tool-display.md +++ b/docs/tool-display.md @@ -22,7 +22,7 @@ agents: ### `full` (default) -Shows each tool call with its complete title. Consecutive repeats of the same tool are collapsed into a single line with an `(×N)` suffix, so a burst like three back-to-back `ToolSearch` calls renders as one line — not three. When more than 3 **distinct** tool groups finish or are still running mid-stream, individual lines collapse into a raw-count summary (`✅ 5 · ❌ 1 tool(s) completed` / `🔧 4 more running` + the trailing few). +Shows each tool call with its complete title. **Consecutive** repeats of the same tool are collapsed into a single line with an `(×N)` suffix, so a burst like three back-to-back `ToolSearch` calls renders as one line — not three. A non-consecutive repeat (e.g. `curl → grep → curl`) still renders as three separate lines — grouping is adjacency-only, order-preserving. When the resulting **run count** exceeds 3 for either the finished or the still-running set mid-stream, that set collapses into a raw-count summary (`✅ 5 · ❌ 1 tool(s) completed` / `🔧 4 more running` + the trailing few grouped runs). ``` ✅ `curl -s "https://ghcr.io/v2/openabdev/charts/openab/tags/list"` @@ -68,4 +68,4 @@ Best for: clean output when you only care about the final answer. - **Default**: `full` shows complete tool titles. Use `tool_display = "compact"` for a cleaner count-only summary, or `"none"` to hide tools entirely. - **Reaction emojis are independent**: The emoji reactions on messages (👀→🤔→🔧→🆗) work regardless of `tool_display` setting. -- **Streaming behavior**: In `compact` mode, the count updates in real-time as tools start and finish. In `full` mode, individual tool lines appear and update during streaming. +- **Streaming behavior**: In `compact` mode, the count updates in real-time as tools start and finish. In `full` mode, individual and grouped-repeat lines appear up to 3 runs per set (finished / running); once either set has more than 3 runs, that set switches to a raw-count summary (`✅ N · ❌ M tool(s) completed` / `🔧 N more running` + the trailing groups).