External History Adapter#
The useExternalHistory hook (useExternalHistory.ts) manages two concerns for AI SDK-backed threads: loading persisted messages on mount and saving new messages after each run completes. It is wired up inside useAISDKRuntime and accepts a ThreadHistoryAdapter (the external storage backend) together with a MessageFormatAdapter that handles serialization.
Mount-Time Load#
On first mount, loadHistory() is guarded by a loadedRef (useRef(false)) so it fires at most once per hook instance — safe under React Strict Mode double-effects . The flow:
- Sets
isLoadingtotruebefore awaitingformatAdapter.load(). - Converts the stored repository with
toExportedMessageRepository, imports it into the runtime, and seeds the external store's message list. - Populates
historyIds— auseRef(new Set<string>())— with every loaded message ID to prevent double-writes during the later persist phase . - Clears
isLoadinginfinally, even on error.
If the thread has no remoteId (i.e., it is a new, unsaved thread), the load is skipped and isLoading is immediately set to false .
Post-Run Persist Loop#
After mount, a thread subscription fires on every state change . The persist logic is gated by several interacting mechanisms:
isRunning Transition Guard (wasRunningRef)#
wasRunningRef holds the previous isRunning value. On each subscription tick the code reads the current value, updates the ref, and then only proceeds if wasRunning === true and isRunning === false — i.e., exclusively on the true → false edge . Transitions where the thread was already idle are no-ops.
Debounce Timer (persistTimerRef)#
Persist work is deferred by a setTimeout(..., 0) stored in persistTimerRef. This absorbs agentic-step flickers where isRunning briefly drops to false between tool-call steps and immediately returns to true. When isRunning flips back to true before the timer fires, the pending timer is cancelled and a new one is scheduled on the next true → false transition . Inside the callback, the state is re-read and a latest.isRunning guard provides a second safety check .
Telemetry: runStartRef and Step Boundaries#
runStartRef records the wall-clock start of a run . stepBoundariesRef accumulates per-tool-call timing offsets by watching the count of tool-call content parts grow on the last assistant message . These are used to compute durationMs and per-step timestamps passed to formatAdapter.reportTelemetry() .
isReady Filter — The requires-action Exclusion#
Inside the persist loop, each message is checked for readiness before it can be saved :
isReady = status === undefined
|| status.type === "complete"
|| status.type === "incomplete"
Any message whose status is not one of those three values is skipped. This means messages with status requires-action — the status used when a tool call is awaiting human approval — are never persisted. The code advances lastInnerMessageId to keep the parent-chain correct but does not call formatAdapter.append() for the skipped message .
Known behavior (potential bug): If a run ends while a tool call is pending approval (status =
requires-action), the affected message is silently dropped from the save operation. It will not appear in the stored history after reload.
Deduplication via historyIds#
Messages already present in historyIds are not re-appended. If a durationMs is available, formatAdapter.update() is called instead to attach telemetry to previously-saved messages . New messages are added to historyIds before appending so duplicate concurrent persist calls are blocked .
deleteMessage#
The exported deleteMessage callback resolves the full inner-message chain for a given message ID, calls formatAdapter.delete(), and removes the ID from historyIds so it can be re-persisted if re-created.
Key References#
| Symbol | Location |
|---|---|
useExternalHistory hook entry point | lines 48–54 |
Mount-time load guard (loadedRef, historyIds init) | lines 55–120 |
wasRunningRef + subscription handler | lines 122–165 |
| Debounce timer + persist body | lines 173–283 |
isReady filter (requires-action exclusion) | lines 241–250 |
| Deduplication + telemetry update | lines 252–270 |
deleteMessage | lines 295–322 |
See also: Chat History Loading — broader context on isLoading forwarding, stream resumption, and the mount-time race condition fix.