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 in runFiber() when chatRecovery is enabled.
continueLastTurn() wraps continuation work in runFiber().
- WebSocket chat requests wrap the normal chat turn body in
runFiber().
- Auto-continuation wraps continuation work in
runFiber().
_handleInternalFiberRecovery() recognizes internal chat fibers named with Think.CHAT_FIBER_NAME, reconstructs partial stream state, calls onChatRecovery(), 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 through saveMessages() / 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.partialParts where 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 through ResumableStream/stream metadata so _handleInternalFiberRecovery() can reconstruct partial text exactly like WebSocket turns? Or should it persist accumulated partial assistant state directly for chat() only?
- Callback liveness: After eviction, the original
StreamCallback RPC 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 run interrupted unless/until live-tail reattachment is designed. That is okay; this issue is about making the child turn recover correctly.
- Abort signal semantics:
AbortSignal cannot cross DO RPC durably. If the DO restarts, the original options.signal is gone. This should match the existing documented limitation for saveMessages() / continueLastTurn() recovery.
Implementation sketch
Possible approach:
- Refactor the body of
chat() into a local chatBody async function, matching the style used by saveMessages() and continueLastTurn().
- 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 in runFiber() when chatRecovery is enabled.
- A process kill during a child
chat() turn triggers onChatRecovery() on the child Think agent after restart.
- Partial assistant output from a killed
chat() turn is persisted or otherwise available to onChatRecovery() 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 through callback.onEvent(), completion calls callback.onDone(), and errors call callback.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.md match the final behavior.
Useful files
packages/think/src/think.ts
Think.chat()
saveMessages()
continueLastTurn()
_handleInternalFiberRecovery()
_chatRecoveryContinue()
docs/think/sub-agents.md
packages/think/src/e2e-tests/chat-recovery.test.ts
packages/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.
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 throughStreamCallback, but it does not appear to be wrapped in the samechatRecovery/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().runFiber().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:_turnQueuethis.session_runInferenceLoop({ signal: options?.signal, callerTools: options?.tools })result.toUIMessageStream()callback.onEvent(...)callback.onDone()/callback.onError(...)Because that path does not use
runFiber(), a process eviction duringchat()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.mdalready describes the desired end state, saying that all four turn paths, including sub-agentchat()RPC, are wrapped inrunFiber(). 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: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
chatRecoveryworks for browser-driven turns but notchat(), parent agents have to choose between:chat()and reimplementing orchestration throughsaveMessages()/ custom streaming.Think should make the durable path the default path.
Desired behavior
When
this.chatRecoveryis 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.ctx.partialText/ctx.partialPartswhere possible.onChatRecovery()opts out with{ persist: false }.onChatRecovery()opts out with{ continue: false }.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:
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?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.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.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:
chat()into a localchatBodyasync function, matching the style used bysaveMessages()andcontinueLastTurn()._turnQueue.enqueue(requestId, ...), run:chat()records enough stream metadata for_handleInternalFiberRecovery()to find and persist partial output.callback.onError()while the callback is live.Acceptance criteria
Think.chat()is wrapped inrunFiber()whenchatRecoveryis enabled.chat()turn triggersonChatRecovery()on the child Think agent after restart.chat()turn is persisted or otherwise available toonChatRecovery()according to the chosen design.continueLastTurn()for the child without depending on the original RPC callback still being alive.chat()behavior remains unchanged: chunks still flow throughcallback.onEvent(), completion callscallback.onDone(), and errors callcallback.onError()or throw if no error callback is provided.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.tsNotes
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.