Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-inner-compaction-response.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---

Keep summarized assistant steps and tool results out of future `chat.agent()` model context after inner compaction, while preserving the full visible conversation.
6 changes: 4 additions & 2 deletions docs/ai-chat/lifecycle-hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -405,14 +405,16 @@ Receives the same fields as [`TurnCompleteEvent`](/ai-chat/reference#turncomplet

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.

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.

| Field | Type | Description |
| -------------------- | ------------------------ | -------------------------------------------------------------------------------------------- |
| `ctx` | `TaskRunContext` | Full task run context. See [reference](/ai-chat/reference#task-context-ctx). |
| `chatId` | `string` | Chat session ID |
| `messages` | `ModelMessage[]` | Full accumulated conversation (model format) |
| `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) |
| `newMessages` | `ModelMessage[]` | Only this turn's messages (model format) |
| `newUIMessages` | `UIMessage[]` | Only this turn's messages (UI format) |
| `newMessages` | `ModelMessage[]` | This turn's model messages, including full same-ID replacement responses |
| `newUIMessages` | `UIMessage[]` | New or updated UI messages; upsert by message ID |
| `responseMessage` | `UIMessage \| undefined` | The assistant's response for this turn |
| `turn` | `number` | Turn number (0-indexed) |
| `runId` | `string` | The Trigger.dev run ID |
Expand Down
6 changes: 4 additions & 2 deletions docs/ai-chat/reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -299,14 +299,16 @@ Passed to the `onTurnStart` callback.

Passed to the `onTurnComplete` callback.

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.

| Field | Type | Description |
| -------------------- | --------------------------------- | ---------------------------------------------------- |
| `ctx` | `TaskRunContext` | Full task run context — see [Task context](#task-context-ctx) |
| `chatId` | `string` | Chat session ID |
| `messages` | `ModelMessage[]` | Full accumulated conversation (model format) |
| `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) |
| `newMessages` | `ModelMessage[]` | Only this turn's messages (model format) |
| `newUIMessages` | `UIMessage[]` | Only this turn's messages (UI format) |
| `newMessages` | `ModelMessage[]` | This turn's model messages, including full same-ID replacement responses |
| `newUIMessages` | `UIMessage[]` | New or updated UI messages; upsert by message ID |
| `responseMessage` | `UIMessage \| undefined` | The assistant's response for this turn |
| `rawResponseMessage` | `UIMessage \| undefined` | Raw response before abort cleanup |
| `turn` | `number` | Turn number (0-indexed) |
Expand Down
60 changes: 53 additions & 7 deletions packages/trigger-sdk/src/v3/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import {
type TranscriptStorage,
type TranscriptStorageContext,
} from "./transcriptStorage.js";
import { responseAfterCompaction } from "./compactionResponse.js";

let transcriptStorageOverride: TranscriptStorage<unknown> | undefined;

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

/** @internal */
Expand Down Expand Up @@ -4173,6 +4176,7 @@ async function chatCompact(
locals.set(chatCompactionStateKey, {
summary,
baseResponseMessageCount: currentStep.response.messages.length,
baseResponseStepCount: steps.length,
});

// Set model-only override — UI messages stay intact for persistence.
Expand Down Expand Up @@ -5903,13 +5907,17 @@ export type TurnCompleteEvent<TClientData = unknown, TUIM extends UIMessage = UI
*/
uiMessages: TUIM[];
/**
* Only the new model messages from this turn (user message(s) + assistant response).
* Useful for appending to an existing conversation record.
* Model messages for this turn's user message(s) and complete assistant response,
* including steps summarized during the turn. Same-ID approval and handover
* continuations include the full replacement response, so these are not always
* an append-only delta. Persist `messages` for future model context, or upsert
* `newUIMessages` by ID for the visible conversation.
*/
newMessages: ModelMessage[];
/**
* Only the new UI messages from this turn (user message(s) + assistant response).
* Useful for inserting individual message records instead of overwriting the full history.
* New or updated UI messages from this turn (user message(s) + assistant response).
* Upsert by message ID: approval and handover continuations can replace an
* existing assistant message.
*/
newUIMessages: TUIM[];
/** The assistant's response for this turn, with aborted parts cleaned up when `stopped` is true. Undefined if `pipeChat` was used manually. */
Expand Down Expand Up @@ -9425,6 +9433,14 @@ function chatAgent<
// Check if compaction set a model-only override (preserves UI messages).
// Apply compactUIMessages/compactModelMessages callbacks if configured.
const modelOnlyOverride = locals.get(chatOverrideModelMessagesKey);
const responseCompaction = modelOnlyOverride
? locals.get(chatCompactionStateKey)
: undefined;
// Capture the original assistant before compactUIMessages can remove it.
const originalResponse =
responseCompaction && capturedResponseMessage
? accumulatedUIMessages.find((m) => m.id === capturedResponseMessage?.id)
: undefined;
if (modelOnlyOverride) {
const compactionSummary = locals.get(chatCompactionStateKey)?.summary ?? "";
const taskCompactionConfig = locals.get(chatAgentCompactionKey);
Expand Down Expand Up @@ -9529,10 +9545,37 @@ function chatAgent<
// rationale (TRI-9137).
recordToolCallIdsFromMessage(capturedResponseMessage);
try {
const responseForModel = responseAfterCompaction(
capturedResponseMessage,
responseCompaction?.baseResponseStepCount,
originalResponse
);
// Preserve the complete persistence response, including same-ID
// replacements whose old tool parts can contain new results.
// Convert prefix and suffix separately so each tool output is
// converted once, while only the suffix enters model context.
const responsePrefixMessages = responseCompaction
? await toModelMessages([
stripProviderMetadata({
...capturedResponseMessage,
parts: capturedResponseMessage.parts.slice(
0,
capturedResponseMessage.parts.length -
responseForModel.parts.length
),
}),
])
: [];
const responseModelMessages = await toModelMessages([
stripProviderMetadata(capturedResponseMessage),
stripProviderMetadata(responseForModel),
]);
if (existingIdx !== -1) {
if (responseCompaction) {
// The summary already replaced the original response, including
// a same-ID approval/handover prefix. Replacing its old model run
// would miss and fall back to the full, uncompacted UI history.
accumulatedMessages.push(...responseModelMessages);
locals.set(chatHandoverSplicedRunKey, undefined);
} else if (existingIdx !== -1) {
const spliced = locals.get(chatHandoverSplicedRunKey);
const splicedRun =
spliced && previousAtIdx && spliced.id === previousAtIdx.id
Expand All @@ -9559,7 +9602,10 @@ function chatAgent<
} else {
accumulatedMessages.push(...responseModelMessages);
}
turnNewModelMessages.push(...responseModelMessages);
turnNewModelMessages.push(
...responsePrefixMessages,
...responseModelMessages
);
} catch {
// Conversion failed — skip accumulation for this turn
}
Expand Down
216 changes: 216 additions & 0 deletions packages/trigger-sdk/src/v3/compactionResponse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import {
convertToModelMessages,
readUIMessageStream,
type ModelMessage,
type UIMessage,
type UIMessageChunk,
} from "ai";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { responseAfterCompaction } from "./compactionResponse.js";
import { restoreModelLane } from "./transcriptStorage.js";

const summary: ModelMessage = { role: "assistant", content: "SUMMARY" };
const oldUser: UIMessage = { id: "u1", role: "user", parts: [{ type: "text", text: "OLD_USER" }] };
const nextUser: UIMessage = {
id: "u2",
role: "user",
parts: [{ type: "text", text: "NEXT_QUESTION" }],
};
const convert = (messages: UIMessage[]) =>
convertToModelMessages(messages, { ignoreIncompleteToolCalls: true });
const text = (messages: unknown) => JSON.stringify(messages);
const step = (...parts: UIMessage["parts"]): UIMessage["parts"] => [
{ type: "step-start" },
...parts,
];
const toolResult = (
id: string,
output: string,
providerExecuted = false
): UIMessage["parts"][number] => ({
type: "dynamic-tool",
toolName: "lookup",
toolCallId: id,
state: "output-available",
input: { id },
output,
providerExecuted,
});
function response(compactedSteps: number): UIMessage {
return {
id: "a1",
role: "assistant",
parts: [
...Array.from({ length: compactedSteps }, (_, i) =>
step({ type: "text", text: `OLD_ASSISTANT_${i}` }, toolResult(`old-${i}`, `OLD_TOOL_${i}`))
).flat(),
...step(toolResult("new", "NEW_TOOL")),
...step({ type: "text", text: "NEW_ANSWER" }),
],
};
}

describe("response model history after inner compaction", () => {
it.each([1, 2, 3])(
"keeps only the response after %i compacted steps, including on restoration",
async (count) => {
const ui = response(count);
const before = structuredClone(ui);
const model = [summary, ...(await convert([responseAfterCompaction(ui, count)]))];
expect(text(model)).toContain("SUMMARY");
expect(text(model)).toContain("NEW_TOOL");
expect(text(model)).toContain("NEW_ANSWER");
expect.soft(text(model)).not.toContain("OLD_ASSISTANT");
expect.soft(text(model)).not.toContain("OLD_TOOL");
expect(ui).toEqual(before);
expect(text(ui)).toContain("OLD_TOOL");
const state = JSON.parse(
JSON.stringify({ v: 1, compaction: { modelMessages: model, throughId: ui.id } })
);
const restored = await restoreModelLane([oldUser, ui, nextUser], state, convert);
expect.soft(text(restored.messages)).not.toContain("OLD_");
expect(restored.messages).toEqual([...model, ...(await convert([nextUser]))]);
}
);

it("keeps the complete response when this turn did not compact", () => {
const ui = response(1);
expect(responseAfterCompaction(ui)).toBe(ui);
});

it("does not confuse an empty or hidden-reasoning step with a model message", async () => {
const ui: UIMessage = {
id: "a1",
role: "assistant",
parts: [
...step(), // A provider step whose reasoning was omitted from the UI stream.
...step(toolResult("old", "OLD_TOOL", true)),
...step({ type: "text", text: "NEW_ANSWER" }),
],
};
const model = await convert([responseAfterCompaction(ui, 2)]);
expect(model).toEqual([{ role: "assistant", content: [{ type: "text", text: "NEW_ANSWER" }] }]);
});

it("retains a partially streamed post-compaction answer", async () => {
const ui: UIMessage = {
id: "a1",
role: "assistant",
parts: [
...step(toolResult("old", "OLD_TOOL")),
...step({ type: "text", text: "PARTIAL", state: "streaming" }),
],
};
const model = await convert([responseAfterCompaction(ui, 1)]);
expect(text(model)).toContain("PARTIAL");
expect(text(model)).not.toContain("OLD_TOOL");
});

it("appends nothing if stopped before the first post-compaction step", async () => {
const ui: UIMessage = {
id: "a1",
role: "assistant",
parts: step(toolResult("old", "OLD_TOOL")),
};
expect(await convert([responseAfterCompaction(ui, 1)])).toEqual([]);
});

it.each([true, false])(
"handles a same-ID continuation whose original has step markers: %s",
async (hasMarkers) => {
const original: UIMessage = {
id: "a1",
role: "assistant",
parts: [
...(hasMarkers ? [{ type: "step-start" as const }] : []),
toolResult("prior", "PRIOR_TURN_TOOL"),
],
};
const ui = response(1);
ui.parts.unshift(...original.parts);
const originalBefore = structuredClone(original);
const model = await convert([responseAfterCompaction(ui, 1, original)]);
expect(text(model)).toContain("NEW_ANSWER");
expect(text(model)).toContain("NEW_TOOL");
expect(text(model)).not.toContain("OLD_");
expect(text(model)).not.toContain("PRIOR_TURN_TOOL");
expect(original).toEqual(originalBefore);
}
);

it("ignores an original response with a different ID", async () => {
const original = { ...response(3), id: "other" };
const model = await convert([responseAfterCompaction(response(1), 1, original)]);
expect(text(model)).toContain("NEW_ANSWER");
expect(text(model)).not.toContain("OLD_");
});

it("keeps post-compaction provider tools and custom tool output conversion", async () => {
const ui: UIMessage = {
id: "a1",
role: "assistant",
parts: [...step(toolResult("old", "OLD_TOOL")), ...step(toolResult("new", "NEW_TOOL", true))],
};
const model = await convertToModelMessages([responseAfterCompaction(ui, 1)], {
tools: {
lookup: {
inputSchema: z.object({ id: z.string() }),
toModelOutput: ({ output }: { output: unknown }) => ({
type: "text" as const,
value: `CONVERTED:${output}`,
}),
},
},
});
expect(model).toEqual([
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "new",
toolName: "lookup",
input: { id: "new" },
providerExecuted: true,
},
{
type: "tool-result",
toolCallId: "new",
toolName: "lookup",
output: { type: "text", value: "CONVERTED:NEW_TOOL" },
},
],
},
]);
});

it("uses the step boundaries produced by the real UI stream reader", async () => {
const chunks: UIMessageChunk[] = [
{ type: "start", messageId: "a1" },
{ type: "start-step" },
{ type: "tool-input-available", toolCallId: "old", toolName: "lookup", input: {} },
{ type: "tool-output-available", toolCallId: "old", output: "OLD_TOOL" },
{ type: "finish-step" },
{ type: "start-step" },
{ type: "text-start", id: "t" },
{ type: "text-delta", id: "t", delta: "NEW_ANSWER" },
{ type: "text-end", id: "t" },
{ type: "finish-step" },
{ type: "finish" },
];
let ui: UIMessage | undefined;
for await (const message of readUIMessageStream({
stream: new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk);
controller.close();
},
}),
}))
ui = message;
expect(ui).toBeDefined();
expect(text(await convert([responseAfterCompaction(ui!, 1)]))).not.toContain("OLD_TOOL");
expect(text(ui)).toContain("OLD_TOOL");
});
});
Loading
Loading