Session Control and Long Tasks: Stopping, Retrying, Branching, and Subagents
Turn one-shot question and answer into a session people can actually work in: stop mid-run, retry after failure, edit and resend the last turn, branch from any point. Then handle progress for long tasks, subagent visualization, and how to resume a cut stream without producing duplicate messages.
Today's Goals
- Implement stopping with AbortController, and say what the server still has to do after the client aborts
- Implement edit-and-resend and session branching, and explain why a branch tree fits agent sessions better than linear history
- Handle resumption after a cut stream, and explain why deduplication keys on message id rather than content
Everything so far has lived within a single run. Today we handle the relationships between runs, which is what separates a session from a one-shot question. When you finish, scroll back up and tick the three goals off.
Plain-Language Walkthrough
Stopping: cutting the interpreter's mic while the venue stays powered
If the organizers call a halt, the interpreter mutes immediately. But that only means you stop hearing them — the speaker is still talking, the recorders are still rolling, the venue is still burning electricity.
AbortController is that mute button.
const controller = new AbortController()
await fetch('/api/agent', { signal: controller.signal, ... })
// user clicks stop
controller.abort()abort() closes the client's side of the connection. The server's run keeps going, and tokens keep burning.
This matters directly for cost: on a three-minute run where the user stops at ten seconds, an abort-only implementation still pays for the remaining two minutes and fifty seconds.
Actually stopping takes two things
Correct stopping looks like this:
export async function stopRun(controller: AbortController, runId: string) {
controller.abort() // UI responds immediately
await fetch('/api/agent/cancel', {
// the server actually stops
method: 'POST',
body: JSON.stringify({ runId }),
// note: do NOT reuse the already-aborted signal here
})
}Each half is necessary: abort() makes the UI respond instantly, and the cancel request ends the server's run so the bill stops.
One more detail: a deliberate stop is not an error. Aborting fetch throws an AbortError, and if that falls into your generic error handling the UI pops a red failure telling the user something went wrong — when all they did was press stop.
catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
setRunState('idle') // back to idle, no error
} else {
setErrorText(...)
}
}This is the same judgment as day three's "a rejection is not a failure", applied a second time: a user's choice, whatever it is, is not a fault.
Resumption: the server will always re-send some events
A stream breaking midway is normal, not exceptional: network hiccups, proxy timeouts, a backgrounded tab whose connection the OS reclaimed.
To resume you reissue the request and tell the server how far you got. It continues from there.
But there is an unavoidable fact: the server's replay boundary cannot be exact. Its recorded progress might be "event 12" while you actually received half of event 12. To avoid losing content, it must send a few extra — duplication is inevitable.
So deduplication is the client's job, unavoidably.
Deduplicate by identifier, never by content
This is the easiest thing to get wrong today.
Give every event a monotonically increasing sequence number (produced by the server, carried in the event's metadata). The client remembers the highest it has seen and sends it when resuming:
accept(event: BaseEvent): boolean {
const seq = event.metadata?.seq
if (typeof seq === 'number') {
if (seq <= this.lastSeq) return false // replayed, skip
this.lastSeq = seq
return true
}
// fall back to identifier dedup when there is no sequence number
...
}Why not deduplicate by content — serialize the event and skip anything already seen?
Because a model can legitimately emit two identical consecutive deltas: two spaces, a repeated punctuation mark, the same word twice. Content-based dedup eats those.
The symptom is text that mysteriously loses a few characters, and it is nearly impossible to reproduce — you would have to hit exactly the moment the model repeated itself. Debugging that costs enormously.
There is another layer: delta events cannot be deduplicated by identifier either. All deltas of one message share a messageId, so identifier-based dedup would keep only the first character of the whole message. Classify your keys: idempotent events (start, end, tool result) dedupe by identifier; delta events rely on sequence numbers and pass through when there are none.
Retrying without causing side effects twice
Stopping done, its neighbor is retrying.
Retrying after failure sounds simple: the request died, send it again. But agent scenarios carry a particular risk: the previous run may already have caused side effects.
Picture a run that called a "send email" tool. The tool succeeded, and then the stream died. The user sees a frozen interface and hits retry. If your retry resends the turn verbatim, the model decides to send the email again, and the same message goes out twice.
So retries must distinguish where the failure happened:
- The request never left (network error, DNS failure): safe, resend as-is
- The stream died mid-run with tool results already returned: unsafe, resending re-executes those tools
- The server returned an error: depends on the kind — an unavailable model service is retryable, a validation failure will fail identically every time
For the second case, the right move is not resending the turn but resuming — carrying the tool results you already have back as context so the model continues from the break rather than re-deciding from scratch. That is the other purpose of the resumption mechanism above: not just saving bandwidth but avoiding repeated side effects.
If your backend cannot resume, the fallback is giving side-effecting tools an idempotency key: the same key executes once and repeat calls return the previous result. An old distributed-systems technique that applies directly here.
An actionable rule: assume any tool that changes the outside world may be called twice.
A session is a tree, not a line
Now the other half of today.
Suppose the user wants to revise something three turns back. If history is an array, you have exactly one option: discard everything after it. One edited sentence costs a dozen turns of conversation.
And what they usually want is to compare two phrasings.
Switch history to a tree and this falls out naturally:
// edit-and-resend = branch from this turn's parent, i.e. replace this turn
export function editAndResend(tree, nodeId, newText) {
const node = tree.nodes[nodeId]
return branchFrom(tree, node.parentId, newText)
}The old branch stays intact, hanging off the same parent. The UI shows "2 / 3" with arrows so the user can switch and compare.
Rendering walks only the path from root to the active leaf; other branches wait in the background. That is the entire complexity of the structure — modest, and it solves a real problem.
Long tasks: show steps, not a fake percentage
Agents running for minutes are normal, and the interface cannot just spin for that long.
AG-UI uses STEP_STARTED and STEP_FINISHED to split a run into named stages, which the frontend renders as a visible progress row:
✓ break down task ● gather in parallel write summary
One deliberate decision: do not draw a percentage bar.
You do not know the total number of steps — the agent may decide on two more tool calls mid-run. Pretending otherwise produces a bar that stalls at 90%, and that is worse than no bar at all. Showing completed steps plus the current one is honest and useful.
Subagents: group by identifier, because events interleave
Complex tasks often spawn subagents working in parallel. SUBAGENT_STARTED announces a subrun, and events belonging to it carry a subagentRunId.
The key point is that two subagents' events arrive interleaved:
sub-a chunk 1 → sub-b chunk 1 → sub-a chunk 2 → ...
Assembling by arrival order will cross the wires. You must group by subagentRunId.
That is the fourth time this rule has come up: messages by messageId, tools by toolCallId, generative payloads by their own ordering, subagents by subagentRunId.
Everything in a streaming protocol merges by identifier, never by order.
Remembering that is worth more than memorizing any specific event name.
A debt from daily accretion, and how it was paid
Finally, a real problem from this course's own lab, because it is more common than any technical point.
The server route's script selection started as an if-chain on day one and gained a branch every day. By day six a bug appeared: one inject value had been assigned two meanings, making the second branch permanently unreachable.
That bug only surfaced because TypeScript's type narrowing happened to flag it — with a plain string it would have been completely silent.
The fix was replacing the chain with a lookup table, where duplicate keys are impossible at the syntax level.
That is the characteristic cost of daily accretion: each day looks reasonable on its own, and by day six it breaks. In your own projects, the places where you keep "just adding a branch" deserve a periodic second look.
What to persist, and when
Everything today lives in memory, which means a refresh loses it. Real products have to decide what survives.
The useful distinction is between the session tree and the run state. The tree is durable content: turns, messages, tool results, branches. It belongs in storage, and reloading should reconstruct it. Run state — which run is in flight, the current step, the resume cursor — is ephemeral, and reconstructing it after a refresh is usually wrong. A run that was mid-flight before the reload should come back as "interrupted", giving the user the resume affordance rather than silently reconnecting.
Silently reconnecting seems friendlier and is not: the user has no idea a run is consuming tokens on their behalf.
Storage choice follows from how much you keep. Per-viewer convenience such as the active branch fits browser storage; anything the user expects to find on another device belongs on the server. The tree structure makes this easier than it sounds, since nodes are append-only and branches are never mutated in place — you are persisting an ever-growing log rather than reconciling edits.
Source Reading
Hands-On Lab
Today's lab is the heaviest of the course. Suggested order: the session tree first (pure data structure, easy to verify), then deduplication (best covered by the selftest), and finally stopping and UI assembly.
- Implement stopping with AbortController and leave a visible trace of the interruption
- Implement edit-and-resend and retry, verifying no duplicate messages appear
- Convert session history to a tree and implement branch switching
- Cut a stream, then implement resumption and deduplication
- Render subagents in their own areas, grouped by subagent run id
- Render long-task progress from run-stage events
Interview Questions
Four questions below, focused on the client-server choreography of stopping, the data structure behind session branching, and resumption with idempotency. Open each one and read the analysis before the answer points — practicing the derivation beats memorizing bullets. The "common in China / common globally" tags let you filter by target market.
Checklist and Tomorrow
- Implement stopping with AbortController, and say what the server still has to do after the client aborts
- Implement edit-and-resend and session branching, and explain why a branch tree fits agent sessions better than linear history
- Handle resumption after a cut stream, and explain why deduplication keys on message id rather than content
- Explain why long tasks should not display a percentage progress bar
- All 6 lab acceptance criteria pass
- Answer at least 3 of the 4 interview questions without looking at the answer points
Tomorrow (D7) is the last day, covering two things we have been deferring: accessibility and performance budgets. The accessibility half overturns a very natural intuition — pointing a live region at the streaming element is both the most common and the most wrong approach for screen readers. With those two done, seven days of work becomes a project that belongs in a portfolio and survives a code review.
Interview questions
The user clicks stop. Is calling AbortController enough on the frontend? What does the server still need to do?用户点了停止,前端调用中止控制器就够了吗?服务端还需要做什么?
Common in ChinaCommon overseasIntermediate#abort-controller#cost-control#streamingHow to reason about it · think before answering
- The discriminator is whether you see 'the UI stopped' and 'the bill stopped' as two different things. Answering only 'call abort' usually means you have not worked on a cost-sensitive product.
- State the fact: `AbortController.abort()` closes **the frontend's side** of the connection. The server's run keeps going and keeps burning tokens. On a three-minute run stopped at ten seconds, an abort-only implementation still pays for the remaining two minutes fifty.
- So do both: abort for immediate UI response, plus an explicit cancel request so the server actually ends the run, typically by flagging it so the run loop exits at its next check.
- Some runtimes detect a closed connection and terminate on their own, but **do not rely on it**: behind a gateway, a load balancer, or server-side buffering, that signal may never reach the run loop. Explicit cancellation is the only reliable path.
- Raise the easy trap unprompted: the cancel request **must not reuse the already-aborted signal**, or it is cancelled before it is sent. The bug is nearly invisible because the UI behaves correctly — only the bill reveals it.
- Expect the follow-up on what the UI shows after stopping: aborting `fetch` throws an AbortError, and **it must not be rendered as an error**. The user pressed stop; showing a red failure is wrong. Same judgment as 'a rejected tool call is not a failure': a user's choice is not a fault.
分析过程 · 先想清楚再作答
- 这题的区分度在于你有没有意识到「界面停了」和「账单停了」是两件事。只答「调 abort」的人通常没做过成本敏感的项目。
- 先说清楚事实:`AbortController.abort()` 关闭的是**前端这一侧**的连接。服务端那边的运行还在跑,token 还在烧。一个跑三分钟的 Agent,用户第十秒点停止,只做 abort 的话剩下两分五十秒的费用照付。
- 所以正确做法是两件事一起做:abort 让界面立刻响应,另外发一个显式的取消请求让服务端真的结束这次运行。服务端收到后给那个运行打取消标记,由运行循环在下一步检查时退出。
- 有些运行时能感知连接关闭并自动终止,但**不能依赖它**:经过网关、负载均衡、或者服务端有缓冲时,连接关闭的信号可能根本传不到运行循环。显式取消是唯一可靠的。
- 一个很容易踩的坑值得主动说:取消请求**不能复用同一个已经 abort 的 signal**,否则它在发出前就被取消掉了。这个 bug 极隐蔽,因为界面表现完全正常(确实停了),只有账单会告诉你真相。
- 可预期的追问是「打断后界面该显示什么」——`fetch` 被 abort 会抛 AbortError,**不要把它当错误渲染**。用户只是点了停止,弹一个红色报错说「出错了」是错的。这和「用户拒绝工具调用不是失败」是同一条判断:用户的选择不是故障。
Key points
- Abort closes only the client side; the server run continues and keeps consuming tokens.
- Do both: abort for instant UI feedback, plus an explicit cancel request so the server actually stops.
- Do not rely on the server noticing a closed connection; gateways and buffering can swallow that signal.
- Never reuse the aborted signal for the cancel request, or it is cancelled before sending.
- Do not render AbortError as a failure; a deliberate stop is not a fault.
答题要点
- abort 只关闭前端这一侧的连接,服务端的运行还在跑、token 还在烧。
- 正确做法是两件事一起做:abort 让界面立刻停,显式取消请求让服务端真的结束。
- 不能依赖服务端自动感知连接关闭,经过网关或有缓冲时那个信号可能传不到。
- 取消请求不能复用已 abort 的 signal,否则它在发出前就被取消,界面正常但账单不停。
- AbortError 不要当错误渲染,用户主动打断不是故障。
Should conversation history be an array or a tree? How do edit-and-resend and branching affect that choice?会话历史用数组还是用树?编辑重发和分支这两个需求会怎么影响你的选择?
Common in ChinaCommon overseasIntermediate#data-structure#session-management#uxHow to reason about it · think before answering
- This tests deriving a data structure from requirements. Answering 'a tree' without the why does not survive 'why not an array?'
- Name the array's dead end: if the user wants to revise something three turns back, an array leaves exactly one option — **discard everything after it**. One edited sentence costs a dozen turns.
- But the user's actual intent is usually to **compare two phrasings**, not to throw away what followed. An array fundamentally cannot express that.
- A tree makes it natural: edit-and-resend branches from that turn's **parent**, leaving the old branch intact under the same parent. The UI shows '2 / 3' with arrows, and rendering walks only the path from root to the active leaf.
- Call out the line most easily got wrong: when attaching the new node, append to the parent's children rather than replacing them. Replacing silently degrades the tree back to linear history with no visible symptom, and silent degradation is the worst kind.
- Expect the follow-up on what a tree complicates: scroll position, search highlighting, and exporting all have to answer 'which path?'. So the deciding factor is whether the product needs branching at all. For one-shot Q&A, an array is fine, and adopting a tree early buys complexity you will not use.
分析过程 · 先想清楚再作答
- 这题考的是从需求推数据结构的能力。直接答「用树」而不说为什么,接不住「数组不行吗」的追问。
- 先把数组方案的死角说清楚:用户想改三轮前的一句话重新问,数组方案只有一个选择——**丢掉后面的全部内容**。用户改一句话,代价是十几轮对话没了。
- 而用户的真实意图往往是**对比两种问法的结果**,不是把后面的都不要了。数组结构从根本上表达不了这个意图。
- 树的模型下这件事很自然:编辑重发等于从这一轮的**父节点**分叉出一个新分支,旧分支完整保留挂在同一个父节点下。界面上用「2 / 3」加左右箭头切换。渲染时只画从根到当前活动叶子的那条路径。
- 实现上最容易写错的一行值得说出来:把新节点挂到父节点下时,父节点原有的 children 不能覆盖只能追加。写成覆盖就悄悄退化成线性历史了,而界面上什么异常都看不出来——这种静默的退化最危险。
- 可预期的追问是「树会不会让别的功能变复杂」——会。滚动定位、搜索高亮、导出会话都要处理「哪条路径」的问题。所以判断依据是产品到底要不要分支:只做一次性问答的场景,数组完全够用,不要为了显得完备提前上树。
Key points
- With an array, edit-and-resend must discard everything after it, though users usually just want to compare phrasings.
- A tree branches from the parent, keeping the old branch intact and switchable.
- Render only the path from root to the active leaf; other branches sit in the background.
- Append rather than replace the parent's children, or the tree silently degrades to linear history.
- Trees complicate scrolling, search, and export; arrays are fine for one-shot Q&A.
答题要点
- 数组方案下编辑重发只能丢掉后面全部内容,而用户往往只是想对比两种问法。
- 树的模型下编辑重发是从父节点分叉,旧分支完整保留可切换对比。
- 渲染只画从根到活动叶子的那条路径,其余分支在背景待着。
- 实现要点是挂新节点时追加而不是覆盖父节点的 children,覆盖会静默退化成线性。
- 树会让滚动定位、搜索、导出变复杂,只做一次性问答时数组完全够用。
A stream breaks midway and the client reconnects to resume. How do you prevent duplicate messages?流断在一半,前端重连续播,怎么保证不出现重复的消息?
Common in ChinaCommon overseasDeep dive#resumption#deduplication#streamingHow to reason about it · think before answering
- The key is not the dedup mechanism but recognizing that **duplication is inevitable**, and what choosing the wrong dedup key costs you.
- Why it is inevitable: on resume the client says where it got to, but the server's recorded progress cannot be byte-exact — it may have logged 'event 12' while you received half of event 12. To avoid losing content it must replay a few extra. Deduplication is therefore unavoidably the client's job.
- The mechanism: give every event a **monotonically increasing sequence number** produced by the server. The client tracks the highest it has seen, sends it on resume, and drops anything at or below it.
- Then the critical rule: **never deduplicate by content**. Serializing events and skipping ones you have seen looks convenient, but a model can legitimately emit two identical deltas — two spaces, a repeated punctuation mark, the same word twice. Content-based dedup eats those, producing text that mysteriously loses characters, nearly impossible to reproduce.
- One more easily missed layer: **delta events cannot be deduplicated by identifier either**, since all deltas of a message share one messageId and you would keep only the first character. So classify your keys: idempotent events like start, end, and tool results dedupe by identifier; deltas rely on sequence numbers and otherwise pass through.
- Expect the follow-up on server support: persist an event log and support replay from a sequence number. Failing that, make the whole run idempotently re-runnable, though that is expensive for long tasks.
分析过程 · 先想清楚再作答
- 这题的关键不在「怎么去重」,而在你有没有意识到**重复是必然的**,以及去重的口径选错会造成什么。
- 先说为什么必然重复:续播时前端告诉服务端「我收到哪了」,但服务端记录的进度不可能精确到字节——它记的可能是第 12 条事件,而你实际收到的是第 12 条的一半。为了不丢内容,它只能往前多发几条。所以去重是前端跑不掉的责任。
- 做法是给每个事件一个**单调递增的序号**,由服务端产生。前端记住收到的最大序号,续播时带上它,收到序号小于等于它的事件就跳过。
- 然后是最关键的一条:**绝不按内容去重**。把事件序列化成字符串、见过就跳过,看起来很省事,但模型完全可能连续吐出两个一模一样的增量——两个空格、重复的标点、同一个词说两遍。按内容去重会把这些合法的重复吃掉,表现是正文莫名其妙少字,而且极难复现,排查成本极高。
- 还有一层容易漏:**增量类事件也不能按标识去重**。同一条消息的所有增量共用一个 messageId,按标识去重会让整条消息只剩第一个字。所以去重键要分类——开始、结束、工具结果这类幂等事件按标识去重,增量类靠序号,没有序号时一律放行。
- 可预期的追问是「服务端该怎么配合」——保存事件日志并支持从某个序号之后重放。如果做不到,退而求其次是让整次运行可重跑且结果幂等,但那对长任务代价太大。
Key points
- Duplication is inevitable since the server's replay boundary cannot be exact and it must over-send to avoid loss.
- Deduplicate with a server-issued monotonic sequence number, tracked client-side and sent on resume.
- Never dedupe by content: models legitimately emit identical consecutive deltas, and content dedup silently drops text.
- Deltas also cannot dedupe by identifier, since all deltas of one message share a messageId.
- Classify keys: identifier for idempotent events, sequence numbers for deltas, pass through when neither applies.
答题要点
- 重复是必然的:服务端的重放边界不可能精确,为了不丢内容只能多发几条。
- 用服务端产生的单调递增序号去重,前端记住最大序号并在续播时带上。
- 绝不按内容去重:模型会合法地连续吐出相同增量,按内容去重会让正文莫名少字。
- 增量类事件也不能按标识去重,同一条消息的增量共用 messageId。
- 去重键要分类:幂等事件按标识,增量靠序号,没有序号时放行。
An agent runs a three-minute task. How do you present progress in the UI?Agent 跑一个三分钟的长任务,界面上你会怎么表现进度?
Common in ChinaCommon overseasIntermediate#long-running-tasks#progress-ui#uxHow to reason about it · think before answering
- It looks open-ended but has one clearly wrong answer that costs you points: a percentage progress bar.
- Why not: **you do not know how many steps there are**. The agent may decide on two more tool calls, or converge early. Pretending otherwise yields a bar that stalls at 90%, which is worse than no bar — users conclude it hung, and stop trusting your progress indicators generally.
- The right approach shows **completed steps plus the current one**. The protocol has run-stage events with names; render them as a row where finished steps are ticked, the current one is highlighted, and nothing ahead is promised. That is honest and far more informative than a number, since the user can see whether it is 'gathering data' or 'writing the summary'.
- If the task spawns subagents, surface their individual progress too, grouped by subagent run id, since multiple subagents' events interleave and order-based assembly will cross the wires.
- One addition worth raising: **long tasks must account for an absent user**. Three minutes is plenty of time to switch away, so either support background continuation with a completion notification, or restore state correctly when the tab returns to the foreground. Foreground-only progress assumes the user is watching.
- Expect a follow-up on estimated time remaining: same answer, avoid it unless you have reliable historical data. An inaccurate ETA does the same damage as an inaccurate percentage.
分析过程 · 先想清楚再作答
- 这题看着开放,其实有一个明确的错误答案,答了就减分:画百分比进度条。
- 为什么不能画:**你不知道总共有几步**。Agent 可能中途决定多做两轮工具调用,也可能提前收敛。假装知道的结果是一个走到 90% 就卡住的进度条,而那比没有进度条更伤信任——用户会觉得程序挂了,而且以后再也不信你的进度条。
- 正确做法是显示**已完成的步骤加当前步骤**。协议层有运行阶段事件,每个阶段有名字,前端渲染成一行:已完成的打勾,当前的高亮,后面的不预告。这是诚实的,而且信息量比一个数字大得多——用户知道它在「收集数据」还是在「汇总成文」。
- 如果任务会派子 Agent,还要把它们各自的进展显示出来。归组靠子运行标识,因为多个子 Agent 的事件是交错到达的,按顺序拼一定串台。
- 有一个补充手段值得提:**长任务要考虑用户不在场的情况**。三分钟足够用户切走去干别的,所以要么支持后台继续并在完成时通知,要么在页面回到前台时能正确恢复显示。只做前台可见的进度,等于假设用户会盯着看。
- 可预期的追问是「那要不要显示预计剩余时间」——同理不要,除非你有可靠的历史数据做估算。不准的剩余时间和不准的百分比是同一类伤害。
Key points
- Avoid percentage bars: you do not know the step count, and stalling at 90% is worse than no bar.
- Show completed plus current steps from the protocol's run-stage events, promising nothing ahead.
- Surface each subagent's progress grouped by subagent run id, since their events interleave.
- Account for the user leaving: continue in the background with a notification, or restore correctly on return.
- Skip unreliable time estimates for the same reason as unreliable percentages.
答题要点
- 不要画百分比进度条:你不知道总共几步,卡在 90% 比没有进度条更伤信任。
- 显示已完成步骤加当前步骤,用协议的运行阶段事件,后面的不预告。
- 有子 Agent 时各自显示进展,按子运行标识归组,因为事件是交错到达的。
- 长任务要考虑用户切走:支持后台继续并通知,或回前台时正确恢复。
- 同理不要显示不准的预计剩余时间,除非有可靠的历史数据。