import type { UIMessage, UIMessageChunk } from "ai"; import { resourceCatalog } from "@trigger.dev/core/v3"; import type { LocalsKey } from "@trigger.dev/core/v3"; import { runInMockTaskContext, type MockTaskContextOptions } from "@trigger.dev/core/v3/test"; import { __setSessionOpenImplForTests, __setSessionStartImplForTests } from "../sessions.js"; import { __setReadChatSnapshotImplForTests, __setReplaySessionInTailImplForTests, __setReplaySessionOutTailImplForTests, __setWriteChatSnapshotImplForTests, type ChatSnapshotV1, } from "../ai.js"; import { createTestSessionHandle, type TestSessionOutState } from "./test-session-handle.js"; /** Pre-seed locals before the agent's `run()` starts. */ export type SetupLocals = (locals: { set(key: LocalsKey, value: T): void; }) => void | Promise; // The slim wire payload shape used by chat.agent tasks. Kept loose here so we // don't import from the backend-only ai.ts module. At most ONE message per // record — runtime rebuilds prior history from snapshot + replay at boot. type ChatWirePayload = { /** At most one message — singular under the slim wire. Set on submit-message. */ message?: UIMessage; /** Bespoke escape hatch — only set on `trigger: "handover-prepare"`. */ headStartMessages?: UIMessage[]; chatId: string; trigger: | "submit-message" | "regenerate-message" | "preload" | "close" | "action" | "handover-prepare"; messageId?: string; metadata?: unknown; action?: unknown; continuation?: boolean; previousRunId?: string; idleTimeoutInSeconds?: number; sessionId?: string; }; /** A reference to a `chat.agent` task returned by `chat.agent({ id, ... })`. */ type ChatAgentHandle = { id: string }; /** * Options for `mockChatAgent`. */ export type MockChatAgentOptions = { /** The chat session id passed into every wire payload. Defaults to `"test-chat"`. */ chatId?: string; /** Client-provided metadata (`clientData`) for the session. */ clientData?: unknown; /** Task context overrides passed through to {@link runInMockTaskContext}. */ taskContext?: MockTaskContextOptions; /** * Whether to start the task in preload mode. Defaults to `true` so the * first `sendMessage()` triggers the first turn via the preload path. * Set to `false` to skip preload — the first `sendMessage()` starts turn 0 directly. * * Ignored when `mode: "handover-prepare"` is set. */ preload?: boolean; /** * Initial trigger the agent boots with. Defaults to `"preload"` (or * `"submit-message"` when `preload: false`, or `"continuation"` when * `continuation: true`). * * - `"preload"` — fresh chat preloaded via `transport.preload`. Fires * `onPreload`, waits for the first message. * - `"submit-message"` — fresh chat with the first message in the boot * payload (the `chat.createStartSessionAction({ basePayload: { message } })` * pattern). Goes straight to turn 0. * - `"continuation"` — new run picking up an existing session after the * prior run ended (`chat.endRun`, waitpoint timeout, `chat.requestUpgrade`). * Boots with `trigger` omitted and `continuation: true` — mirrors what * the server's `ensureRunForSession` / `swapSessionRun` produces in * production. The SDK enters its continuation-wait branch; `onPreload` * and `onChatStart` do NOT fire on this run. * - `"handover-prepare"` — drives the chat.handover wait branch; call * `sendHandover()` / `sendHandoverSkip()` to dispatch the handover signal. */ mode?: "preload" | "submit-message" | "handover-prepare" | "continuation"; /** * First-turn UIMessage history shipped on the BOOT payload. Only * meaningful with `mode: "handover-prepare"` — mirrors the * `chat.headStart` route handler's `basePayload.headStartMessages`. */ headStartMessages?: UIMessage[]; /** * Pre-seed the snapshot the agent reads at run boot. The runtime's * snapshot read is replaced with one that returns this snapshot * (skipping the real S3 GET). Use to drive boot scenarios — fresh * boot with prior history, OOM-retry boot with stale snapshot, etc. * Pass `undefined` (the default) to start with no snapshot. * * See plan section B.3 for the boot orchestration spec. */ snapshot?: ChatSnapshotV1; /** * Set `payload.continuation = true` on the initial wire payload. Used * to simulate a continuation-run boot (a new run picking up after a * prior run on the same session ended via `chat.endRun`, waitpoint * timeout, or `chat.requestUpgrade`). * * Setting this without specifying `mode` auto-selects `mode: * "continuation"` — the SDK boot path enters its continuation-wait * branch and waits silently on `session.in` for the first user * message. `onPreload` and `onChatStart` do NOT fire on this run. * * Defaults to `false` (fresh run). */ continuation?: boolean; /** * Set `payload.previousRunId` on the initial wire payload. Forwarded * to `onChatStart` / `onTurnStart` and used by the boot gate as a * prior-state signal. Usually paired with `continuation: true`. */ previousRunId?: string; /** * Callback that runs **before** the agent's `run()` is invoked, with a * `set` function for pre-seeding locals. Use this to inject server-side * dependencies (database clients, service stubs) that the agent reads * via `locals.get()` in its hooks. * * @example * ```ts * import { dbKey } from "./db"; * * const harness = mockChatAgent(agent, { * chatId: "test-1", * setupLocals: (locals) => { * locals.set(dbKey, testDb); * }, * }); * ``` */ setupLocals?: SetupLocals; }; /** * Result of a single turn, returned by driver methods like `sendMessage()`. */ export type MockChatAgentTurn = { /** UIMessageChunks emitted during this turn (excludes control chunks like turn-complete). */ chunks: UIMessageChunk[]; /** All raw chunks including control chunks (turn-complete, upgrade-required, etc.). */ rawChunks: unknown[]; }; /** * Harness returned by `mockChatAgent`. Drives a `chat.agent` task end-to-end * without network or task runtime. */ export type MockChatAgentHarness = { /** The chat session id used by this harness. */ readonly chatId: string; /** * Send a single user message (or tool-approval-responded assistant * message) and wait for the next turn-complete. Returns the chunks * produced during this turn. * * Slim wire: at most ONE message per send. The agent reconstructs prior * history from snapshot + session.out replay at run boot. */ sendMessage(message: UIMessage): Promise; /** * Send a regenerate signal (no message body — slim wire). The agent * trims trailing assistant messages from its in-memory accumulator and * re-runs. Waits for turn-complete. */ sendRegenerate(): Promise; /** * Drive the head-start path: sends `trigger: "handover-prepare"` with * `headStartMessages` carrying the first-turn UIMessage history. Used * only at the very first turn before any snapshot exists. The route * handler ships full UIMessage history through this path because the * customer's HTTP endpoint isn't subject to the `/in/append` cap. */ sendHeadStart(args: { messages: UIMessage[] }): Promise; /** Send a custom action and wait for the next turn-complete. */ sendAction(action: unknown): Promise; /** Fire a stop signal. Does not wait for the turn — the task keeps running. */ sendStop(message?: string): Promise; /** * Dispatch a `handover` signal — the agent picks up partial assistant * messages and continues the turn. Only meaningful when the harness * was started with `mode: "handover-prepare"`. Waits for turn-complete. * * `isFinal: false` (default) — agent runs `streamText` which executes * any pending tool-calls (via the approval round) and resumes from * step 2. * * `isFinal: true` — agent runs lifecycle hooks but skips `streamText`. * The partial IS the response; `onTurnComplete` fires with it. */ sendHandover(args: { partialAssistantMessage: unknown[]; isFinal?: boolean; messageId?: string; }): Promise; /** * Dispatch a `handover-skip` signal — the agent exits cleanly without * firing turn hooks. Only meaningful when the harness was started * with `mode: "handover-prepare"`. Awaits the run finishing. */ sendHandoverSkip(): Promise; /** * Pre-seed the snapshot read for the next boot. The runtime's snapshot * read returns this snapshot (skipping S3). Pass `undefined` to clear — * the boot then sees no snapshot and falls through to replay-only. * * Effective on the next run boot only. Calling mid-turn is a no-op * because the snapshot read happens once at run boot. */ seedSnapshot(snapshot: ChatSnapshotV1 | undefined): void; /** * Pre-seed `session.out` chunks for the next boot's replay. The runtime's * `replaySessionOutTail` returns whatever the synthetic chunks reduce * to. Pass `[]` to clear (boot replay returns no messages). * * Requires `__setReplaySessionOutTailImplForTests` exported from * `ai.ts`. The harness throws a clear error at call time if that hook * isn't available. */ seedSessionOutTail(chunks?: UIMessageChunk[]): void; /** * Pre-seed a trailing partial assistant message for the next boot's * replay. The runtime's `replaySessionOutTail` returns this as the * `partial` field (alongside whatever `seedSessionOutTail` reduces * to). Use to simulate cancel-mid-stream: an assistant message whose * `finish` chunk never arrived. Pass `undefined` to clear. * * Effective on the next run boot only. */ seedSessionOutPartial(partial: UIMessage | undefined): void; /** * Pre-seed user messages on the `session.in` tail for the next boot's * replay. Each message is paired with a synthetic seq_num (`i + 1`). * Used to simulate in-flight users the dead predecessor was supposed * to process. Pass `[]` to clear. * * Effective on the next run boot only. */ seedSessionInTail(messages: UIMessage[]): void; /** * The most recently written snapshot, or `undefined` if no snapshot * has been written yet. Updated each time `writeChatSnapshot` is * invoked from the run loop's snapshot-write site (plan section B.6). */ getSnapshot(): ChatSnapshotV1 | undefined; /** * Close the chat session cleanly. Sends `trigger: "close"` and awaits the * task's `run()` function returning. Call this at the end of every test * (or use `await using`) so the background task isn't left dangling. */ close(): Promise; /** All UIMessageChunks emitted since the harness was created. */ readonly allChunks: UIMessageChunk[]; /** Every raw chunk (including control chunks) emitted since the harness was created. */ readonly allRawChunks: unknown[]; }; const CONTROL_CHUNK_TYPES = new Set(["trigger:turn-complete", "trigger:upgrade-required"]); function isControlChunk(chunk: unknown): boolean { if (typeof chunk !== "object" || chunk === null) return false; const type = (chunk as { type?: string }).type; return typeof type === "string" && CONTROL_CHUNK_TYPES.has(type); } /** * Create an offline test harness for a `chat.agent` task. * * The harness starts the agent's `run()` function in a mocked task context, * waits in preload for the first message, then exposes driver methods for * sending messages / actions / stop signals and awaiting turn completion. * * Users are responsible for mocking the language model themselves — use * `MockLanguageModelV3` and `simulateReadableStream` from `ai/test` inside * their agent's `run()` function (typically via DI through `clientData`). * * @example * ```ts * import { mockChatAgent } from "@trigger.dev/sdk/ai/test"; * import { MockLanguageModelV3, simulateReadableStream } from "ai/test"; * import { myAgent } from "./my-agent"; * * test("says hello", async () => { * const harness = mockChatAgent(myAgent, { chatId: "test-1" }); * try { * const turn = await harness.sendMessage({ * id: "m1", * role: "user", * parts: [{ type: "text", text: "hi" }], * }); * expect(turn.chunks).toContainEqual( * expect.objectContaining({ type: "text-delta", delta: "hello" }) * ); * } finally { * await harness.close(); * } * }); * ``` */ export function mockChatAgent( agent: ChatAgentHandle, options: MockChatAgentOptions = {} ): MockChatAgentHarness { const chatId = options.chatId ?? "test-chat"; // The agent opens the session with `payload.sessionId ?? payload.chatId`. // We pass no sessionId, so it falls back to chatId. const sessionId = chatId; // `continuation: true` without an explicit mode auto-selects "continuation" // — the canonical shape for a continuation-run boot. const mode: "preload" | "submit-message" | "handover-prepare" | "continuation" = options.mode ?? (options.continuation === true ? "continuation" : options.preload === false ? "submit-message" : "preload"); const clientData = options.clientData; const taskEntry = resourceCatalog.getTask(agent.id); if (!taskEntry) { throw new Error( `mockChatAgent: no task registered with id "${agent.id}". ` + `Import "@trigger.dev/sdk/ai/test" before your agent module so tasks register correctly.` ); } const runFn = taskEntry.fns.run; // Session .out state: chunks + listener registry. Shared between the // harness and the TestSessionOutputChannel installed via the open-override. const sessionOutState: TestSessionOutState = { chunks: [], listeners: new Set(), }; // Buffers that survive across harness method calls const allRawChunks: unknown[] = []; const allChunks: UIMessageChunk[] = []; // Promise that resolves when the background task run() function returns. let taskFinished!: Promise; let sendSessionInput!: (sessionId: string, data: unknown) => Promise; let closeSessionInput: ((sessionId: string) => void) | undefined; let runSignal!: AbortController; // A latch that resolves every time `trigger:turn-complete` appears on the chat stream. // We use a shared pending promise and replace it after each completion. let turnCompleteResolvers: Array<() => void> = []; const waitForTurnComplete = () => new Promise((resolve) => { turnCompleteResolvers.push(resolve); }); // Signal that the caller is ready to observe output let harnessReadyResolve!: () => void; const harnessReady = new Promise((resolve) => { harnessReadyResolve = resolve; }); // ── Snapshot read/write override state ─────────────────────────────── // The runtime's snapshot read returns whatever `seededSnapshot` is at // boot time. The runtime's snapshot write captures into // `lastWrittenSnapshot` for harness consumers to assert via // `getSnapshot()`. Installed below alongside the session overrides; // cleared on close in the same finally block. let seededSnapshot: ChatSnapshotV1 | undefined = options.snapshot; let lastWrittenSnapshot: ChatSnapshotV1 | undefined; let seededReplayChunks: UIMessageChunk[] = []; let seededReplayPartial: UIMessage | undefined; let seededSessionInMessages: UIMessage[] = []; __setReadChatSnapshotImplForTests((_id: string) => { return seededSnapshot as ChatSnapshotV1 | undefined; }); __setWriteChatSnapshotImplForTests( (_id: string, snapshot: ChatSnapshotV1) => { lastWrittenSnapshot = snapshot as ChatSnapshotV1; } ); // Replay override: install a default that returns whatever // `seededReplayChunks` reduces to. `mockChatAgent` doesn't model the // settled-vs-partial split — seeded chunks always reduce to the // `settled` array with `partial: undefined`. Recovery-specific // tests can install their own override to seed a partial. // Cleared in the same `finally` block as the other test overrides. __setReplaySessionOutTailImplForTests(async () => { const settled = seededReplayChunks.length === 0 ? [] : ((await reduceChunksToMessages(seededReplayChunks)) as unknown[]); // For the mock harness, `partialRaw` is the same as `partial` — we // don't model cleanupAbortedParts separately. Recovery tests that // need a partialRaw distinct from partial install their own stub. return { settled, partial: seededReplayPartial, partialRaw: seededReplayPartial, } as never; }); // session.in tail override: each seeded UIMessage becomes a // { message, metadata: undefined, seqNum: i+1 } entry. Mirrors the // seq-num pattern from the out-tail stub so cursor-advance logic is // exercised correctly. `metadata` is `undefined` for seeded users — // the boot path falls back to `payload.metadata` for those. __setReplaySessionInTailImplForTests(async () => { return seededSessionInMessages.map((message, i) => ({ message, metadata: undefined, seqNum: i + 1, })) as never; }); // Install the session open override so `sessions.open(id)` returns a // SessionHandle with an in-memory `.out` that captures writes. The // `.in` channel routes record subscriptions (`on`/`once`/`peek`) // through the `sessionStreams` global — the mock task context // installs a `TestSessionStreamManager` there — and stubs `wait()` // so the suspend path resolves cleanly on `runSignal.abort()` without // touching the api client. __setSessionOpenImplForTests((id) => createTestSessionHandle(id, sessionOutState, () => runSignal?.signal) ); // Install the session start override so any test path that invokes // `sessions.start()` (typically through a server action shim like // `chat.createStartSessionAction`) becomes a no-op fixture instead of // hitting a real API. Most chat.agent tests trigger the run directly // via `sendPayloadAndWait` and never go through this path, but the // stub keeps the API safe to call from inside tested code. __setSessionStartImplForTests((body) => { if (process.env.TRIGGER_CHAT_TEST_DEBUG === "1") { console.log("[mockChatAgent] sessions.start override:", body); } const fakeRunId = `run_test_${body.externalId ?? "anon"}`; return { id: `session_test_${body.externalId ?? "anon"}`, externalId: body.externalId ?? null, type: body.type, taskIdentifier: body.taskIdentifier, triggerConfig: body.triggerConfig, currentRunId: fakeRunId, runId: fakeRunId, publicAccessToken: "tr_test_session_pat", tags: body.tags ?? [], metadata: (body.metadata ?? null) as Record | null, closedAt: null, closedReason: null, expiresAt: null, createdAt: new Date(0), updatedAt: new Date(0), isCached: false, }; }); taskFinished = runInMockTaskContext(async (drivers) => { runSignal = new AbortController(); // For `mode: "continuation"`, omit `trigger` from the wire payload — // mirrors what the server's `ensureRunForSession` / `swapSessionRun` // produces (the continuation overrides clear `trigger` so the SDK // boot path falls into the continuation-wait branch instead of // re-firing the basePayload's stale first-run trigger). `continuation: // true` is set unconditionally for this mode so the boot path's // continuation-wait condition matches. const isContinuationMode = mode === "continuation"; const initialPayload: ChatWirePayload = { chatId, ...(isContinuationMode ? { trigger: undefined as never, continuation: true } : { trigger: mode }), metadata: clientData, ...(!isContinuationMode && options.continuation ? { continuation: true } : {}), ...(options.previousRunId ? { previousRunId: options.previousRunId } : {}), ...(options.headStartMessages ? { headStartMessages: options.headStartMessages } : {}), }; sendSessionInput = drivers.sessions.in.send; closeSessionInput = drivers.sessions.in.close; // Record every chunk written to session.out, detect turn-complete. const listener = (chunk: unknown) => { allRawChunks.push(chunk); if (!isControlChunk(chunk)) { allChunks.push(chunk as UIMessageChunk); } if ( typeof chunk === "object" && chunk !== null && (chunk as { type?: string }).type === "trigger:turn-complete" ) { const resolvers = turnCompleteResolvers; turnCompleteResolvers = []; for (const resolve of resolvers) resolve(); } }; sessionOutState.listeners.add(listener); const unsubscribe = () => sessionOutState.listeners.delete(listener); if (options.setupLocals) { await options.setupLocals({ set: drivers.locals.set }); } harnessReadyResolve(); try { if (process.env.TRIGGER_CHAT_TEST_DEBUG === "1") { console.log("[mockChatAgent] Starting runFn with payload:", initialPayload); } await runFn(initialPayload, { ctx: drivers.ctx, signal: runSignal.signal, }); if (process.env.TRIGGER_CHAT_TEST_DEBUG === "1") { console.log("[mockChatAgent] runFn returned"); } } catch (err) { if (process.env.TRIGGER_CHAT_TEST_DEBUG === "1") { console.log("[mockChatAgent] runFn threw:", err); } throw err; } finally { unsubscribe(); // Resolve any outstanding turn-complete waiters so callers don't hang const resolvers = turnCompleteResolvers; turnCompleteResolvers = []; for (const resolve of resolvers) resolve(); } }, options.taskContext) .catch((err) => { // Propagate errors to pending turn waiters instead of dropping them const resolvers = turnCompleteResolvers; turnCompleteResolvers = []; for (const resolve of resolvers) resolve(); throw err; }) .finally(() => { // Always clear the test overrides, even if the task threw. __setSessionOpenImplForTests(undefined); __setSessionStartImplForTests(undefined); __setReadChatSnapshotImplForTests(undefined); __setWriteChatSnapshotImplForTests(undefined); __setReplaySessionOutTailImplForTests(undefined); __setReplaySessionInTailImplForTests(undefined); }); const sendPayloadAndWait = async (payload: ChatWirePayload): Promise => { await harnessReady; const before = allRawChunks.length; const turnComplete = waitForTurnComplete(); await sendSessionInput(sessionId, { kind: "message", payload }); await turnComplete; const rawChunks = allRawChunks.slice(before); const chunks = rawChunks.filter((c) => !isControlChunk(c)) as UIMessageChunk[]; return { chunks, rawChunks }; }; const harness: MockChatAgentHarness = { chatId, async sendMessage(message) { return sendPayloadAndWait({ message, chatId, trigger: "submit-message", metadata: clientData, }); }, async sendRegenerate() { return sendPayloadAndWait({ chatId, trigger: "regenerate-message", metadata: clientData, }); }, async sendHeadStart({ messages }) { return sendPayloadAndWait({ headStartMessages: messages, chatId, trigger: "handover-prepare", metadata: clientData, }); }, async sendAction(action) { return sendPayloadAndWait({ chatId, trigger: "action", action, metadata: clientData, }); }, async sendStop(message) { await harnessReady; await sendSessionInput(sessionId, { kind: "stop", message }); }, async sendHandover(args) { await harnessReady; const before = allRawChunks.length; const turnComplete = waitForTurnComplete(); await sendSessionInput(sessionId, { kind: "handover", partialAssistantMessage: args.partialAssistantMessage, messageId: args.messageId, isFinal: args.isFinal ?? false, }); await turnComplete; const rawChunks = allRawChunks.slice(before); const chunks = rawChunks.filter((c) => !isControlChunk(c)) as UIMessageChunk[]; return { chunks, rawChunks }; }, async sendHandoverSkip() { await harnessReady; // No turn-complete on skip — the agent exits without firing hooks. // Send the chunk and wait for the run to finish. await sendSessionInput(sessionId, { kind: "handover-skip" }); await Promise.race([ taskFinished.catch(() => {}), new Promise((resolve) => setTimeout(resolve, 1000)), ]); }, seedSnapshot(snapshot) { seededSnapshot = snapshot; }, seedSessionOutTail(chunks) { seededReplayChunks = chunks ?? []; }, seedSessionOutPartial(partial) { seededReplayPartial = partial; }, seedSessionInTail(messages) { seededSessionInMessages = messages; }, getSnapshot() { return lastWrittenSnapshot; }, async close() { await harnessReady; // Send a close trigger wrapped as a `kind: "message"` ChatInputChunk. // The turn loop checks for this after a successful turn and exits // cleanly. On error-recovery paths the loop just loops back with // the close payload, so we also close the session input below to // unblock any pending once() waiters. try { await sendSessionInput(sessionId, { kind: "message", payload: { chatId, trigger: "close", }, }); } catch { // best-effort } // Resolve any pending once() waiters on the session input with a // timeout error — that makes waitWithIdleTimeout return // `{ ok: false }` and the turn loop exits cleanly. closeSessionInput?.(sessionId); // Also abort the run signal so anything downstream (streamText, // deferred work) unwinds promptly. runSignal?.abort("close"); // Wait for run() to return. The loop's error recovery path will // see !next.ok and exit. Use a bounded wait so tests never hang. await Promise.race([ taskFinished.catch(() => {}), new Promise((resolve) => setTimeout(resolve, 1000)), ]); }, get allChunks() { return allChunks.slice(); }, get allRawChunks() { return allRawChunks.slice(); }, }; return harness; } /** * Reduce a synthetic UIMessageChunk[] sequence into the UIMessage[] that * the runtime's `replaySessionOutTail` would produce. Splits chunks at * `start` boundaries and feeds each segment through AI SDK's * `readUIMessageStream`. The trailing un-finished segment goes through * `cleanupAbortedParts`. Mirrors the production reducer used in * `ai.ts:replaySessionOutTail`. */ async function reduceChunksToMessages(chunks: UIMessageChunk[]): Promise { if (chunks.length === 0) return []; const aiModule = (await import("ai")) as { readUIMessageStream?: (args: { stream: ReadableStream; }) => AsyncIterable; cleanupAbortedParts?: (msg: UIMessage) => UIMessage; }; const readUIMessageStream = aiModule.readUIMessageStream; const cleanupAbortedParts = aiModule.cleanupAbortedParts; if (!readUIMessageStream) return []; type Segment = { chunks: UIMessageChunk[]; closed: boolean }; const segments: Segment[] = []; let current: Segment | undefined; for (const chunk of chunks) { if (chunk.type === "start") { current = { chunks: [chunk], closed: false }; segments.push(current); continue; } if (!current) { current = { chunks: [], closed: false }; segments.push(current); } current.chunks.push(chunk); if (chunk.type === "finish") { current.closed = true; current = undefined; } } const out: UIMessage[] = []; for (let i = 0; i < segments.length; i++) { const seg = segments[i]!; const isTrailing = i === segments.length - 1 && !seg.closed; const segmentStream = new ReadableStream({ start(controller) { for (const c of seg.chunks) controller.enqueue(c); controller.close(); }, }); let last: UIMessage | undefined; try { for await (const snapshot of readUIMessageStream({ stream: segmentStream })) { last = snapshot; } } catch { // Skip malformed segment — tests can assert by inspecting what makes it through. continue; } if (!last) continue; if (isTrailing && cleanupAbortedParts) { const cleaned = cleanupAbortedParts(last); if (!cleaned.parts || cleaned.parts.length === 0) continue; out.push(cleaned); } else { out.push(last); } } return out; }