diff --git a/.changeset/chat-agent-preserve-partial-on-stream-failure.md b/.changeset/chat-agent-preserve-partial-on-stream-failure.md new file mode 100644 index 00000000000..2c2236e6033 --- /dev/null +++ b/.changeset/chat-agent-preserve-partial-on-stream-failure.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Preserve the partial assistant message when a chat turn's model stream fails mid-response (e.g. a transport timeout). Previously the streamed-so-far output was dropped: `chat.agent`'s `onTurnComplete` fired with `responseMessage: undefined`, and `chat.createSession`'s `turn.complete()` rethrew without keeping the partial. Now the recovered partial is passed to `onTurnComplete` (on `responseMessage`, `uiMessages`, and `newUIMessages`) and accumulated before `turn.complete()` rethrows, so it survives for persistence even when `hydrateMessages` disables boot-time replay recovery. The turn is still reported as errored. diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 4612302c4ae..ed8aa0dfb79 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -6389,6 +6389,15 @@ function chatAgent< // Declared here so the finally can detach it — a handler leaked past // its turn duplicates every mid-stream message into the shared buffer. let turnMsgSub: { off: () => void } | undefined; + // Declared at turn scope (not inside the span callback) so the error + // handler below can recover the partial assistant output when a + // source-stream failure abandons the turn before onTurnComplete's + // success-path capture runs. `capturedPartialResponse` holds the + // onFinish message if it fired; otherwise the buffered chunks are + // reconstructed as the fallback (mirrors chat.pipeAndCapture). + let capturedPartialResponse: TUIMessage | undefined; + let responseCommitted = false; + const turnBufferedChunks: UIMessageChunk[] = []; try { // Extract turn-level context before entering the span. Slim // wire: at most one delta message per record. `headStartMessages` @@ -7175,11 +7184,17 @@ function chatAgent< finishReason?: FinishReason; }) => { capturedResponseMessage = responseMessage as TUIMessage; + capturedPartialResponse = responseMessage as TUIMessage; capturedFinishReason = finishReason; resolveOnFinish!(); }, }); - await pipeChat(uiStream, { + // Buffer chunks as they flow to the pipe so a source-stream + // transport failure — which abandons the UI stream before + // onFinish fires — can still reconstruct the partial in the + // error handler instead of dropping it. The happy path pays + // nothing extra; reassembly only runs in the error fallback. + await pipeChat(tapUIMessageChunks(uiStream, turnBufferedChunks), { signal: combinedSignal, spanName: "stream response", }); @@ -7390,6 +7405,12 @@ function chatAgent< } } + if (capturedResponseMessage) { + responseCommitted = true; + capturedPartialResponse = capturedResponseMessage; + turnBufferedChunks.length = 0; + } + if (runSignal.aborted) return "exit"; // Await deferred background work (e.g. DB writes from onTurnStart) @@ -7611,6 +7632,7 @@ function chatAgent< parts: [...(msg.parts ?? []), ...lateParts], } as TUIMessage; capturedResponseMessage = accumulatedUIMessages[idx] as TUIMessage; + capturedPartialResponse = capturedResponseMessage; turnCompleteEvent.responseMessage = capturedResponseMessage; turnCompleteEvent.uiMessages = accumulatedUIMessages; } @@ -7904,10 +7926,105 @@ function chatAgent< ? [...accumulatedUIMessages, erroredWireMessage] : accumulatedUIMessages; + // Recover the partial assistant output the model streamed before the + // failure. A source-stream transport error (e.g. UND_ERR_BODY_TIMEOUT) + // abandons the UI stream before onFinish fires, so fall back to + // reconstructing the partial from the buffered chunks. Surfacing it on + // the error-path event lets persistence keep the partial instead of + // losing it — critical when hydrateMessages disables boot-time tail + // replay, so the recovery path can't reclaim it later. Empty for + // non-stream failures (nothing buffered), preserving prior behavior. + let partialResponse: TUIMessage | undefined = + capturedPartialResponse ?? + ((await assemblePartialFromChunks(turnBufferedChunks)) as TUIMessage | undefined); + if (partialResponse) { + partialResponse = cleanupAbortedParts(partialResponse); + } + + // Build the complete error UI state. A HITL/tool continuation partial + // reuses an existing assistant id, so replace it in place (like the + // success path) instead of dropping it as a dup; otherwise append. + // `erroredWireMessage` was already folded into `erroredUIMessages` + // above when the pre-run merge hadn't happened. + let partialIdx = partialResponse?.id + ? erroredUIMessages.findIndex((m) => m.id === partialResponse!.id) + : -1; + if (partialResponse && capturedPartialResponse === undefined && partialIdx !== -1) { + partialResponse = undefined; + partialIdx = -1; + } + if (partialResponse && !partialResponse.id) { + partialResponse = { ...partialResponse, id: generateMessageId() } as TUIMessage; + } + if (partialResponse && !responseCommitted) { + const queuedParts = locals.get(chatResponsePartsKey); + if (queuedParts && queuedParts.length > 0) { + partialResponse = { + ...partialResponse, + parts: [...partialResponse.parts, ...(queuedParts as UIMessage["parts"])], + } as TUIMessage; + locals.set(chatResponsePartsKey, []); + } + } + const includePartial = partialResponse != null && !responseCommitted; + let erroredUIMessagesWithPartial: TUIMessage[] = !includePartial + ? erroredUIMessages + : partialIdx === -1 + ? [...erroredUIMessages, partialResponse!] + : (erroredUIMessages.map((m, i) => + i === partialIdx ? partialResponse! : m + ) as TUIMessage[]); + + let erroredNewUIMessages: TUIMessage[] = erroredWireMessage ? [erroredWireMessage] : []; + if (includePartial) { + erroredNewUIMessages.push(partialResponse!); + } + + let erroredNewModelMessages: ModelMessage[] = []; + + // Commit the complete error state to the canonical accumulator so the + // errored user message and any recovered partial survive past this + // hook: the run stays alive after an error, so the next turn sees + // them, and the error-path snapshot below (the recovery source for + // non-hydrate apps) persists them for reboot. Matches the success + // path. Skip when nothing changed so an errored turn never needlessly + // reconverts. When the only change is an appended partial, push just + // its model messages to preserve a prior turn's compaction (mirrors + // the success path's append branch); otherwise reconvert from UI. + // Guard the conversion so a secondary failure can't crash the run. + if (!responseCommitted) { + try { + if (erroredNewUIMessages.length > 0) { + erroredNewModelMessages = await toModelMessages( + erroredNewUIMessages.map((m) => stripProviderMetadata(m)) + ); + } + if (erroredUIMessagesWithPartial !== accumulatedUIMessages) { + if (partialIdx === -1) { + const appended = erroredUIMessagesWithPartial.slice( + accumulatedUIMessages.length + ); + accumulatedMessages.push( + ...(await toModelMessages(appended.map((m) => stripProviderMetadata(m)))) + ); + } else { + accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial); + } + accumulatedUIMessages = erroredUIMessagesWithPartial; + locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); + } + } catch { + // Keep the prior model accumulator if conversion fails. + erroredNewModelMessages = []; + erroredUIMessagesWithPartial = erroredUIMessages; + erroredNewUIMessages = erroredWireMessage ? [erroredWireMessage] : []; + } + } + // Fire onTurnComplete on the error path too — the docs promise it // runs "after every turn, successful or errored" so customers can - // mark the turn failed. `responseMessage` is undefined/partial and - // `error` carries the thrown value. + // mark the turn failed. `responseMessage` carries any partial + // recovered above and `error` carries the thrown value. if (onTurnComplete) { try { await tracer.startActiveSpan( @@ -7917,11 +8034,11 @@ function chatAgent< ctx, chatId: currentWirePayload.chatId, messages: accumulatedMessages, - uiMessages: erroredUIMessages, - newMessages: [], - newUIMessages: erroredWireMessage ? [erroredWireMessage] : [], - responseMessage: undefined, - rawResponseMessage: undefined, + uiMessages: erroredUIMessagesWithPartial, + newMessages: erroredNewModelMessages, + newUIMessages: erroredNewUIMessages, + responseMessage: partialResponse, + rawResponseMessage: partialResponse, turn, runId: ctx.run.id, chatAccessToken: "", @@ -7967,7 +8084,7 @@ function chatAgent< await writeChatSnapshot(sessionIdForSnapshot, { version: 1, savedAt: Date.now(), - messages: erroredUIMessages, + messages: erroredUIMessagesWithPartial, lastOutEventId: errorTurnCompleteResult?.lastEventId, lastInEventId: errorSnapshotInCursor !== undefined ? String(errorSnapshotInCursor) : undefined, @@ -8887,28 +9004,26 @@ export type PipeAndCaptureResult = { * can return, and propagates a source error to the consumer after buffering * whatever streamed first. See {@link pipeChatAndCapture} for why. */ -async function* tapUIMessageChunks( +function tapUIMessageChunks( source: AsyncIterable | ReadableStream, buffer: UIMessageChunk[] -): AsyncGenerator { +): ReadableStream | AsyncGenerator { if (isReadableStream(source)) { - const reader = source.getReader(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer.push(value as UIMessageChunk); - yield value; - } - } finally { - reader.releaseLock(); - } - } else { + return source.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + buffer.push(chunk as UIMessageChunk); + controller.enqueue(chunk); + }, + }) + ); + } + return (async function* () { for await (const chunk of source) { buffer.push(chunk as UIMessageChunk); yield chunk; } - } + })(); } /** @@ -9744,6 +9859,15 @@ function createChatSession( // Surface a genuine stream failure to the caller. A user stop // (status "aborted") falls through so the partial is accumulated. if (captured.status === "error") { + if (captured.message) { + const partial = cleanupAbortedParts(captured.message); + const queuedParts = locals.get(chatResponsePartsKey); + if (queuedParts && queuedParts.length > 0) { + (partial as any).parts = [...(partial.parts ?? []), ...queuedParts]; + locals.set(chatResponsePartsKey, []); + } + await accumulator.addResponse(partial); + } throw captured.error; } response = captured.message; diff --git a/packages/trigger-sdk/test/chat-agent-source-stream-error.test.ts b/packages/trigger-sdk/test/chat-agent-source-stream-error.test.ts new file mode 100644 index 00000000000..8ec6998ad59 --- /dev/null +++ b/packages/trigger-sdk/test/chat-agent-source-stream-error.test.ts @@ -0,0 +1,443 @@ +// Import the test harness FIRST — this installs the resource catalog so +// `chat.agent()` calls below register their task functions correctly. +import { mockChatAgent } from "../src/v3/test/index.js"; + +import { describe, expect, it } from "vitest"; +import type { ModelMessage, UIMessage } from "ai"; +import { simulateReadableStream, streamText } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { chat } from "../src/v3/ai.js"; +import type { TurnCompleteEvent } from "../src/v3/ai.js"; + +// ── Helpers ──────────────────────────────────────────────────────────── + +function userMessage(text: string, id: string): UIMessage { + return { id, role: "user", parts: [{ type: "text", text }] }; +} + +function extractText(message: UIMessage | undefined): string { + if (!message) return ""; + return (message.parts as Array<{ type: string; text?: string }>) + .filter((p) => p.type === "text") + .map((p) => p.text ?? "") + .join(""); +} + +async function waitFor(check: () => boolean, timeoutMs = 5_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) return; + await new Promise((r) => setTimeout(r, 20)); + } + throw new Error("waitFor timed out"); +} + +/** + * A `run()` return value that looks like a `StreamTextResult` (has + * `toUIMessageStream()`) but whose UI stream emits a partial assistant + * message and then errors — reproducing a source-stream transport failure + * (e.g. `UND_ERR_BODY_TIMEOUT`) mid-turn. `onFinish` is never invoked, which + * is exactly what happens on a hard transport error. Chunks are delivered + * one-per-pull before the error so they aren't discarded (calling + * `controller.error()` in the same tick as `enqueue()` resets the queue). + */ +function erroringSource(errorMessage: string) { + const partialChunks = [ + { type: "start", messageId: "a-err" }, + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: "partial answer" }, + ]; + return sourceFromChunks(partialChunks, errorMessage); +} + +function sourceFromChunks(chunks: unknown[], errorMessage: string) { + return { + toUIMessageStream() { + let i = 0; + return new ReadableStream({ + pull(controller) { + if (i < chunks.length) { + controller.enqueue(chunks[i++]); + } else { + controller.error(new Error(errorMessage)); + } + }, + }); + }, + }; +} + +// ── Tests ────────────────────────────────────────────────────────────── + +describe("chat.agent managed loop — source-stream failure", () => { + it("preserves the partial assistant message on onTurnComplete when the source stream fails", async () => { + const turnCompletes: TurnCompleteEvent[] = []; + + const agent = chat.agent({ + id: "chatAgent.source-stream-error", + run: async () => erroringSource("UND_ERR_BODY_TIMEOUT") as never, + onTurnComplete: async (event) => { + turnCompletes.push(event); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "cae-source-error" }); + try { + await harness.sendMessage(userMessage("hi", "u-1")); + await waitFor(() => turnCompletes.length >= 1); + + const evt = turnCompletes[0]!; + + // The turn is reported as errored, carrying the thrown transport error. + expect(evt.finishReason).toBe("error"); + expect(evt.error).toBeInstanceOf(Error); + expect((evt.error as Error).message).toBe("UND_ERR_BODY_TIMEOUT"); + + // The partial assistant output that streamed before the failure must be + // preserved so persistence / recovery can keep it, instead of being + // dropped (responseMessage: undefined). + expect(evt.responseMessage).toBeDefined(); + expect(extractText(evt.responseMessage)).toBe("partial answer"); + + const newAssistantText = (evt.newMessages as ModelMessage[]) + .filter((m) => m.role === "assistant") + .map((m) => + typeof m.content === "string" + ? m.content + : (m.content as Array<{ type: string; text?: string }>) + .filter((p) => p.type === "text") + .map((p) => p.text ?? "") + .join("") + ) + .join(""); + expect(newAssistantText).toBe("partial answer"); + + expect((evt.newMessages as ModelMessage[]).some((m) => m.role === "user")).toBe(true); + expect((evt.newUIMessages as UIMessage[]).some((m) => m.role === "user")).toBe(true); + } finally { + await harness.close(); + } + }); + + it("carries the recovered partial into the next turn's accumulated messages", async () => { + let turn = 0; + let turn2Messages: ModelMessage[] | undefined; + + const okStream = () => + simulateReadableStream({ + chunks: [ + { type: "text-start", id: "t2" }, + { type: "text-delta", id: "t2", delta: "second answer" }, + { type: "text-end", id: "t2" }, + { + type: "finish", + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { total: 5, noCache: 5, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 5, text: 5, reasoning: undefined }, + }, + }, + ] as LanguageModelV3StreamPart[], + }); + + const agent = chat.agent({ + id: "chatAgent.source-stream-error-continuation", + run: async ({ messages }) => { + turn++; + if (turn === 1) { + return erroringSource("UND_ERR_BODY_TIMEOUT") as never; + } + // Second turn: the failed turn's partial assistant output must be in + // the accumulated history the model now sees. + turn2Messages = messages; + return streamText({ + model: new MockLanguageModelV3({ doStream: async () => ({ stream: okStream() }) }), + messages, + }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "cae-source-error-cont" }); + try { + await harness.sendMessage(userMessage("hi", "u-1")); + await harness.sendMessage(userMessage("still there?", "u-2")); + await waitFor(() => turn2Messages !== undefined); + + const assistantText = turn2Messages! + .filter((m) => m.role === "assistant") + .map((m) => + typeof m.content === "string" + ? m.content + : (m.content as Array<{ type: string; text?: string }>) + .filter((p) => p.type === "text") + .map((p) => p.text ?? "") + .join("") + ) + .join(""); + expect(assistantText).toContain("partial answer"); + } finally { + await harness.close(); + } + }); + + it("does not overwrite an already-committed enriched response when a post-response hook throws", async () => { + const events: TurnCompleteEvent[] = []; + + const okModel = (text: string) => + new MockLanguageModelV3({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { + type: "finish", + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { + total: 5, + noCache: 5, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: 5, text: 5, reasoning: undefined }, + }, + }, + ] as LanguageModelV3StreamPart[], + }), + }), + }); + + const agent = chat.agent({ + id: "chatAgent.post-commit-hook-throw", + run: async ({ messages }) => { + chat.response.write({ type: "data-marker", data: { kept: true } } as never); + return streamText({ model: okModel("full response"), messages }); + }, + onTurnComplete: async (event) => { + events.push(event); + if (event.error == null) { + throw new Error("hook boom after commit"); + } + }, + }); + + const harness = mockChatAgent(agent, { chatId: "cae-post-commit-throw" }); + try { + await harness.sendMessage(userMessage("hi", "u-1")); + await waitFor(() => events.some((e) => e.error != null)); + + const errorEvent = events.find((e) => e.error != null)!; + const assistant = (errorEvent.uiMessages as UIMessage[]).find((m) => m.role === "assistant"); + expect(assistant).toBeDefined(); + expect( + (assistant!.parts as Array<{ type: string }>).some((p) => p.type === "data-marker") + ).toBe(true); + expect(extractText(assistant)).toBe("full response"); + } finally { + await harness.close(); + } + }); + + it("does not clobber an existing message when a reconstructed fragment reuses its id", async () => { + let turn = 0; + let firstAssistantId: string | undefined; + const events: TurnCompleteEvent[] = []; + + const okModel = () => + new MockLanguageModelV3({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: "first answer" }, + { type: "text-end", id: "t1" }, + { + type: "finish", + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { + total: 5, + noCache: 5, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { total: 5, text: 5, reasoning: undefined }, + }, + }, + ] as LanguageModelV3StreamPart[], + }), + }), + }); + + const collidingErroringSource = (id: string) => ({ + toUIMessageStream() { + const chunks = [ + { type: "start", messageId: id }, + { type: "text-start", id: "t2" }, + { type: "text-delta", id: "t2", delta: "clobber" }, + ]; + let i = 0; + return new ReadableStream({ + pull(controller) { + if (i < chunks.length) controller.enqueue(chunks[i++]); + else controller.error(new Error("UND_ERR_BODY_TIMEOUT")); + }, + }); + }, + }); + + const agent = chat.agent({ + id: "chatAgent.fragment-id-collision", + run: async ({ messages }) => { + turn++; + if (turn === 1) { + return streamText({ model: okModel(), messages }); + } + return collidingErroringSource(firstAssistantId!) as never; + }, + onTurnComplete: async (event) => { + events.push(event); + if (event.error == null && event.responseMessage) { + firstAssistantId = event.responseMessage.id; + } + }, + }); + + const harness = mockChatAgent(agent, { chatId: "cae-fragment-collision" }); + try { + await harness.sendMessage(userMessage("hi", "u-1")); + await waitFor(() => firstAssistantId !== undefined); + await harness.sendMessage(userMessage("again", "u-2")); + await waitFor(() => events.some((e) => e.error != null)); + + const errorEvent = events.find((e) => e.error != null)!; + const preserved = (errorEvent.uiMessages as UIMessage[]).find( + (m) => m.id === firstAssistantId + ); + expect(preserved).toBeDefined(); + expect(extractText(preserved)).toBe("first answer"); + expect( + (errorEvent.uiMessages as UIMessage[]).some((m) => extractText(m).includes("clobber")) + ).toBe(false); + } finally { + await harness.close(); + } + }); + + it("cleans dangling tool parts from the recovered partial while keeping its text", async () => { + const turnCompletes: TurnCompleteEvent[] = []; + + const agent = chat.agent({ + id: "chatAgent.error-partial-cleanup", + run: async () => + sourceFromChunks( + [ + { type: "start", messageId: "a-tool" }, + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: "thinking" }, + { type: "text-end", id: "t1" }, + { type: "tool-input-start", toolCallId: "tc1", toolName: "search" }, + { + type: "tool-input-available", + toolCallId: "tc1", + toolName: "search", + input: { q: "x" }, + }, + ], + "UND_ERR_BODY_TIMEOUT" + ) as never, + onTurnComplete: async (event) => { + turnCompletes.push(event); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "cae-partial-cleanup" }); + try { + await harness.sendMessage(userMessage("hi", "u-1")); + await waitFor(() => turnCompletes.length >= 1); + + const evt = turnCompletes[0]!; + expect(evt.responseMessage).toBeDefined(); + const parts = evt.responseMessage!.parts as Array<{ type: string }>; + expect(extractText(evt.responseMessage)).toBe("thinking"); + expect(parts.some((p) => p.type.startsWith("tool-"))).toBe(false); + } finally { + await harness.close(); + } + }); + + it("folds queued response data parts into the recovered partial", async () => { + const turnCompletes: TurnCompleteEvent[] = []; + + const agent = chat.agent({ + id: "chatAgent.error-queued-parts", + run: async () => { + chat.response.write({ type: "data-marker", data: { kept: true } } as never); + return erroringSource("UND_ERR_BODY_TIMEOUT") as never; + }, + onTurnComplete: async (event) => { + turnCompletes.push(event); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "cae-error-queued-parts" }); + try { + await harness.sendMessage(userMessage("hi", "u-1")); + await waitFor(() => turnCompletes.length >= 1); + + const evt = turnCompletes[0]!; + expect(evt.responseMessage).toBeDefined(); + const parts = evt.responseMessage!.parts as Array<{ type: string }>; + expect(extractText(evt.responseMessage)).toBe("partial answer"); + expect(parts.some((p) => p.type === "data-marker")).toBe(true); + } finally { + await harness.close(); + } + }); +}); + +describe("chat.createSession turn.complete() — source-stream failure", () => { + it("accumulates the partial before rethrowing so the caller can persist it", async () => { + let caughtError: unknown; + let uiMessagesAfterError: UIMessage[] = []; + + const agent = chat.customAgent({ + id: "createSession.source-stream-error", + run: async (payload) => { + const session = chat.createSession(payload, { + signal: new AbortController().signal, + idleTimeoutInSeconds: 2, + }); + for await (const turn of session) { + try { + await turn.complete(erroringSource("UND_ERR_BODY_TIMEOUT") as never); + } catch (err) { + caughtError = err; + // The partial must be accumulated so persistence from the session + // state keeps it, rather than being lost on the rethrow. + uiMessagesAfterError = [...turn.uiMessages]; + await turn.done(); + } + } + }, + }); + + const harness = mockChatAgent(agent, { chatId: "cs-source-error" }); + try { + await harness.sendMessage(userMessage("hi", "u-1")); + await waitFor(() => caughtError !== undefined); + + expect(caughtError).toBeInstanceOf(Error); + expect((caughtError as Error).message).toBe("UND_ERR_BODY_TIMEOUT"); + + const partial = uiMessagesAfterError.find((m) => m.role === "assistant"); + expect(partial).toBeDefined(); + expect(extractText(partial)).toBe("partial answer"); + } finally { + await harness.close(); + } + }); +});