Repository navigation
apply recovery to Think.chat() #1429
Description
Parent roadmap: #1439
Summary
Think.chat() is the sub-agent/RPC entry point for driving a Think agent from another agent. It currently runs a full turn and streams chunks through StreamCallback, but it does not appear to be wrapped in the same chatRecovery / runFiber() machinery as the other Think turn paths.
That leaves the sub-agent chat() path less durable than the rest of Think, even though sub-agent orchestration is one of the main Project Think use cases.
Current state
The durable chat recovery path already exists for other turn paths:
saveMessages()wraps its programmatic turn body inrunFiber()whenchatRecoveryis enabled.continueLastTurn()wraps continuation work inrunFiber().- WebSocket chat requests wrap the normal chat turn body in
runFiber(). - Auto-continuation wraps continuation work in
runFiber(). _handleInternalFiberRecovery()recognizes internal chat fibers named withThink.CHAT_FIBER_NAME, reconstructs partial stream state, callsonChatRecovery(), persists/finishes orphaned streams, and schedules_chatRecoveryContinue().
Think.chat() currently has its own direct path:
- creates a request id
- enqueues work in
_turnQueue - appends the user message to
this.session - calls
_runInferenceLoop({ signal: options?.signal, callerTools: options?.tools }) - iterates
result.toUIMessageStream() - streams chunks through
callback.onEvent(...) - accumulates and persists the assistant message
- calls
callback.onDone()/callback.onError(...)
Because that path does not use runFiber(), a process eviction during chat() can lose the active model/tool turn rather than entering the same recovery path as other Think turns.
There is also a docs mismatch: docs/think/sub-agents.md already describes the desired end state, saying that all four turn paths, including sub-agent chat() RPC, are wrapped in runFiber(). The implementation should either be brought up to that behavior or the docs should be corrected. The roadmap assumes the implementation should be fixed.
Why this matters
Sub-agent chat() is the simple orchestration primitive:
const researcher = await this.subAgent(ResearchAgent, "research");
await researcher.chat("Research this topic", streamCallback);For long-running research/coding agents, the child turn may be doing LLM calls, tool calls, workspace edits, MCP calls, or nested delegation. This is exactly where process eviction or deployment interruption is likely to happen.
If chatRecovery works for browser-driven turns but not chat(), parent agents have to choose between:
- using the ergonomic child chat RPC and accepting weaker durability; or
- bypassing
chat()and reimplementing orchestration throughsaveMessages()/ custom streaming.
Think should make the durable path the default path.
Desired behavior
When this.chatRecovery is enabled, Think.chat() should wrap the child turn in a chat fiber using the same internal naming convention:
`${(this.constructor as typeof Think).CHAT_FIBER_NAME}:${requestId}`On restart, recovery should behave consistently with other Think turns:
onChatRecovery(ctx)fires in the child Think agent.- Partial assistant output is available through
ctx.partialText/ctx.partialPartswhere possible. - The partial assistant message is persisted unless
onChatRecovery()opts out with{ persist: false }. - Recovery schedules continuation unless
onChatRecovery()opts out with{ continue: false }. - Fiber rows are cleaned up after recovery.
Design questions
The main subtlety is that chat() streams through an RPC callback, not a browser WebSocket/resumable stream.
Questions to answer in the implementation:
- Partial stream storage: Should
chat()write chunks throughResumableStream/stream metadata so_handleInternalFiberRecovery()can reconstruct partial text exactly like WebSocket turns? Or should it persist accumulated partial assistant state directly forchat()only? - Callback liveness: After eviction, the original
StreamCallbackRPC object is gone. Recovery probably should not attempt to call the old callback. It should persist/continue the child turn and let higher-level orchestration decide whether to replay, mark interrupted, or reattach. - Agent tools interaction:
runAgentTool()/agentTool()already have retained parent-side run state and event replay. If a Think child used by agent tools recovers after the parent forwarding loop is gone, the parent may still mark the runinterruptedunless/until live-tail reattachment is designed. That is okay; this issue is about making the child turn recover correctly. - Abort signal semantics:
AbortSignalcannot cross DO RPC durably. If the DO restarts, the originaloptions.signalis gone. This should match the existing documented limitation forsaveMessages()/continueLastTurn()recovery.
Implementation sketch
Possible approach:
- Refactor the body of
chat()into a localchatBodyasync function, matching the style used bysaveMessages()andcontinueLastTurn(). - Inside the existing
_turnQueue.enqueue(requestId, ...), run:
if (this.chatRecovery) {
await this.runFiber(
`${(this.constructor as typeof Think).CHAT_FIBER_NAME}:${requestId}`,
async () => {
await chatBody();
}
);
} else {
await chatBody();
}- Ensure
chat()records enough stream metadata for_handleInternalFiberRecovery()to find and persist partial output. - Keep callback behavior unchanged for the live/non-recovery case.
- Ensure errors still call
callback.onError()while the callback is live. - Add regression coverage for both top-level recovery and helper/sub-agent recovery.
Acceptance criteria
Think.chat()is wrapped inrunFiber()whenchatRecoveryis enabled.- A process kill during a child
chat()turn triggersonChatRecovery()on the child Think agent after restart. - Partial assistant output from a killed
chat()turn is persisted or otherwise available toonChatRecovery()according to the chosen design. - Recovery cleans up the chat fiber row.
- Recovery can schedule
continueLastTurn()for the child without depending on the original RPC callback still being alive. - Existing live
chat()behavior remains unchanged: chunks still flow throughcallback.onEvent(), completion callscallback.onDone(), and errors callcallback.onError()or throw if no error callback is provided. - Abort behavior remains consistent with programmatic turns: live signals can abort the active turn, but signals do not survive hibernation/recovery.
- Docs in
docs/think/sub-agents.mdmatch the final behavior.
Useful files
packages/think/src/think.tsThink.chat()saveMessages()continueLastTurn()_handleInternalFiberRecovery()_chatRecoveryContinue()
docs/think/sub-agents.mdpackages/think/src/e2e-tests/chat-recovery.test.tspackages/think/src/tests/fiber.test.ts
Notes
The current docs already describe this as intended behavior, but the issue should be treated as implementation verification/fix work. If a branch has already landed the chat() wrapping since this issue was opened, use this issue to verify tests/docs and then close it with the relevant PR.
Activity
threepointone commentedon May 13, 2026
fixed in #1517