Skip to content

Commit fc77bfc

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
fix(sdk): keep compacted steps out of later chat turns
Keep assistant steps and tool results replaced by inner compaction out of subsequent `chat.agent()` model context. Turn completion now appends only response steps generated after compaction, while preserving the full visible conversation. Persistence hooks retain the complete response, including same-ID replacements for approvals and handovers. Track the completed-step boundary and trim the captured response before model-message conversion. Same-ID handover continuations also skip their original assistant prefix, avoiding a fallback that rebuilds uncompacted model history. Mono-RevId: 55a2d4354c501682b159c8853255b1f08a13d3cb
1 parent 08fdfc8 commit fc77bfc

9 files changed

Lines changed: 514 additions & 30 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Keep summarized assistant steps and tool results out of future `chat.agent()` model context after inner compaction, while preserving the full visible conversation.

docs/ai-chat/lifecycle-hooks.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,14 +405,16 @@ Receives the same fields as [`TurnCompleteEvent`](/ai-chat/reference#turncomplet
405405

406406
Fires after each turn completes, after the response is captured and the stream is closed. This is the primary hook for persisting the assistant's response. Does not include a `writer` since the stream is already closed.
407407

408+
Same-ID approval and handover continuations include a full replacement assistant response. Persist `messages` for future model context, or upsert `newUIMessages` by message ID for the visible conversation. `newMessages` includes steps summarized during the turn and is not an append-only delta for these continuations.
409+
408410
| Field | Type | Description |
409411
| -------------------- | ------------------------ | -------------------------------------------------------------------------------------------- |
410412
| `ctx` | `TaskRunContext` | Full task run context. See [reference](/ai-chat/reference#task-context-ctx). |
411413
| `chatId` | `string` | Chat session ID |
412414
| `messages` | `ModelMessage[]` | Full accumulated conversation (model format) |
413415
| `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) |
414-
| `newMessages` | `ModelMessage[]` | Only this turn's messages (model format) |
415-
| `newUIMessages` | `UIMessage[]` | Only this turn's messages (UI format) |
416+
| `newMessages` | `ModelMessage[]` | This turn's model messages, including full same-ID replacement responses |
417+
| `newUIMessages` | `UIMessage[]` | New or updated UI messages; upsert by message ID |
416418
| `responseMessage` | `UIMessage \| undefined` | The assistant's response for this turn |
417419
| `turn` | `number` | Turn number (0-indexed) |
418420
| `runId` | `string` | The Trigger.dev run ID |

docs/ai-chat/reference.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -299,14 +299,16 @@ Passed to the `onTurnStart` callback.
299299

300300
Passed to the `onTurnComplete` callback.
301301

302+
Same-ID approval and handover continuations include a full replacement assistant response. Persist `messages` for future model context, or upsert `newUIMessages` by message ID for the visible conversation. `newMessages` includes steps summarized during the turn and is not an append-only delta for these continuations.
303+
302304
| Field | Type | Description |
303305
| -------------------- | --------------------------------- | ---------------------------------------------------- |
304306
| `ctx` | `TaskRunContext` | Full task run context — see [Task context](#task-context-ctx) |
305307
| `chatId` | `string` | Chat session ID |
306308
| `messages` | `ModelMessage[]` | Full accumulated conversation (model format) |
307309
| `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) |
308-
| `newMessages` | `ModelMessage[]` | Only this turn's messages (model format) |
309-
| `newUIMessages` | `UIMessage[]` | Only this turn's messages (UI format) |
310+
| `newMessages` | `ModelMessage[]` | This turn's model messages, including full same-ID replacement responses |
311+
| `newUIMessages` | `UIMessage[]` | New or updated UI messages; upsert by message ID |
310312
| `responseMessage` | `UIMessage \| undefined` | The assistant's response for this turn |
311313
| `rawResponseMessage` | `UIMessage \| undefined` | Raw response before abort cleanup |
312314
| `turn` | `number` | Turn number (0-indexed) |

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ import {
9393
type TranscriptStorage,
9494
type TranscriptStorageContext,
9595
} from "./transcriptStorage.js";
96+
import { responseAfterCompaction } from "./compactionResponse.js";
9697

9798
let transcriptStorageOverride: TranscriptStorage<unknown> | undefined;
9899

@@ -3357,6 +3358,8 @@ const chatOverrideModelMessagesKey = locals.create<ModelMessage[]>("chat.overrid
33573358
interface CompactionState {
33583359
summary: string;
33593360
baseResponseMessageCount: number;
3361+
/** Completed steps summarized this turn; unlike message counts, matches UI step markers. */
3362+
baseResponseStepCount: number;
33603363
}
33613364

33623365
/** @internal */
@@ -4173,6 +4176,7 @@ async function chatCompact(
41734176
locals.set(chatCompactionStateKey, {
41744177
summary,
41754178
baseResponseMessageCount: currentStep.response.messages.length,
4179+
baseResponseStepCount: steps.length,
41764180
});
41774181

41784182
// Set model-only override — UI messages stay intact for persistence.
@@ -5903,13 +5907,17 @@ export type TurnCompleteEvent<TClientData = unknown, TUIM extends UIMessage = UI
59035907
*/
59045908
uiMessages: TUIM[];
59055909
/**
5906-
* Only the new model messages from this turn (user message(s) + assistant response).
5907-
* Useful for appending to an existing conversation record.
5910+
* Model messages for this turn's user message(s) and complete assistant response,
5911+
* including steps summarized during the turn. Same-ID approval and handover
5912+
* continuations include the full replacement response, so these are not always
5913+
* an append-only delta. Persist `messages` for future model context, or upsert
5914+
* `newUIMessages` by ID for the visible conversation.
59085915
*/
59095916
newMessages: ModelMessage[];
59105917
/**
5911-
* Only the new UI messages from this turn (user message(s) + assistant response).
5912-
* Useful for inserting individual message records instead of overwriting the full history.
5918+
* New or updated UI messages from this turn (user message(s) + assistant response).
5919+
* Upsert by message ID: approval and handover continuations can replace an
5920+
* existing assistant message.
59135921
*/
59145922
newUIMessages: TUIM[];
59155923
/** The assistant's response for this turn, with aborted parts cleaned up when `stopped` is true. Undefined if `pipeChat` was used manually. */
@@ -9425,6 +9433,14 @@ function chatAgent<
94259433
// Check if compaction set a model-only override (preserves UI messages).
94269434
// Apply compactUIMessages/compactModelMessages callbacks if configured.
94279435
const modelOnlyOverride = locals.get(chatOverrideModelMessagesKey);
9436+
const responseCompaction = modelOnlyOverride
9437+
? locals.get(chatCompactionStateKey)
9438+
: undefined;
9439+
// Capture the original assistant before compactUIMessages can remove it.
9440+
const originalResponse =
9441+
responseCompaction && capturedResponseMessage
9442+
? accumulatedUIMessages.find((m) => m.id === capturedResponseMessage?.id)
9443+
: undefined;
94289444
if (modelOnlyOverride) {
94299445
const compactionSummary = locals.get(chatCompactionStateKey)?.summary ?? "";
94309446
const taskCompactionConfig = locals.get(chatAgentCompactionKey);
@@ -9529,10 +9545,37 @@ function chatAgent<
95299545
// rationale (TRI-9137).
95309546
recordToolCallIdsFromMessage(capturedResponseMessage);
95319547
try {
9548+
const responseForModel = responseAfterCompaction(
9549+
capturedResponseMessage,
9550+
responseCompaction?.baseResponseStepCount,
9551+
originalResponse
9552+
);
9553+
// Preserve the complete persistence response, including same-ID
9554+
// replacements whose old tool parts can contain new results.
9555+
// Convert prefix and suffix separately so each tool output is
9556+
// converted once, while only the suffix enters model context.
9557+
const responsePrefixMessages = responseCompaction
9558+
? await toModelMessages([
9559+
stripProviderMetadata({
9560+
...capturedResponseMessage,
9561+
parts: capturedResponseMessage.parts.slice(
9562+
0,
9563+
capturedResponseMessage.parts.length -
9564+
responseForModel.parts.length
9565+
),
9566+
}),
9567+
])
9568+
: [];
95329569
const responseModelMessages = await toModelMessages([
9533-
stripProviderMetadata(capturedResponseMessage),
9570+
stripProviderMetadata(responseForModel),
95349571
]);
9535-
if (existingIdx !== -1) {
9572+
if (responseCompaction) {
9573+
// The summary already replaced the original response, including
9574+
// a same-ID approval/handover prefix. Replacing its old model run
9575+
// would miss and fall back to the full, uncompacted UI history.
9576+
accumulatedMessages.push(...responseModelMessages);
9577+
locals.set(chatHandoverSplicedRunKey, undefined);
9578+
} else if (existingIdx !== -1) {
95369579
const spliced = locals.get(chatHandoverSplicedRunKey);
95379580
const splicedRun =
95389581
spliced && previousAtIdx && spliced.id === previousAtIdx.id
@@ -9559,7 +9602,10 @@ function chatAgent<
95599602
} else {
95609603
accumulatedMessages.push(...responseModelMessages);
95619604
}
9562-
turnNewModelMessages.push(...responseModelMessages);
9605+
turnNewModelMessages.push(
9606+
...responsePrefixMessages,
9607+
...responseModelMessages
9608+
);
95639609
} catch {
95649610
// Conversion failed — skip accumulation for this turn
95659611
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import {
2+
convertToModelMessages,
3+
readUIMessageStream,
4+
type ModelMessage,
5+
type UIMessage,
6+
type UIMessageChunk,
7+
} from "ai";
8+
import { describe, expect, it } from "vitest";
9+
import { z } from "zod";
10+
import { responseAfterCompaction } from "./compactionResponse.js";
11+
import { restoreModelLane } from "./transcriptStorage.js";
12+
13+
const summary: ModelMessage = { role: "assistant", content: "SUMMARY" };
14+
const oldUser: UIMessage = { id: "u1", role: "user", parts: [{ type: "text", text: "OLD_USER" }] };
15+
const nextUser: UIMessage = {
16+
id: "u2",
17+
role: "user",
18+
parts: [{ type: "text", text: "NEXT_QUESTION" }],
19+
};
20+
const convert = (messages: UIMessage[]) =>
21+
convertToModelMessages(messages, { ignoreIncompleteToolCalls: true });
22+
const text = (messages: unknown) => JSON.stringify(messages);
23+
const step = (...parts: UIMessage["parts"]): UIMessage["parts"] => [
24+
{ type: "step-start" },
25+
...parts,
26+
];
27+
const toolResult = (
28+
id: string,
29+
output: string,
30+
providerExecuted = false
31+
): UIMessage["parts"][number] => ({
32+
type: "dynamic-tool",
33+
toolName: "lookup",
34+
toolCallId: id,
35+
state: "output-available",
36+
input: { id },
37+
output,
38+
providerExecuted,
39+
});
40+
function response(compactedSteps: number): UIMessage {
41+
return {
42+
id: "a1",
43+
role: "assistant",
44+
parts: [
45+
...Array.from({ length: compactedSteps }, (_, i) =>
46+
step({ type: "text", text: `OLD_ASSISTANT_${i}` }, toolResult(`old-${i}`, `OLD_TOOL_${i}`))
47+
).flat(),
48+
...step(toolResult("new", "NEW_TOOL")),
49+
...step({ type: "text", text: "NEW_ANSWER" }),
50+
],
51+
};
52+
}
53+
54+
describe("response model history after inner compaction", () => {
55+
it.each([1, 2, 3])(
56+
"keeps only the response after %i compacted steps, including on restoration",
57+
async (count) => {
58+
const ui = response(count);
59+
const before = structuredClone(ui);
60+
const model = [summary, ...(await convert([responseAfterCompaction(ui, count)]))];
61+
expect(text(model)).toContain("SUMMARY");
62+
expect(text(model)).toContain("NEW_TOOL");
63+
expect(text(model)).toContain("NEW_ANSWER");
64+
expect.soft(text(model)).not.toContain("OLD_ASSISTANT");
65+
expect.soft(text(model)).not.toContain("OLD_TOOL");
66+
expect(ui).toEqual(before);
67+
expect(text(ui)).toContain("OLD_TOOL");
68+
const state = JSON.parse(
69+
JSON.stringify({ v: 1, compaction: { modelMessages: model, throughId: ui.id } })
70+
);
71+
const restored = await restoreModelLane([oldUser, ui, nextUser], state, convert);
72+
expect.soft(text(restored.messages)).not.toContain("OLD_");
73+
expect(restored.messages).toEqual([...model, ...(await convert([nextUser]))]);
74+
}
75+
);
76+
77+
it("keeps the complete response when this turn did not compact", () => {
78+
const ui = response(1);
79+
expect(responseAfterCompaction(ui)).toBe(ui);
80+
});
81+
82+
it("does not confuse an empty or hidden-reasoning step with a model message", async () => {
83+
const ui: UIMessage = {
84+
id: "a1",
85+
role: "assistant",
86+
parts: [
87+
...step(), // A provider step whose reasoning was omitted from the UI stream.
88+
...step(toolResult("old", "OLD_TOOL", true)),
89+
...step({ type: "text", text: "NEW_ANSWER" }),
90+
],
91+
};
92+
const model = await convert([responseAfterCompaction(ui, 2)]);
93+
expect(model).toEqual([{ role: "assistant", content: [{ type: "text", text: "NEW_ANSWER" }] }]);
94+
});
95+
96+
it("retains a partially streamed post-compaction answer", async () => {
97+
const ui: UIMessage = {
98+
id: "a1",
99+
role: "assistant",
100+
parts: [
101+
...step(toolResult("old", "OLD_TOOL")),
102+
...step({ type: "text", text: "PARTIAL", state: "streaming" }),
103+
],
104+
};
105+
const model = await convert([responseAfterCompaction(ui, 1)]);
106+
expect(text(model)).toContain("PARTIAL");
107+
expect(text(model)).not.toContain("OLD_TOOL");
108+
});
109+
110+
it("appends nothing if stopped before the first post-compaction step", async () => {
111+
const ui: UIMessage = {
112+
id: "a1",
113+
role: "assistant",
114+
parts: step(toolResult("old", "OLD_TOOL")),
115+
};
116+
expect(await convert([responseAfterCompaction(ui, 1)])).toEqual([]);
117+
});
118+
119+
it.each([true, false])(
120+
"handles a same-ID continuation whose original has step markers: %s",
121+
async (hasMarkers) => {
122+
const original: UIMessage = {
123+
id: "a1",
124+
role: "assistant",
125+
parts: [
126+
...(hasMarkers ? [{ type: "step-start" as const }] : []),
127+
toolResult("prior", "PRIOR_TURN_TOOL"),
128+
],
129+
};
130+
const ui = response(1);
131+
ui.parts.unshift(...original.parts);
132+
const originalBefore = structuredClone(original);
133+
const model = await convert([responseAfterCompaction(ui, 1, original)]);
134+
expect(text(model)).toContain("NEW_ANSWER");
135+
expect(text(model)).toContain("NEW_TOOL");
136+
expect(text(model)).not.toContain("OLD_");
137+
expect(text(model)).not.toContain("PRIOR_TURN_TOOL");
138+
expect(original).toEqual(originalBefore);
139+
}
140+
);
141+
142+
it("ignores an original response with a different ID", async () => {
143+
const original = { ...response(3), id: "other" };
144+
const model = await convert([responseAfterCompaction(response(1), 1, original)]);
145+
expect(text(model)).toContain("NEW_ANSWER");
146+
expect(text(model)).not.toContain("OLD_");
147+
});
148+
149+
it("keeps post-compaction provider tools and custom tool output conversion", async () => {
150+
const ui: UIMessage = {
151+
id: "a1",
152+
role: "assistant",
153+
parts: [...step(toolResult("old", "OLD_TOOL")), ...step(toolResult("new", "NEW_TOOL", true))],
154+
};
155+
const model = await convertToModelMessages([responseAfterCompaction(ui, 1)], {
156+
tools: {
157+
lookup: {
158+
inputSchema: z.object({ id: z.string() }),
159+
toModelOutput: ({ output }: { output: unknown }) => ({
160+
type: "text" as const,
161+
value: `CONVERTED:${output}`,
162+
}),
163+
},
164+
},
165+
});
166+
expect(model).toEqual([
167+
{
168+
role: "assistant",
169+
content: [
170+
{
171+
type: "tool-call",
172+
toolCallId: "new",
173+
toolName: "lookup",
174+
input: { id: "new" },
175+
providerExecuted: true,
176+
},
177+
{
178+
type: "tool-result",
179+
toolCallId: "new",
180+
toolName: "lookup",
181+
output: { type: "text", value: "CONVERTED:NEW_TOOL" },
182+
},
183+
],
184+
},
185+
]);
186+
});
187+
188+
it("uses the step boundaries produced by the real UI stream reader", async () => {
189+
const chunks: UIMessageChunk[] = [
190+
{ type: "start", messageId: "a1" },
191+
{ type: "start-step" },
192+
{ type: "tool-input-available", toolCallId: "old", toolName: "lookup", input: {} },
193+
{ type: "tool-output-available", toolCallId: "old", output: "OLD_TOOL" },
194+
{ type: "finish-step" },
195+
{ type: "start-step" },
196+
{ type: "text-start", id: "t" },
197+
{ type: "text-delta", id: "t", delta: "NEW_ANSWER" },
198+
{ type: "text-end", id: "t" },
199+
{ type: "finish-step" },
200+
{ type: "finish" },
201+
];
202+
let ui: UIMessage | undefined;
203+
for await (const message of readUIMessageStream({
204+
stream: new ReadableStream({
205+
start(controller) {
206+
for (const chunk of chunks) controller.enqueue(chunk);
207+
controller.close();
208+
},
209+
}),
210+
}))
211+
ui = message;
212+
expect(ui).toBeDefined();
213+
expect(text(await convert([responseAfterCompaction(ui!, 1)]))).not.toContain("OLD_TOOL");
214+
expect(text(ui)).toContain("OLD_TOOL");
215+
});
216+
});

0 commit comments

Comments
 (0)