diff --git a/src/hooks/session-recovery/index.test.ts b/src/hooks/session-recovery/index.test.ts index 257fe05a5..1cb3768d9 100644 --- a/src/hooks/session-recovery/index.test.ts +++ b/src/hooks/session-recovery/index.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, it } from "bun:test" +import { existsSync, readFileSync, rmSync } from "node:fs" +import { join } from "node:path" import { detectErrorType } from "./index" +import { prependThinkingPart, prependThinkingPartAsync } from "./storage/thinking-prepend" +import { PART_STORAGE } from "../../shared/opencode-storage-paths" + +const { describe, expect, it, mock } = require("bun:test") describe("detectErrorType", () => { describe("thinking_block_order errors", () => { @@ -278,3 +283,249 @@ describe("detectErrorType", () => { }) }) }) + +type StoredPartRecord = { + id: string + sessionID: string + messageID: string + type: string + signature?: string + thinking?: string + text?: string +} + +function cleanupParts(messageID: string): void { + rmSync(join(PART_STORAGE, messageID), { recursive: true, force: true }) +} + +describe("thinking-prepend", () => { + it("writes the original signed thinking part verbatim for file-backed recovery", () => { + const sessionID = "ses_thinking_prepend_sync" + const targetMessageID = "msg_target_signed" + const originalPart = { + id: "prt_prev_signed", + sessionID, + messageID: "msg_prev_signed", + type: "thinking", + thinking: "prior reasoning", + signature: "sig_prev", + } as const satisfies StoredPartRecord + + const result = prependThinkingPart(sessionID, targetMessageID, { + isSqliteBackend: () => false, + patchPart: async () => true, + log: mock(() => {}), + findLastThinkingPart: () => originalPart, + findLastThinkingPartFromSDK: async () => null, + readTargetPartIDs: () => ["prt_target_text"], + readTargetPartIDsFromSDK: async () => [], + }) + + expect(result).toBe(true) + const writtenPath = join(PART_STORAGE, targetMessageID, `${originalPart.id}.json`) + expect(existsSync(writtenPath)).toBe(true) + expect(JSON.parse(readFileSync(writtenPath, "utf-8"))).toEqual(originalPart) + + cleanupParts(targetMessageID) + }) + + it("returns false without writing when no signed thinking part exists in history", () => { + const sessionID = "ses_thinking_prepend_sync_missing" + const targetMessageID = "msg_target_missing" + + const result = prependThinkingPart(sessionID, targetMessageID, { + isSqliteBackend: () => false, + patchPart: async () => true, + log: mock(() => {}), + findLastThinkingPart: () => null, + findLastThinkingPartFromSDK: async () => null, + readTargetPartIDs: () => [], + readTargetPartIDsFromSDK: async () => [], + }) + + expect(result).toBe(false) + expect(existsSync(join(PART_STORAGE, targetMessageID))).toBe(false) + + cleanupParts(targetMessageID) + }) + + it("returns false immediately when sqlite backend is active", () => { + const result = prependThinkingPart("ses_sqlite", "msg_sqlite", { + isSqliteBackend: () => true, + patchPart: async () => true, + log: mock(() => {}), + findLastThinkingPart: () => null, + findLastThinkingPartFromSDK: async () => null, + readTargetPartIDs: () => [], + readTargetPartIDsFromSDK: async () => [], + }) + + expect(result).toBe(false) + }) + + it("returns false when the reused signed thinking part would not sort before target parts", () => { + const sessionID = "ses_thinking_prepend_sync_out_of_order" + const targetMessageID = "msg_target_out_of_order" + const originalPart = { + id: "prt_z_reused", + sessionID, + messageID: "msg_prev_signed", + type: "thinking", + thinking: "prior reasoning", + signature: "sig_prev", + } as const satisfies StoredPartRecord + + const result = prependThinkingPart(sessionID, targetMessageID, { + isSqliteBackend: () => false, + patchPart: async () => true, + log: mock(() => {}), + findLastThinkingPart: () => originalPart, + findLastThinkingPartFromSDK: async () => null, + readTargetPartIDs: () => ["prt_a_target"], + readTargetPartIDsFromSDK: async () => [], + }) + + expect(result).toBe(false) + expect(existsSync(join(PART_STORAGE, targetMessageID))).toBe(false) + }) + + it("patches the original signed thinking part verbatim for sdk-backed recovery", async () => { + const prependThinkingPartAsyncUntyped = Reflect.get( + { prependThinkingPartAsync }, + "prependThinkingPartAsync" + ) + const sessionID = "ses_thinking_prepend_async" + const targetMessageID = "msg_target_async" + const patchPartMock = mock(async () => true) + const originalPart = { + id: "prt_prev_async", + type: "thinking", + thinking: "prior reasoning", + signature: "sig_async", + } as const + const client = { + session: { + messages: async () => ({ + data: [ + { + info: { id: "msg_prev_async", role: "assistant" }, + parts: [originalPart], + }, + { + info: { id: targetMessageID, role: "assistant" }, + parts: [{ id: "prt_target_text", type: "text", text: "tool result" }], + }, + ], + }), + }, + } + + const result = await Reflect.apply(prependThinkingPartAsyncUntyped, undefined, [ + client, + sessionID, + targetMessageID, + { + isSqliteBackend: () => false, + patchPart: patchPartMock, + log: mock(() => {}), + findLastThinkingPart: () => null, + findLastThinkingPartFromSDK: async () => originalPart, + readTargetPartIDs: () => [], + readTargetPartIDsFromSDK: async () => ["prt_target_text"], + }, + ]) + + expect(result).toBe(true) + expect(patchPartMock).toHaveBeenCalledTimes(1) + expect(patchPartMock.mock.calls[0]).toEqual([ + client, + sessionID, + targetMessageID, + "prt_prev_async", + originalPart, + ]) + }) + + it("returns false without patching when sdk history has no signed thinking part", async () => { + const prependThinkingPartAsyncUntyped = Reflect.get( + { prependThinkingPartAsync }, + "prependThinkingPartAsync" + ) + const sessionID = "ses_thinking_prepend_async_missing" + const targetMessageID = "msg_target_async_missing" + const patchPartMock = mock(async () => true) + const client = { + session: { + messages: async () => ({ + data: [ + { + info: { id: "msg_prev_async", role: "assistant" }, + parts: [{ id: "prt_prev_reasoning", type: "reasoning", text: "unsigned reasoning" }], + }, + { + info: { id: targetMessageID, role: "assistant" }, + parts: [{ id: "prt_target_text", type: "text", text: "tool result" }], + }, + ], + }), + }, + } + + const result = await Reflect.apply(prependThinkingPartAsyncUntyped, undefined, [ + client, + sessionID, + targetMessageID, + { + isSqliteBackend: () => false, + patchPart: patchPartMock, + log: mock(() => {}), + findLastThinkingPart: () => null, + findLastThinkingPartFromSDK: async () => null, + readTargetPartIDs: () => [], + readTargetPartIDsFromSDK: async () => ["prt_target_text"], + }, + ]) + + expect(result).toBe(false) + expect(patchPartMock).toHaveBeenCalledTimes(0) + }) + + it("returns false when the sdk reused signed thinking part would not sort before target parts", async () => { + const prependThinkingPartAsyncUntyped = Reflect.get( + { prependThinkingPartAsync }, + "prependThinkingPartAsync" + ) + const sessionID = "ses_thinking_prepend_async_out_of_order" + const targetMessageID = "msg_target_async_out_of_order" + const patchPartMock = mock(async () => true) + const originalPart = { + id: "prt_z_reused", + type: "thinking", + thinking: "prior reasoning", + signature: "sig_async", + } as const + const client = { + session: { + messages: async () => ({ data: [] }), + }, + } + + const result = await Reflect.apply(prependThinkingPartAsyncUntyped, undefined, [ + client, + sessionID, + targetMessageID, + { + isSqliteBackend: () => false, + patchPart: patchPartMock, + log: mock(() => {}), + findLastThinkingPart: () => null, + findLastThinkingPartFromSDK: async () => originalPart, + readTargetPartIDs: () => [], + readTargetPartIDsFromSDK: async () => ["prt_a_target"], + }, + ]) + + expect(result).toBe(false) + expect(patchPartMock).toHaveBeenCalledTimes(0) + }) +}) diff --git a/src/hooks/session-recovery/storage/thinking-prepend.ts b/src/hooks/session-recovery/storage/thinking-prepend.ts index 464898c9f..9ccb7131c 100644 --- a/src/hooks/session-recovery/storage/thinking-prepend.ts +++ b/src/hooks/session-recovery/storage/thinking-prepend.ts @@ -2,19 +2,115 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs" import { join } from "node:path" import type { PluginInput } from "@opencode-ai/plugin" import { PART_STORAGE, THINKING_TYPES } from "../constants" -import type { MessageData } from "../types" +import type { MessageData, StoredPart } from "../types" import { readMessages } from "./messages-reader" import { readParts } from "./parts-reader" import { log, isSqliteBackend, patchPart } from "../../../shared" import { normalizeSDKResponse } from "../../../shared" type OpencodeClient = PluginInput["client"] +type StoredSignedThinkingPart = StoredPart & { + type: "thinking" | "redacted_thinking" + signature: string +} +type SDKMessagePart = NonNullable[number] +type SDKSignedThinkingPart = SDKMessagePart & { + id: string + type: "thinking" | "redacted_thinking" + signature: string +} -function findLastThinkingContent(sessionID: string, beforeMessageID: string): string { +type ThinkingPrependDeps = { + isSqliteBackend: typeof isSqliteBackend + patchPart: typeof patchPart + log: typeof log + findLastThinkingPart: typeof findLastThinkingPart + findLastThinkingPartFromSDK: typeof findLastThinkingPartFromSDK + readTargetPartIDs: typeof readTargetPartIDs + readTargetPartIDsFromSDK: typeof readTargetPartIDsFromSDK +} + +const thinkingPrependDeps: ThinkingPrependDeps = { + isSqliteBackend, + patchPart, + log, + findLastThinkingPart, + findLastThinkingPartFromSDK, + readTargetPartIDs, + readTargetPartIDsFromSDK, +} + +function readTargetPartIDs(messageID: string): string[] { + return readParts(messageID) + .map((part) => part.id) + .filter((id): id is string => typeof id === "string") +} + +async function readTargetPartIDsFromSDK( + client: OpencodeClient, + sessionID: string, + messageID: string +): Promise { + try { + const response = await client.session.messages({ path: { id: sessionID } }) + const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) + const targetMessage = messages.find((message) => message.info?.id === messageID) + if (!targetMessage?.parts) { + return [] + } + + return targetMessage.parts + .map((part) => part.id) + .filter((id): id is string => typeof id === "string") + } catch { + return [] + } +} + +function canPrependBeforeTargetParts(partID: string, targetPartIDs: string[]): boolean { + const firstTargetPartID = [...targetPartIDs].sort((left, right) => left.localeCompare(right))[0] + return !firstTargetPartID || partID.localeCompare(firstTargetPartID) < 0 +} + +function isStoredSignedThinkingPart(part: StoredPart): part is StoredSignedThinkingPart { + if (!THINKING_TYPES.has(part.type)) { + return false + } + + if (part.type === "reasoning") { + return false + } + + const signature = Reflect.get(part, "signature") + return typeof signature === "string" && signature.length > 0 +} + +function isSDKSignedThinkingPart(part: SDKMessagePart): part is SDKSignedThinkingPart { + if (!part.type || !THINKING_TYPES.has(part.type)) { + return false + } + + if (part.type === "reasoning") { + return false + } + + return typeof part.id === "string" + && typeof (part as { signature?: unknown }).signature === "string" + && ((part as { signature?: string }).signature?.length ?? 0) > 0 +} + +function toPatchBody(part: SDKSignedThinkingPart): Record { + return { ...part } +} + +function findLastThinkingPart( + sessionID: string, + beforeMessageID: string +): StoredSignedThinkingPart | null { const messages = readMessages(sessionID) const currentIndex = messages.findIndex((message) => message.id === beforeMessageID) - if (currentIndex === -1) return "" + if (currentIndex === -1) return null for (let i = currentIndex - 1; i >= 0; i--) { const message = messages[i] @@ -22,63 +118,62 @@ function findLastThinkingContent(sessionID: string, beforeMessageID: string): st const parts = readParts(message.id) for (const part of parts) { - if (THINKING_TYPES.has(part.type)) { - const thinking = (part as { thinking?: string; text?: string }).thinking - const reasoning = (part as { thinking?: string; text?: string }).text - const content = thinking || reasoning - if (content && content.trim().length > 0) { - return content - } + if (isStoredSignedThinkingPart(part)) { + return part } } } - return "" + return null } -export function prependThinkingPart(sessionID: string, messageID: string): boolean { - if (isSqliteBackend()) { +export function prependThinkingPart( + sessionID: string, + messageID: string, + deps: ThinkingPrependDeps = thinkingPrependDeps +): boolean { + if (deps.isSqliteBackend()) { log("[session-recovery] Disabled on SQLite backend: prependThinkingPart (use async variant)") return false } + const previousThinkingPart = deps.findLastThinkingPart(sessionID, messageID) + if (!previousThinkingPart) { + return false + } + + if (!canPrependBeforeTargetParts(previousThinkingPart.id, deps.readTargetPartIDs(messageID))) { + return false + } + const partDir = join(PART_STORAGE, messageID) if (!existsSync(partDir)) { mkdirSync(partDir, { recursive: true }) } - const previousThinking = findLastThinkingContent(sessionID, messageID) - - const partId = `prt_0000000000_${messageID}_thinking` - const part = { - id: partId, - sessionID, - messageID, - type: "thinking", - thinking: previousThinking || "[Continuing from previous reasoning]", - synthetic: true, - } - try { - writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2)) + writeFileSync( + join(partDir, `${previousThinkingPart.id}.json`), + JSON.stringify(previousThinkingPart, null, 2) + ) return true } catch { return false } } -async function findLastThinkingContentFromSDK( +async function findLastThinkingPartFromSDK( client: OpencodeClient, sessionID: string, beforeMessageID: string -): Promise { +): Promise { try { const response = await client.session.messages({ path: { id: sessionID } }) const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true }) const currentIndex = messages.findIndex((m) => m.info?.id === beforeMessageID) - if (currentIndex === -1) return "" + if (currentIndex === -1) return null for (let i = currentIndex - 1; i >= 0; i--) { const msg = messages[i] @@ -86,39 +181,43 @@ async function findLastThinkingContentFromSDK( if (!msg.parts) continue for (const part of msg.parts) { - if (part.type && THINKING_TYPES.has(part.type)) { - const content = part.thinking || part.text - if (content && content.trim().length > 0) return content + if (isSDKSignedThinkingPart(part)) { + return part } } } } catch { - return "" + return null } - return "" + return null } export async function prependThinkingPartAsync( client: OpencodeClient, sessionID: string, - messageID: string + messageID: string, + deps: ThinkingPrependDeps = thinkingPrependDeps ): Promise { - const previousThinking = await findLastThinkingContentFromSDK(client, sessionID, messageID) + const previousThinkingPart = await deps.findLastThinkingPartFromSDK(client, sessionID, messageID) + if (!previousThinkingPart) { + return false + } - const partId = `prt_0000000000_${messageID}_thinking` - const part: Record = { - id: partId, - sessionID, - messageID, - type: "thinking", - thinking: previousThinking || "[Continuing from previous reasoning]", - synthetic: true, + const targetPartIDs = await deps.readTargetPartIDsFromSDK(client, sessionID, messageID) + if (!canPrependBeforeTargetParts(previousThinkingPart.id, targetPartIDs)) { + return false } try { - return await patchPart(client, sessionID, messageID, partId, part) + return await deps.patchPart( + client, + sessionID, + messageID, + previousThinkingPart.id, + toPatchBody(previousThinkingPart) + ) } catch (error) { - log("[session-recovery] prependThinkingPartAsync failed", { error: String(error) }) + deps.log("[session-recovery] prependThinkingPartAsync failed", { error: String(error) }) return false } } diff --git a/src/hooks/thinking-block-validator/hook.test.ts b/src/hooks/thinking-block-validator/hook.test.ts new file mode 100644 index 000000000..9eab6dff6 --- /dev/null +++ b/src/hooks/thinking-block-validator/hook.test.ts @@ -0,0 +1,108 @@ +const { describe, expect, test } = require("bun:test") + +const { createThinkingBlockValidatorHook } = require("./hook") + +type TestPart = { + type: string + id: string + text?: string + thinking?: string + data?: string + signature?: string +} + +type TestMessage = { + info: { + role: string + id?: string + modelID?: string + } + parts: TestPart[] +} + +function createMessage(info: TestMessage["info"], parts: TestPart[]): TestMessage { + return { info, parts } +} + +function createTextPart(id: string, text: string): TestPart { + return { type: "text", id, text } +} + +function createSignedThinkingPart(id: string, thinking: string, signature: string): TestPart { + return { type: "thinking", id, thinking, signature } +} + +function createRedactedThinkingPart(id: string, signature: string): TestPart { + return { type: "redacted_thinking", id, data: "encrypted", signature } +} + +describe("createThinkingBlockValidatorHook", () => { + test("reuses the previous signed thinking part verbatim when assistant content lacks a leading thinking block", async () => { + const transform = Reflect.get(createThinkingBlockValidatorHook(), "experimental.chat.messages.transform") + expect(typeof transform).toBe("function") + + const previousThinkingPart = createSignedThinkingPart("prt_prev_signed", "prior reasoning", "sig_prev") + const targetTextPart = createTextPart("prt_target_text", "tool result") + const messages: TestMessage[] = [ + createMessage({ role: "user", modelID: "claude-opus-4-6-thinking" }, [createTextPart("prt_user_text", "continue")]), + createMessage({ role: "assistant", id: "msg_prev" }, [previousThinkingPart, createTextPart("prt_prev_text", "done")]), + createMessage({ role: "assistant", id: "msg_target" }, [targetTextPart]), + ] + + await Reflect.apply(transform, undefined, [{}, { messages }]) + + expect(messages[2]?.parts[0]).toBe(previousThinkingPart) + expect(messages[2]?.parts).toEqual([previousThinkingPart, targetTextPart]) + }) + + test("skips injection when no signed Anthropic thinking part exists in history", async () => { + const transform = Reflect.get(createThinkingBlockValidatorHook(), "experimental.chat.messages.transform") + expect(typeof transform).toBe("function") + + const targetTextPart = createTextPart("prt_target_text", "tool result") + const messages: TestMessage[] = [ + createMessage({ role: "user", modelID: "claude-opus-4-6-thinking" }, [createTextPart("prt_user_text", "continue")]), + createMessage({ role: "assistant", id: "msg_prev" }, [{ type: "reasoning", id: "prt_reason", text: "gpt reasoning" }]), + createMessage({ role: "assistant", id: "msg_target" }, [targetTextPart]), + ] + + await Reflect.apply(transform, undefined, [{}, { messages }]) + + expect(messages[2]?.parts).toEqual([targetTextPart]) + }) + + test("does not inject when the assistant message already starts with redacted thinking", async () => { + const transform = Reflect.get(createThinkingBlockValidatorHook(), "experimental.chat.messages.transform") + expect(typeof transform).toBe("function") + + const existingThinkingPart = createRedactedThinkingPart("prt_redacted", "sig_redacted") + const targetTextPart = createTextPart("prt_target_text", "tool result") + const messages: TestMessage[] = [ + createMessage({ role: "user", modelID: "claude-opus-4-6-thinking" }, [createTextPart("prt_user_text", "continue")]), + createMessage({ role: "assistant", id: "msg_target" }, [existingThinkingPart, targetTextPart]), + ] + + await Reflect.apply(transform, undefined, [{}, { messages }]) + + expect(messages[1]?.parts).toEqual([existingThinkingPart, targetTextPart]) + }) + + test("skips processing for models without extended thinking", async () => { + const transform = Reflect.get(createThinkingBlockValidatorHook(), "experimental.chat.messages.transform") + expect(typeof transform).toBe("function") + + const previousThinkingPart = createSignedThinkingPart("prt_prev_signed", "prior reasoning", "sig_prev") + const targetTextPart = createTextPart("prt_target_text", "tool result") + const messages: TestMessage[] = [ + createMessage({ role: "user", modelID: "gpt-5.4" }, [createTextPart("prt_user_text", "continue")]), + createMessage({ role: "assistant", id: "msg_prev" }, [previousThinkingPart]), + createMessage({ role: "assistant", id: "msg_target" }, [targetTextPart]), + ] + + await Reflect.apply(transform, undefined, [{}, { messages }]) + + expect(messages[2]?.parts).toEqual([targetTextPart]) + }) +}) + +export {} diff --git a/src/hooks/thinking-block-validator/hook.ts b/src/hooks/thinking-block-validator/hook.ts index 18a4d3e0b..67b6c12c0 100644 --- a/src/hooks/thinking-block-validator/hook.ts +++ b/src/hooks/thinking-block-validator/hook.ts @@ -21,16 +21,9 @@ interface MessageWithParts { parts: Part[] } -interface ThinkingPart { - thinking?: string - text?: string -} - -interface MessageInfoExtended { - id: string - role: string - sessionID?: string - modelID?: string +type SignedThinkingPart = Part & { + type: "thinking" | "redacted_thinking" + signature: string } type MessagesTransformHook = { @@ -83,57 +76,45 @@ function startsWithThinkingBlock(parts: Part[]): boolean { const firstPart = parts[0] const type = firstPart.type as string - return type === "thinking" || type === "reasoning" + return type === "thinking" || type === "redacted_thinking" || type === "reasoning" } -/** - * Find the most recent thinking content from previous assistant messages - */ -function findPreviousThinkingContent( +function isSignedThinkingPart(part: Part): part is SignedThinkingPart { + const type = part.type as string + if (type !== "thinking" && type !== "redacted_thinking") { + return false + } + + const signature = (part as { signature?: unknown }).signature + return typeof signature === "string" && signature.length > 0 +} + +function findPreviousThinkingPart( messages: MessageWithParts[], currentIndex: number -): string { +): SignedThinkingPart | null { // Search backwards from current message for (let i = currentIndex - 1; i >= 0; i--) { const msg = messages[i] if (msg.info.role !== "assistant") continue - // Look for thinking parts if (!msg.parts) continue for (const part of msg.parts) { - const type = part.type as string - if (type === "thinking" || type === "reasoning") { - const thinking = (part as unknown as ThinkingPart).thinking || (part as unknown as ThinkingPart).text - if (thinking && typeof thinking === "string" && thinking.trim().length > 0) { - return thinking - } + if (isSignedThinkingPart(part)) { + return part } } } - return "" + return null } -/** - * Prepend a thinking block to a message's parts array - */ -function prependThinkingBlock(message: MessageWithParts, thinkingContent: string): void { +function prependThinkingBlock(message: MessageWithParts, thinkingPart: SignedThinkingPart): void { if (!message.parts) { message.parts = [] } - // Create synthetic thinking part - const thinkingPart = { - type: "thinking" as const, - id: `prt_0000000000_synthetic_thinking`, - sessionID: (message.info as unknown as MessageInfoExtended).sessionID || "", - messageID: message.info.id, - thinking: thinkingContent, - synthetic: true, - } - - // Prepend to parts array - message.parts.unshift(thinkingPart as unknown as Part) + message.parts.unshift(thinkingPart) } /** @@ -150,7 +131,8 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook { // Get the model info from the last user message const lastUserMessage = messages.findLast(m => m.info.role === "user") - const modelID = (lastUserMessage?.info as unknown as MessageInfoExtended)?.modelID || "" + const modelIDValue = (lastUserMessage?.info as { modelID?: unknown } | undefined)?.modelID + const modelID = typeof modelIDValue === "string" ? modelIDValue : "" // Only process if extended thinking might be enabled if (!isExtendedThinkingModel(modelID)) { @@ -166,13 +148,12 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook { // Check if message has content parts but doesn't start with thinking if (hasContentParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) { - // Find thinking content from previous turns - const previousThinking = findPreviousThinkingContent(messages, i) + const previousThinkingPart = findPreviousThinkingPart(messages, i) + if (!previousThinkingPart) { + continue + } - // Prepend thinking block with content from previous turn or placeholder - const thinkingContent = previousThinking || "[Continuing from previous reasoning]" - - prependThinkingBlock(msg, thinkingContent) + prependThinkingBlock(msg, previousThinkingPart) } } },