Skip to content

Commit b7e86f2

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
fix(webapp,core,sdk): page chat history and keep model context private
# Chat history reads a page at a time, and the model's context stays private Reading a `chat.agent` conversation's history used to download and parse the whole conversation, so it got slower as a chat grew. A conversation's stored transcript now carries an index, so reading the most recent messages fetches only those messages. The same change closes a narrower problem: the model-side context an agent keeps, its compacted history and any injected context, was reachable from a browser. It is not part of a transcript and is no longer served with one. ## Before ```mermaid sequenceDiagram participant Browser participant Webapp participant Store as Object store Browser->>Webapp: open a chat Webapp-->>Browser: presigned URL for the whole snapshot Browser->>Store: GET the entire object Store-->>Browser: messages AND model context Note over Browser,Store: the whole conversation, including context a transcript never shows Webapp->>Store: GET the entire object (page read) Webapp->>Webapp: parse all of it, return 50 messages ``` Every read paid for the whole conversation: request-thread CPU for the API, bytes over the wire for the browser. Paging alone could not fix it, because the messages a page needs sit at the end of one JSON document, so finding them meant parsing all of it. ## After ```mermaid sequenceDiagram participant Browser participant Webapp participant Store as Object store Browser->>Webapp: open a chat Webapp->>Store: ranged GET, end of the object Store-->>Webapp: index + newest entries Webapp-->>Browser: messages and a cursor Note over Webapp,Store: one ranged read, and the browser never touches the store ``` The stored transcript is a header line holding the cursors and the agent's own state, one line per message, an index, and a fixed-width trailer giving the index's length. A reader takes the end of the object and decodes only the bytes holding the page it was asked for. ## Safety contract - The private state lives in the header, so a page read cannot return it by layout, not by remembering to strip a field. - A cursor the transcript no longer holds yields an empty page, never the newest entries, which would present recent messages as older ones. - Page timestamps come from a message's position in the whole transcript, not in its page, so pages fetched newest-first still merge into conversation order. - Trimming is expressed relative to the compaction watermark, because everything after it is live context the next boot converts and must never be dropped. ## Scope and impact A conversation saved by an earlier version still reads correctly: the reader recognises the old format and reads the whole object. Each conversation moves to the new layout the next time it saves, so there is no migration step. An older reader cannot read the new format, so roll forward rather than back. The built-in storage is deliberately basic about long conversations: once an agent has compacted it keeps roughly the last hundred messages and drops the rest, so what it rewrites each turn stops growing. A conversation that never compacts is kept whole. An app that renders history further back than that keeps its own transcript storage. ## Rollout controls No flag. The read change is the same data through a cheaper path, and gating the exposure fix would mean leaving it open by default. Revert is a deploy revert: stored objects are untouched and the state is still written and read on the private path. Presigned URLs already issued stay valid for their lifetime, so the exposure fix is not retroactive for the few minutes before a deploy. What a regression looks like: `chat.agent: snapshot version/shape mismatch` at run boot, or `transcript endpoint: ranged read failed` in the webapp logs. The first would mean a conversation a later run cannot read, which is the one worth paging on. ## Verification Tests prove: paging over byte offsets, including multibyte content where a character-indexed table would slice mid-message; reading the previous format; a conversation whose stored media type disagrees with its contents; a cursor the transcript no longer holds; each page's position in the whole transcript; that no byte a page read fetches contains the private state; that a trimmed transcript still restores the model's context; and that a watermark outside the retained window does not discard the summary. One test asserts the failure that guard prevents, so it cannot be dropped quietly. Real runs additionally cover a cold continuation booting from a trimmed snapshot and snapshot size plateauing over 130 turns. Mono-RevId: 7e57e5e9b859592827719a35e6a624c1cd7123f6
1 parent c5491f0 commit b7e86f2

25 files changed

Lines changed: 1805 additions & 226 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"@trigger.dev/sdk": patch
4+
---
5+
6+
Reading a page of a chat agent's conversation no longer downloads the whole conversation. The saved transcript now carries an index, so asking for the most recent messages fetches only those messages, and history loads in roughly constant time however long the chat gets.
7+
8+
A paged read also returns only the conversation itself. The model-side context an agent keeps, its compacted history and any injected context, is no longer included, so it cannot reach a browser through a load-transcript server action.
9+
10+
The built-in storage is deliberately basic about long conversations: once an agent has compacted, it keeps roughly the last hundred messages and drops the rest, so what it rewrites each turn stops growing. A conversation that never compacts is kept whole. If your app renders history further back than that, give the agent your own transcript storage.
11+
12+
The saved format has changed and an older SDK cannot read it, so a deployment rolled back to an earlier version will not find a readable transcript for conversations the newer version already saved, and those conversations continue from the live stream tail instead. Roll forward rather than back, or keep your own transcript storage.

apps/webapp/app/components/runs/v3/agent/AgentView.tsx

Lines changed: 98 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import type { UIMessage } from "@ai-sdk/react";
22
import { SSEStreamSubscription } from "@trigger.dev/core/v3";
3-
import { useEffect, useMemo, useRef, useState } from "react";
3+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4+
import { Button } from "~/components/primitives/Buttons";
45
import { Paragraph } from "~/components/primitives/Paragraph";
56
import { Spinner } from "~/components/primitives/Spinner";
67
import { AgentMessageView } from "~/components/runs/v3/agent/AgentMessageView";
7-
import { seedFromTranscriptSnapshot } from "~/components/runs/v3/agent/transcriptSnapshotSeed";
88
import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom";
9+
import type { TranscriptSeed } from "~/services/realtime/transcriptSeed.server";
910
import { useEnvironment } from "~/hooks/useEnvironment";
1011
import { useOrganization } from "~/hooks/useOrganizations";
1112
import { useProject } from "~/hooks/useProject";
@@ -29,14 +30,13 @@ export type AgentViewAuth = {
2930
*/
3031
initialMessages: UIMessage[];
3132
/**
32-
* Presigned GET URL for the session's chat-snapshot S3 blob (written
33-
* by the agent after each turn-complete; see `ChatSnapshotV1`).
34-
* Optional — sessions that registered a `hydrateMessages` hook skip
35-
* snapshot writes and the URL fetch will 404. In that case the
36-
* dashboard falls back to seq=0 SSE (which, post-trim, shows only the
37-
* most recent turn). Generated server-side by `SessionPresenter`.
33+
* The most recent messages of the session's saved transcript, read
34+
* server-side by `SessionPresenter`, with the stream cursor to resume just
35+
* past them. Absent for sessions that registered a `hydrateMessages` hook and
36+
* so save no transcript; the dashboard then falls back to seq=0 SSE (which,
37+
* post-trim, shows only the most recent turn).
3838
*/
39-
snapshotPresignedUrl?: string;
39+
transcriptSeed?: TranscriptSeed;
4040
};
4141

4242
/**
@@ -84,14 +84,14 @@ export function AgentView({ agentView }: { agentView: AgentViewAuth }) {
8484
const project = useProject();
8585
const environment = useEnvironment();
8686

87-
const messages = useAgentSessionMessages({
87+
const { messages, hasEarlier, isLoadingEarlier, loadEarlier } = useAgentSessionMessages({
8888
sessionId: agentView.sessionId,
8989
apiOrigin: agentView.apiOrigin,
9090
orgSlug: organization.slug,
9191
projectSlug: project.slug,
9292
envSlug: environment.slug,
9393
initialMessages: agentView.initialMessages,
94-
snapshotPresignedUrl: agentView.snapshotPresignedUrl,
94+
transcriptSeed: agentView.transcriptSeed,
9595
});
9696

9797
// Sticky-bottom auto-scroll: walks up to find the inspector's scroll
@@ -112,7 +112,16 @@ export function AgentView({ agentView }: { agentView: AgentViewAuth }) {
112112
</div>
113113
</div>
114114
) : (
115-
<AgentMessageView messages={messages} />
115+
<>
116+
{hasEarlier ? (
117+
<div className="flex justify-center pb-2">
118+
<Button variant="minimal/small" onClick={loadEarlier} disabled={isLoadingEarlier}>
119+
{isLoadingEarlier ? "Loading…" : "Load earlier messages"}
120+
</Button>
121+
</div>
122+
) : null}
123+
<AgentMessageView messages={messages} />
124+
</>
116125
)}
117126
</div>
118127
);
@@ -234,35 +243,46 @@ function useAgentSessionMessages({
234243
projectSlug,
235244
envSlug,
236245
initialMessages,
237-
snapshotPresignedUrl,
246+
transcriptSeed,
238247
}: {
239248
sessionId: string;
240249
apiOrigin: string;
241250
orgSlug: string;
242251
projectSlug: string;
243252
envSlug: string;
244253
initialMessages: UIMessage[];
245-
snapshotPresignedUrl?: string;
246-
}): UIMessage[] {
254+
transcriptSeed?: TranscriptSeed;
255+
}): {
256+
messages: UIMessage[];
257+
hasEarlier: boolean;
258+
isLoadingEarlier: boolean;
259+
loadEarlier: () => void;
260+
} {
247261
// Seed with the user messages from the run's task payload.
248262
const seedMessages = useMemo(
249263
() => initialMessages.filter((m) => m.role === "user"),
250264
[initialMessages]
251265
);
252266

253-
// The snapshot URL is re-signed by the loader on every navigation
254-
// (tab switches in the inspector pane re-run the session loader),
255-
// which would otherwise re-trigger the subscription effect below
256-
// and replay post-snapshot `.out` chunks on top of the messages we
257-
// already accumulated — duplicating any assistant content that
258-
// lives past `snapshot.lastOutEventId` (e.g., a canceled run whose
259-
// turn never completed). Hold the URL behind a ref and keep it
260-
// out of the effect's deps so the effect runs exactly once per
261-
// mount.
262-
const snapshotUrlRef = useRef(snapshotPresignedUrl);
267+
// The seed is re-read by the loader on every navigation (tab switches in the
268+
// inspector pane re-run the session loader), which would otherwise re-trigger
269+
// the subscription effect below and replay post-snapshot `.out` chunks on top
270+
// of the messages we already accumulated — duplicating any assistant content
271+
// that lives past `lastOutEventId` (e.g., a canceled run whose turn never
272+
// completed). Hold it behind a ref and keep it out of the effect's deps so
273+
// the effect runs exactly once per mount.
274+
const transcriptSeedRef = useRef(transcriptSeed);
263275
useEffect(() => {
264-
snapshotUrlRef.current = snapshotPresignedUrl;
265-
}, [snapshotPresignedUrl]);
276+
transcriptSeedRef.current = transcriptSeed;
277+
}, [transcriptSeed]);
278+
279+
// Cursor for history older than the seeded page. The dashboard seeds the
280+
// most recent page server-side; earlier pages are fetched on demand so a long
281+
// conversation is not rendered truncated.
282+
const [earlierCursor, setEarlierCursor] = useState<string | undefined>(
283+
transcriptSeed?.nextCursor
284+
);
285+
const [isLoadingEarlier, setIsLoadingEarlier] = useState(false);
266286

267287
// `pendingRef` is the authoritative, eagerly-updated message state:
268288
// chunks mutate this synchronously as they arrive. A throttled flush
@@ -386,28 +406,17 @@ function useAgentSessionMessages({
386406
* have a snapshot (e.g. `hydrateMessages` customers, or sessions that
387407
* have never completed a turn).
388408
*/
389-
const loadSnapshot = async (): Promise<string | undefined> => {
390-
const url = snapshotUrlRef.current;
391-
if (!url) return undefined;
392-
try {
393-
const resp = await fetch(url, { signal: abort.signal });
394-
if (!resp.ok) return undefined;
395-
const json = (await resp.json()) as unknown;
396-
const seed = seedFromTranscriptSnapshot(json);
397-
if (!seed) return undefined;
398-
for (const { id, message, timestamp } of seed.messages) {
399-
// The snapshot's seed wins over the task-payload seed for any
400-
// overlapping ids (the snapshot represents the agent's
401-
// canonical accumulator, post-turn).
402-
pendingRef.current.set(id, message);
403-
timestampsRef.current.set(id, timestamp);
404-
}
405-
scheduleFlush.current();
406-
return seed.lastOutEventId;
407-
} catch {
408-
// 404 / network / parse / abort — fall back to seq=0 SSE
409-
return undefined;
409+
const loadSnapshot = (): string | undefined => {
410+
const seed = transcriptSeedRef.current;
411+
if (!seed) return undefined;
412+
for (const { id, message, timestamp } of seed.messages) {
413+
// The transcript wins over the task-payload seed for any overlapping
414+
// ids: it is the agent's canonical accumulator, post-turn.
415+
pendingRef.current.set(id, message);
416+
timestampsRef.current.set(id, timestamp);
410417
}
418+
if (seed.messages.length > 0) scheduleFlush.current();
419+
return seed.lastOutEventId;
411420
};
412421

413422
const outputSubOptions = (lastEventId: string | undefined) =>
@@ -449,7 +458,7 @@ function useAgentSessionMessages({
449458
// at seq=0 — which, post-trim, contains roughly one turn of
450459
// records (acceptable fallback for `hydrateMessages` sessions
451460
// and fresh sessions).
452-
const snapshotLastEventId = await loadSnapshot();
461+
const snapshotLastEventId = loadSnapshot();
453462
if (abort.signal.aborted) return;
454463

455464
const sub = new SSEStreamSubscription(outputUrl, outputSubOptions(snapshotLastEventId));
@@ -656,7 +665,39 @@ function useAgentSessionMessages({
656665
// eslint-disable-next-line react-hooks/exhaustive-deps
657666
}, [sessionId, apiOrigin, orgSlug, projectSlug, envSlug]);
658667

659-
return useMemo(() => {
668+
const loadEarlier = useCallback(() => {
669+
if (earlierCursor === undefined || isLoadingEarlier) return;
670+
setIsLoadingEarlier(true);
671+
672+
const origin = typeof window !== "undefined" ? window.location.origin : apiOrigin;
673+
const url =
674+
`${origin}/resources/orgs/${orgSlug}/projects/${projectSlug}/env/${envSlug}` +
675+
`/sessions/${encodeURIComponent(sessionId)}/transcript?before=${encodeURIComponent(
676+
earlierCursor
677+
)}`;
678+
679+
fetch(url)
680+
.then((resp) => (resp.ok ? resp.json() : Promise.reject(new Error(String(resp.status)))))
681+
.then((raw) => {
682+
const body = raw as { messages?: TranscriptSeed["messages"]; nextCursor?: string };
683+
for (const { id, message, timestamp } of body.messages ?? []) {
684+
// Earlier history never overwrites a message already on screen: what
685+
// is here came from the stream or a newer page and is at least as
686+
// current.
687+
if (pendingRef.current.has(id)) continue;
688+
pendingRef.current.set(id, message);
689+
timestampsRef.current.set(id, timestamp);
690+
}
691+
setEarlierCursor(body.nextCursor);
692+
scheduleFlush.current();
693+
})
694+
.catch(() => {
695+
// Leave the cursor in place so the control stays available to retry.
696+
})
697+
.finally(() => setIsLoadingEarlier(false));
698+
}, [earlierCursor, isLoadingEarlier, apiOrigin, orgSlug, projectSlug, envSlug, sessionId]);
699+
700+
const sorted = useMemo(() => {
660701
const timestamps = timestampsRef.current;
661702
const arr = Array.from(messagesById.values());
662703

@@ -670,6 +711,13 @@ function useAgentSessionMessages({
670711
});
671712
return arr;
672713
}, [messagesById]);
714+
715+
return {
716+
messages: sorted,
717+
hasEarlier: earlierCursor !== undefined,
718+
isLoadingEarlier,
719+
loadEarlier,
720+
};
673721
}
674722

675723
// ---------------------------------------------------------------------------

apps/webapp/app/components/runs/v3/agent/transcriptSnapshotSeed.test.ts

Lines changed: 0 additions & 62 deletions
This file was deleted.

apps/webapp/app/components/runs/v3/agent/transcriptSnapshotSeed.ts

Lines changed: 0 additions & 32 deletions
This file was deleted.

0 commit comments

Comments
 (0)