summaryrefslogtreecommitdiffhomepage
path: root/tasks.md
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 /tasks.md
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 'tasks.md')
-rw-r--r--tasks.md44
1 files changed, 43 insertions, 1 deletions
diff --git a/tasks.md b/tasks.md
index 5ac0325..b4a118e 100644
--- a/tasks.md
+++ b/tasks.md
@@ -5,7 +5,49 @@
> Keep this lean and current; do not let it re-accrete a step-by-step changelog.
## Status (current)
-`tsc -b` EXIT 0 · biome clean · **1443 vitest** green.
+`tsc -b` EXIT 0 · biome clean · **1453 vitest** green.
+
+## Broken-chat self-repair (read-time reconcile) (DONE)
+Conversation `77574596` broke unrecoverably: `reconcile()` only repaired orphaned
+tool-calls, not (a) a trailing assistant message whose only chunk is `error`
+(serializes to empty content → uncontinuable) and (b) a `tool-call` whose `input`
+is a raw malformed-JSON string (re-sent as OpenAI `arguments` → provider 400s on
+every continuation). `load()` also had no try/catch on `JSON.parse` (one corrupt
+row would brick a chat). Fix = read-time repair so broken chats auto-heal on next
+open — NO DB surgery (append-only preserved; repair is a turn-path transform on
+`load()`). Full diagnosis + plan: `broken-chat-repair-handoff.md` +
+`reports/broken-chat-repair-diagnosis.md`.
+- **Layer 1 — `conversation-store` `reconcile.ts` (protects ALL providers):**
+ `reconcileWithReport` now (1) strips `error` chunks from assistant messages, (2)
+ drops any assistant message left with no `text`/`tool-call` (the emptied error-only
+ msg — safe: never followed by a `tool` msg), (3) keeps orphaned-tool-call synthesis
+ unchanged. `ReconcileReport` +2 additive counts (`strippedErrorChunks`,
+ `droppedEmptyMessages`) for the repair span. `loadSince` (FE reads) intentionally
+ NOT reconciled — the user still SEES the error while the provider gets clean history.
+ **Hardening:** `store.ts` `load()` wraps per-chunk `JSON.parse` in try/catch →
+ corrupt row skipped (log + continue), reconcile runs on the rest. +6 reconcile/store
+ tests.
+- **Layer 2 — `openai-stream` `convert-messages.ts` (per-provider args safety):** new
+ pure `serializeToolArguments` — object→stringify; valid-string→parse+restringify;
+ malformed-string→fallback `{ _malformed_arguments: <truncated 200> }`. Output ALWAYS
+ `JSON.parse`s → provider stops 400ing on stored malformed args. +4 tests.
+- **Layer 2 (equiv) — `../claude` `provider-anthropic` `convert.ts`:** `safeJson` now
+ returns a valid object fallback (`{ _malformed_arguments: s.slice(0,200) }`) on
+ parse failure, not the raw string (`tool_use.input` must be an object for Anthropic).
+ Exported for direct testing. +3 tests. (Separate repo, separate agent.)
+- **Wave 1+2 (parallel, disjoint):** conversation-store + openai-stream (arch-rewrite)
+ + provider-anthropic (`../claude`). All in-lane; zero internal mocks; no contract/type
+ change. Reports: `reports/conversation-store.md`, `reports/openai-stream.md`,
+ `../claude/reports/provider-anthropic.md`.
+- [x] Verified: arch-rewrite `tsc -b` EXIT 0, biome clean, **1453 vitest** (was 1443);
+ `../claude` `tsc -b` EXIT 0, 71 vitest, biome clean. Both pure-core units zero
+ internal mocks.
+- [x] **LIVE-VERIFIED** (dev stack `bin/up` :24203): reproduced 77574596's REAL broken
+ tail (the actual malformed-args tool-call + trailing error chunk) in the dev DB;
+ `POST /chat` continued it cleanly (`text-delta:"OK"` → `done` reason `"stop"`, no
+ 400) — the provider accepted the reconciled history (error stripped, args sanitized).
+ The historical error chunk remains in storage by design (read-time repair only); no
+ new error was appended. Cleaned up the test conversation after.
## LSP — broken-server recovery + config source attribution (DONE)
Handoff from an agent running in raylib-jamstack (configuring ruby-lsp under the