handleStreamClosed drives what happens after one round's stream finishes: dispatch the next pending tool call, or, if none, finalize the turn and hand control back. A turn ends precisely when the assistant emits no tool calls; there is no loop tool to land on.
()
| 909 | // finish it's already 0 (applyDone folded the round into turnTokens at |
| 910 | // EventDone). But a Ctrl+C or error mid-stream interrupts before EventDone, |
| 911 | // so the cancelled round's tokens (often the whole generation) sit only in |
| 912 | // streamingEstimate; without this they'd vanish from the avg and the session |
| 913 | // total would drop backward. char/4 is the best count for a round that never |
| 914 | // reported usage. |
| 915 | m.turnTokens += m.streamingEstimate |
| 916 | m.sessionTokens += m.streamingEstimate |
| 917 | m.streamingEstimate = 0 |
| 918 | wall := time.Since(m.turnStart) |
| 919 | m.lastElapsed = wall |
| 920 | m.lastTokens = m.turnTokens |
| 921 | m.lastOutcome = outcome |
| 922 | avg := humanRate(m.turnTokens, wall) |
| 923 | if avg != "" { |
| 924 | avg = " · " + avg + " avg" |
| 925 | } |
| 926 | dbgWritef("turn_end", "%s · %s wall%s · session_total=%s", |
| 927 | humanTokens(m.turnTokens), wall.Round(time.Millisecond), avg, humanTokens(m.sessionTokens)) |
| 928 | m.turnStart = time.Time{} |
| 929 | m.turnTokens = 0 |
| 930 | } |
| 931 | |
| 932 | // handleStreamClosed drives what happens after one round's stream finishes: |
| 933 | // dispatch the next pending tool call, or, if none, finalize the turn and |
| 934 | // hand control back. A turn ends precisely when the assistant emits no tool |
| 935 | // calls; there is no loop tool to land on. |
| 936 | func (m Model) handleStreamClosed() (tea.Model, tea.Cmd) { |
| 937 | m.stream = nil |
| 938 | // Stale close from a cancelled turn (handleCtrlC / EventError reset phase |
| 939 | // to idle). |
| 940 | if !m.phase.active() { |
| 941 | return m, nil |
| 942 | } |
| 943 | if len(m.pending) > 0 { |
| 944 | // The model issued a tool call, genuine progress. Re-arm the empty-reply |
| 945 | // latch so a LATER transient empty on this same (long) turn earns its own |
| 946 | // re-prompt instead of hitting the leak-and-die branch below. The latch |
| 947 | // exists to stop a server that deterministically swallows EVERY call, not |
| 948 | // to cap recoveries on a turn that keeps advancing: a flaky stream that |
| 949 | // drops the occasional call must not abandon a half-built file (the galaxy1 |
| 950 | // failure: empty → nudge → recovered with a write → empty again → died). |
| 951 | // Two CONSECUTIVE empties still terminate: pending is 0 on that path, so |
| 952 | // this never re-arms there. |
| 953 | m.emptyNudged = false |
| 954 | return m.dispatchNextTool() |
| 955 | } |
| 956 | // The turn is ending with no tool calls. If the model said nothing and called |
| 957 | // nothing, it either stopped mid-task or its tool call was swallowed (a |
| 958 | // thinking model streams the call into the reasoning channel, which never |
| 959 | // reaches us as content or a structured call). Re-prompt once to re-issue or |
| 960 | // finish; the emptyNudged latch bounds it to a single retry so a server that |
| 961 | // deterministically swallows the call can't loop. If it persists, surface it |
| 962 | // rather than dying silently: the prior behaviour left a half-done artifact |
| 963 | // with no banner at all. |
| 964 | outcome := outcomeDone // a clean, non-empty finish; stall/leak below downgrade it |
| 965 | if newestAssistantEmpty(m.history) { |
| 966 | if !m.emptyNudged { |
| 967 | m.emptyNudged = true |
| 968 | dbgWritef("nudge", "empty-reply nudge injected (turn ended with no content and no tool call)") |