1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
use saya_agent::ToolEffect;
use super::super::{Transcript, chapters};
use super::{Block, BlockKind, PendingToolCall};
impl Transcript {
/// A run folds only when it is a multi-call all-ok group: two or more
/// completed calls, every summary success-shaped. A single call renders as
/// today; a group with a failure keeps the failure's full pair live. The
/// failure half uses the grouper's own predicate on the displayed summary
/// text (the transcript owns no `AgentEvent`s): the day the contract
/// changes, both must move together.
fn folds_run(completed: &[(String, serde_json::Value, Option<ToolEffect>, String)]) -> bool {
completed.len() >= 2
&& completed
.iter()
.all(|(_, _, _, summary)| !crate::render::tool_groups::is_failure_summary(summary))
}
/// Buffers a tool request: mirrors today's per-call line onto the tail so
/// the running stream stays legible, and holds the facts for the grouper.
/// Through [`Transcript::mutate`] — anchors and counts like any append.
pub(crate) fn buffer_tool_request(
&mut self,
name: String,
arguments: serde_json::Value,
effect: Option<ToolEffect>,
) {
self.mutate(|t| {
let chapter = chapters::chapter_for(&t.blocks, BlockKind::Tool);
let before = t.blocks.len();
for line in super::tool_lines::live_request_lines(&name, &arguments) {
t.blocks.push(Block {
kind: BlockKind::Tool,
text: line,
chapter,
group: None,
});
}
let live_blocks = t.blocks.len().saturating_sub(before);
t.pending_tools.push(PendingToolCall {
name,
arguments,
effect,
summary: None,
live_blocks,
open: true,
});
});
}
/// Pairs a completion with its open request: mirrors the shared
/// completion line onto the tail (`✓` for success, `✗` for failure).
/// Returns false when no request is open — a stray completion the caller
/// renders directly, outside any group. Through [`Transcript::mutate`],
/// so the mark is on the very next render.
pub(crate) fn buffer_tool_completion(&mut self, name: &str, summary: &str) -> bool {
self.mutate(|t| {
// Oldest open call of this name, not newest. The loop emits every
// `ToolRequested` of a parallel batch first, then every
// `ToolCompleted`, both in `assistant.tool_calls` order
// (`turn_tools.rs`), so completions arrive in request order and must
// pair FIFO. Matching newest-first swapped the summaries of two
// same-name parallel calls, and the flushed group then showed one
// call's arguments beside another call's result. Pairing is by name
// only — there is no call id on the events — so FIFO is the strongest
// correct rule available here.
let Some(pending) = t
.pending_tools
.iter_mut()
.find(|call| call.open && call.name == name)
else {
return false;
};
let chapter = chapters::chapter_for(&t.blocks, BlockKind::Tool);
let before = t.blocks.len();
t.blocks.push(Block {
kind: BlockKind::Tool,
text: crate::render::tool_groups::live_completion_line(name, summary),
chapter,
group: None,
});
pending.live_blocks += t.blocks.len().saturating_sub(before);
pending.summary = Some(summary.to_owned());
pending.open = false;
true
})
}
/// Drops the buffered run without rendering — the `TurnReset` retry path,
/// through [`Transcript::mutate`]; removal only shrinks, so the anchor
/// there is a no-op and the boundary is a plain invalidation.
pub(crate) fn discard_tool_buffer(&mut self) {
self.mutate(|t| {
let live: usize = t.pending_tools.iter().map(|call| call.live_blocks).sum();
for _ in 0..live {
if t.blocks
.last()
.is_some_and(|b| b.kind == BlockKind::Tool && !b.is_collapsible())
{
t.blocks.pop();
} else {
break;
}
}
t.pending_tools.clear();
});
}
/// Folds the buffered run into blocks: completed calls shape through the
/// shared grouper; a multi-call all-ok group lands as one collapsed block
/// (the summary header with the per-call lines as view state), everything
/// else keeps the live lines exactly as streamed. In-flight requests (no
/// completion yet) keep their live lines and stay buffered: the boundary
/// closed nothing for them. Through [`Transcript::mutate`] — folding only
/// shrinks, so the anchor there is a no-op and the bookkeeping (bounds,
/// invalidation) stays uniform. Nothing to fold is no mutation: no
/// bookkeeping either.
pub(crate) fn flush_tool_buffer(
&mut self,
request_lines: impl Fn(&str, &serde_json::Value) -> Vec<String>,
completion_line: impl Fn(&str, &str) -> String,
) {
if self.pending_tools.is_empty() {
return;
}
self.mutate(|t| {
let completed: Vec<(String, serde_json::Value, Option<ToolEffect>, String)> = t
.pending_tools
.iter()
.filter(|call| !call.open)
.filter_map(|call| {
call.summary.as_ref().map(|summary| {
(
call.name.clone(),
call.arguments.clone(),
call.effect,
summary.clone(),
)
})
})
.collect();
// Only a multi-call all-ok group folds: its live per-call lines pop
// off and one collapsed block takes their place. A one-member group
// or a group with a failure keeps the live lines exactly as streamed
// — today's `→` / `✓` rendering, byte for byte, never the piped
// text's `Using tool:` lines.
if !Self::folds_run(&completed) {
t.pending_tools.retain(|call| call.open);
return;
}
let live: usize = t
.pending_tools
.iter()
.filter(|call| !call.open)
.map(|call| call.live_blocks)
.sum();
for _ in 0..live {
if t.blocks
.last()
.is_some_and(|b| b.kind == BlockKind::Tool && !b.is_collapsible())
{
t.blocks.pop();
} else {
break;
}
}
t.pending_tools.retain(|call| call.open);
if completed.is_empty() {
return;
}
let events: Vec<saya_agent::AgentEvent> = completed
.iter()
.flat_map(|(name, arguments, effect, summary)| {
[
saya_agent::AgentEvent::ToolRequested {
name: name.clone(),
arguments: arguments.clone(),
effect: *effect,
},
saya_agent::AgentEvent::ToolCompleted {
name: name.clone(),
summary: summary.clone(),
},
]
})
.collect();
let groups = crate::render::tool_groups::group_tool_events(&events);
for group in &groups {
let shaped = crate::render::tool_groups::shape_group(group);
if group.calls.len() >= 2
&& shaped.len() == 1
&& !group.calls.iter().any(|call| call.failed)
{
let detail: Vec<String> = group
.calls
.iter()
.flat_map(|call| {
let mut lines = request_lines(&call.name, &call.arguments);
lines.push(completion_line(
&call.name,
call.summary.as_deref().unwrap_or(""),
));
lines
})
.collect();
let open_header = format!("▾{}", shaped[0].trim_start_matches('▸'));
let mut folded = Block::tool_group(shaped[0].clone(), detail, open_header);
folded.chapter = chapters::chapter_for(&t.blocks, BlockKind::Tool);
t.blocks.push(folded);
continue;
}
// Unreachable today: `is_collapsible_run` gates on exactly the
// shape above, so every group here folds. The arm stays so a
// future grouper change lands verbatim instead of vanishing.
let chapter = chapters::chapter_for(&t.blocks, BlockKind::Tool);
for line in shaped {
t.blocks.push(Block {
kind: BlockKind::Tool,
text: line,
chapter,
group: None,
});
}
}
});
}
}
#[cfg(test)]
#[path = "tool_buffer_tests.rs"]
mod parallel_pairing_tests;