summaryrefslogtreecommitdiffhomepage
path: root/packages/openai-stream/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-24 13:43:40 +0900
committerAdam Malczewski <[email protected]>2026-06-24 13:43:40 +0900
commitb58fb8373a1f7311cead23aa9a4d1fcd6927634f (patch)
tree839e0e51a235ed5d9bb8d24ac0f367552c1d61ac /packages/openai-stream/src
parentd274567893ff3283878ac0dcafd51a0b127653d7 (diff)
downloaddispatch-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.ts105
-rw-r--r--packages/openai-stream/src/convert-messages.ts30
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);
+}