Gate live tool rendering behind preceding stream text
- commit
- 7da9114a0532d1d16547d76ad3a3a7c11af98aff
- author
- patriceckhart · · 2026-06-07 14:58 UTC
- parent
- 63e33d9
- signature
- unsigned
- refs
- tag: v0.2.15
2 files · +190 -11
M packages/agent/modes/interactive.go +89 -11
| @@ -260,17 +260,28 @@ type Interactive struct { | |||
| 260 | 260 | streamFlushPending bool | |
| 261 | 261 | toolCalls map[string]*tui.ToolCallView | |
| 262 | 262 | toolOrder []string | |
| 263 | - | statusErr string | |
| 264 | - | statusOK string | |
| 265 | - | liveBlock []string // live streaming/tool progress rendered outside scrollback | |
| 266 | - | helpBlock []string // rendered above the chat when /help was typed | |
| 267 | - | cumUsage provider.Usage | |
| 268 | - | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | |
| 269 | - | busy bool | |
| 270 | - | dirty chan struct{} | |
| 271 | - | cancelTurn context.CancelFunc | |
| 272 | - | scrollOffset int // rows from the bottom; 0 = pinned to latest | |
| 273 | - | prevScrollOffset int // last value redraw snapped against; tracks intent | |
| 263 | + | // toolGate records, per tool-call id, how many runes of paced | |
| 264 | + | // assistant text must have drained into `streaming` before that | |
| 265 | + | // tool block may appear. It exists to make stream ordering | |
| 266 | + | // deterministic: a tool call can arrive from the provider while | |
| 267 | + | // the prose that logically precedes it is still being typed out | |
| 268 | + | // by the pacer. Without gating, the tool block would render | |
| 269 | + | // immediately while the intro paragraph keeps filling in below | |
| 270 | + | // it. We snapshot the total expected stream length (already | |
| 271 | + | // streamed + still pending) at the moment the tool starts, and | |
| 272 | + | // hold the block back until the pacer reaches it. | |
| 273 | + | toolGate map[string]int | |
| 274 | + | statusErr string | |
| 275 | + | statusOK string | |
| 276 | + | liveBlock []string // live streaming/tool progress rendered outside scrollback | |
| 277 | + | helpBlock []string // rendered above the chat when /help was typed | |
| 278 | + | cumUsage provider.Usage | |
| 279 | + | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | |
| 280 | + | busy bool | |
| 281 | + | dirty chan struct{} | |
| 282 | + | cancelTurn context.CancelFunc | |
| 283 | + | scrollOffset int // rows from the bottom; 0 = pinned to latest | |
| 284 | + | prevScrollOffset int // last value redraw snapped against; tracks intent | |
| 274 | 285 | ||
| 275 | 286 | // prevChatLen and prevChatCols track the chat buffer's size at the | |
| 276 | 287 | // last redraw so that when content grows below the user's viewport | |
| @@ -440,6 +451,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 440 | 451 | ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)), | |
| 441 | 452 | rend: renderer, | |
| 442 | 453 | toolCalls: map[string]*tui.ToolCallView{}, | |
| 454 | + | toolGate: map[string]int{}, | |
| 443 | 455 | dirty: make(chan struct{}, 8), | |
| 444 | 456 | dialog: newLoginDialog(), | |
| 445 | 457 | modelDialog: newModelDialog(), | |
| @@ -824,6 +836,14 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 824 | 836 | i.view.ToolCalls = i.view.ToolCalls[:0] | |
| 825 | 837 | if i.busy { | |
| 826 | 838 | for _, id := range i.toolOrder { | |
| 839 | + | // Deterministic ordering: a tool block stays hidden until | |
| 840 | + | // the paced assistant text that preceded it has finished | |
| 841 | + | // typing out. toolOrder is append-only in arrival order, | |
| 842 | + | // so once one tool is still gated, every later tool is too, | |
| 843 | + | // so stop here to avoid showing a tool out of sequence. | |
| 844 | + | if !i.toolGateOpenLocked(id) { | |
| 845 | + | break | |
| 846 | + | } | |
| 827 | 847 | if tc, ok := i.toolCalls[id]; ok { | |
| 828 | 848 | i.view.ToolCalls = append(i.view.ToolCalls, *tc) | |
| 829 | 849 | } | |
| @@ -2412,6 +2432,7 @@ func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||
| 2412 | 2432 | i.cfg.Model = model | |
| 2413 | 2433 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 2414 | 2434 | i.toolOrder = nil | |
| 2435 | + | i.toolGate = map[string]int{} | |
| 2415 | 2436 | i.helpBlock = nil | |
| 2416 | 2437 | i.parkedTurn = 0 | |
| 2417 | 2438 | i.statusErr = "" | |
| @@ -3081,6 +3102,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 3081 | 3102 | i.mu.Lock() | |
| 3082 | 3103 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 3083 | 3104 | i.toolOrder = nil | |
| 3105 | + | i.toolGate = map[string]int{} | |
| 3084 | 3106 | i.statusErr = "" | |
| 3085 | 3107 | i.statusOK = "" | |
| 3086 | 3108 | i.helpBlock = nil | |
| @@ -3200,6 +3222,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 3200 | 3222 | i.mu.Lock() | |
| 3201 | 3223 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 3202 | 3224 | i.toolOrder = nil | |
| 3225 | + | i.toolGate = map[string]int{} | |
| 3203 | 3226 | i.helpBlock = nil | |
| 3204 | 3227 | i.parkedTurn = 0 | |
| 3205 | 3228 | i.statusOK = "cwd " + i.cfg.CWD | |
| @@ -4078,6 +4101,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 4078 | 4101 | i.lastCtxInput = 0 | |
| 4079 | 4102 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 4080 | 4103 | i.toolOrder = nil | |
| 4104 | + | i.toolGate = map[string]int{} | |
| 4081 | 4105 | i.view.InvalidateRenderCache() | |
| 4082 | 4106 | // Pop queued prompt if any. | |
| 4083 | 4107 | if len(i.queued) > 0 { | |
| @@ -4266,6 +4290,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||
| 4266 | 4290 | i.streamOn = true | |
| 4267 | 4291 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 4268 | 4292 | i.toolOrder = nil | |
| 4293 | + | i.toolGate = map[string]int{} | |
| 4269 | 4294 | i.shellBlock = nil // sending a prompt clears any parked shell-escape log | |
| 4270 | 4295 | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | |
| 4271 | 4296 | i.scrollOffset = 0 // jump back to the bottom on new turn | |
| @@ -4533,6 +4558,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 4533 | 4558 | // will populate fresh entries for this turn's tools. | |
| 4534 | 4559 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 4535 | 4560 | i.toolOrder = nil | |
| 4561 | + | i.toolGate = map[string]int{} | |
| 4536 | 4562 | case core.EvTextDelta: | |
| 4537 | 4563 | // Buffer into streamPending; the paintPace ticker drains | |
| 4538 | 4564 | // it into i.streaming a few runes at a time for a smooth | |
| @@ -4566,6 +4592,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 4566 | 4592 | Streaming: true, | |
| 4567 | 4593 | } | |
| 4568 | 4594 | i.toolOrder = append(i.toolOrder, e.ID) | |
| 4595 | + | i.gateToolLocked(e.ID) | |
| 4569 | 4596 | } | |
| 4570 | 4597 | case core.EvToolUseArgs: | |
| 4571 | 4598 | if tc, ok := i.toolCalls[e.ID]; ok { | |
| @@ -4597,6 +4624,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 4597 | 4624 | Args: tui.ShortArgs(e.Name, e.Args), | |
| 4598 | 4625 | } | |
| 4599 | 4626 | i.toolOrder = append(i.toolOrder, e.ID) | |
| 4627 | + | i.gateToolLocked(e.ID) | |
| 4600 | 4628 | } | |
| 4601 | 4629 | case core.EvToolResult: | |
| 4602 | 4630 | if tc, ok := i.toolCalls[e.ID]; ok { | |
| @@ -5531,6 +5559,55 @@ func (i *Interactive) resetStreamingStateLocked() { | |||
| 5531 | 5559 | i.streamPending = i.streamPending[:0] | |
| 5532 | 5560 | i.streamFlushPending = false | |
| 5533 | 5561 | i.streamOn = false | |
| 5562 | + | i.openAllToolGatesLocked() | |
| 5563 | + | } | |
| 5564 | + | ||
| 5565 | + | // openAllToolGatesLocked drops every pending tool gate so that any | |
| 5566 | + | // tool registered during this turn renders unconditionally from now | |
| 5567 | + | // on. Called when streaming finalizes (the paced text has fully | |
| 5568 | + | // drained and `streaming` is about to reset to length 0): without | |
| 5569 | + | // this, the gate comparison against a freshly-reset streaming buffer | |
| 5570 | + | // would wrongly re-hide tools that had already cleared their gate. | |
| 5571 | + | // Must be called with i.mu held. | |
| 5572 | + | func (i *Interactive) openAllToolGatesLocked() { | |
| 5573 | + | for id := range i.toolGate { | |
| 5574 | + | i.toolGate[id] = 0 | |
| 5575 | + | } | |
| 5576 | + | } | |
| 5577 | + | ||
| 5578 | + | // gateToolLocked records the stream position at which a tool call may | |
| 5579 | + | // become visible. The gate is the total length the streaming buffer | |
| 5580 | + | // will reach once the pacer has drained everything currently queued | |
| 5581 | + | // (already painted + still pending). Holding the tool block back | |
| 5582 | + | // until the pacer crosses that mark guarantees the prose emitted | |
| 5583 | + | // before the tool call finishes typing out above it, instead of the | |
| 5584 | + | // tool block snapping in while the paragraph is still filling in. | |
| 5585 | + | // | |
| 5586 | + | // We only gate while text is actively streaming. If no stream is in | |
| 5587 | + | // flight (gate 0), the tool shows immediately, which is the correct | |
| 5588 | + | // behaviour for tool-only turns and replayed sessions. First | |
| 5589 | + | // registration wins so a later EvToolCall can't move an existing | |
| 5590 | + | // gate. Must be called with i.mu held. | |
| 5591 | + | func (i *Interactive) gateToolLocked(id string) { | |
| 5592 | + | if _, ok := i.toolGate[id]; ok { | |
| 5593 | + | return | |
| 5594 | + | } | |
| 5595 | + | if !i.streamOn { | |
| 5596 | + | i.toolGate[id] = 0 | |
| 5597 | + | return | |
| 5598 | + | } | |
| 5599 | + | i.toolGate[id] = i.streaming.Len() + len(i.streamPending) | |
| 5600 | + | } | |
| 5601 | + | ||
| 5602 | + | // toolGateOpenLocked reports whether the gated tool block may render | |
| 5603 | + | // yet, i.e. the pacer has drained enough text to reach the position | |
| 5604 | + | // recorded when the tool call arrived. Must be called with i.mu held. | |
| 5605 | + | func (i *Interactive) toolGateOpenLocked(id string) bool { | |
| 5606 | + | gate, ok := i.toolGate[id] | |
| 5607 | + | if !ok || gate == 0 { | |
| 5608 | + | return true | |
| 5609 | + | } | |
| 5610 | + | return i.streaming.Len() >= gate | |
| 5534 | 5611 | } | |
| 5535 | 5612 | ||
| 5536 | 5613 | // assistantMessageSideEffects runs the non-visual hooks attached to | |
| @@ -5603,6 +5680,7 @@ func (i *Interactive) runStreamPacer(ctx context.Context) { | |||
| 5603 | 5680 | i.streamFlushPending = false | |
| 5604 | 5681 | i.streaming.Reset() | |
| 5605 | 5682 | i.streamOn = false | |
| 5683 | + | i.openAllToolGatesLocked() | |
| 5606 | 5684 | i.mu.Unlock() | |
| 5607 | 5685 | i.invalidate() | |
| 5608 | 5686 | continue | |
| @@ -260,17 +260,28 @@ type Interactive struct { | |||||
| 260 | streamFlushPending bool | 260 | streamFlushPending bool | ||
| 261 | toolCalls map[string]*tui.ToolCallView | 261 | toolCalls map[string]*tui.ToolCallView | ||
| 262 | toolOrder []string | 262 | toolOrder []string | ||
| 263 | - | statusErr string | 263 | + | // toolGate records, per tool-call id, how many runes of paced |
| 264 | - | statusOK string | 264 | + | // assistant text must have drained into `streaming` before that |
| 265 | - | liveBlock []string // live streaming/tool progress rendered outside scrollback | 265 | + | // tool block may appear. It exists to make stream ordering |
| 266 | - | helpBlock []string // rendered above the chat when /help was typed | 266 | + | // deterministic: a tool call can arrive from the provider while |
| 267 | - | cumUsage provider.Usage | 267 | + | // the prose that logically precedes it is still being typed out |
| 268 | - | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | 268 | + | // by the pacer. Without gating, the tool block would render |
| 269 | - | busy bool | 269 | + | // immediately while the intro paragraph keeps filling in below |
| 270 | - | dirty chan struct{} | 270 | + | // it. We snapshot the total expected stream length (already |
| 271 | - | cancelTurn context.CancelFunc | 271 | + | // streamed + still pending) at the moment the tool starts, and |
| 272 | - | scrollOffset int // rows from the bottom; 0 = pinned to latest | 272 | + | // hold the block back until the pacer reaches it. |
| 273 | - | prevScrollOffset int // last value redraw snapped against; tracks intent | 273 | + | toolGate map[string]int |
| 274 | + | statusErr string | |||
| 275 | + | statusOK string | |||
| 276 | + | liveBlock []string // live streaming/tool progress rendered outside scrollback | |||
| 277 | + | helpBlock []string // rendered above the chat when /help was typed | |||
| 278 | + | cumUsage provider.Usage | |||
| 279 | + | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | |||
| 280 | + | busy bool | |||
| 281 | + | dirty chan struct{} | |||
| 282 | + | cancelTurn context.CancelFunc | |||
| 283 | + | scrollOffset int // rows from the bottom; 0 = pinned to latest | |||
| 284 | + | prevScrollOffset int // last value redraw snapped against; tracks intent | |||
| 274 | 285 | ||||
| 275 | // prevChatLen and prevChatCols track the chat buffer's size at the | 286 | // prevChatLen and prevChatCols track the chat buffer's size at the | ||
| 276 | // last redraw so that when content grows below the user's viewport | 287 | // last redraw so that when content grows below the user's viewport | ||
| @@ -440,6 +451,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 440 | ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)), | 451 | ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)), | ||
| 441 | rend: renderer, | 452 | rend: renderer, | ||
| 442 | toolCalls: map[string]*tui.ToolCallView{}, | 453 | toolCalls: map[string]*tui.ToolCallView{}, | ||
| 454 | + | toolGate: map[string]int{}, | |||
| 443 | dirty: make(chan struct{}, 8), | 455 | dirty: make(chan struct{}, 8), | ||
| 444 | dialog: newLoginDialog(), | 456 | dialog: newLoginDialog(), | ||
| 445 | modelDialog: newModelDialog(), | 457 | modelDialog: newModelDialog(), | ||
| @@ -824,6 +836,14 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||||
| 824 | i.view.ToolCalls = i.view.ToolCalls[:0] | 836 | i.view.ToolCalls = i.view.ToolCalls[:0] | ||
| 825 | if i.busy { | 837 | if i.busy { | ||
| 826 | for _, id := range i.toolOrder { | 838 | for _, id := range i.toolOrder { | ||
| 839 | + | // Deterministic ordering: a tool block stays hidden until | |||
| 840 | + | // the paced assistant text that preceded it has finished | |||
| 841 | + | // typing out. toolOrder is append-only in arrival order, | |||
| 842 | + | // so once one tool is still gated, every later tool is too, | |||
| 843 | + | // so stop here to avoid showing a tool out of sequence. | |||
| 844 | + | if !i.toolGateOpenLocked(id) { | |||
| 845 | + | break | |||
| 846 | + | } | |||
| 827 | if tc, ok := i.toolCalls[id]; ok { | 847 | if tc, ok := i.toolCalls[id]; ok { | ||
| 828 | i.view.ToolCalls = append(i.view.ToolCalls, *tc) | 848 | i.view.ToolCalls = append(i.view.ToolCalls, *tc) | ||
| 829 | } | 849 | } | ||
| @@ -2412,6 +2432,7 @@ func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||||
| 2412 | i.cfg.Model = model | 2432 | i.cfg.Model = model | ||
| 2413 | i.toolCalls = map[string]*tui.ToolCallView{} | 2433 | i.toolCalls = map[string]*tui.ToolCallView{} | ||
| 2414 | i.toolOrder = nil | 2434 | i.toolOrder = nil | ||
| 2435 | + | i.toolGate = map[string]int{} | |||
| 2415 | i.helpBlock = nil | 2436 | i.helpBlock = nil | ||
| 2416 | i.parkedTurn = 0 | 2437 | i.parkedTurn = 0 | ||
| 2417 | i.statusErr = "" | 2438 | i.statusErr = "" | ||
| @@ -3081,6 +3102,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 3081 | i.mu.Lock() | 3102 | i.mu.Lock() | ||
| 3082 | i.toolCalls = map[string]*tui.ToolCallView{} | 3103 | i.toolCalls = map[string]*tui.ToolCallView{} | ||
| 3083 | i.toolOrder = nil | 3104 | i.toolOrder = nil | ||
| 3105 | + | i.toolGate = map[string]int{} | |||
| 3084 | i.statusErr = "" | 3106 | i.statusErr = "" | ||
| 3085 | i.statusOK = "" | 3107 | i.statusOK = "" | ||
| 3086 | i.helpBlock = nil | 3108 | i.helpBlock = nil | ||
| @@ -3200,6 +3222,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 3200 | i.mu.Lock() | 3222 | i.mu.Lock() | ||
| 3201 | i.toolCalls = map[string]*tui.ToolCallView{} | 3223 | i.toolCalls = map[string]*tui.ToolCallView{} | ||
| 3202 | i.toolOrder = nil | 3224 | i.toolOrder = nil | ||
| 3225 | + | i.toolGate = map[string]int{} | |||
| 3203 | i.helpBlock = nil | 3226 | i.helpBlock = nil | ||
| 3204 | i.parkedTurn = 0 | 3227 | i.parkedTurn = 0 | ||
| 3205 | i.statusOK = "cwd " + i.cfg.CWD | 3228 | i.statusOK = "cwd " + i.cfg.CWD | ||
| @@ -4078,6 +4101,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||||
| 4078 | i.lastCtxInput = 0 | 4101 | i.lastCtxInput = 0 | ||
| 4079 | i.toolCalls = map[string]*tui.ToolCallView{} | 4102 | i.toolCalls = map[string]*tui.ToolCallView{} | ||
| 4080 | i.toolOrder = nil | 4103 | i.toolOrder = nil | ||
| 4104 | + | i.toolGate = map[string]int{} | |||
| 4081 | i.view.InvalidateRenderCache() | 4105 | i.view.InvalidateRenderCache() | ||
| 4082 | // Pop queued prompt if any. | 4106 | // Pop queued prompt if any. | ||
| 4083 | if len(i.queued) > 0 { | 4107 | if len(i.queued) > 0 { | ||
| @@ -4266,6 +4290,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||||
| 4266 | i.streamOn = true | 4290 | i.streamOn = true | ||
| 4267 | i.toolCalls = map[string]*tui.ToolCallView{} | 4291 | i.toolCalls = map[string]*tui.ToolCallView{} | ||
| 4268 | i.toolOrder = nil | 4292 | i.toolOrder = nil | ||
| 4293 | + | i.toolGate = map[string]int{} | |||
| 4269 | i.shellBlock = nil // sending a prompt clears any parked shell-escape log | 4294 | i.shellBlock = nil // sending a prompt clears any parked shell-escape log | ||
| 4270 | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | 4295 | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | ||
| 4271 | i.scrollOffset = 0 // jump back to the bottom on new turn | 4296 | i.scrollOffset = 0 // jump back to the bottom on new turn | ||
| @@ -4533,6 +4558,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 4533 | // will populate fresh entries for this turn's tools. | 4558 | // will populate fresh entries for this turn's tools. | ||
| 4534 | i.toolCalls = map[string]*tui.ToolCallView{} | 4559 | i.toolCalls = map[string]*tui.ToolCallView{} | ||
| 4535 | i.toolOrder = nil | 4560 | i.toolOrder = nil | ||
| 4561 | + | i.toolGate = map[string]int{} | |||
| 4536 | case core.EvTextDelta: | 4562 | case core.EvTextDelta: | ||
| 4537 | // Buffer into streamPending; the paintPace ticker drains | 4563 | // Buffer into streamPending; the paintPace ticker drains | ||
| 4538 | // it into i.streaming a few runes at a time for a smooth | 4564 | // it into i.streaming a few runes at a time for a smooth | ||
| @@ -4566,6 +4592,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 4566 | Streaming: true, | 4592 | Streaming: true, | ||
| 4567 | } | 4593 | } | ||
| 4568 | i.toolOrder = append(i.toolOrder, e.ID) | 4594 | i.toolOrder = append(i.toolOrder, e.ID) | ||
| 4595 | + | i.gateToolLocked(e.ID) | |||
| 4569 | } | 4596 | } | ||
| 4570 | case core.EvToolUseArgs: | 4597 | case core.EvToolUseArgs: | ||
| 4571 | if tc, ok := i.toolCalls[e.ID]; ok { | 4598 | if tc, ok := i.toolCalls[e.ID]; ok { | ||
| @@ -4597,6 +4624,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 4597 | Args: tui.ShortArgs(e.Name, e.Args), | 4624 | Args: tui.ShortArgs(e.Name, e.Args), | ||
| 4598 | } | 4625 | } | ||
| 4599 | i.toolOrder = append(i.toolOrder, e.ID) | 4626 | i.toolOrder = append(i.toolOrder, e.ID) | ||
| 4627 | + | i.gateToolLocked(e.ID) | |||
| 4600 | } | 4628 | } | ||
| 4601 | case core.EvToolResult: | 4629 | case core.EvToolResult: | ||
| 4602 | if tc, ok := i.toolCalls[e.ID]; ok { | 4630 | if tc, ok := i.toolCalls[e.ID]; ok { | ||
| @@ -5531,6 +5559,55 @@ func (i *Interactive) resetStreamingStateLocked() { | |||||
| 5531 | i.streamPending = i.streamPending[:0] | 5559 | i.streamPending = i.streamPending[:0] | ||
| 5532 | i.streamFlushPending = false | 5560 | i.streamFlushPending = false | ||
| 5533 | i.streamOn = false | 5561 | i.streamOn = false | ||
| 5562 | + | i.openAllToolGatesLocked() | |||
| 5563 | + | } | |||
| 5564 | + | ||||
| 5565 | + | // openAllToolGatesLocked drops every pending tool gate so that any | |||
| 5566 | + | // tool registered during this turn renders unconditionally from now | |||
| 5567 | + | // on. Called when streaming finalizes (the paced text has fully | |||
| 5568 | + | // drained and `streaming` is about to reset to length 0): without | |||
| 5569 | + | // this, the gate comparison against a freshly-reset streaming buffer | |||
| 5570 | + | // would wrongly re-hide tools that had already cleared their gate. | |||
| 5571 | + | // Must be called with i.mu held. | |||
| 5572 | + | func (i *Interactive) openAllToolGatesLocked() { | |||
| 5573 | + | for id := range i.toolGate { | |||
| 5574 | + | i.toolGate[id] = 0 | |||
| 5575 | + | } | |||
| 5576 | + | } | |||
| 5577 | + | ||||
| 5578 | + | // gateToolLocked records the stream position at which a tool call may | |||
| 5579 | + | // become visible. The gate is the total length the streaming buffer | |||
| 5580 | + | // will reach once the pacer has drained everything currently queued | |||
| 5581 | + | // (already painted + still pending). Holding the tool block back | |||
| 5582 | + | // until the pacer crosses that mark guarantees the prose emitted | |||
| 5583 | + | // before the tool call finishes typing out above it, instead of the | |||
| 5584 | + | // tool block snapping in while the paragraph is still filling in. | |||
| 5585 | + | // | |||
| 5586 | + | // We only gate while text is actively streaming. If no stream is in | |||
| 5587 | + | // flight (gate 0), the tool shows immediately, which is the correct | |||
| 5588 | + | // behaviour for tool-only turns and replayed sessions. First | |||
| 5589 | + | // registration wins so a later EvToolCall can't move an existing | |||
| 5590 | + | // gate. Must be called with i.mu held. | |||
| 5591 | + | func (i *Interactive) gateToolLocked(id string) { | |||
| 5592 | + | if _, ok := i.toolGate[id]; ok { | |||
| 5593 | + | return | |||
| 5594 | + | } | |||
| 5595 | + | if !i.streamOn { | |||
| 5596 | + | i.toolGate[id] = 0 | |||
| 5597 | + | return | |||
| 5598 | + | } | |||
| 5599 | + | i.toolGate[id] = i.streaming.Len() + len(i.streamPending) | |||
| 5600 | + | } | |||
| 5601 | + | ||||
| 5602 | + | // toolGateOpenLocked reports whether the gated tool block may render | |||
| 5603 | + | // yet, i.e. the pacer has drained enough text to reach the position | |||
| 5604 | + | // recorded when the tool call arrived. Must be called with i.mu held. | |||
| 5605 | + | func (i *Interactive) toolGateOpenLocked(id string) bool { | |||
| 5606 | + | gate, ok := i.toolGate[id] | |||
| 5607 | + | if !ok || gate == 0 { | |||
| 5608 | + | return true | |||
| 5609 | + | } | |||
| 5610 | + | return i.streaming.Len() >= gate | |||
| 5534 | } | 5611 | } | ||
| 5535 | 5612 | ||||
| 5536 | // assistantMessageSideEffects runs the non-visual hooks attached to | 5613 | // assistantMessageSideEffects runs the non-visual hooks attached to | ||
| @@ -5603,6 +5680,7 @@ func (i *Interactive) runStreamPacer(ctx context.Context) { | |||||
| 5603 | i.streamFlushPending = false | 5680 | i.streamFlushPending = false | ||
| 5604 | i.streaming.Reset() | 5681 | i.streaming.Reset() | ||
| 5605 | i.streamOn = false | 5682 | i.streamOn = false | ||
| 5683 | + | i.openAllToolGatesLocked() | |||
| 5606 | i.mu.Unlock() | 5684 | i.mu.Unlock() | ||
| 5607 | i.invalidate() | 5685 | i.invalidate() | ||
| 5608 | continue | 5686 | continue | ||
A packages/agent/modes/tool_gate_test.go +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func newGateTestInteractive() *Interactive { | |
| 6 | + | return &Interactive{ | |
| 7 | + | toolGate: map[string]int{}, | |
| 8 | + | dirty: make(chan struct{}, 1), | |
| 9 | + | } | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | // While paced text is still draining, a tool that arrives mid-stream | |
| 13 | + | // must stay gated until the streaming buffer reaches the position | |
| 14 | + | // captured when the tool started. | |
| 15 | + | func TestToolGateHoldsToolUntilTextDrains(t *testing.T) { | |
| 16 | + | i := newGateTestInteractive() | |
| 17 | + | ||
| 18 | + | // Simulate a turn that streamed 10 runes already with 20 more | |
| 19 | + | // queued in the pacer when the tool call arrives. | |
| 20 | + | i.streamOn = true | |
| 21 | + | i.streaming.WriteString("0123456789") // 10 painted | |
| 22 | + | i.streamPending = []rune("01234567890123456789") | |
| 23 | + | ||
| 24 | + | i.gateToolLocked("t1") | |
| 25 | + | if got := i.toolGate["t1"]; got != 30 { | |
| 26 | + | t.Fatalf("gate = %d, want 30 (10 painted + 20 pending)", got) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | if i.toolGateOpenLocked("t1") { | |
| 30 | + | t.Fatal("tool should be gated while only 10/30 runes painted") | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // Drain halfway: still gated. | |
| 34 | + | i.streaming.WriteString("0123456789") // 20 painted | |
| 35 | + | if i.toolGateOpenLocked("t1") { | |
| 36 | + | t.Fatal("tool should still be gated at 20/30") | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Reach the gate: now visible. | |
| 40 | + | i.streaming.WriteString("0123456789") // 30 painted | |
| 41 | + | if !i.toolGateOpenLocked("t1") { | |
| 42 | + | t.Fatal("tool should be visible once streaming reaches the gate") | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // A tool call on a turn with no active text stream shows immediately. | |
| 47 | + | func TestToolGateOpenWhenNoStream(t *testing.T) { | |
| 48 | + | i := newGateTestInteractive() | |
| 49 | + | i.streamOn = false | |
| 50 | + | ||
| 51 | + | i.gateToolLocked("t1") | |
| 52 | + | if got := i.toolGate["t1"]; got != 0 { | |
| 53 | + | t.Fatalf("gate = %d, want 0 for non-streaming turn", got) | |
| 54 | + | } | |
| 55 | + | if !i.toolGateOpenLocked("t1") { | |
| 56 | + | t.Fatal("tool should be visible immediately when no text is streaming") | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // First registration wins: a later EvToolCall must not move an | |
| 61 | + | // existing gate (e.g. push it forward as more text arrives). | |
| 62 | + | func TestToolGateFirstRegistrationWins(t *testing.T) { | |
| 63 | + | i := newGateTestInteractive() | |
| 64 | + | i.streamOn = true | |
| 65 | + | i.streaming.WriteString("01234") // 5 | |
| 66 | + | i.streamPending = []rune("01234") | |
| 67 | + | ||
| 68 | + | i.gateToolLocked("t1") // gate 10 | |
| 69 | + | first := i.toolGate["t1"] | |
| 70 | + | ||
| 71 | + | // More text queues up, then the same tool is re-registered. | |
| 72 | + | i.streamPending = append(i.streamPending, []rune("567890")...) | |
| 73 | + | i.gateToolLocked("t1") | |
| 74 | + | ||
| 75 | + | if i.toolGate["t1"] != first { | |
| 76 | + | t.Fatalf("gate moved from %d to %d; first registration must win", first, i.toolGate["t1"]) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // Once streaming finalizes, the buffer resets to length 0; gates that | |
| 81 | + | // were already satisfied must not re-hide their tools. | |
| 82 | + | func TestOpenAllToolGatesSurvivesStreamReset(t *testing.T) { | |
| 83 | + | i := newGateTestInteractive() | |
| 84 | + | i.streamOn = true | |
| 85 | + | i.streaming.WriteString("0123456789") | |
| 86 | + | i.gateToolLocked("t1") // gate 10, satisfied | |
| 87 | + | ||
| 88 | + | if !i.toolGateOpenLocked("t1") { | |
| 89 | + | t.Fatal("precondition: tool should be open before reset") | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // Finalize the stream (mirrors resetStreamingStateLocked / pacer | |
| 93 | + | // flush): buffer resets to 0 but gates are opened first. | |
| 94 | + | i.openAllToolGatesLocked() | |
| 95 | + | i.streaming.Reset() | |
| 96 | + | i.streamOn = false | |
| 97 | + | ||
| 98 | + | if !i.toolGateOpenLocked("t1") { | |
| 99 | + | t.Fatal("tool re-hidden after stream reset; gate should have been opened") | |
| 100 | + | } | |
| 101 | + | } | |
| @@ -0,0 +1,101 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func newGateTestInteractive() *Interactive { | |||
| 6 | + | return &Interactive{ | |||
| 7 | + | toolGate: map[string]int{}, | |||
| 8 | + | dirty: make(chan struct{}, 1), | |||
| 9 | + | } | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | // While paced text is still draining, a tool that arrives mid-stream | |||
| 13 | + | // must stay gated until the streaming buffer reaches the position | |||
| 14 | + | // captured when the tool started. | |||
| 15 | + | func TestToolGateHoldsToolUntilTextDrains(t *testing.T) { | |||
| 16 | + | i := newGateTestInteractive() | |||
| 17 | + | ||||
| 18 | + | // Simulate a turn that streamed 10 runes already with 20 more | |||
| 19 | + | // queued in the pacer when the tool call arrives. | |||
| 20 | + | i.streamOn = true | |||
| 21 | + | i.streaming.WriteString("0123456789") // 10 painted | |||
| 22 | + | i.streamPending = []rune("01234567890123456789") | |||
| 23 | + | ||||
| 24 | + | i.gateToolLocked("t1") | |||
| 25 | + | if got := i.toolGate["t1"]; got != 30 { | |||
| 26 | + | t.Fatalf("gate = %d, want 30 (10 painted + 20 pending)", got) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | if i.toolGateOpenLocked("t1") { | |||
| 30 | + | t.Fatal("tool should be gated while only 10/30 runes painted") | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // Drain halfway: still gated. | |||
| 34 | + | i.streaming.WriteString("0123456789") // 20 painted | |||
| 35 | + | if i.toolGateOpenLocked("t1") { | |||
| 36 | + | t.Fatal("tool should still be gated at 20/30") | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // Reach the gate: now visible. | |||
| 40 | + | i.streaming.WriteString("0123456789") // 30 painted | |||
| 41 | + | if !i.toolGateOpenLocked("t1") { | |||
| 42 | + | t.Fatal("tool should be visible once streaming reaches the gate") | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // A tool call on a turn with no active text stream shows immediately. | |||
| 47 | + | func TestToolGateOpenWhenNoStream(t *testing.T) { | |||
| 48 | + | i := newGateTestInteractive() | |||
| 49 | + | i.streamOn = false | |||
| 50 | + | ||||
| 51 | + | i.gateToolLocked("t1") | |||
| 52 | + | if got := i.toolGate["t1"]; got != 0 { | |||
| 53 | + | t.Fatalf("gate = %d, want 0 for non-streaming turn", got) | |||
| 54 | + | } | |||
| 55 | + | if !i.toolGateOpenLocked("t1") { | |||
| 56 | + | t.Fatal("tool should be visible immediately when no text is streaming") | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // First registration wins: a later EvToolCall must not move an | |||
| 61 | + | // existing gate (e.g. push it forward as more text arrives). | |||
| 62 | + | func TestToolGateFirstRegistrationWins(t *testing.T) { | |||
| 63 | + | i := newGateTestInteractive() | |||
| 64 | + | i.streamOn = true | |||
| 65 | + | i.streaming.WriteString("01234") // 5 | |||
| 66 | + | i.streamPending = []rune("01234") | |||
| 67 | + | ||||
| 68 | + | i.gateToolLocked("t1") // gate 10 | |||
| 69 | + | first := i.toolGate["t1"] | |||
| 70 | + | ||||
| 71 | + | // More text queues up, then the same tool is re-registered. | |||
| 72 | + | i.streamPending = append(i.streamPending, []rune("567890")...) | |||
| 73 | + | i.gateToolLocked("t1") | |||
| 74 | + | ||||
| 75 | + | if i.toolGate["t1"] != first { | |||
| 76 | + | t.Fatalf("gate moved from %d to %d; first registration must win", first, i.toolGate["t1"]) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // Once streaming finalizes, the buffer resets to length 0; gates that | |||
| 81 | + | // were already satisfied must not re-hide their tools. | |||
| 82 | + | func TestOpenAllToolGatesSurvivesStreamReset(t *testing.T) { | |||
| 83 | + | i := newGateTestInteractive() | |||
| 84 | + | i.streamOn = true | |||
| 85 | + | i.streaming.WriteString("0123456789") | |||
| 86 | + | i.gateToolLocked("t1") // gate 10, satisfied | |||
| 87 | + | ||||
| 88 | + | if !i.toolGateOpenLocked("t1") { | |||
| 89 | + | t.Fatal("precondition: tool should be open before reset") | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // Finalize the stream (mirrors resetStreamingStateLocked / pacer | |||
| 93 | + | // flush): buffer resets to 0 but gates are opened first. | |||
| 94 | + | i.openAllToolGatesLocked() | |||
| 95 | + | i.streaming.Reset() | |||
| 96 | + | i.streamOn = false | |||
| 97 | + | ||||
| 98 | + | if !i.toolGateOpenLocked("t1") { | |||
| 99 | + | t.Fatal("tool re-hidden after stream reset; gate should have been opened") | |||
| 100 | + | } | |||
| 101 | + | } | |||