diff options
| author | Adam Malczewski <[email protected]> | 2026-06-24 13:43:40 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-24 13:43:40 +0900 |
| commit | b58fb8373a1f7311cead23aa9a4d1fcd6927634f (patch) | |
| tree | 839e0e51a235ed5d9bb8d24ac0f367552c1d61ac /packages/openai-stream/src | |
| parent | d274567893ff3283878ac0dcafd51a0b127653d7 (diff) | |
| download | dispatch-b58fb8373a1f7311cead23aa9a4d1fcd6927634f.tar.gz dispatch-b58fb8373a1f7311cead23aa9a4d1fcd6927634f.zip | |
fix(broken-chat): read-time self-repair of unrecoverable chats
reconcile() only repaired orphaned tool-calls. Two other broken states made
chats uncontinuable, and load() had no parse-error guard:
- A trailing assistant message whose only chunk is 'error' (a failed-
generation marker) serializes to empty content -> provider rejects/empty
-> chat never continues. 6 of 140 production conversations were stuck.
- A tool-call whose input is a raw malformed-JSON string (model emitted
broken JSON) re-sent as OpenAI arguments -> provider 400s on every
continuation (the 77574596 break).
- load() JSON.parse had no try/catch -> one corrupt row bricked the chat.
Fix = read-time repair (no DB surgery; append-only preserved). reconcile
runs on every load() BEFORE any provider sees messages, so Layer 1
protects ALL providers.
Layer 1 (conversation-store reconcile): strip error chunks from assistant
messages + drop the now-empty error-only messages (safe: never followed by
a tool message); orphaned-tool-call synthesis unchanged; ReconcileReport
+2 additive counts. loadSince (FE reads) intentionally unreconciled so the
user still SEES the error. load() wraps JSON.parse in try/catch (skip
corrupt rows).
Layer 2 (openai-stream): serializeToolArguments ensures tool-call
arguments is always valid JSON (malformed string -> fallback object),
neutralizing already-stored malformed args.
Layer 2 equiv (../claude provider-anthropic): safeJson returns a valid
object fallback on parse failure, not the raw string. (Separate repo.)
Live-verified: reproduced 77574596's real broken tail in the dev DB;
POST /chat continued it cleanly (no 400, model replied) — the provider
accepted the reconciled history.
tsc -b EXIT 0, biome clean, 1453 vitest pass.
Diffstat (limited to 'packages/openai-stream/src')
| -rw-r--r-- | packages/openai-stream/src/convert-messages.test.ts | 105 | ||||
| -rw-r--r-- | packages/openai-stream/src/convert-messages.ts | 30 |
2 files changed, 134 insertions, 1 deletions
diff --git a/packages/openai-stream/src/convert-messages.test.ts b/packages/openai-stream/src/convert-messages.test.ts index 51513ea..004a6b7 100644 --- a/packages/openai-stream/src/convert-messages.test.ts +++ b/packages/openai-stream/src/convert-messages.test.ts @@ -256,4 +256,109 @@ describe("convertMessages", () => { const result = convertMessages(messages); expect(result).toEqual([{ role: "assistant", content: "Let me think...Here is my answer." }]); }); + + it("arguments is valid JSON when input is a malformed string", () => { + // Production seq-134 shape: the model emitted broken JSON as the tool + // arguments and it was stored verbatim. Unquoted key fails JSON.parse at + // some column (position 1 here). + const malformed = '{path: "/src/main.ts"}'; + expect(() => JSON.parse(malformed)).toThrow(); + + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_bad", + toolName: "read_file", + input: malformed, + }, + ], + }, + ]; + + const result = convertMessages(messages); + const args = result[0]?.tool_calls?.[0]?.function.arguments; + expect(args).toBeDefined(); + // The output MUST parse without throwing — the provider receives valid JSON. + expect(() => JSON.parse(args as string)).not.toThrow(); + // And it is the fallback object preserving a truncated hint. + expect(JSON.parse(args as string)).toEqual({ + _malformed_arguments: malformed.slice(0, 200), + }); + }); + + it("arguments passes through valid string input", () => { + const validJson = '{"path":"/src/main.ts"}'; + expect(() => JSON.parse(validJson)).not.toThrow(); + + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_str", + toolName: "read_file", + input: validJson, + }, + ], + }, + ]; + + const result = convertMessages(messages); + const args = result[0]?.tool_calls?.[0]?.function.arguments; + // A valid-JSON string round-trips to a canonical JSON string. + expect(args).toBe(JSON.stringify(JSON.parse(validJson))); + expect(args).toBe('{"path":"/src/main.ts"}'); + }); + + it("stringifies object input", () => { + const input = { path: "/src/main.ts", line: 42 }; + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_obj", + toolName: "read_file", + input, + }, + ], + }, + ]; + + const result = convertMessages(messages); + const args = result[0]?.tool_calls?.[0]?.function.arguments; + expect(args).toBe(JSON.stringify(input)); + }); + + it("truncates the malformed input hint to 200 characters", () => { + // A long bare run of letters is not valid JSON (no quotes/braces). + const malformed = "x".repeat(500); + expect(() => JSON.parse(malformed)).toThrow(); + + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_long", + toolName: "read_file", + input: malformed, + }, + ], + }, + ]; + + const result = convertMessages(messages); + const args = result[0]?.tool_calls?.[0]?.function.arguments; + expect(() => JSON.parse(args as string)).not.toThrow(); + const parsed = JSON.parse(args as string); + expect(parsed).toEqual({ _malformed_arguments: malformed.slice(0, 200) }); + expect(parsed._malformed_arguments.length).toBe(200); + }); }); diff --git a/packages/openai-stream/src/convert-messages.ts b/packages/openai-stream/src/convert-messages.ts index 786a70d..76badc8 100644 --- a/packages/openai-stream/src/convert-messages.ts +++ b/packages/openai-stream/src/convert-messages.ts @@ -71,7 +71,7 @@ function convertAssistantMessage(msg: ChatMessage): OpenAIMessage { type: "function", function: { name: c.toolName, - arguments: typeof c.input === "string" ? c.input : JSON.stringify(c.input), + arguments: serializeToolArguments(c.input), }, }), ); @@ -97,3 +97,31 @@ function convertToolResultMessages(msg: ChatMessage): OpenAIMessage[] { }), ); } + +/** + * Serialize a tool-call's `input` into a JSON string the provider will accept. + * + * The OpenAI `arguments` field MUST be a valid JSON string. A broken chat can + * have a tool-call whose `input` is a raw malformed-JSON string (the model + * emitted broken JSON as the tool arguments and it was stored verbatim). + * Passing that string straight through makes the provider 400 + * `unexpected character` on EVERY continuation, bricking the chat. + * + * - object input → `JSON.stringify(input)` (regression, unchanged shape). + * - string input that is valid JSON → re-serialized to canonical JSON. + * - string input that fails to parse → a valid fallback object preserving a + * truncated hint of the original, so the chat can continue (the model sees + * its tool-call had no usable args and adjusts). + * + * Pure: input → output, no I/O. + */ +function serializeToolArguments(input: unknown): string { + if (typeof input === "string") { + try { + return JSON.stringify(JSON.parse(input)); + } catch { + return JSON.stringify({ _malformed_arguments: input.slice(0, 200) }); + } + } + return JSON.stringify(input); +} |
