diff --git a/src/hooks/anthropic-context-window-limit-recovery/parser.test.ts b/src/hooks/anthropic-context-window-limit-recovery/parser.test.ts new file mode 100644 index 00000000..d0fa1058 --- /dev/null +++ b/src/hooks/anthropic-context-window-limit-recovery/parser.test.ts @@ -0,0 +1,97 @@ +/// +import { describe, expect, it } from "bun:test" +import { parseAnthropicTokenLimitError } from "./parser" + +describe("parseAnthropicTokenLimitError", () => { + it("#given a standard token limit error string #when parsing #then extracts tokens", () => { + //#given + const error = "prompt is too long: 250000 tokens > 200000 maximum" + + //#when + const result = parseAnthropicTokenLimitError(error) + + //#then + expect(result).not.toBeNull() + expect(result!.currentTokens).toBe(250000) + expect(result!.maxTokens).toBe(200000) + }) + + it("#given a non-token-limit error #when parsing #then returns null", () => { + //#given + const error = { message: "internal server error" } + + //#when + const result = parseAnthropicTokenLimitError(error) + + //#then + expect(result).toBeNull() + }) + + it("#given null input #when parsing #then returns null", () => { + //#given + const error = null + + //#when + const result = parseAnthropicTokenLimitError(error) + + //#then + expect(result).toBeNull() + }) + + it("#given a proxy error with non-standard structure #when parsing #then returns null without crashing", () => { + //#given + const proxyError = { + data: [1, 2, 3], + error: "string-not-object", + message: "Failed to process error response", + } + + //#when + const result = parseAnthropicTokenLimitError(proxyError) + + //#then + expect(result).toBeNull() + }) + + it("#given a circular reference error #when parsing #then returns null without crashing", () => { + //#given + const circular: Record = { message: "prompt is too long" } + circular.self = circular + + //#when + const result = parseAnthropicTokenLimitError(circular) + + //#then + expect(result).not.toBeNull() + }) + + it("#given an error where data.responseBody has invalid JSON #when parsing #then handles gracefully", () => { + //#given + const error = { + data: { responseBody: "not valid json {{{" }, + message: "prompt is too long with 300000 tokens exceeds 200000", + } + + //#when + const result = parseAnthropicTokenLimitError(error) + + //#then + expect(result).not.toBeNull() + expect(result!.currentTokens).toBe(300000) + expect(result!.maxTokens).toBe(200000) + }) + + it("#given an error with data as a string (not object) #when parsing #then does not crash", () => { + //#given + const error = { + data: "some-string-data", + message: "token limit exceeded", + } + + //#when + const result = parseAnthropicTokenLimitError(error) + + //#then + expect(result).not.toBeNull() + }) +}) diff --git a/src/hooks/anthropic-context-window-limit-recovery/parser.ts b/src/hooks/anthropic-context-window-limit-recovery/parser.ts index dda87bbd..451d1ab1 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/parser.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/parser.ts @@ -74,6 +74,14 @@ function isTokenLimitError(text: string): boolean { } export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitError | null { + try { + return parseAnthropicTokenLimitErrorUnsafe(err) + } catch { + return null + } +} + +function parseAnthropicTokenLimitErrorUnsafe(err: unknown): ParsedTokenLimitError | null { if (typeof err === "string") { if (err.toLowerCase().includes("non-empty content")) { return { diff --git a/src/hooks/session-recovery/detect-error-type.test.ts b/src/hooks/session-recovery/detect-error-type.test.ts new file mode 100644 index 00000000..d20e7cc9 --- /dev/null +++ b/src/hooks/session-recovery/detect-error-type.test.ts @@ -0,0 +1,129 @@ +/// +import { describe, expect, it } from "bun:test" +import { detectErrorType, extractMessageIndex } from "./detect-error-type" + +describe("detectErrorType", () => { + it("#given a tool_use/tool_result error #when detecting #then returns tool_result_missing", () => { + //#given + const error = { message: "tool_use block must be followed by tool_result" } + + //#when + const result = detectErrorType(error) + + //#then + expect(result).toBe("tool_result_missing") + }) + + it("#given a thinking block order error #when detecting #then returns thinking_block_order", () => { + //#given + const error = { message: "thinking must be the first block in the response" } + + //#when + const result = detectErrorType(error) + + //#then + expect(result).toBe("thinking_block_order") + }) + + it("#given a thinking disabled violation #when detecting #then returns thinking_disabled_violation", () => { + //#given + const error = { message: "thinking is disabled and cannot contain thinking blocks" } + + //#when + const result = detectErrorType(error) + + //#then + expect(result).toBe("thinking_disabled_violation") + }) + + it("#given an unrecognized error #when detecting #then returns null", () => { + //#given + const error = { message: "some random error" } + + //#when + const result = detectErrorType(error) + + //#then + expect(result).toBeNull() + }) + + it("#given a malformed error with circular references #when detecting #then returns null without crashing", () => { + //#given + const circular: Record = {} + circular.self = circular + + //#when + const result = detectErrorType(circular) + + //#then + expect(result).toBeNull() + }) + + it("#given a proxy error with non-standard structure #when detecting #then returns null without crashing", () => { + //#given + const proxyError = { + data: "not-an-object", + error: 42, + nested: { deeply: { error: true } }, + } + + //#when + const result = detectErrorType(proxyError) + + //#then + expect(result).toBeNull() + }) + + it("#given a null error #when detecting #then returns null", () => { + //#given + const error = null + + //#when + const result = detectErrorType(error) + + //#then + expect(result).toBeNull() + }) + + it("#given an error with data.error containing message #when detecting #then extracts correctly", () => { + //#given + const error = { + data: { + error: { + message: "tool_use block requires tool_result", + }, + }, + } + + //#when + const result = detectErrorType(error) + + //#then + expect(result).toBe("tool_result_missing") + }) +}) + +describe("extractMessageIndex", () => { + it("#given an error referencing messages.5 #when extracting #then returns 5", () => { + //#given + const error = { message: "Invalid value at messages.5: tool_result is required" } + + //#when + const result = extractMessageIndex(error) + + //#then + expect(result).toBe(5) + }) + + it("#given a malformed error #when extracting #then returns null without crashing", () => { + //#given + const circular: Record = {} + circular.self = circular + + //#when + const result = extractMessageIndex(circular) + + //#then + expect(result).toBeNull() + }) +}) diff --git a/src/hooks/session-recovery/detect-error-type.ts b/src/hooks/session-recovery/detect-error-type.ts index f51c0d0d..3f2f9a1c 100644 --- a/src/hooks/session-recovery/detect-error-type.ts +++ b/src/hooks/session-recovery/detect-error-type.ts @@ -34,40 +34,48 @@ function getErrorMessage(error: unknown): string { } export function extractMessageIndex(error: unknown): number | null { - const message = getErrorMessage(error) - const match = message.match(/messages\.(\d+)/) - return match ? parseInt(match[1], 10) : null + try { + const message = getErrorMessage(error) + const match = message.match(/messages\.(\d+)/) + return match ? parseInt(match[1], 10) : null + } catch { + return null + } } export function detectErrorType(error: unknown): RecoveryErrorType { - const message = getErrorMessage(error) + try { + const message = getErrorMessage(error) - if ( - message.includes("assistant message prefill") || - message.includes("conversation must end with a user message") - ) { - return "assistant_prefill_unsupported" + if ( + message.includes("assistant message prefill") || + message.includes("conversation must end with a user message") + ) { + return "assistant_prefill_unsupported" + } + + if ( + message.includes("thinking") && + (message.includes("first block") || + message.includes("must start with") || + message.includes("preceeding") || + message.includes("final block") || + message.includes("cannot be thinking") || + (message.includes("expected") && message.includes("found"))) + ) { + return "thinking_block_order" + } + + if (message.includes("thinking is disabled") && message.includes("cannot contain")) { + return "thinking_disabled_violation" + } + + if (message.includes("tool_use") && message.includes("tool_result")) { + return "tool_result_missing" + } + + return null + } catch { + return null } - - if ( - message.includes("thinking") && - (message.includes("first block") || - message.includes("must start with") || - message.includes("preceeding") || - message.includes("final block") || - message.includes("cannot be thinking") || - (message.includes("expected") && message.includes("found"))) - ) { - return "thinking_block_order" - } - - if (message.includes("thinking is disabled") && message.includes("cannot contain")) { - return "thinking_disabled_violation" - } - - if (message.includes("tool_use") && message.includes("tool_result")) { - return "tool_result_missing" - } - - return null }