Skip to content

apply recovery to Think.chat() #1429

Description

@threepointone

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:

  1. 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?
  2. 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.
  3. 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.
  4. 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:

  1. Refactor the body of chat() into a local chatBody async function, matching the style used by saveMessages() and continueLastTurn().
  2. 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();
}
  1. Ensure chat() records enough stream metadata for _handleInternalFiberRecovery() to find and persist partial output.
  2. Keep callback behavior unchanged for the live/non-recovery case.
  3. Ensure errors still call callback.onError() while the callback is live.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requeston the roadmapFeature accepted and planned for implementation

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions