Topic 3.7
Build: Mitra Can Be Stopped and Recovers
In one line
Mitra gains a Stop button, honest labels for stopped, cut-off and broken answers, Continue, Try again, a stall watchdog and a rate-limit countdown. One status variable and one function decide what the screen shows for every ending.
Think of it like this
A market stall that has learned from its first busy day. It now has a sign that says "back in 5 minutes", a bell for the customer who changed their mind, and a rule for what to do when the supply runs short mid-order. The goods are the same. What changed is that nothing leaves the customer guessing.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- run()
- Mitra's single function that sends a request and decides what every possible ending means for the screen.
- reason
- A second value next to
statusthat says why an answer was cut:length,stalled,brokenorfailed. - MESSAGES
- One table that turns a status and reason into a sentence for the user, so wording lives in one place.
- continueFrom
- The partial answer sent back with a Continue request so the model can carry on.
- Watchdog
- A timer that gives up on a request that has gone quiet. Mitra has one for the first token and one for silence.
- Countdown
- The seconds left before a rate-limited Mitra may ask again, shown on the button.
- Scripted visitor
- A few lines at the end of the example that press the buttons for you, so you see each situation in turn.
Step by step
01Start from Phase 2's Mitra
Mitra has a form (#prompt, #send), a message area (#messages), an error line (#error) and a status line (#status). It streams from /api/chat. This phase keeps all of those ids and adds #stop, #continue, #retry and #regenerate.
02The server learns to misbehave on purpose
The route is the stand-in model with a few scripted cases so every scenario can be demonstrated: a question containing "policy" ends at its length limit, "busy" is refused once with Retry-After, and "stall" goes silent after two words the first time. In your real app none of this exists: the provider decides.
03The new request function
run() is the heart. Read it top to bottom once: controller and watchdogs first, then the request, then the branches for 429, status, finish and stream end, and finally the catch that tells a stall, a Stop and a failure apart. The finally clears the timers.
04Buttons come from the status
None of the buttons has its own flag. Each is shown by a condition on status and reason, so a state that has no button for an action cannot show one. The Try again button also reads wait, which makes it a countdown during a rate limit.
05Watch it run
The scripted visitor asks about shipping and stops the answer, asks again, asks about the policy (cut off, then Continue), asks something that gets a 429 and waits one second, and finally asks something that stalls, then presses Try again. The console shows each ending in order. Then ask your own questions: try the words policy, busy and stall.
Try it yourself
- 1
Ask your own questions
After the scripted visitor finishes, type "busy again". The first time the word is new, so it is refused once with a one-second countdown. Then type "policy" and press Continue.
Each situation takes a different path through
run(), and the buttons that appear are the ones that make sense for that ending. - 2
Remove the stall watchdog
In the Mitra example (the last one), delete the line
const first = setTimeout(...)and the body ofwatchdog(make it an empty function), then run and wait.The scripted visitor reaches the stall question and never finishes: the status stays
streamingand the last log line never appears. This is what a user sees on a dead stream without a watchdog. - 3
Press Send twice
While an answer streams, type another question and press Enter.
Nothing happens:
Sendis disabled andrun()returns at the first line while busy. To send a new question you press Stop first, which is the control Phase 3 added.
Code & diagrams
Ten situations, one table. Moving the rules into a function like this is how Phase 4 turns them into a reducer, and how Phase 13 tests them without a browser. Note the order: a stall is checked before a user stop, because the watchdog also uses abort.
Preview
Console
Nothing logged yet.Expected console output
normal answer -> done | keep partial text: true
empty answer, finished -> empty | keep partial text: false
stopped with text -> stopped | keep partial text: true
stopped before any text -> stopped | keep partial text: false
length limit -> cut/length | keep partial text: true
blocked -> blocked | keep partial text: false
connection dropped -> cut/broken | keep partial text: true
stalled -> cut/stalled | keep partial text: true
429 from server -> limited | keep partial text: false
500 from server -> error | keep partial text: falseA scripted visitor walks through five situations: Stop, Ask again, a length cut-off with Continue, a rate limit with its countdown, and a stalled stream with Try again. Then ask your own questions with the words policy, busy and stall. In a real app the stand-in is your provider, and the cases happen by themselves.
Preview
Console
Nothing logged yet.Expected console output
stopped, kept: "Orders ship in 1 to 2"
done: "Orders ship in 1 to 2 working days. You get a tracking link by email."
cut off by length, kept: "Returns are free within 7 days of delivery."
done: "Returns are free within 7 days of delivery. The item must be unopened and you need your order number."
rate limited, wait 1 s
done: "Orders ship in 1 to 2 working days."
stalled, kept: "Orders ship "
done: "Orders ship in 1 to 2 working days. You get a tracking link by email."Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Drop the idle watchdog
In the second example (the Mitra component, the last one on this page), change the watchdog line to const watchdog = () => {}; and run it.
Myth vs fact
Myth
A failed request is one case, and one message covers it.
Fact
Stop, length, stall, broken stream, rate limit and failure need different words and different buttons. One status plus a reason is enough to tell them apart.
Myth
Resilience code belongs in a library.
Fact
A library can do the plumbing (Phase 5), but you still decide what the screen says and offers. That decision logic is the same either way.
Myth
More flags make the screen more flexible.
Fact
More flags make impossible combinations. One status with a reason, and buttons computed from it, keeps the screen consistent.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
run()mixes three jobs: talking to the network, deciding the ending and updating state. In Phase 4 the decision part moves into a reducer so it can be tested without a browser, and the network part into a small client with the sameAbortControllerrules. - ▸
The stand-in server here is shaped by the test cases. On a real provider you cannot make a stall or a length cut-off happen on demand, so keep a debug switch (a header or a query parameter that only works in development) that asks your own route to simulate each failure.
- ▸
Every
setTimeoutinrun()is cleared infinally. That one habit prevents the most common bug in code like this: a timer from an old request firing into a new one. - ▸
Continue is a plain join here. Topic 3.2 showed the seam problem, and a production version would use the careful join and a cap on the number of continues.
Remember this
- 1
The problem this solves: Mitra from Phase 2 streams an answer well when everything works. Real use is messier: people press Stop, phones lose signal, the model is busy, and a stream sometimes goes quiet. This topic joins Topics 3.1 to 3.6 into one working component, so you can see how the pieces share state instead of fighting each other.
- 2
Where we start. Phase 2 left Mitra with the route
/api/chat(now the OpenAI-compatible stand-in),readSSE-style parsing, smooth rendering and one status variable. It could stream an answer, but it could not be stopped, could not tell a cut-off answer from a finished one, treated every failure alike, and waited forever on a silent stream. - 3
One status, many endings.
statusnow takes the valuesidle,waiting,streaming,done,stopped,cut,limitedanderror. Forcutanderror, a second valuereasonsays why (length,stalled,broken,failed). One table of messages (MESSAGES) turns the pair into a sentence. The buttons are computed from the same pair: Stop while busy, Continue after a length cut-off, Try again after a break or a rate limit, Ask again after a finish or a stop. - 4
One function does the work.
run(question, { continueFrom })creates a freshAbortController, arms two watchdogs (no first token, then silence), sends the request and branches in a single place: a 429 starts the countdown, a non-OK status is a failure,finish_reason: lengthis a cut-off, a missing[DONE]is a broken stream, a stall is a stall, anAbortErroris a Stop. Thefinallyblock clears both timers on every path. - 5
Every scenario this version handles. *Stop* after some text and before any text. *Ask again* after a stop. *Length cut-off*, then *Continue* (the text is joined, not replaced). *Rate limit* with a countdown and a locked button. *Stall*: the watchdog gives up, the partial text stays, and Try again works. *Failure before any text* shows a plain message. *Double submit* is refused while busy. *Empty input* keeps Send disabled.
- 6
What Mitra can do now that it could not before: be stopped, say exactly how an answer ended, keep what arrived, continue a cut-off answer, wait out a rate limit and stop waiting on a dead stream. What is still missing, and the phase that adds it: it still holds one question and one answer, so there is no conversation or saved history (Phase 4); the plumbing is hand-written (Phase 5 replaces it with the AI SDK); answers are plain text, not formatted (Phase 6); no cards, tools or approvals (Phases 7 and 8); and protection against hostile content, budgets and tests come in Phases 9 to 13. The Continue join here is simple concatenation (Topic 3.2 shows the careful version).
Explain it without notes
Why does Mitra keep a reason next to status?
Walk through what run() does when a stream stalls after two words.
Which buttons should be visible after each ending, and why?
What does Mitra still lack at the end of this phase?
Practice
Add a cap of two Continue presses per answer to Mitra. After the second, hide the Continue button and show "This answer is too long to continue."
Make the Stop button also appear during the limited countdown, with the label "Cancel", which clears the countdown. What should status become?
Trade-offs
- ↔
One big
run()is easy to read in a lesson and gets hard to test as cases grow. Phase 4 splits it, at the price of more files and a reducer to learn. - ↔
Showing every ending precisely helps users and support, and costs a message table and a few more branches. For a small internal tool, fewer endings may be fine; for a customer-facing assistant, spell them out.
Done when you can
I can explain how
statusandreasontogether decide the message and the buttons.I can follow
run()from the click to each of its endings.I can say what the stand-in server simulates and what a real provider does instead.
I can list what Mitra cannot do yet and the phase that adds it.
I can add a new ending without breaking the others.