diff --git a/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts new file mode 100644 index 00000000..e7d0e8ee --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, mock, beforeEach } from "bun:test" +import { fixEmptyMessagesWithSDK } from "./empty-content-recovery-sdk" + +const mockReplaceEmptyTextParts = mock(() => Promise.resolve(false)) +const mockInjectTextPart = mock(() => Promise.resolve(false)) + +mock.module("../session-recovery/storage/empty-text", () => ({ + replaceEmptyTextPartsAsync: mockReplaceEmptyTextParts, +})) +mock.module("../session-recovery/storage/text-part-injector", () => ({ + injectTextPartAsync: mockInjectTextPart, +})) + +function createMockClient(messages: Array<{ info?: { id?: string }; parts?: Array<{ type?: string; text?: string }> }>) { + return { + session: { + messages: mock(() => Promise.resolve({ data: messages })), + }, + } as never +} + +describe("fixEmptyMessagesWithSDK", () => { + beforeEach(() => { + mockReplaceEmptyTextParts.mockReset() + mockInjectTextPart.mockReset() + mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(false)) + mockInjectTextPart.mockReturnValue(Promise.resolve(false)) + }) + + it("returns fixed=false when no empty messages exist", async () => { + //#given + const client = createMockClient([ + { info: { id: "msg_1" }, parts: [{ type: "text", text: "Hello" }] }, + ]) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + }) + + //#then + expect(result.fixed).toBe(false) + expect(result.fixedMessageIds).toEqual([]) + expect(result.scannedEmptyCount).toBe(0) + }) + + it("fixes empty message via replace when scanning all", async () => { + //#given + const client = createMockClient([ + { info: { id: "msg_1" }, parts: [{ type: "text", text: "" }] }, + ]) + mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true)) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + }) + + //#then + expect(result.fixed).toBe(true) + expect(result.fixedMessageIds).toContain("msg_1") + expect(result.scannedEmptyCount).toBe(1) + }) + + it("falls back to inject when replace fails", async () => { + //#given + const client = createMockClient([ + { info: { id: "msg_1" }, parts: [] }, + ]) + mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(false)) + mockInjectTextPart.mockReturnValue(Promise.resolve(true)) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + }) + + //#then + expect(result.fixed).toBe(true) + expect(result.fixedMessageIds).toContain("msg_1") + }) + + it("fixes target message by index when provided", async () => { + //#given + const client = createMockClient([ + { info: { id: "msg_0" }, parts: [{ type: "text", text: "ok" }] }, + { info: { id: "msg_1" }, parts: [] }, + ]) + mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true)) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + messageIndex: 1, + }) + + //#then + expect(result.fixed).toBe(true) + expect(result.fixedMessageIds).toContain("msg_1") + expect(result.scannedEmptyCount).toBe(0) + }) + + it("skips messages without info.id", async () => { + //#given + const client = createMockClient([ + { parts: [] }, + { info: {}, parts: [] }, + ]) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + }) + + //#then + expect(result.fixed).toBe(false) + expect(result.scannedEmptyCount).toBe(0) + }) + + it("treats thinking-only messages as empty", async () => { + //#given + const client = createMockClient([ + { info: { id: "msg_1" }, parts: [{ type: "thinking", text: "hmm" }] }, + ]) + mockReplaceEmptyTextParts.mockReturnValue(Promise.resolve(true)) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + }) + + //#then + expect(result.fixed).toBe(true) + expect(result.fixedMessageIds).toContain("msg_1") + }) + + it("treats tool_use messages as non-empty", async () => { + //#given + const client = createMockClient([ + { info: { id: "msg_1" }, parts: [{ type: "tool_use" }] }, + ]) + + //#when + const result = await fixEmptyMessagesWithSDK({ + sessionID: "ses_1", + client, + placeholderText: "[recovered]", + }) + + //#then + expect(result.fixed).toBe(false) + expect(result.scannedEmptyCount).toBe(0) + }) +}) diff --git a/src/hooks/session-recovery/recover-empty-content-message-sdk.test.ts b/src/hooks/session-recovery/recover-empty-content-message-sdk.test.ts new file mode 100644 index 00000000..acf178ec --- /dev/null +++ b/src/hooks/session-recovery/recover-empty-content-message-sdk.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, mock, beforeEach } from "bun:test" +import { recoverEmptyContentMessageFromSDK } from "./recover-empty-content-message-sdk" +import type { MessageData } from "./types" + +function createMockClient(messages: MessageData[]) { + return { + session: { + messages: mock(() => Promise.resolve({ data: messages })), + }, + } as never +} + +function createDeps(overrides?: Partial[4]>) { + return { + placeholderText: "[recovered]", + replaceEmptyTextPartsAsync: mock(() => Promise.resolve(false)), + injectTextPartAsync: mock(() => Promise.resolve(false)), + findMessagesWithEmptyTextPartsFromSDK: mock(() => Promise.resolve([] as string[])), + ...overrides, + } +} + +const emptyMsg: MessageData = { info: { id: "msg_1", role: "assistant" }, parts: [] } +const contentMsg: MessageData = { info: { id: "msg_2", role: "assistant" }, parts: [{ type: "text", text: "Hello" }] } +const thinkingOnlyMsg: MessageData = { info: { id: "msg_3", role: "assistant" }, parts: [{ type: "thinking", text: "hmm" }] } + +describe("recoverEmptyContentMessageFromSDK", () => { + it("returns false when no empty messages exist", async () => { + //#given + const client = createMockClient([contentMsg]) + const deps = createDeps() + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", contentMsg, new Error("test"), deps, + ) + + //#then + expect(result).toBe(false) + }) + + it("fixes messages with empty text parts via replace", async () => { + //#given + const client = createMockClient([emptyMsg]) + const deps = createDeps({ + findMessagesWithEmptyTextPartsFromSDK: mock(() => Promise.resolve(["msg_1"])), + replaceEmptyTextPartsAsync: mock(() => Promise.resolve(true)), + }) + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", emptyMsg, new Error("test"), deps, + ) + + //#then + expect(result).toBe(true) + }) + + it("injects text part into thinking-only messages", async () => { + //#given + const client = createMockClient([thinkingOnlyMsg]) + const deps = createDeps({ + injectTextPartAsync: mock(() => Promise.resolve(true)), + }) + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", thinkingOnlyMsg, new Error("test"), deps, + ) + + //#then + expect(result).toBe(true) + expect(deps.injectTextPartAsync).toHaveBeenCalledWith( + client, "ses_1", "msg_3", "[recovered]", + ) + }) + + it("targets message by index from error", async () => { + //#given + const client = createMockClient([contentMsg, emptyMsg]) + const error = new Error("messages: index 1 has empty content") + const deps = createDeps({ + replaceEmptyTextPartsAsync: mock(() => Promise.resolve(true)), + }) + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", emptyMsg, error, deps, + ) + + //#then + expect(result).toBe(true) + }) + + it("falls back to failedID when targetIndex fix fails", async () => { + //#given + const failedMsg: MessageData = { info: { id: "msg_fail" }, parts: [] } + const client = createMockClient([contentMsg]) + const deps = createDeps({ + replaceEmptyTextPartsAsync: mock(() => Promise.resolve(false)), + injectTextPartAsync: mock(() => Promise.resolve(true)), + }) + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", failedMsg, new Error("test"), deps, + ) + + //#then + expect(result).toBe(true) + expect(deps.injectTextPartAsync).toHaveBeenCalledWith( + client, "ses_1", "msg_fail", "[recovered]", + ) + }) + + it("returns false when SDK throws during message read", async () => { + //#given + const client = { session: { messages: mock(() => Promise.reject(new Error("SDK error"))) } } as never + const deps = createDeps() + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", emptyMsg, new Error("test"), deps, + ) + + //#then + expect(result).toBe(false) + }) + + it("scans all empty messages when no target index available", async () => { + //#given + const empty1: MessageData = { info: { id: "e1" }, parts: [] } + const empty2: MessageData = { info: { id: "e2" }, parts: [] } + const client = createMockClient([empty1, empty2]) + const replaceMock = mock(() => Promise.resolve(true)) + const deps = createDeps({ replaceEmptyTextPartsAsync: replaceMock }) + + //#when + const result = await recoverEmptyContentMessageFromSDK( + client, "ses_1", empty1, new Error("test"), deps, + ) + + //#then + expect(result).toBe(true) + }) +})