Turns and streaming
A turn is one user input, answered once. It streams live events while it runs, settles with a reply or an error, and leaves a few entries in the session's journal.
input ─▶ accepted ─▶ responder starts ─▶ live events stream ─▶ reply or error settles ─▶ journal records the outcome
The responder that answers is covered in Responders, and the journal in Sessions and stores.
A turn
import { createChat, type ChatStore, type Responder } from "june/chat";
declare const responder: Responder;
declare const store: ChatStore;
const chat = createChat({ responder, store });
const session = await chat.createSession();
const { text } = await session.send("What is June?").result;
send() starts the turn right away, exactly once. Awaiting turn.result gives you the reply without reading any events; the turn runs either way.
Turns in a session run one at a time. A turn sent while another is running waits for it, so it always sees the previous reply:
import type { Session } from "june/chat";
declare const session: Session;
await session.send("Design an auth API.").result;
await session.send("Make it smaller.").result;
Streaming
A turn is also an async iterable of live events, for showing progress while it runs:
import type { Session } from "june/chat";
declare const session: Session;
const turn = session.send("What is June?");
for await (const event of turn) {
if (event.type === "text-delta") process.stdout.write(event.text);
}
const { text } = await turn.result;
Events are how a caller watches a turn; the journal is what survives it. Events are never stored, so a reply that streamed and was then canceled is visible in the events you received but isn't recorded. Iterate the turn, await turn.result, or both. A turn's events can be read by one iterator, which gets every event from the start even if it begins late.
A turn yields ChatEvents, in order:
turn-start, with thesessionIdandturnId, once the input is accepted.text-delta, with a piece of the reply'stext, as a responder streams it. Model chats stream every reply this way.run-update, with a snapshot of the tracetree, each time a workflow turn's run commits.approval-request, with the tool call'srequest, when a workflow step asks to run a tool that needs approval.turn-end, with the turn as it now stands, once it ends: completed, failed, canceled or interrupted.
New event types may be added, so ignore the ones you don't handle.
turn.result resolves with the recorded reply: sessionId, turnId, text, the turn's metadata, and, for workflow turns, the run behind it.
Cancel
Canceling stops the current turn; it has no effect on later ones.
turn.cancel(reason) aborts the responder: its signal aborts, a model request is aborted, and a workflow's run is aborted with every step in flight. The turn ends as canceled right away, even if the responder ignores the signal, and turn.result rejects with reason, or an AbortError without one. Any text that streamed before the cancel stays visible in the live events you already received, but it's never recorded as a reply: a canceled turn has no output, isn't in messages(), and isn't part of later context.
A turn canceled before it's accepted writes nothing. Once the responder has returned its reply and June has started recording it, canceling has no effect and the turn completes.
Steer
Steering adds input to the current turn while it's being answered, say a correction mid-way through a long task. It's recorded on that turn, so if the turn completes, later turns see it too as part of the conversation.
import type { Session } from "june/chat";
declare const session: Session;
const turn = session.send("Draft the migration plan.");
await turn.accepted;
await session.steer("Assume we can't take downtime.");
await turn.result;
Steering is a capability, not a promise. It only works when the running turn's responder implements steer() on its execution, and session.steer() rejects when no turn is running or its responder can't steer. The built-in model and agent responders don't: neither the AI SDK nor June's runtime has a safe point to add input to a call that's already been sent, and June won't pretend by rewriting a prompt after the fact.
A custom responder accepts steering by implementing steer(input) on its execution, resolving once it has taken the input into account or throwing to refuse it. Only input the responder accepts before its reply is in is recorded: a turn.steered entry on the same turn, written before the turn's outcome, so the journal shows why the reply changed. Steering that arrives after the reply, or after a cancel, is refused. session.steer() waits for the turn's responder to start, so it works right after turn.accepted. It belongs to that turn rather than becoming a turn of its own: the turn's steering in turns() lists it, and in messages() and later context it appears as an extra user message between the turn's input and its reply.
When a turn doesn't complete
| Status | What happened | What the caller sees | What the journal records | Retry? |
|---|---|---|---|---|
failed | the responder, or a workflow step no <Catch> handled, threw | turn.result rejects with the error | turn.failed, with the error | yes, a new turn |
canceled | someone called turn.cancel() | turn.result rejects with the reason | turn.canceled, with the reason | yes, a new turn |
interrupted | the process stopped, or the outcome couldn't be written | the turn, in this process or when a session is reopened; turn.result rejects if the outcome couldn't be written | nothing: turn.accepted has no outcome | yes, a new turn |
None of them is a reply, so none reaches later context. Each stays in turns() and the journal, so you can see what was asked and why it wasn't answered. Side effects, such as a workflow's tool calls, come from the responder, and may have happened before any of these.
Retry
A retry runs the work again. Model calls, workflow steps and tools all run again from the start, side effects included. June never retries on its own.
session.retry(turnId) answers a failed, canceled or interrupted turn's input again, as a new turn:
import type { Session } from "june/chat";
declare const session: Session;
for (const turn of session.turns()) {
if (turn.status === "interrupted") {
const retry = session.retry(turn.id);
console.log((await retry.result).text);
}
}
- It starts from the beginning: a new turn, a new model call or a new workflow run. Nothing resumes where the original stopped, because June doesn't checkpoint turns in flight.
- The original turn stays as it was, with its own entries. The new turn's
turn.acceptedentry recordsretryOf, andturns()shows it asretryOfon the new turn. - Completed turns can't be retried; send the input again if you want another answer.
- Each call is a new attempt: retrying the same turn twice makes two retries, both linked to it.
retry()can only tell a turn is still running if thisSessionobject is running it: a turn another process is still answering reads asinterruptedhere, so don't retry it.
Side effects happen again
June Chat doesn't guarantee exactly-once anything. When you retry a turn:
- its model calls run again, and are billed again;
- a workflow's steps and tools run again from the start, including ones that already ran before the process stopped: a deploy, an email, a payment;
- an interrupted turn may have done any part of its work before it stopped, and June can't tell how much.
So retry deliberately. Make tools that change the world idempotent, for example by passing an idempotency key derived from the turn id, or ask before retrying a turn whose tools have side effects. Interrupted agent turns are never retried for you.