Skip to content

Commit 2f92655

Browse files
Yuriy ButenkoYuriy Butenko
authored andcommitted
fix(acp): stream durable session updates that follow a message chunk
`DurableUpdateSink::handle_notification` buffered every non-message `session/update` — `tool_call`, `tool_call_update`, `plan`, `mode`, `available_commands_update` — whenever a message chunk had already opened the completion buffer, and committed the buffer only at the next message boundary. Any agent that streams text before calling a tool therefore withholds its whole durable stream until the *post-tool* message arrives. Pi prints a banner chunk on some turns, so roughly a third of prompt turns delivered `tool_call_update { in_progress }` for a `sleep 60` only when the command finished, together with every other update of the turn. A caller that waits for that boundary before cancelling never observes a live turn: `session/cancel` then races prompt completion and reports `no_active_prompt`. Commit the in-progress text run first, then the update. The durable sequence is unchanged — `coalesce_completed_message` already ends the text run it is building at any non-text update, so the same events are emitted in the same order, only in more (smaller) batches — and the update now reaches the host the moment the adapter produces it. The regression test drives an ACP adapter that emits one message chunk, then the tool updates, then holds the turn open for two seconds. It records the live event sink and fails if the `in_progress` update lands at turn end: on the previous behavior it arrives at 2.06s of a 2.06s turn.
1 parent 90ec6e1 commit 2f92655

2 files changed

Lines changed: 518 additions & 14 deletions

File tree

crates/agentos-sidecar/src/acp/turn.rs

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,20 +1017,24 @@ impl DurableUpdateSink {
10171017
}
10181018

10191019
// Tool, plan, mode, and other durable updates may be interleaved with
1020-
// message deltas. Keep their native order inside the in-progress
1021-
// completion buffer instead of treating them as a message boundary.
1022-
if self.buffered_kind.is_some() {
1023-
self.buffered_bytes = checked_acp_bytes(
1024-
&self.user_session_id,
1025-
self.buffered_bytes,
1026-
update_bytes,
1027-
self.limits.max_completed_message_bytes,
1028-
"limits.acp.maxCompletedMessageBytes",
1029-
)?;
1030-
self.buffered.push(update);
1031-
} else {
1032-
self.persist(ctx, events, vec![update]).await?;
1033-
}
1020+
// message deltas. Commit the in-progress text run first, then this
1021+
// update, so it reaches the host the moment the adapter produces it.
1022+
//
1023+
// Holding it in the completion buffer instead would keep it invisible
1024+
// until the next message boundary: an agent that streams any text
1025+
// before calling a tool (Pi prints a banner chunk on some turns) arms
1026+
// the buffer, and every later `tool_call` / `tool_call_update` is then
1027+
// withheld until the *post-tool* message arrives — so a
1028+
// `tool_call_update { in_progress }` for a `sleep 60` reaches the host
1029+
// only when the command finishes, and a caller waiting on that
1030+
// boundary before cancelling never gets a live turn to cancel.
1031+
//
1032+
// The durable sequence is unchanged: `coalesce_completed_message`
1033+
// already ends the text run it is building at any non-text update, so
1034+
// flushing here emits the same events in the same order, just in more
1035+
// (smaller) batches.
1036+
self.flush(ctx, events).await?;
1037+
self.persist(ctx, events, vec![update]).await?;
10341038
Ok(true)
10351039
}
10361040

0 commit comments

Comments
 (0)