fix(tui): restore typewriter streaming for turns after tool use
The first assistant turn always streamed correctly, but any follow-up assistant text after a tool round-trip popped in all at once when the turn ended instead of typewriter-streaming. Two bugs, fixed together: 1) handleEvent had no case for EvAssistantStart. The core emits this event at the top of every oneTurn including every follow-up, but the tui was ignoring it. So after the first EvAssistantMessage fired (for the tool_use message), streamOn stayed false, subsequent EvTextDelta events filled i.streaming but never made it on-screen because StreamingActive wasn't flipped back on. Added case core.EvAssistantStart that resets the buffer and sets streamOn=true. Belt-and-suspenders: the EvTextDelta case also sets streamOn=true so stray delta sequences without a preceding start still render. 2) The belt-and-suspenders guard in redraw() was too aggressive: it hid streaming whenever the last transcript message was assistant-role, which is always true during a follow-up turn (the last message is the assistant tool_use from the previous oneTurn). Narrowed to a strict duplicate check: only hide streaming when assistantText(lastMsg) == streamingBuffer, meaning EvAssistantMessage already promoted this exact text into the transcript but the next render tick hasnt flipped streamOn off yet. That is the only actual race this guard was protecting against. Added a tiny assistantText helper (concatenate TextBlocks of a message) to implement the dedupe check. Kept in the same file; no new package API.
- commit
- 676b5d75103737055147c6e9198d1839517c793a
- author
- patriceckhart · · 2026-04-20 10:56 UTC
- parent
- 616eed3
- signature
- unsigned
- refs
- tag: v0.0.64
1 file · +42 -5
M internal/agent/modes/interactive.go +42 -5
| @@ -528,11 +528,23 @@ func (i *Interactive) redraw() { | |||
| 528 | 528 | } | |
| 529 | 529 | i.view.Streaming = i.streaming.String() | |
| 530 | 530 | i.view.StreamingActive = i.streamOn | |
| 531 | - | // Belt and suspenders: if the transcript's last message is already an | |
| 532 | - | // assistant message, the streaming view is stale (EvAssistantMessage may | |
| 533 | - | // not have fired yet) — suppress it to avoid double-rendering the reply. | |
| 534 | - | if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant { | |
| 535 | - | i.view.StreamingActive = false | |
| 531 | + | // Guard against the narrow race where EvAssistantMessage has | |
| 532 | + | // just promoted a streaming reply into the transcript but a | |
| 533 | + | // render tick hasn't flipped streamOn off yet. Without the | |
| 534 | + | // guard, the same text would appear twice (once as the | |
| 535 | + | // in-flight streaming block, once as the last transcript | |
| 536 | + | // message). We detect the duplicate strictly: the last | |
| 537 | + | // assistant message's visible text must equal the streaming | |
| 538 | + | // buffer. Just matching on role is too broad — it also hides | |
| 539 | + | // the next round's typewriter streaming after a tool turn, | |
| 540 | + | // because the last transcript message is always assistant | |
| 541 | + | // (the tool-use block) until the follow-up summary lands. | |
| 542 | + | if i.streamOn && i.streaming.Len() > 0 { | |
| 543 | + | if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant { | |
| 544 | + | if assistantText(i.view.Messages[n-1]) == i.streaming.String() { | |
| 545 | + | i.view.StreamingActive = false | |
| 546 | + | } | |
| 547 | + | } | |
| 536 | 548 | } | |
| 537 | 549 | // Live tool-call view: only shown while a turn is in flight. Once | |
| 538 | 550 | // the agent is idle, every tool call has already been folded into | |
| @@ -2141,8 +2153,17 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 2141 | 2153 | i.mu.Lock() | |
| 2142 | 2154 | defer i.mu.Unlock() | |
| 2143 | 2155 | switch e := ev.(type) { | |
| 2156 | + | case core.EvAssistantStart: | |
| 2157 | + | // Fires at the top of every oneTurn, including follow-up | |
| 2158 | + | // turns after tool use. Without this, the streaming buffer | |
| 2159 | + | // is still marked off from the previous assistant message | |
| 2160 | + | // and the final summary text pops in all at once instead | |
| 2161 | + | // of typewriter-streaming delta by delta. | |
| 2162 | + | i.streaming.Reset() | |
| 2163 | + | i.streamOn = true | |
| 2144 | 2164 | case core.EvTextDelta: | |
| 2145 | 2165 | i.streaming.WriteString(e.Delta) | |
| 2166 | + | i.streamOn = true | |
| 2146 | 2167 | case core.EvAssistantMessage: | |
| 2147 | 2168 | i.streaming.Reset() | |
| 2148 | 2169 | i.streamOn = false | |
| @@ -2918,3 +2939,19 @@ func (i *Interactive) applyForkSelection(msgIdx int) { | |||
| 2918 | 2939 | func formatInt(n int) string { | |
| 2919 | 2940 | return fmt.Sprintf("%d", n) | |
| 2920 | 2941 | } | |
| 2942 | + | ||
| 2943 | + | // assistantText returns the concatenated text of every TextBlock in | |
| 2944 | + | // m. Used by the streaming-view dedupe guard to tell when a live | |
| 2945 | + | // streamed reply has already been promoted into the transcript. | |
| 2946 | + | func assistantText(m provider.Message) string { | |
| 2947 | + | var sb strings.Builder | |
| 2948 | + | for _, c := range m.Content { | |
| 2949 | + | if tb, ok := c.(provider.TextBlock); ok { | |
| 2950 | + | if sb.Len() > 0 { | |
| 2951 | + | sb.WriteByte('\n') | |
| 2952 | + | } | |
| 2953 | + | sb.WriteString(tb.Text) | |
| 2954 | + | } | |
| 2955 | + | } | |
| 2956 | + | return sb.String() | |
| 2957 | + | } | |
| @@ -528,11 +528,23 @@ func (i *Interactive) redraw() { | |||||
| 528 | } | 528 | } | ||
| 529 | i.view.Streaming = i.streaming.String() | 529 | i.view.Streaming = i.streaming.String() | ||
| 530 | i.view.StreamingActive = i.streamOn | 530 | i.view.StreamingActive = i.streamOn | ||
| 531 | - | // Belt and suspenders: if the transcript's last message is already an | 531 | + | // Guard against the narrow race where EvAssistantMessage has |
| 532 | - | // assistant message, the streaming view is stale (EvAssistantMessage may | 532 | + | // just promoted a streaming reply into the transcript but a |
| 533 | - | // not have fired yet) — suppress it to avoid double-rendering the reply. | 533 | + | // render tick hasn't flipped streamOn off yet. Without the |
| 534 | - | if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant { | 534 | + | // guard, the same text would appear twice (once as the |
| 535 | - | i.view.StreamingActive = false | 535 | + | // in-flight streaming block, once as the last transcript |
| 536 | + | // message). We detect the duplicate strictly: the last | |||
| 537 | + | // assistant message's visible text must equal the streaming | |||
| 538 | + | // buffer. Just matching on role is too broad — it also hides | |||
| 539 | + | // the next round's typewriter streaming after a tool turn, | |||
| 540 | + | // because the last transcript message is always assistant | |||
| 541 | + | // (the tool-use block) until the follow-up summary lands. | |||
| 542 | + | if i.streamOn && i.streaming.Len() > 0 { | |||
| 543 | + | if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant { | |||
| 544 | + | if assistantText(i.view.Messages[n-1]) == i.streaming.String() { | |||
| 545 | + | i.view.StreamingActive = false | |||
| 546 | + | } | |||
| 547 | + | } | |||
| 536 | } | 548 | } | ||
| 537 | // Live tool-call view: only shown while a turn is in flight. Once | 549 | // Live tool-call view: only shown while a turn is in flight. Once | ||
| 538 | // the agent is idle, every tool call has already been folded into | 550 | // the agent is idle, every tool call has already been folded into | ||
| @@ -2141,8 +2153,17 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 2141 | i.mu.Lock() | 2153 | i.mu.Lock() | ||
| 2142 | defer i.mu.Unlock() | 2154 | defer i.mu.Unlock() | ||
| 2143 | switch e := ev.(type) { | 2155 | switch e := ev.(type) { | ||
| 2156 | + | case core.EvAssistantStart: | |||
| 2157 | + | // Fires at the top of every oneTurn, including follow-up | |||
| 2158 | + | // turns after tool use. Without this, the streaming buffer | |||
| 2159 | + | // is still marked off from the previous assistant message | |||
| 2160 | + | // and the final summary text pops in all at once instead | |||
| 2161 | + | // of typewriter-streaming delta by delta. | |||
| 2162 | + | i.streaming.Reset() | |||
| 2163 | + | i.streamOn = true | |||
| 2144 | case core.EvTextDelta: | 2164 | case core.EvTextDelta: | ||
| 2145 | i.streaming.WriteString(e.Delta) | 2165 | i.streaming.WriteString(e.Delta) | ||
| 2166 | + | i.streamOn = true | |||
| 2146 | case core.EvAssistantMessage: | 2167 | case core.EvAssistantMessage: | ||
| 2147 | i.streaming.Reset() | 2168 | i.streaming.Reset() | ||
| 2148 | i.streamOn = false | 2169 | i.streamOn = false | ||
| @@ -2918,3 +2939,19 @@ func (i *Interactive) applyForkSelection(msgIdx int) { | |||||
| 2918 | func formatInt(n int) string { | 2939 | func formatInt(n int) string { | ||
| 2919 | return fmt.Sprintf("%d", n) | 2940 | return fmt.Sprintf("%d", n) | ||
| 2920 | } | 2941 | } | ||
| 2942 | + | ||||
| 2943 | + | // assistantText returns the concatenated text of every TextBlock in | |||
| 2944 | + | // m. Used by the streaming-view dedupe guard to tell when a live | |||
| 2945 | + | // streamed reply has already been promoted into the transcript. | |||
| 2946 | + | func assistantText(m provider.Message) string { | |||
| 2947 | + | var sb strings.Builder | |||
| 2948 | + | for _, c := range m.Content { | |||
| 2949 | + | if tb, ok := c.(provider.TextBlock); ok { | |||
| 2950 | + | if sb.Len() > 0 { | |||
| 2951 | + | sb.WriteByte('\n') | |||
| 2952 | + | } | |||
| 2953 | + | sb.WriteString(tb.Text) | |||
| 2954 | + | } | |||
| 2955 | + | } | |||
| 2956 | + | return sb.String() | |||
| 2957 | + | } | |||