diff --git a/CLAUDE.md b/CLAUDE.md index 21c997e7..09682a87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,7 +117,8 @@ one verified pair, both pinned in `vendor/lody/UPSTREAM.md`: bump them together. vendor edit there first, or do not make it. - The daemon patches in `packages/box/patches/` (`lody-local-platform.mjs`, cloud→local platform; `lody-acp-auth-queue.mjs`, the ACP-auth queue chain; - `lody-code-collab-worktree-root.mjs`, Code Collab's worktree root) are + `lody-code-collab-worktree-root.mjs`, Code Collab's worktree root; + `lody-agent-message-split.mjs`, assistant text grouped by `messageId`) are guarded against the published bundle (sha256; version + anchor count) and must be re-verified on every daemon bump. - Upstream merges follow the runbook `docs/LODY-MERGE.md`. Where Lody upstream diff --git a/docs/LODY-MERGE.md b/docs/LODY-MERGE.md index dfcbad91..07e7fd4d 100644 --- a/docs/LODY-MERGE.md +++ b/docs/LODY-MERGE.md @@ -116,7 +116,7 @@ npm view lody@ dist.shasum Then, in ONE change: 1. bump `lody@` in `packages/box/Dockerfile`; -2. re-audit BOTH patches in `packages/box/patches/` — see §5, neither is +2. re-audit ALL FOUR patches in `packages/box/patches/` — see §5, none is optional and each is guarded twice; 3. record both numbers in `vendor/lody/UPSTREAM.md`. @@ -236,10 +236,10 @@ prominently in the pull request body. ## 5. Re-audit the npm-artifact patches -Three scripts in `packages/box/patches/` are applied to the **published npm +Four scripts in `packages/box/patches/` are applied to the **published npm artifact**, in the Dockerfile's order. The order is load-bearing: `lody-local-platform.mjs` guards on a sha256 of the file AS PUBLISHED, so nothing -may rewrite it first. All three are idempotent — re-running any of them on an +may rewrite it first. All four are idempotent — re-running any of them on an already-patched bundle reports it and exits 0, which is what lets the daemon test harness copy a real box's bundle and re-apply them to the copy. @@ -325,6 +325,41 @@ npx vitest run test/lody-worktree-session.test.ts # in packages/webapp, on a b DELETE this patch rather than updating it.** Their own `lib/terminal-workdir-resolver.ts:97` already does, so the two may converge. +### 5d. The assistant-message split patch + +`packages/box/patches/lody-agent-message-split.mjs` groups streamed assistant +text by the `messageId` the ACP adapter already stamps on every chunk. Without +it, ONE Anthropic message is stored as two text blocks whenever anything — a +tool call, a subagent task — lands between two of its deltas, so the reader gets +a sentence cut in half around a tool card: + +``` +[21] text "Three" +[22] tool_call toolu_0166kpDv… (grep …) +[23] text " characterization agents are running in parallel, plus …" +``` + +`claude-acp.js` computes the id (`messageIdForGrouping`, the API message `id`) +and `applyMessageId` puts it on the update; the schema keeps it +(`zContentChunk.messageId`); the history applier drops it and merges only into +`items[items.length - 1]`. The patch carries the id onto the stored item and +makes both `appendOrMergeAdjacentText` copies scan back past trailing non-text +items to the block with the same id. With no id it is byte-for-byte today's +behaviour, so every other adapter is untouched. + +Six hunks, guarded by the installed package version plus each anchor at exactly +one occurrence. Re-auditing means: + +```sh +grep -n 'appendOrMergeAdjacentText' /tmp/package/dist/index.js # expect 6 lines, 2 definitions +grep -n 'messageId' /tmp/package/dist/index.js | head # the applier must still ignore it +node packages/box/patches/lody-agent-message-split.mjs /tmp/package/dist/index.js +``` + +**If the new version merges by message id itself, DELETE this patch rather than +updating it.** The daemon already emits the id explicitly for grouping, so this +is the fix upstream is one step away from. + ## 6. Dependencies and the patch-file audit Upstream resolves renderer dependencies through pnpm's catalog; BlitzOS resolves diff --git a/packages/box/Dockerfile b/packages/box/Dockerfile index c8fa1897..3483ef17 100644 --- a/packages/box/Dockerfile +++ b/packages/box/Dockerfile @@ -41,13 +41,16 @@ RUN npm install --global --omit=dev \ COPY packages/box/patches/lody-local-platform.mjs /tmp/lody-local-platform.mjs COPY packages/box/patches/lody-acp-auth-queue.mjs /tmp/lody-acp-auth-queue.mjs COPY packages/box/patches/lody-code-collab-worktree-root.mjs /tmp/lody-code-collab-worktree-root.mjs +COPY packages/box/patches/lody-agent-message-split.mjs /tmp/lody-agent-message-split.mjs # Order matters only for the first: its guard is a sha256 of the bundle AS # PUBLISHED, so nothing may rewrite the file before it runs. The others guard on # the package version and their own anchor instead, for that reason. RUN node /tmp/lody-local-platform.mjs /opt/blitz/npm/lib/node_modules/lody/dist/index.js \ && node /tmp/lody-acp-auth-queue.mjs /opt/blitz/npm/lib/node_modules/lody/dist/index.js \ && node /tmp/lody-code-collab-worktree-root.mjs /opt/blitz/npm/lib/node_modules/lody/dist/index.js \ - && rm /tmp/lody-local-platform.mjs /tmp/lody-acp-auth-queue.mjs /tmp/lody-code-collab-worktree-root.mjs + && node /tmp/lody-agent-message-split.mjs /opt/blitz/npm/lib/node_modules/lody/dist/index.js \ + && rm /tmp/lody-local-platform.mjs /tmp/lody-acp-auth-queue.mjs /tmp/lody-code-collab-worktree-root.mjs \ + /tmp/lody-agent-message-split.mjs FROM node:22.20.0-bookworm-slim@sha256:b21fe589dfbe5cc39365d0544b9be3f1f33f55f3c86c87a76ff65a02f8f5848e AS runtime-base diff --git a/packages/box/patches/lody-agent-message-split.mjs b/packages/box/patches/lody-agent-message-split.mjs new file mode 100644 index 00000000..779818c0 --- /dev/null +++ b/packages/box/patches/lody-agent-message-split.mjs @@ -0,0 +1,455 @@ +#!/usr/bin/env node +// Stops one assistant message from being stored as two text blocks with a tool +// card wedged between them, in the PUBLISHED `lody` npm bundle. +// +// WHY THIS EXISTS. A real transcript from a canary box, three consecutive items +// of ONE assistant entry: +// +// [21] text "Three" +// [22] tool_call toolu_0166kpDv… (grep …) +// [23] text " characterization agents are running in parallel, plus the +// full suite on step 1." +// +// Item 23 begins with a space because it is the SAME Anthropic message as item +// 21, cut in half. Measured on this box: 13 of 271 stored assistant text blocks +// (~5%) start that way. +// +// The daemon already knows which message a delta belongs to. `claude-acp.js` +// computes `messageIdForGrouping(message)` — the Anthropic assistant message +// `id`, falling back to `message.uuid` — and `applyMessageId(update, messageId)` +// stamps it on every `agent_message_chunk` / `agent_thought_chunk` / +// `user_message_chunk`. The streaming path uses the same value +// (`currentStreamMessageId`, read from the `message_start` event), so every +// delta of one API message carries one id. The ACP schema keeps it: +// `zContentChunk = withMeta({ content: zContentBlock, messageId: z.string().nullish() })`. +// It is emitted explicitly FOR GROUPING. +// +// The history applier throws it away. `buildMessageContentFromNotification` +// maps the chunk to `{ type: "text", text }` and drops the id, and both copies +// of `appendOrMergeAdjacentText` merge a delta only into `items[items.length - 1]`. +// So ANY item appended between two deltas of one message — a tool_call, a +// subagent_task, an image — ends that text block permanently, and the rest of +// the sentence lands in a new one. +// +// WHAT THE PATCH DOES, in six hunks that are one idea: +// +// 1. one shared helper, `blitzTextMergeTargetIndex`, beside +// `compactAdjacentTextAndThought`; +// 2. `buildMessageContentFromNotification` carries `update.messageId` onto the +// `text` and `thought` items it emits; +// 3.+5. the two `case "text"` / `case "thought"` arms (the class applier and +// the batch applier) forward `message.messageId` to the merge; +// 4.+6. both `appendOrMergeAdjacentText` copies ask the helper WHERE to merge +// instead of assuming the last slot, and stamp the id on what they write. +// +// The helper scans back past trailing NON-text items and stops at the first +// text-or-thought item it meets. That item decides: same kind and same id, merge +// there; anything else, push a new block. The scan is O(1) amortized — once a +// new block exists it is the last item again. +// +// THE DISCRIMINATOR IS THE ID AND NOTHING ELSE. No text heuristic: "starts with +// a space", "starts lowercase" and friends all corrupt legitimate content, and a +// legitimate `text → tool → text` across TWO messages carries two different ids +// and must still render as two blocks. It does. +// +// BACKWARD COMPATIBLE IN BOTH DIRECTIONS. With no id on the incoming delta +// (every non-Claude adapter, and `applyMessageContentsBatch`'s materialized rich +// content) the helper returns exactly today's answer: the last slot if it is of +// the right kind, otherwise a new block. With an id on the delta but NO id on +// the stored item — history written by a pre-patch daemon, or a text block that +// `postProcessTouchedAssistantEntries` re-parsed out of `` tags — it +// also returns today's answer, so an untagged item can never be split by this +// change. It gains the id when it is merged into, and groups normally after that. +// +// The extra field is safe to persist: `LoroSessionDoc.updateHistory` writes the +// items straight into the Loro mirror with no schema in the way, and every zod +// object that reads history back is a stripping `z.object`, not `.strict()`. +// The conditional spread means an id-less chunk still produces the byte-identical +// `{ type, text }` object it produces today — nothing writes `messageId: undefined`. +// +// SCOPE. Strictly a MERGE-TARGET change. No item is dropped, no item is +// reordered, no item type other than `text`/`thought` is touched. The one +// behaviour that narrows is two ADJACENT stored text items that both carry ids +// and disagree: they stay two blocks where today they would fuse. That is the +// same rule this patch exists to enforce, and it is the rare shape — two API +// messages with nothing at all between them. +// +// WHY THE GUARD IS NOT A WHOLE-FILE SHA. Four patches now run over the same +// artifact, and a file hash can only pin whichever runs first — a later one +// would have to hash its siblings' output and would break whenever any of them +// changed. So this one pins the two things that are actually load-bearing: the +// installed package's VERSION, and each anchor's exact text at exactly one +// occurrence. A refactor that moves any of the six fails here with a count of 0. +// +// Recorded in vendor/lody/BLITZ-PATCHES.md. Usage: +// node lody-agent-message-split.mjs + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +const EXPECTED_VERSION = "0.88.1"; +const EXPECTED_OCCURRENCES = 1; + +/** The marker the idempotency check looks for. It is the shared helper's name, + * which appears nowhere in the published bundle. */ +const MARKER = "blitzTextMergeTargetIndex"; + +/** Emitted once, immediately above `compactAdjacentTextAndThought`, which is a + * top-level `const` in the same bundle scope as BOTH appliers and is defined + * before either of them runs. + * + * These two are a TRANSCRIPTION of `findStreamedTextMergeIndex` and + * `mergeStreamedTextItem` from the upstream fix (blitzdotdev/Lody + * `fix/acp-text-block-split-by-tool-call`, `packages/shared/src/acp/history-apply.ts`). + * Keep them byte-equivalent in BEHAVIOUR: this patch only exists until a daemon + * release carries that fix, and a box must not answer differently from the + * renderer beside it (CLAUDE.md: copy Lody's behaviour rather than reconcile). + * + * Adjacency still wins first, so a delta landing right after its own block + * merges exactly as it does today, whatever the ids say. What `messageId` adds + * is REACH: when the last item is not text, the first text/thought item from + * the end decides -- same kind and same id merges there, past any tool_call in + * between; anything else starts a new block. A merge never invents an id, and a + * block that ends up holding two messages drops the id it can no longer claim. */ +const HELPER = ` const blitzTextMergeTargetIndex = (items2, kind, messageId) => { + const lastIndex = items2.length - 1; + const last2 = items2[lastIndex]; + if (last2 && last2.type === kind) return lastIndex; + if (typeof messageId !== "string" || messageId.length === 0) return -1; + for (let i2 = lastIndex; i2 >= 0; i2--) { + const item = items2[i2]; + if (!item || item.type !== "text" && item.type !== "thought") continue; + return item.type === kind && item.messageId === messageId ? i2 : -1; + } + return -1; + }; + const blitzMergedTextItem = (existing, text, messageId) => { + if (existing.messageId !== void 0 && messageId !== void 0 && existing.messageId !== messageId) { + const { messageId: spansTwoMessages, ...rest } = existing; + return { ...rest, text }; + } + return { ...existing, text }; + }; +`; + +/** `messageId` is written only when the delta carried one, so an id-less delta + * still yields the byte-identical `{ type, text }` object it yields today. + * Takes the indent of the site so the emitted bundle stays readable. */ +const idSpread = (indent) => + `...typeof messageId === "string" && messageId.length > 0 ? {\n` + + `${indent} messageId\n` + + `${indent}} : {}`; + +const hunks = [ + { + name: "the shared merge-target helper", + what: "compactAdjacentTextAndThought's definition", + find: ` const compactAdjacentTextAndThought = (items2) => {`, + replace: `${HELPER} const compactAdjacentTextAndThought = (items2) => {`, + }, + { + name: "carry messageId out of the notification", + what: "buildMessageContentFromNotification's two chunk arms", + find: ` case "agent_message_chunk": + switch (update2.content.type) { + case "text": + return [ + { + type: "text", + text: update2.content.text + } + ]; + case "image": + case "audio": + case "resource_link": + case "resource": + return []; + } + case "agent_thought_chunk": + switch (update2.content.type) { + case "text": + return [ + { + type: "thought", + text: update2.content.text + } + ]; + case "image": + case "audio": + case "resource_link": + case "resource": + return []; + }`, + replace: ` case "agent_message_chunk": + switch (update2.content.type) { + case "text": + return [ + { + type: "text", + text: update2.content.text, + ...typeof update2.messageId === "string" && update2.messageId.length > 0 ? { + messageId: update2.messageId + } : {} + } + ]; + case "image": + case "audio": + case "resource_link": + case "resource": + return []; + } + case "agent_thought_chunk": + switch (update2.content.type) { + case "text": + return [ + { + type: "thought", + text: update2.content.text, + ...typeof update2.messageId === "string" && update2.messageId.length > 0 ? { + messageId: update2.messageId + } : {} + } + ]; + case "image": + case "audio": + case "resource_link": + case "resource": + return []; + }`, + }, + { + name: "forward the id (streaming applier)", + what: "NotificationOnHistoryApplier.applyMessageContent's text/thought arms", + find: ` applyMessageContent(message) { + switch (message.type) { + case "text": { + const text = sanitizeLodyInternalInstructions(message.text); + if (!text) return; + const entryIndex = this.ensureActiveAssistantEntry(); + this.appendOrMergeAdjacentText(entryIndex, "text", text); + return; + } + case "thought": { + const text = sanitizeLodyInternalInstructions(message.text); + if (!text) return; + const entryIndex = this.ensureActiveAssistantEntry(); + this.appendOrMergeAdjacentText(entryIndex, "thought", text); + return; + }`, + replace: ` applyMessageContent(message) { + switch (message.type) { + case "text": { + const text = sanitizeLodyInternalInstructions(message.text); + if (!text) return; + const entryIndex = this.ensureActiveAssistantEntry(); + this.appendOrMergeAdjacentText(entryIndex, "text", text, message.messageId); + return; + } + case "thought": { + const text = sanitizeLodyInternalInstructions(message.text); + if (!text) return; + const entryIndex = this.ensureActiveAssistantEntry(); + this.appendOrMergeAdjacentText(entryIndex, "thought", text, message.messageId); + return; + }`, + }, + { + name: "merge by id (streaming applier)", + what: "NotificationOnHistoryApplier.appendOrMergeAdjacentText", + find: ` appendOrMergeAdjacentText(entryIndex, kind, delta) { + if (!delta) return; + const items2 = this.ensureEntryItems(entryIndex); + const last2 = items2[items2.length - 1]; + if (last2 && last2.type === kind) { + const existing = last2; + const text = sanitizeLodyInternalInstructions(mergeStreamChunk(existing.text, delta)); + if (!text) { + items2.pop(); + this.touchedAssistantEntryIndices.add(entryIndex); + this.changed = true; + return; + } + items2[items2.length - 1] = { + ...existing, + text + }; + this.touchedAssistantEntryIndices.add(entryIndex); + this.changed = true; + return; + } + items2.push({ + type: kind, + text: delta + }); + this.touchedAssistantEntryIndices.add(entryIndex); + this.changed = true; + }`, + replace: ` appendOrMergeAdjacentText(entryIndex, kind, delta, messageId) { + if (!delta) return; + const items2 = this.ensureEntryItems(entryIndex); + const targetIndex = blitzTextMergeTargetIndex(items2, kind, messageId); + if (targetIndex >= 0) { + const existing = items2[targetIndex]; + const text = sanitizeLodyInternalInstructions(mergeStreamChunk(existing.text, delta)); + if (!text) { + items2.splice(targetIndex, 1); + this.touchedAssistantEntryIndices.add(entryIndex); + this.changed = true; + return; + } + items2[targetIndex] = blitzMergedTextItem(existing, text, messageId); + this.touchedAssistantEntryIndices.add(entryIndex); + this.changed = true; + return; + } + items2.push({ + type: kind, + text: delta, + ${idSpread(" ")} + }); + this.touchedAssistantEntryIndices.add(entryIndex); + this.changed = true; + }`, + }, + { + name: "forward the id (batch applier)", + what: "applyMessageContentsBatch's message loop", + find: ` for (const message of messages) { + switch (message.type) { + case "text": { + const text = sanitizeLodyInternalInstructions(message.text); + if (!text) break; + const entryIndex = ensureActiveAssistantEntry(); + appendOrMergeAdjacentText(entryIndex, "text", text); + break; + } + case "thought": { + const text = sanitizeLodyInternalInstructions(message.text); + if (!text) break; + const entryIndex = ensureActiveAssistantEntry(); + appendOrMergeAdjacentText(entryIndex, "thought", text); + break; + }`, + replace: ` for (const message of messages) { + switch (message.type) { + case "text": { + const text = sanitizeLodyInternalInstructions(message.text); + if (!text) break; + const entryIndex = ensureActiveAssistantEntry(); + appendOrMergeAdjacentText(entryIndex, "text", text, message.messageId); + break; + } + case "thought": { + const text = sanitizeLodyInternalInstructions(message.text); + if (!text) break; + const entryIndex = ensureActiveAssistantEntry(); + appendOrMergeAdjacentText(entryIndex, "thought", text, message.messageId); + break; + }`, + }, + { + name: "merge by id (batch applier)", + what: "applyMessageContentsBatch's appendOrMergeAdjacentText", + find: ` const appendOrMergeAdjacentText = (entryIndex, kind, delta) => { + if (!delta) return; + const state2 = entryStates[entryIndex]; + if (!state2) return; + const last2 = state2.items[state2.items.length - 1]; + if (last2 && last2.type === kind) { + const existing = last2; + const text = sanitizeLodyInternalInstructions(mergeStreamChunk(existing.text, delta)); + if (text) { + state2.items[state2.items.length - 1] = { + ...existing, + text + }; + } else { + state2.items.pop(); + } + } else { + state2.items.push({ + type: kind, + text: delta + }); + } + state2.dirty = true; + };`, + replace: ` const appendOrMergeAdjacentText = (entryIndex, kind, delta, messageId) => { + if (!delta) return; + const state2 = entryStates[entryIndex]; + if (!state2) return; + const targetIndex = blitzTextMergeTargetIndex(state2.items, kind, messageId); + if (targetIndex >= 0) { + const existing = state2.items[targetIndex]; + const text = sanitizeLodyInternalInstructions(mergeStreamChunk(existing.text, delta)); + if (text) { + state2.items[targetIndex] = blitzMergedTextItem(existing, text, messageId); + } else { + state2.items.splice(targetIndex, 1); + } + } else { + state2.items.push({ + type: kind, + text: delta, + ${idSpread(" ")} + }); + } + state2.dirty = true; + };`, + }, +]; + +const target = process.argv[2]; +if (target === undefined) { + console.error("usage: lody-agent-message-split.mjs "); + process.exit(2); +} + +// `dist/index.js` -> the package root beside it. Read rather than assumed: the +// version is what a bump changes, and it is the first thing to check. +const manifestPath = join(dirname(dirname(target)), "package.json"); +let version; +try { + version = JSON.parse(readFileSync(manifestPath, "utf8")).version; +} catch (cause) { + console.error(`lody-agent-message-split: cannot read ${manifestPath}: ${String(cause)}`); + process.exit(1); +} +if (version !== EXPECTED_VERSION) { + console.error( + `lody-agent-message-split: refusing to patch ${target}.\n` + + ` expected lody@${EXPECTED_VERSION}, found lody@${String(version)}\n` + + " The pinned lody version moved. Re-check whether the history applier still\n" + + " drops `update.messageId` and still merges only into the LAST item — if a\n" + + " bump groups by message id upstream, DELETE this patch instead of updating it.", + ); + process.exit(1); +} + +let source = readFileSync(target, "utf8"); +if (source.includes(MARKER)) { + console.log(`lody-agent-message-split: ${target} is already patched.`); + process.exit(0); +} + +for (const hunk of hunks) { + const occurrences = source.split(hunk.find).length - 1; + if (occurrences !== EXPECTED_OCCURRENCES) { + console.error( + `lody-agent-message-split: expected ${EXPECTED_OCCURRENCES} occurrence of\n` + + ` ${hunk.what} in ${target}, found ${occurrences}.\n` + + ` Hunk "${hunk.name}" cannot be applied. The ACP history applier moved.\n` + + " Re-audit it before shipping a box: without this patch one assistant\n" + + " message is stored as two text blocks whenever a tool call lands between\n" + + " two of its deltas, which reads as a sentence cut in half.", + ); + process.exit(1); + } + source = source.split(hunk.find).join(hunk.replace); +} + +writeFileSync(target, source); +console.log( + `lody-agent-message-split: grouped assistant text by messageId in ${target} ` + + `(lody@${EXPECTED_VERSION}, ${hunks.length} hunks).`, +); diff --git a/packages/webapp/test/lody-daemon-harness.ts b/packages/webapp/test/lody-daemon-harness.ts index 14512f3c..4ec02eab 100644 --- a/packages/webapp/test/lody-daemon-harness.ts +++ b/packages/webapp/test/lody-daemon-harness.ts @@ -71,6 +71,7 @@ const PATCH_SCRIPTS = [ join(repoRoot(), "packages/box/patches/lody-local-platform.mjs"), join(repoRoot(), "packages/box/patches/lody-acp-auth-queue.mjs"), join(repoRoot(), "packages/box/patches/lody-code-collab-worktree-root.mjs"), + join(repoRoot(), "packages/box/patches/lody-agent-message-split.mjs"), ]; const BRIDGE_SCRIPT = join(repoRoot(), "packages/box/rootfs/usr/local/libexec/blitz-lody-bridge"); const REPO_NODE_MODULES = join(repoRoot(), "node_modules"); diff --git a/vendor/lody/BLITZ-PATCHES.md b/vendor/lody/BLITZ-PATCHES.md index 6dadea0a..eb541047 100644 --- a/vendor/lody/BLITZ-PATCHES.md +++ b/vendor/lody/BLITZ-PATCHES.md @@ -1236,12 +1236,13 @@ obligation at every version bump**. | `packages/box/patches/lody-local-platform.mjs` | `lody/dist/index.js` | 4× `resolvePlatformKind("cloud")` | `lody@0.88.1` on npm is the CLOUD build: its Vite config inlines the platform as a literal, so the local composition root is unreachable and the daemon blocks on a device-authorization login. The patch restores the `LODY_PLATFORM` env read. Without it a box cannot start the daemon at all. | | `packages/box/patches/lody-acp-auth-queue.mjs` | `lody/dist/index.js` | the `extractQueueKey` switch tail in `MessageProcessor` | Every `machine/*` message falls to `extractQueueKey`'s `default: return null`, and `ConcurrentQueue` maps `null` onto ONE serial chain (`__default__`). `machine/acp-authenticate` with `action: 'start'` runs `claude auth login --claudeai`, which blocks on stdin until the member pastes the code back — so the `submit-code` carrying that code queues behind the login waiting for it, and so does `cancel`. The patch gives a `start` its own per-agent chain. Without it an interactive agent sign-in can never be completed, only timed out after 285 s. | | `packages/box/patches/lody-code-collab-worktree-root.mjs` | `lody/dist/index.js` | the `project?.kind === "local"` branch of `resolveCodeCollabWorkspaceRoot` | That branch answers with the local project's ROOT PATH and never reads `project.useWorktree` or `meta.isWorktree`, so once no live `Session` object is left the whole Code Collab surface of a worktree session — All Changes, the Files tab, every file chip — resolves to the `/workspace/` clone instead of the worktree. The clone is clean by design, so the panel renders an empty SUCCESS ("No changes yet.") rather than an error. The patch answers with the worktree when the session is a worktree session and the worktree exists. Without it the side panel of every BlitzOS worktree session is silently empty. | +| `packages/box/patches/lody-agent-message-split.mjs` | `lody/dist/index.js` | `buildMessageContentFromNotification`'s two chunk arms, both `appendOrMergeAdjacentText` copies, and the two `case "text"` / `case "thought"` arms that call them | The ACP adapter stamps `messageId` on every `agent_message_chunk` / `agent_thought_chunk` — the Anthropic assistant message `id`, emitted explicitly FOR GROUPING — and the schema keeps it (`zContentChunk.messageId`). The history applier throws it away and merges a delta only into `items[items.length - 1]`, so anything appended between two deltas of ONE message ends that text block permanently and the rest of the sentence becomes a second one. Measured on a canary box 2026-09-02: 23 of 375 stored assistant text blocks (~6%) begin mid-sentence directly after a tool card. The corpus is live, so the ratio holds rather than the integers — the same box read 13 of 271 six hours earlier. The patch carries the id onto the stored item and merges into the most recent block that shares it, scanning back past trailing non-text items. Without it a reader sees sentences cut in half around tool cards. | Applied in that order. **The order is not cosmetic:** `lody-local-platform` guards on a sha256 of `dist/index.js` AS PUBLISHED, so nothing may rewrite the -file before it runs. The other two therefore guard on the installed package's -version plus their own anchor at exactly one occurrence — a file hash can -only ever pin the first patch in a chain. All three are idempotent: re-running +file before it runs. The other three therefore guard on the installed package's +version plus their own anchors at exactly one occurrence — a file hash can +only ever pin the first patch in a chain. All four are idempotent: re-running any of them on an already-patched bundle reports it and exits 0, which is what lets `packages/webapp/test/lody-daemon-harness.ts` copy a real box's bundle and re-apply the image build's patches to the copy. @@ -1281,6 +1282,56 @@ worktree is gone, keeps the answer it has today. `packages/webapp/test/lody-worktree-session.test.ts` measures both directions against a real daemon. +`lody-agent-message-split.mjs` is guarded the same two ways, over six anchors +rather than one. Re-auditing it means confirming that +`buildMessageContentFromNotification` still drops `update.messageId`, and that +both copies of `appendOrMergeAdjacentText` — the streaming +`NotificationOnHistoryApplier` and the batch `applyMessageContentsBatch` — still +merge only into `items[items.length - 1]`. + +**The discriminator is the id and nothing else.** No text heuristic: "starts +with a space" and "starts with a lowercase letter" both corrupt legitimate +content, and a genuine `text → tool → text` across TWO API messages carries two +different ids and must still render as two blocks. The patch is a MERGE-TARGET +change only — no item is dropped, reordered, or retyped. Its scan stops at the +first text-or-thought item it meets going backwards, so a thought emitted after +the text cannot be jumped over and ordering is never rewritten. + +**Adjacency still wins first.** A delta landing directly on a block of its own +kind merges there whatever the ids say, so this patch can only ever JOIN what +today splits — it can never split what today joins. The two emitted helpers, +`blitzTextMergeTargetIndex` and `blitzMergedTextItem`, are a behavioural +transcription of `findStreamedTextMergeIndex` and `mergeStreamedTextItem` in the +upstream PR below, verified equal over a 69-case matrix (merge target AND the id +the merged item keeps). Keep them equal: a box must not answer differently from +the renderer beside it, and when the daemon bump lands this patch is deleted +rather than re-derived. + +It is backward compatible in both directions. A delta with no id gets exactly +today's answer, and the object written is byte-identical to today's — nothing +writes `messageId: undefined`, so Codex, Grok, DeepSeek and the batch applier's +materialized rich content are untouched. A delta WITH an id landing on a stored +item WITHOUT one — history written by a pre-patch daemon, or a block that +`postProcessTouchedAssistantEntries` re-parsed out of `` tags — also +gets today's answer, so nothing that predates the patch can be split by it. The +extra field is safe to persist: `LoroSessionDoc.updateHistory` writes items +straight into the Loro mirror with no schema in the way, and every zod object +that reads history back is a stripping `z.object`, not `.strict()`. + +**Opened upstream as blitzdotdev/Lody#22, "group streamed text by messageId so +a tool call cannot split a message". Drop this patch when it merges and the +daemon bump carries it.** +The upstream diff is the same six hunks against +`packages/shared/src/acp/history-apply.ts` (`buildMessageContentFromNotification` +~1073 and ~1085, `appendOrMergeAdjacentText` ~1606 and ~1915, and their callers +~1540 and ~2035) plus one field on the `text` and `thought` members of +`MessageContent` (`packages/shared/src/ai.ts:1419`) and on their +`NonSystemNoticeMessageContentSchema` entries +(`packages/shared/src/message-schemas.ts:2922`) — `messageId?: string`, beside +the `spans?` that the `text` member already carries. Nothing else moves: the id +is produced, validated and forwarded today, and is only ever discarded at the +last step. + Re-auditing means: confirm the anchor still selects the platform, confirm the count, run `LODY_PLATFORM=local lody start` and see "Starting in local platform mode", then update `EXPECTED_INPUT_SHA256`, `EXPECTED_VERSION` and the Dockerfile