Command Palette

Search for a command to run...

Hectal
PHASE 3Intermediate ~28 min· topic 2 of 7

Topic 3.2

Keeping a Partial Answer

In one line

An answer can end early for four reasons: the user stopped it, the model hit its length limit, the model was blocked, or the connection failed. Keep whatever arrived, label exactly why it is unfinished, and offer the next step that fits.

Think of it like this

A waiter brings half your order and says nothing. Is the rest coming? Did the kitchen run out? Did someone cancel it? A half answer with no label is the same puzzle: the reader cannot tell "finished" from "cut off", and a cut-off sentence can say something untrue.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

finish_reason
A field that says why the model stopped writing: stop (done), length (hit the token limit), content_filter (blocked), and others such as tool_calls.
max tokens
A limit on how long one answer may be. When the model reaches it, it stops mid-sentence and reports length.
Partial answer
The text that arrived before the answer ended early. Valuable, but incomplete.
Ending
The way an answer finished: finished, stopped, cut off, blocked or broken. It decides the label and the buttons.
Continue
A request that sends the partial answer back and asks the model to carry on from there.
Seam
The place where the old text and the continuation are joined. It may repeat words or start inside a word.
Content filter
A provider safety system that can refuse to produce or finish an answer.
Prompt tokens
The count of tokens you sent in a request. Continuing sends the whole conversation again, so this number grows.

Step by step

01Collect the finish reason, not only the text

The reading loop from Phase 2 collects delta.content. Add one line to remember finish_reason when it is not null. Without it the code cannot tell a finished answer from a length cut-off.

readSSE.js (changed lines)whole filejs
const choice = JSON.parse(line.slice(6)).choices[0];
yield { text: choice.delta.content || "", finish: choice.finish_reason }; // finish is null until the last chunk

02One function from ending to label

The first example runs six asks and prints how each ended. Two come from the user (a Stop with some text, a Stop before any text), one from the length limit, one from the content filter, one is a finish with an empty answer and one is a normal finish. label() is a single function that turns an ending into a badge and a sentence, so the wording lives in one place.

03Never show an unfinished answer as finished

The page renders the text and, underneath, a note for every ending except a normal finish. A reader should not have to guess. If the badge is missing the answer is complete.

Never show an unfinished answer as finisheddiagram
Rendering diagram…

04Continue, and the seam

The second example cuts an answer in the middle of the word "packaging", then sends it back with a Continue request. The stand-in model repeats part of what was already written, the way real models sometimes do. Joining with plain + leaves "packin its original packaging". A small join() that drops the repeated part fixes it.

Look at the prompt token numbers: the second request is much bigger than the first, because it carries the whole conversation plus the partial answer. (These counts come from the stand-in's estimate, but the growth is real.)

05Stopping before anything arrives

The last ask in the example stops on the first chunk, so the text is empty (the line above it, empty, is a different case: the model finished normally and sent nothing). label() returns a different sentence for it, and the page renders no empty bubble. This is the case that a quick demo never exercises and a real user finds in the first minute.

Try it yourself

  1. 1

    Forget the label

    In the first example, change the "length" line of label() so it returns { badge: "", note: "" }, and look at the page.

    The cut-off answer "…the item must be unopened" now sits on the page looking complete. A customer could take it as the whole return policy. The label is what makes a partial answer safe to show.

  2. 2

    Change the stop point

    Change the 25 in ["stopme", 25] to 5, then to 80.

    At 5 only the first word arrives before the Stop; at 80 the answer is shorter than the limit and finishes first, so the ending is stop and no badge appears. A Stop that arrives after the last token changes nothing.

  3. 3

    Continue twice

    In the second example, send a third request: another post([...]) with the joined text as the assistant message.

    The third prompt-token number is larger again. Every continue pays for the whole conversation, which is why a cap on continues is part of the design.

Code & diagrams

Six situations, honest labels

The last two lines are both a Stop: one after some text, one before any text. Same ending, different sentence. The blocked and the empty answers have no text at all, so the page shows only their notes.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

normal   | ending: stop | text: "Orders ship in 1 to 2 working days." | badge: (none)
long     | ending: length | text: "Our return policy has four parts. First, the item must be unopened" | badge: Cut off
blocked  | ending: content_filter | text: "" | badge: Blocked
empty    | ending: stop | text: "" | badge: Empty
stopme   | ending: stopped | text: "Orders ship in 1 to 2 working" | badge: Stopped
stopme   | ending: stopped | text: "" | badge: Stopped
Continue: the seam, the repeat and the cost

The first answer was cut inside the word "packaging". In a real app the stand-in is your provider, and the only line that changes is the model on your server. The prompt token numbers are the stand-in's estimate (about four characters per token): treat the growth as real and the exact numbers as illustrative.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

first answer: "Returns are free within 7 days. The item must be unopened, in its original pack"
finish: length | prompt tokens: 15
continuation: "in its original packaging, and you need the order number. Refunds reach your account in 3 to 5 days."
prompt tokens: 59 (the whole conversation is sent again)
joined naively:  "Returns are free within 7 days. The item must be unopened, in its original packin its original packaging, and you need the order number. Refunds reach your account in 3 to 5 days."
joined carefully: "Returns are free within 7 days. The item must be unopened, in its original packaging, and you need the order number. Refunds reach your account in 3 to 5 days."

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

Throw away the finish reason

In the first example, delete the line if (piece.finish) finish = piece.finish; and run it.

terminal
$ The console
── what you'll see ──
normal | ending: null | text: "Orders ship in 1 to 2 working days." | badge: (none)
long | ending: null | text: "Our return policy has four parts. First, the item must be unopened" | badge: (none)
blocked | ending: null | text: "" | badge: (none)
empty | ending: null | text: "" | badge: (none)
stopme | ending: stopped | text: "Orders ship in 1 to 2 working" | badge: Stopped
stopme | ending: stopped | text: "" | badge: Stopped

Myth vs fact

Myth

If the stream ended without an error, the answer is complete.

Fact

A length cut-off ends the stream normally. Only finish_reason tells you whether the model stopped by itself.

Myth

A partial answer is useless, so delete it on Stop or error.

Fact

The user has already read it, and often it contains what they wanted. Keep it and label it.

Myth

Continue always picks up exactly where the answer stopped.

Fact

The model may repeat words or start mid-word, and the request re-sends and re-bills the whole conversation. Join carefully and cap the number of continues.

Myth

finish_reason: "length" means the answer is too long for the screen.

Fact

It means the model hit the maximum tokens it was allowed to write (or the context limit). It says nothing about your layout.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Providers name finish reasons differently. OpenAI-compatible APIs use stop, length, content_filter and tool_calls; others use their own words (for example max_tokens or end_turn). Map them to your own small set of endings in one function, so the UI never depends on a provider's spelling.

  • ▸

    A better fix than Continue is often a bigger max_tokens limit, or asking for a shorter answer. Continue is the fallback for when the limit is a real ceiling. If the cause is a full context window, Continue will fail the same way, so check usage before offering it.

  • ▸

    When you store a stopped or cut-off answer in the history, decide deliberately whether to send it back to the model next turn. Sending a half answer as if it were complete can make the model treat the half sentence as settled. Many apps send it with a note, or drop it. Topic 4.3 returns to this.

  • ▸

    Do not log the partial text as a successful completion in analytics. Record the ending (stopped, length, blocked, broken) as a field, so "how many answers get cut off" becomes a number you can watch.

Remember this

  1. 1

    The problem this solves: After a Stop, a length limit or a broken connection you are holding some text and a question: what do you show? Deleting it wastes what the user already read. Showing it with no label makes an unfinished answer look finished. This topic gives a short list of endings, an honest label for each, and the one action that fits each.

  2. 2

    Every answer ends in exactly one of these ways. *Finished* (the model stopped by itself: finish_reason is "stop"). *Stopped* (the user pressed Stop). *Cut off by length* (finish_reason is "length": the model reached the maximum number of tokens it may write, or the conversation reached the model's context limit). *Blocked* ("content_filter": the provider withheld the answer). *Broken* (the connection failed or the stream never finished; Topic 3.3). Each needs a different sentence on screen.

  3. 3

    **finish_reason lives in the last chunk.** In a streamed reply, the final chunk before [DONE] carries choices[0].finish_reason; earlier chunks have null. A reader that only collects delta.content throws this information away, and then "cut off by length" looks exactly like "finished". Keep the value the moment you see it. If the stream ends with no [DONE] and no finish reason, the answer is unfinished by definition.

  4. 4

    Keep what arrived, and mark it. Leave the text visible and add a short note beside it, in words a person understands: *You stopped this answer*, *Mitra ran out of room before finishing*, *Mitra could not answer that. Try rephrasing.* Use a visible label and also role="status" so screen readers announce it. Store the ending with the message (a status field); Topic 4.1 makes that part of the message model.

  5. 5

    Edge cases that need their own code. Stopped before the first token: you have no text, so show *You stopped before Mitra answered*, never an empty speech bubble. A finished answer with empty text (a model can return nothing): say so instead of showing a blank. Cut off in the middle of a word or a Markdown code fence: the text is literally unfinished, which is one more reason to label it (Phase 6 shows how to render an unclosed fence).

  6. 6

    "Continue" and its limits. For a length cut-off you can offer a Continue button: send the conversation again with the partial answer as an assistant message and a new user message such as "Continue exactly where you stopped." It works, but remember the costs. The whole conversation is sent and billed again as input. The model may repeat a few words at the join, or start mid-word, so joining is more than a + b. And a conversation that reached the context limit will hit it again; Continue helps only when the limit was the length of one answer. Cap the number of continues, or the user can loop forever.

Explain it without notes

01

What are the ways an AI answer can end, and why does each need a different message?

02

Where do you find out that an answer was cut off by length, and what do you do with that?

03

What are the limits of a Continue button?

04

What should the screen show when the user presses Stop before any text has arrived?

Practice

01

Add a fifth ending to the first example: a stream that ends with no finish reason at all (the connection closed early). What label would you give it, and which button?

02

Write joinContinuation(partial, more) that handles the case where the model starts the continuation with a space and the partial already ends with one.

Trade-offs

  • ↔

    Showing every ending with its own wording is more work than one generic "incomplete" label, but it is the difference between a user who knows what to do and one who retries blindly.

  • ↔

    Keeping partial answers in history preserves context for the next question but risks the model building on a half sentence. Dropping them is safer for correctness and loses what the user read.

Done when you can

  • I can list the endings of an answer and the sentence each one deserves.

  • I can read finish_reason from the last chunk and use it.

  • I can show a cut-off, stopped or blocked answer so it never looks finished.

  • I can handle a Stop before the first token without showing an empty bubble.

  • I can build a Continue request and explain its cost and its seam problem.