You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Think] Add client projection hooks for server-only message context #1966
Think Session is the canonical history used for model execution, tools, compaction, and recovery. Applications may need to persist server-owned, model-visible context with a turn, such as retrieval snapshots, system reminders, or internal tool details, without exposing it to browser clients.
Today, Think uses the same this.messages view for model assembly and client synchronization. Persisting internal parts therefore risks exposing them through broadcasts, /get-messages, reconnects, rollback, or resumable replay. beforeTurn is too late to persist context, while Agent lifecycle hooks do not cover every client output path. This forces applications to either split conversation state into sidecars or replace the built-in browser transport.
The exact API may differ, but the important guarantees are:
server-only fields are created or validated before Session persistence;
model execution, compaction, and recovery use the complete canonical history;
every client-facing history, broadcast, reconnect, rollback, and replay path uses the client projection;
stream projection preserves complete tool-call lifecycles and terminal events;
clients cannot mark their own content as trusted/internal.
Why
A single canonical Session transcript should be able to support different audiences without requiring application-level transcript joins or a second chat protocol. This would provide one extensible boundary for retrieval snapshots, reminders, private server tools, and future model-only context.
Observed with @cloudflare/think@0.13.0 and agents@0.17.3.
@cloudflare/think: a protected projectMessagesForClient(messages: UIMessage[], context: { reason: "broadcast" | "connect" | "hydrate" | "rollback" }): UIMessage[] | Promise<UIMessage[]> whose default returns messages unchanged. All four emitters of cf_agent_chat_messages route through it: _broadcastMessages (every persist-then-broadcast caller), _buildIdleConnectMessages (the connect frame), the GET …/get-messages route, and _rollbackDroppedSubmit. The hook receives a snapshot of the list as it stood when the send was issued; a synchronous projection is sent inline (no behaviour change for existing subclasses), an asynchronous one is queued so a later broadcast cannot overtake an earlier snapshot. Model assembly, compaction, and recovery never see the projection.
agents/sessions: HistoryReadOptions.overlays (default true) — overlays: false on history() / historyBatches() / getHistory() reads the stored rows underneath a compaction overlay, so a projection can hand people the real conversation while the model keeps the summary.
prepareMessageForSession and projectStreamChunkForClient are left for a follow-up; stream replay re-sends cf_agent_use_chat_response chunks and never a message list, so the history projection is self-contained.
Activity
nim442 commentedon Sep 11, 2026
I have an implementation of the
projectMessagesForClienthalf of this on a branch, against currentmain(Think 0.18.0 / agents 0.23.0): https://github.com/nim442/agents/tree/client-projection-hook — compare view: https://github.com/cloudflare/agents/compare/main...nim442:agents:client-projection-hook?expand=1.gh pr createis currently refused for my account on this repo, so the PR will follow as soon as that clears; the branch is complete (code, tests, docs, changesets) in the meantime.What it does:
@cloudflare/think: a protectedprojectMessagesForClient(messages: UIMessage[], context: { reason: "broadcast" | "connect" | "hydrate" | "rollback" }): UIMessage[] | Promise<UIMessage[]>whose default returnsmessagesunchanged. All four emitters ofcf_agent_chat_messagesroute through it:_broadcastMessages(every persist-then-broadcast caller),_buildIdleConnectMessages(the connect frame), theGET …/get-messagesroute, and_rollbackDroppedSubmit. The hook receives a snapshot of the list as it stood when the send was issued; a synchronous projection is sent inline (no behaviour change for existing subclasses), an asynchronous one is queued so a later broadcast cannot overtake an earlier snapshot. Model assembly, compaction, and recovery never see the projection.agents/sessions:HistoryReadOptions.overlays(defaulttrue) —overlays: falseonhistory()/historyBatches()/getHistory()reads the stored rows underneath a compaction overlay, so a projection can hand people the real conversation while the model keeps the summary.prepareMessageForSessionandprojectStreamChunkForClientare left for a follow-up; stream replay re-sendscf_agent_use_chat_responsechunks and never a message list, so the history projection is self-contained.apoorvasheera commentedon Sep 17, 2026
Do we know if this fix is coming soon, or should we build around it in our implementations? @cjol