diff --git a/.changeset/fix-inner-compaction-response.md b/.changeset/fix-inner-compaction-response.md new file mode 100644 index 00000000000..8ca5c04024a --- /dev/null +++ b/.changeset/fix-inner-compaction-response.md @@ -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. diff --git a/docs/ai-chat/lifecycle-hooks.mdx b/docs/ai-chat/lifecycle-hooks.mdx index 3c062bb4629..23defb4a7ab 100644 --- a/docs/ai-chat/lifecycle-hooks.mdx +++ b/docs/ai-chat/lifecycle-hooks.mdx @@ -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 | diff --git a/docs/ai-chat/reference.mdx b/docs/ai-chat/reference.mdx index e968a37f134..8b691653baf 100644 --- a/docs/ai-chat/reference.mdx +++ b/docs/ai-chat/reference.mdx @@ -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) | diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 9729bf729cb..a9d40d27091 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -93,6 +93,7 @@ import { type TranscriptStorage, type TranscriptStorageContext, } from "./transcriptStorage.js"; +import { responseAfterCompaction } from "./compactionResponse.js"; let transcriptStorageOverride: TranscriptStorage | undefined; @@ -3357,6 +3358,8 @@ const chatOverrideModelMessagesKey = locals.create("chat.overrid interface CompactionState { summary: string; baseResponseMessageCount: number; + /** Completed steps summarized this turn; unlike message counts, matches UI step markers. */ + baseResponseStepCount: number; } /** @internal */ @@ -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. @@ -5903,13 +5907,17 @@ export type TurnCompleteEvent m.id === capturedResponseMessage?.id) + : undefined; if (modelOnlyOverride) { const compactionSummary = locals.get(chatCompactionStateKey)?.summary ?? ""; const taskCompactionConfig = locals.get(chatAgentCompactionKey); @@ -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 @@ -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 } diff --git a/packages/trigger-sdk/src/v3/compactionResponse.test.ts b/packages/trigger-sdk/src/v3/compactionResponse.test.ts new file mode 100644 index 00000000000..52cb3237e13 --- /dev/null +++ b/packages/trigger-sdk/src/v3/compactionResponse.test.ts @@ -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"); + }); +}); diff --git a/packages/trigger-sdk/src/v3/compactionResponse.ts b/packages/trigger-sdk/src/v3/compactionResponse.ts new file mode 100644 index 00000000000..c2a76c186e2 --- /dev/null +++ b/packages/trigger-sdk/src/v3/compactionResponse.ts @@ -0,0 +1,35 @@ +import type { UIMessage } from "ai"; + +/** + * Inner compaction summarizes completed model steps, but the captured UI response + * still contains every step. Trim that prefix before model-message conversion: + * one step can produce zero, one or several model messages (hidden reasoning, + * provider tools, custom tool outputs), so a model-message count is not a UI offset. + * The full response is left untouched for display and transcript persistence. + */ +export function responseAfterCompaction( + response: T, + compactedSteps?: number, + originalResponse?: UIMessage +): T { + if (!compactedSteps) return response; + + // Approval and head-start continuations append new steps to the existing + // assistant. Its original parts were also summarized, but are not in this + // turn's prepareStep.steps. Count their markers separately. Prefixes without + // markers are naturally skipped before the first new step. + const originalSteps = + originalResponse?.id === response.id + ? originalResponse.parts.filter((part) => part.type === "step-start").length + : 0; + const stepsToSkip = originalSteps + compactedSteps; + let step = 0; + let start = response.parts.length; + for (let i = 0; i < response.parts.length; i++) { + if (response.parts[i]?.type === "step-start" && ++step > stepsToSkip) { + start = i; + break; + } + } + return { ...response, parts: response.parts.slice(start) }; +} diff --git a/packages/trigger-sdk/test/handover-lane-replace.test.ts b/packages/trigger-sdk/test/handover-lane-replace.test.ts index 454db679d01..3c2151a3e60 100644 --- a/packages/trigger-sdk/test/handover-lane-replace.test.ts +++ b/packages/trigger-sdk/test/handover-lane-replace.test.ts @@ -7,7 +7,13 @@ */ import { mockChatAgent } from "../src/v3/test/index.js"; import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; -import { simulateReadableStream, streamText, tool, type UIMessage } from "ai"; +import { + convertToModelMessages, + simulateReadableStream, + streamText, + tool, + type UIMessage, +} from "ai"; import { MockLanguageModelV3 } from "ai/test"; import { logger } from "@trigger.dev/core/v3"; import { describe, expect, it, vi } from "vitest"; @@ -49,11 +55,17 @@ function toolUseIds(prompt: unknown): string[] { return ids; } -for (const withMessageId of [false, true]) { - describe(`a head-start turn whose handed-over tool call is followed by more steps (messageId=${withMessageId})`, () => { - it("leaves the next turn one copy of each tool call", { timeout: 30_000 }, async () => { +for (const [withMessageId, compact] of [ + [false, false], + [true, false], + [false, true], + [true, true], +] as const) { + describe(`a head-start turn whose handed-over tool call is followed by more steps (messageId=${withMessageId}, compaction=${compact})`, () => { + it("keeps uncompacted context on the next turn", { timeout: 30_000 }, async () => { const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); const prompts: unknown[] = []; + const turnResponses: { newMessages: unknown; fullReplacement: unknown }[] = []; const steps = [toolStep("tc_2"), textStep("found it"), textStep("second answer")]; let call = 0; const model = new MockLanguageModelV3({ @@ -64,7 +76,21 @@ for (const withMessageId of [false, true]) { }, }); const agent = chat.agent({ - id: `handover-lane-replace-${withMessageId}`, + id: `handover-lane-replace-${withMessageId}-${compact}`, + ...(compact + ? { + compaction: { + shouldCompact: ({ source, turn }) => source === "inner" && turn === 0, + summarize: async () => "HANDOVER_SUMMARY", + }, + } + : {}), + onTurnComplete: async ({ newMessages, newUIMessages }) => { + turnResponses.push({ + newMessages, + fullReplacement: await convertToModelMessages(newUIMessages), + }); + }, tools: { lookup: tool({ description: "lookup", @@ -78,11 +104,12 @@ for (const withMessageId of [false, true]) { messages, tools, abortSignal: signal, + ...chat.toStreamTextOptions(), stopWhen: ({ steps }) => steps.length >= 5, }), }); const harness = mockChatAgent(agent, { - chatId: `handover-lane-replace-${withMessageId}`, + chatId: `handover-lane-replace-${withMessageId}-${compact}`, mode: "handover-prepare", headStartMessages: [userMessage("what errors?", "u1")], }); @@ -106,20 +133,26 @@ for (const withMessageId of [false, true]) { } as never); await new Promise((r) => setTimeout(r, 50)); await harness.sendMessage(userMessage("follow up", "u2")); + // The handover prefix is introduced in this turn, so it belongs in + // the persistence response even when compaction removes it from context. + expect(JSON.stringify(turnResponses[0]!.newMessages)).toContain("tc_hs"); + expect(JSON.stringify(turnResponses[0]!.newMessages)).toContain("tc_2"); + expect(turnResponses[0]!.newMessages).toEqual(turnResponses[0]!.fullReplacement); const last = prompts[prompts.length - 1]; const ids = toolUseIds(last); - expect(ids).toEqual(["tc_hs", "tc_2"]); + expect(ids).toEqual(compact ? [] : ["tc_hs", "tc_2"]); + if (compact) { + expect(JSON.stringify(last)).toContain("HANDOVER_SUMMARY"); + expect(JSON.stringify(last)).toContain("found it"); + expect(JSON.stringify(last)).not.toContain("what errors?"); + } // The lane is the completed conversation, in order, with the partial gone. const roles = (last as Array<{ role: string }>).map((m) => m.role); - expect(roles).toEqual([ - "user", - "assistant", - "tool", - "assistant", - "tool", - "assistant", - "user", - ]); + expect(roles).toEqual( + compact + ? ["assistant", "assistant", "user"] + : ["user", "assistant", "tool", "assistant", "tool", "assistant", "user"] + ); const reconverted = warnSpy.mock.calls.filter((args) => String(args[0]).includes("reconverting the lane") ); diff --git a/packages/trigger-sdk/test/hitl-uncompacts.test.ts b/packages/trigger-sdk/test/hitl-uncompacts.test.ts index 62130beba23..2fe529deafb 100644 --- a/packages/trigger-sdk/test/hitl-uncompacts.test.ts +++ b/packages/trigger-sdk/test/hitl-uncompacts.test.ts @@ -1,7 +1,7 @@ import { mockChatAgent } from "../src/v3/test/index.js"; import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; -import { simulateReadableStream, streamText, tool } from "ai"; +import { convertToModelMessages, simulateReadableStream, streamText, tool } from "ai"; import type { UIMessage } from "ai"; import { MockLanguageModelV3 } from "ai/test"; import { describe, expect, it } from "vitest"; @@ -156,3 +156,128 @@ describe("a tool-approval turn after compaction", () => { } }); }); + +describe("same-ID approval response persistence", () => { + it.each([false, true])( + "keeps the complete replacement response with inner compaction=%s", + { timeout: 30_000 }, + async (compact) => { + const prompts: string[] = []; + const turns: { + uiMessages: UIMessage[]; + newMessages: unknown; + fullReplacement: unknown; + }[] = []; + let step = 0; + const model = new MockLanguageModelV3({ + doStream: async ({ prompt }) => { + prompts.push(JSON.stringify(prompt)); + const n = step++; + const chunks = + n === 0 + ? approvalToolCall("tc-approval") + : n === 1 + ? [ + { + type: "tool-call" as const, + toolCallId: "tc-current", + toolName: "lookup", + input: "{}", + }, + { + type: "finish" as const, + finishReason: { unified: "tool-calls" as const, raw: "tool-calls" }, + usage: USAGE, + }, + ] + : textChunks(`answer-${n}`); + return { stream: simulateReadableStream({ chunks }) }; + }, + }); + const agent = chat.agent({ + id: `approval-response-persistence-${compact}`, + ...(compact + ? { + compaction: { + shouldCompact: ({ source, turn }) => source === "inner" && turn === 1, + summarize: async () => "APPROVAL_SUMMARY", + }, + } + : {}), + onTurnComplete: async ({ uiMessages, newMessages, newUIMessages }) => { + turns.push({ + uiMessages: structuredClone(uiMessages), + newMessages: structuredClone(newMessages), + fullReplacement: await convertToModelMessages(newUIMessages, { + ignoreIncompleteToolCalls: true, + }), + }); + }, + run: async ({ messages, signal }) => + streamText({ + model, + messages, + abortSignal: signal, + tools: { + risky: tool({ + inputSchema: z.object({ what: z.string() }), + needsApproval: true, + execute: async () => "NEW_APPROVAL_RESULT", + }), + lookup: tool({ + inputSchema: z.object({}), + execute: async () => "CURRENT_STEP_RESULT", + }), + }, + ...chat.toStreamTextOptions(), + stopWhen: ({ steps }) => steps.length >= 5, + }), + }); + const harness = mockChatAgent(agent, { chatId: `approval-response-persistence-${compact}` }); + try { + await harness.sendMessage(userMessage("approve this", "u-1")); + await waitFor(() => turns.length >= 1, "approval request hook"); + const original = turns[0]!.uiMessages.at(-1)!; + const approval = original.parts.find((p) => p.type === "tool-risky") as { + toolCallId: string; + approval: { id: string }; + }; + await harness.sendMessage({ + id: original.id, + role: "assistant", + parts: [ + { + type: "tool-risky", + toolCallId: approval.toolCallId, + state: "approval-responded", + approval: { id: approval.approval.id, approved: true }, + }, + ], + } as unknown as UIMessage); + await waitFor(() => turns.length >= 2, "approval continuation hook"); + const continued = turns[1]!; + expect(continued.uiMessages.at(-1)!.id).toBe(original.id); + // Continuations replace the original assistant. Its old tool part now + // holds a new result, so dropping the original parts would lose it. + expect(JSON.stringify(turns[0]!.newMessages)).not.toContain("NEW_APPROVAL_RESULT"); + expect(JSON.stringify(continued.newMessages)).toContain("NEW_APPROVAL_RESULT"); + expect(JSON.stringify(continued.newMessages)).toContain("CURRENT_STEP_RESULT"); + expect(JSON.stringify(continued.newMessages)).toContain("answer-2"); + expect(continued.newMessages).toEqual(continued.fullReplacement); + await harness.sendMessage(userMessage("follow up", "u-2")); + const next = prompts.at(-1)!; + if (compact) { + expect(next).toContain("APPROVAL_SUMMARY"); + expect(next).not.toContain("NEW_APPROVAL_RESULT"); + expect(next).not.toContain("CURRENT_STEP_RESULT"); + } else { + expect(next).toContain("NEW_APPROVAL_RESULT"); + expect(next).toContain("CURRENT_STEP_RESULT"); + } + expect(next).toContain("answer-2"); + } finally { + await harness.close(); + } + } + ); +}); diff --git a/packages/trigger-sdk/test/steering-compaction-lanes.test.ts b/packages/trigger-sdk/test/steering-compaction-lanes.test.ts index 5de7ed4a89d..a075f80a2f4 100644 --- a/packages/trigger-sdk/test/steering-compaction-lanes.test.ts +++ b/packages/trigger-sdk/test/steering-compaction-lanes.test.ts @@ -2,7 +2,7 @@ import { mockChatAgent } from "../src/v3/test/index.js"; import { sessionStreams } from "@trigger.dev/core/v3"; import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; -import { simulateReadableStream, stepCountIs, streamText, tool } from "ai"; +import { convertToModelMessages, simulateReadableStream, stepCountIs, streamText, tool } from "ai"; import { MockLanguageModelV3 } from "ai/test"; import { describe, expect, it } from "vitest"; import { z } from "zod"; @@ -103,7 +103,7 @@ describe("chat.agent steering with compaction in the same turn", () => { execute: async () => { toolEntered = true; await toolGate.promise; - return "ok"; + return "COMPACTED_TOOL_SENTINEL"; }, }); @@ -123,10 +123,18 @@ describe("chat.agent steering with compaction in the same turn", () => { }, }); + const completedTurns: { messages: string; newMessages: string; fullDelta: string }[] = []; let compacted = 0; const agent = chat.agent({ id: "steer-compaction", pendingMessages: { shouldInject: () => true }, + onTurnComplete: async ({ messages, newMessages, newUIMessages }) => { + completedTurns.push({ + messages: JSON.stringify(messages), + newMessages: JSON.stringify(newMessages), + fullDelta: JSON.stringify(await convertToModelMessages(newUIMessages)), + }); + }, compaction: { // Compact once, at the first step boundary of turn 1. shouldCompact: () => compacted === 0, @@ -170,6 +178,18 @@ describe("chat.agent steering with compaction in the same turn", () => { // ...instead of the message the summary replaced. This is the assertion a // rebuild-based reconciliation fails. expect(turn2).not.toContain("EARLY-SENTINEL"); + // The current turn's tool response was summarized too. Keeping the old + // user message out is insufficient if completion re-appends every step. + expect(allPrompts[1]).not.toContain("COMPACTED_TOOL_SENTINEL"); + expect(turn2Raw).not.toContain("COMPACTED_TOOL_SENTINEL"); + expect(turn2Raw).not.toContain('"toolCallId":"tc-1"'); + + // Append-only history consumers still receive the complete turn delta. + expect(completedTurns[0]?.messages).not.toContain("COMPACTED_TOOL_SENTINEL"); + expect(completedTurns[0]?.newMessages).toContain("COMPACTED_TOOL_SENTINEL"); + expect(completedTurns[0]?.newMessages).toContain('"toolCallId":"tc-1"'); + expect(completedTurns[0]?.newMessages).toContain("done"); + expect(completedTurns[0]?.newMessages).toEqual(completedTurns[0]?.fullDelta); } finally { toolGate.resolve(); await harness.close();