From e2772c810efaf2972f714710dc4f5a33efb6554f Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Fri, 11 Sep 2026 19:43:03 +0200 Subject: [PATCH 1/4] fix(core): compact repeated message diff events Avoid re-appending a summarized user message's full patch array in each message.updated.1 event. Preserve the first durable diff event and projection value for replay compatibility while compacting later updates.\n\nThe focused SQLite regression fixture reduces the repeated event row from 262515 bytes to 284 bytes (99.89%). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/core/src/session/projector.ts | 29 ++++++++- packages/opencode/src/session/session.ts | 17 ++++- .../server/session-diff-missing-patch.test.ts | 62 ++++++++++++++++++- packages/schema/src/v1/session.ts | 28 ++++++++- 4 files changed, 130 insertions(+), 6 deletions(-) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 792067017d14..5726d596cd75 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -19,6 +19,7 @@ type DatabaseService = Database.Interface["db"] const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const encodeMessage = Schema.encodeSync(SessionMessage.Message) +const decodeInfo = Schema.decodeUnknownSync(SessionV1.Info) export class SessionAlreadyProjected extends Error {} @@ -76,8 +77,23 @@ function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInse function messageData( info: (typeof SessionV1.Event.MessageUpdated.Type)["data"]["info"], + current?: typeof MessageTable.$inferSelect.data, ): typeof MessageTable.$inferInsert.data { - const { id: _, sessionID: __, ...rest } = info + const summary = current?.summary + const value = + info.role === "user" && info.summary?.diffs === undefined + ? decodeInfo({ + ...info, + summary: { + ...info.summary, + diffs: + current?.role === "user" && typeof summary === "object" && summary.diffs !== undefined + ? summary.diffs + : [], + }, + }) + : decodeInfo(info) + const { id: _, sessionID: __, ...rest } = value return rest as DeepMutable } @@ -262,7 +278,16 @@ const layer = Layer.effectDiscard( const time_created = event.data.info.time.created const id = event.data.info.id const sessionID = event.data.info.sessionID - const data = messageData(event.data.info) + const current = + event.data.info.role === "user" && event.data.info.summary?.diffs === undefined + ? yield* db + .select({ data: MessageTable.data }) + .from(MessageTable) + .where(and(eq(MessageTable.id, id), eq(MessageTable.session_id, sessionID))) + .get() + .pipe(Effect.orDie) + : undefined + const data = messageData(event.data.info, current?.data) yield* db .insert(MessageTable) .values({ id, session_id: sessionID, time_created, data }) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index a2a91cd47b5e..00544529ede7 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -26,7 +26,7 @@ import { inArray } from "drizzle-orm" import { lt } from "drizzle-orm" import { or } from "drizzle-orm" import type { SQL } from "drizzle-orm" -import { PartTable, SessionTable } from "@opencode-ai/core/session/sql" +import { MessageTable, PartTable, SessionTable } from "@opencode-ai/core/session/sql" import { ProjectTable } from "@opencode-ai/core/project/sql" import { MessageV2 } from "./message-v2" import type { InstanceContext } from "../project/instance-context" @@ -628,7 +628,20 @@ const layer: Layer.Layer< const updateMessage = (msg: T): Effect.Effect => Effect.gen(function* () { - yield* events.publish(SessionV1.Event.MessageUpdated, { sessionID: msg.sessionID, info: msg }) + const existing = + msg.role === "user" && msg.summary?.diffs !== undefined + ? yield* db + .select({ type: sql`json_type(${MessageTable.data}, '$.summary.diffs')` }) + .from(MessageTable) + .where(and(eq(MessageTable.id, msg.id), eq(MessageTable.session_id, msg.sessionID))) + .get() + .pipe(Effect.orDie) + : undefined + const info = + existing?.type === "array" && msg.role === "user" + ? { ...msg, summary: { ...msg.summary, diffs: undefined } } + : msg + yield* events.publish(SessionV1.Event.MessageUpdated, { sessionID: msg.sessionID, info }) return msg }).pipe(Effect.withSpan("Session.updateMessage")) diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts index d2a4211ff1a9..a1606baa6046 100644 --- a/packages/opencode/test/server/session-diff-missing-patch.test.ts +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -20,12 +20,17 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" import { MessageID } from "@/session/schema" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { Database } from "@opencode-ai/core/database/database" +import { EventTable } from "@opencode-ai/core/event/sql" +import { and, eq, sql } from "drizzle-orm" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Storage.node])), httpApiLayer)) +const it = testEffect( + Layer.mergeAll(LayerNode.compile(LayerNode.group([Database.node, Session.node, Storage.node])), httpApiLayer), +) afterEach(async () => { await disposeAllInstances() @@ -94,4 +99,59 @@ describe("session diff with missing patch (#26574)", () => { }), { git: true, config: { formatter: false, lsp: false } }, ) + + it.instance( + "keeps stored turn diffs while compacting later message update events", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "compact-turn-diff" }) + const messageID = MessageID.ascending() + const diff = { + file: "turn.ts", + additions: 1, + deletions: 0, + patch: "x".repeat(262_144), + status: "modified" as const, + } + const message = { + id: messageID, + sessionID: session.id, + role: "user" as const, + time: { created: Date.now() }, + agent: "build", + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("model") }, + summary: { diffs: [diff] }, + } satisfies SessionV1.User + yield* Session.use.updateMessage(message) + yield* Session.use.updateMessage({ ...message, tools: { read: true } }) + + const { db } = yield* Database.Service + const events = yield* db + .select({ bytes: sql`length(${EventTable.data})` }) + .from(EventTable) + .where( + and( + eq(EventTable.aggregate_id, session.id), + eq(EventTable.type, "message.updated.1"), + ), + ) + .orderBy(EventTable.seq) + .all() + .pipe(Effect.orDie) + + expect(events).toHaveLength(2) + expect(events[0]?.bytes).toBeGreaterThan(diff.patch.length) + expect(events[1]?.bytes).toBeLessThan(1_000) + + const response = yield* requestInDirectory( + `${pathFor(SessionPaths.diff, { sessionID: session.id })}?messageID=${messageID}`, + test.directory, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual([diff]) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) }) diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts index 75e9282f117c..20bf4869a9e2 100644 --- a/packages/schema/src/v1/session.ts +++ b/packages/schema/src/v1/session.ts @@ -487,9 +487,35 @@ export type Assistant = Omit Date: Fri, 11 Sep 2026 19:44:01 +0200 Subject: [PATCH 2/4] docs: document message diff compaction Record the chosen replay-compatible event compaction, failing-before and passing-after output, SQLite byte measurement, verification, and residual risk. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- FIX-REPORT.md | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 FIX-REPORT.md diff --git a/FIX-REPORT.md b/FIX-REPORT.md new file mode 100644 index 000000000000..98ec5a42e0c3 --- /dev/null +++ b/FIX-REPORT.md @@ -0,0 +1,113 @@ +# SQLite Message Diff Event Compaction + +## Chosen approach + +Chosen: candidate 1, with two safeguards. The first `message.updated.1` event that introduces a user message's diff array remains full. Later updates to that message omit `info.summary.diffs` only when the current message projection already has an array at `$.summary.diffs`. The session projector restores that prior array before replacing the projected message data. + +This is the smallest compatible change. It leaves the first durable event as the replay source for the patch, makes the repeated events compact, and keeps the message projection used by the diff endpoint, exports, share sync, and clients populated. + +The V1 `User` wire contract still requires `summary.diffs` when a summary exists. Only the `message.updated` event's internal user variant accepts an omitted `diffs` key. Existing event rows with the key decode unchanged; new compact rows decode with it absent. `message.updated.1` remains version 1, so no event migration or protocol generation is required. + +### Rejected candidates + +1. **Strip every MessageUpdated payload.** Rejected because the first event is the durable source for the per-message diff. Stripping it would make a fresh replay and `SessionSummary.diff()` lose the patch. +2. **Skip summarize when the computed diff is equal.** Rejected because it only prevents another summary calculation. Subsequent unrelated `updateMessage` calls still re-emit the existing large array. +3. **Keep only stats on the message and load patches from `session_diff`.** Rejected because current consumers read per-message `summary.diffs`; `session_diff` is session-scoped legacy/revert storage and `Session.Event.Diff` is live-only. This would require a new per-message durable lookup plus changes to exports, share, and clients. +4. **Strip at generic EventV2 encoding.** Rejected because it changes a cross-domain storage boundary and would make replay comparison diverge for old event rows that retain `diffs`. + +## Files changed + +- `packages/schema/src/v1/session.ts`: adds a private MessageUpdated user schema variant with optional diffs, while retaining the existing public V1 User shape. +- `packages/opencode/src/session/session.ts`: detects an already-projected user diff array and omits it from later MessageUpdated events. +- `packages/core/src/session/projector.ts`: restores the existing diff array for compact events before writing the message projection. +- `packages/opencode/test/server/session-diff-missing-patch.test.ts`: real database regression coverage for event size reduction and diff endpoint preservation. +- `FIX-REPORT.md`: this report. + +## Backward compatibility and replay + +Old `message.updated.1` rows retain `info.summary.diffs`; the relaxed event decoder accepts them and the projector writes them normally. New compact events occur only after a full diff-bearing event has projected the message. During a fresh event replay, that full event establishes the projection and the later compact event restores its stored diff. A compact event replayed without a preceding projection falls back to an empty array rather than failing decoding, but this is not a valid complete session history. + +No migration was added. Existing data is read as-is. The current V1 message contract, server HttpApi, and generated client surface were not changed, so `bun run generate` was not applicable. + +## Failing-before evidence + +Implementation files were stashed with `git stash push -- packages/schema/src/v1/session.ts packages/opencode/src/session/session.ts packages/core/src/session/projector.ts`, leaving the new regression test in place, then restored with `git stash pop`. + +```text +bun test v1.4.0 (34cbb9a40) + +test/server/session-diff-missing-patch.test.ts: +140 | .all() +141 | .pipe(Effect.orDie) +142 | +143 | expect(events).toHaveLength(2) +144 | expect(events[0]?.bytes).toBeGreaterThan(diff.patch.length) +145 | expect(events[1]?.bytes).toBeLessThan(1_000) + ^ +error: expect(received).toBeLessThan(expected) + +Expected: < 1000 +Received: 262515 + + at toBeLessThan (unknown:1:1) + at /home/viprix/opencode-core/packages/opencode/test/server/session-diff-missing-patch.test.ts:145:34 + at ~effect/Effect/successCont (/home/viprix/opencode-core/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:870:26) + at runLoop (/home/viprix/opencode-core/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:444:98) + at evaluate (/home/viprix/opencode-core/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:412:23) + at /home/viprix/opencode-core/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:723:15 + at /home/viprix/opencode-core/node_modules/.bun/@effect+platform-node-shared@4.0.0-beta.83+43902b222b0d7d3e/node_modules/@effect/platform-node-shared/dist/NodeFileSystem.js:269:9 + at guarded (internal:shared:112:26) + at processTicksAndRejections (native:7:39) +[17:31:28.217] ERROR (#59045): 140 | .all() +141 | .pipe(Effect.orDie) +142 | +143 | expect(events).toHaveLength(2) +144 | expect(events[0]?.bytes).toBeGreaterThan(diff.patch.length) +145 | expect(events[1]?.bytes).toBeLessThan(1_000) + ^ +error: expect(received).toBeLessThan(expected) + +Expected: < 1000 +Received: 262515 + + at toBeLessThan (unknown:1:1) + at /home/viprix/opencode-core/packages/opencode/test/server/session-diff-missing-patch.test.ts:145:34 + +(fail) session diff with missing patch (#26574) > keeps stored turn diffs while compacting later message update events [1424.89ms] + + 2 pass + 1 fail + 7 expect() calls +Ran 3 tests across 1 file. [10.93s] +``` + +## Passing-after evidence + +```text +bun test v1.4.0 (34cbb9a40) + + 3 pass + 0 fail + 9 expect() calls +Ran 3 tests across 1 file. [10.79s] +``` + +## Measured reduction + +The regression test uses a real SQLite event table and a 262,144-byte patch. On unmodified `dev`, the second `message.updated.1` row was **262,515 bytes**. With this change it was **284 bytes**, measured from SQLite `length(event.data)`. That is a reduction of **262,231 bytes (99.89%)** for each later update of that summarized message. + +The first diff-bearing event remains 262,493 bytes in this fixture. This is intentional: it establishes the durable replay and projection source. The defect is the repeated full rows, not the one required diff write. + +## Verification + +- `bun test test/server/session-diff-missing-patch.test.ts` from `packages/opencode`: passed, 3 tests. +- `bun typecheck` from `packages/schema`: passed. +- `bun typecheck` from `packages/core`: passed. +- `bun typecheck` from `packages/opencode`: passed. +- LSP diagnostics were clean for the Core projector and the regression test. The OpenCode service had no errors and three pre-existing unused-import hints. Fresh diagnostics for the large Schema file timed out after 3 seconds; its package typecheck was clean. + +## Residual risk and limits + +- The MessageTable projection still stores the full patch once and rewrites that JSON when another message field changes. This fix removes the dominant append-only EventTable duplication, measured as 98% of event bytes in the production analysis, but does not redesign message projection storage. +- The projector reads the existing projected message only for a compact user update. This is an additional read, but it avoids the multi-hundred-KB append-only event write and remains inside the existing transaction/replay ordering. +- This change was verified with a focused real SQLite integration test. The full package test suites were not run. From 182fd74d9688ff7279ba353a4e74ef321d334ee3 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Fri, 11 Sep 2026 20:24:38 +0200 Subject: [PATCH 3/4] fix(core): preserve changed message diff events Compact only patch bodies when SQLite-normalized summary diffs exactly match the projected value. Changed summaries retain their full new patches, and replay restores compact event patches without decoding every projection.\n\nThe real summarize regression covers changed diffs and fresh replay. The repeated fixture event falls from 262515 bytes to 360 bytes (99.86%). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/core/src/session/projector.ts | 38 ++--- packages/opencode/src/session/session.ts | 14 +- .../server/session-diff-missing-patch.test.ts | 136 +++++++++++++++++- packages/schema/src/v1/session.ts | 28 +--- 4 files changed, 167 insertions(+), 49 deletions(-) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 5726d596cd75..5d1b48ca7f21 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -19,7 +19,6 @@ type DatabaseService = Database.Interface["db"] const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const encodeMessage = Schema.encodeSync(SessionMessage.Message) -const decodeInfo = Schema.decodeUnknownSync(SessionV1.Info) export class SessionAlreadyProjected extends Error {} @@ -79,21 +78,28 @@ function messageData( info: (typeof SessionV1.Event.MessageUpdated.Type)["data"]["info"], current?: typeof MessageTable.$inferSelect.data, ): typeof MessageTable.$inferInsert.data { + const { id: _, sessionID: __, ...rest } = info const summary = current?.summary - const value = - info.role === "user" && info.summary?.diffs === undefined - ? decodeInfo({ - ...info, - summary: { - ...info.summary, - diffs: - current?.role === "user" && typeof summary === "object" && summary.diffs !== undefined - ? summary.diffs - : [], - }, - }) - : decodeInfo(info) - const { id: _, sessionID: __, ...rest } = value + if ( + info.role === "user" && + info.summary?.diffs !== undefined && + current?.role === "user" && + typeof summary === "object" && + summary.diffs !== undefined && + info.summary.diffs.length === summary.diffs.length && + info.summary.diffs.every((item, index) => { + const stored = summary.diffs[index] + return ( + item.patch === undefined && + item.file === stored?.file && + item.additions === stored?.additions && + item.deletions === stored?.deletions && + item.status === stored?.status + ) + }) + ) { + return { ...rest, summary: { ...info.summary, diffs: summary.diffs } } as DeepMutable + } return rest as DeepMutable } @@ -279,7 +285,7 @@ const layer = Layer.effectDiscard( const id = event.data.info.id const sessionID = event.data.info.sessionID const current = - event.data.info.role === "user" && event.data.info.summary?.diffs === undefined + event.data.info.role === "user" && event.data.info.summary?.diffs?.some((item) => item.patch === undefined) ? yield* db .select({ data: MessageTable.data }) .from(MessageTable) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 00544529ede7..c4417e7459d4 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -631,15 +631,23 @@ const layer: Layer.Layer< const existing = msg.role === "user" && msg.summary?.diffs !== undefined ? yield* db - .select({ type: sql`json_type(${MessageTable.data}, '$.summary.diffs')` }) + .select({ + matches: sql`json_extract(${MessageTable.data}, '$.summary.diffs') = json(${JSON.stringify(msg.summary.diffs)})`, + }) .from(MessageTable) .where(and(eq(MessageTable.id, msg.id), eq(MessageTable.session_id, msg.sessionID))) .get() .pipe(Effect.orDie) : undefined const info = - existing?.type === "array" && msg.role === "user" - ? { ...msg, summary: { ...msg.summary, diffs: undefined } } + existing?.matches === 1 && msg.role === "user" && msg.summary?.diffs !== undefined + ? { + ...msg, + summary: { + ...msg.summary, + diffs: msg.summary.diffs.map(({ patch: _, ...item }) => item), + }, + } : msg yield* events.publish(SessionV1.Event.MessageUpdated, { sessionID: msg.sessionID, info }) return msg diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts index a1606baa6046..d75f40c19591 100644 --- a/packages/opencode/test/server/session-diff-missing-patch.test.ts +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -13,15 +13,20 @@ import { afterEach, describe, expect } from "bun:test" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" +import path from "path" import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" import { Storage } from "@/storage/storage" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { MessageID } from "@/session/schema" +import { MessageID, PartID } from "@/session/schema" +import { SessionSummary } from "@/session/summary" +import { Snapshot } from "@/snapshot" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Database } from "@opencode-ai/core/database/database" -import { EventTable } from "@opencode-ai/core/event/sql" +import { EventV2 } from "@opencode-ai/core/event" +import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql" +import { MessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { and, eq, sql } from "drizzle-orm" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" @@ -29,7 +34,12 @@ import { testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" const it = testEffect( - Layer.mergeAll(LayerNode.compile(LayerNode.group([Database.node, Session.node, Storage.node])), httpApiLayer), + Layer.mergeAll( + LayerNode.compile( + LayerNode.group([Database.node, EventV2.node, Session.node, SessionSummary.node, Snapshot.node, Storage.node]), + ), + httpApiLayer, + ), ) afterEach(async () => { @@ -154,4 +164,124 @@ describe("session diff with missing patch (#26574)", () => { }), { git: true, config: { formatter: false, lsp: false } }, ) + + it.instance( + "persists changed turn diffs through a fresh event replay", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "changed-turn-diff" }) + const messageID = MessageID.ascending() + const message = { + id: messageID, + sessionID: session.id, + role: "user" as const, + time: { created: Date.now() }, + agent: "build", + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("model") }, + } satisfies SessionV1.User + yield* Session.use.updateMessage(message) + const assistant = yield* Session.use.updateMessage({ + id: MessageID.ascending(), + sessionID: session.id, + role: "assistant", + time: { created: Date.now() }, + parentID: messageID, + agent: "build", + modelID: ModelV2.ID.make("model"), + providerID: ProviderV2.ID.make("test"), + mode: "build", + path: { cwd: test.directory, root: test.directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } satisfies SessionV1.Assistant) + const snapshot = yield* Snapshot.Service + const summary = yield* SessionSummary.Service + const start = yield* snapshot.track() + if (!start) return yield* Effect.die("expected initial snapshot") + yield* Effect.promise(() => Bun.write(path.join(test.directory, "first.ts"), "first")) + const first = yield* snapshot.track() + if (!first) return yield* Effect.die("expected first snapshot") + yield* Session.use.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "step-start", + snapshot: start, + }) + yield* Session.use.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "step-finish", + reason: "stop", + snapshot: first, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + yield* summary.summarize({ sessionID: session.id, messageID }) + + yield* Effect.promise(() => Bun.write(path.join(test.directory, "second.ts"), "second")) + const second = yield* snapshot.track() + if (!second) return yield* Effect.die("expected second snapshot") + yield* Session.use.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "step-finish", + reason: "stop", + snapshot: second, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + yield* summary.summarize({ sessionID: session.id, messageID }) + + const { db } = yield* Database.Service + const before = yield* db + .select({ data: MessageTable.data }) + .from(MessageTable) + .where(eq(MessageTable.id, messageID)) + .get() + .pipe(Effect.orDie) + expect(before?.data).toMatchObject({ + role: "user", + summary: { diffs: [{ file: "first.ts" }, { file: "second.ts" }] }, + }) + + const events = yield* db + .select() + .from(EventTable) + .where(eq(EventTable.aggregate_id, session.id)) + .orderBy(EventTable.seq) + .all() + .pipe(Effect.orDie) + yield* db.delete(MessageTable).where(eq(MessageTable.session_id, session.id)).run().pipe(Effect.orDie) + yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, session.id)).run().pipe(Effect.orDie) + yield* db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, session.id)).run().pipe(Effect.orDie) + yield* db.delete(SessionTable).where(eq(SessionTable.id, session.id)).run().pipe(Effect.orDie) + + const event = yield* EventV2.Service + yield* event.replayAll( + events.map((item) => ({ + id: item.id, + type: item.type, + data: item.data, + seq: item.seq, + aggregateID: item.aggregate_id, + })), + ) + + const after = yield* db + .select({ data: MessageTable.data }) + .from(MessageTable) + .where(eq(MessageTable.id, messageID)) + .get() + .pipe(Effect.orDie) + expect(after?.data).toMatchObject({ + role: "user", + summary: { diffs: [{ file: "first.ts" }, { file: "second.ts" }] }, + }) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) }) diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts index 20bf4869a9e2..75e9282f117c 100644 --- a/packages/schema/src/v1/session.ts +++ b/packages/schema/src/v1/session.ts @@ -487,35 +487,9 @@ export type Assistant = Omit Date: Fri, 11 Sep 2026 20:25:06 +0200 Subject: [PATCH 4/4] docs: remove tracked fix report Keep the requested evidence report as a local untracked deliverable rather than adding it to the upstream branch. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- FIX-REPORT.md | 113 -------------------------------------------------- 1 file changed, 113 deletions(-) delete mode 100644 FIX-REPORT.md diff --git a/FIX-REPORT.md b/FIX-REPORT.md deleted file mode 100644 index 98ec5a42e0c3..000000000000 --- a/FIX-REPORT.md +++ /dev/null @@ -1,113 +0,0 @@ -# SQLite Message Diff Event Compaction - -## Chosen approach - -Chosen: candidate 1, with two safeguards. The first `message.updated.1` event that introduces a user message's diff array remains full. Later updates to that message omit `info.summary.diffs` only when the current message projection already has an array at `$.summary.diffs`. The session projector restores that prior array before replacing the projected message data. - -This is the smallest compatible change. It leaves the first durable event as the replay source for the patch, makes the repeated events compact, and keeps the message projection used by the diff endpoint, exports, share sync, and clients populated. - -The V1 `User` wire contract still requires `summary.diffs` when a summary exists. Only the `message.updated` event's internal user variant accepts an omitted `diffs` key. Existing event rows with the key decode unchanged; new compact rows decode with it absent. `message.updated.1` remains version 1, so no event migration or protocol generation is required. - -### Rejected candidates - -1. **Strip every MessageUpdated payload.** Rejected because the first event is the durable source for the per-message diff. Stripping it would make a fresh replay and `SessionSummary.diff()` lose the patch. -2. **Skip summarize when the computed diff is equal.** Rejected because it only prevents another summary calculation. Subsequent unrelated `updateMessage` calls still re-emit the existing large array. -3. **Keep only stats on the message and load patches from `session_diff`.** Rejected because current consumers read per-message `summary.diffs`; `session_diff` is session-scoped legacy/revert storage and `Session.Event.Diff` is live-only. This would require a new per-message durable lookup plus changes to exports, share, and clients. -4. **Strip at generic EventV2 encoding.** Rejected because it changes a cross-domain storage boundary and would make replay comparison diverge for old event rows that retain `diffs`. - -## Files changed - -- `packages/schema/src/v1/session.ts`: adds a private MessageUpdated user schema variant with optional diffs, while retaining the existing public V1 User shape. -- `packages/opencode/src/session/session.ts`: detects an already-projected user diff array and omits it from later MessageUpdated events. -- `packages/core/src/session/projector.ts`: restores the existing diff array for compact events before writing the message projection. -- `packages/opencode/test/server/session-diff-missing-patch.test.ts`: real database regression coverage for event size reduction and diff endpoint preservation. -- `FIX-REPORT.md`: this report. - -## Backward compatibility and replay - -Old `message.updated.1` rows retain `info.summary.diffs`; the relaxed event decoder accepts them and the projector writes them normally. New compact events occur only after a full diff-bearing event has projected the message. During a fresh event replay, that full event establishes the projection and the later compact event restores its stored diff. A compact event replayed without a preceding projection falls back to an empty array rather than failing decoding, but this is not a valid complete session history. - -No migration was added. Existing data is read as-is. The current V1 message contract, server HttpApi, and generated client surface were not changed, so `bun run generate` was not applicable. - -## Failing-before evidence - -Implementation files were stashed with `git stash push -- packages/schema/src/v1/session.ts packages/opencode/src/session/session.ts packages/core/src/session/projector.ts`, leaving the new regression test in place, then restored with `git stash pop`. - -```text -bun test v1.4.0 (34cbb9a40) - -test/server/session-diff-missing-patch.test.ts: -140 | .all() -141 | .pipe(Effect.orDie) -142 | -143 | expect(events).toHaveLength(2) -144 | expect(events[0]?.bytes).toBeGreaterThan(diff.patch.length) -145 | expect(events[1]?.bytes).toBeLessThan(1_000) - ^ -error: expect(received).toBeLessThan(expected) - -Expected: < 1000 -Received: 262515 - - at toBeLessThan (unknown:1:1) - at /home/viprix/opencode-core/packages/opencode/test/server/session-diff-missing-patch.test.ts:145:34 - at ~effect/Effect/successCont (/home/viprix/opencode-core/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:870:26) - at runLoop (/home/viprix/opencode-core/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:444:98) - at evaluate (/home/viprix/opencode-core/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:412:23) - at /home/viprix/opencode-core/node_modules/.bun/effect@4.0.0-beta.83/node_modules/effect/dist/internal/effect.js:723:15 - at /home/viprix/opencode-core/node_modules/.bun/@effect+platform-node-shared@4.0.0-beta.83+43902b222b0d7d3e/node_modules/@effect/platform-node-shared/dist/NodeFileSystem.js:269:9 - at guarded (internal:shared:112:26) - at processTicksAndRejections (native:7:39) -[17:31:28.217] ERROR (#59045): 140 | .all() -141 | .pipe(Effect.orDie) -142 | -143 | expect(events).toHaveLength(2) -144 | expect(events[0]?.bytes).toBeGreaterThan(diff.patch.length) -145 | expect(events[1]?.bytes).toBeLessThan(1_000) - ^ -error: expect(received).toBeLessThan(expected) - -Expected: < 1000 -Received: 262515 - - at toBeLessThan (unknown:1:1) - at /home/viprix/opencode-core/packages/opencode/test/server/session-diff-missing-patch.test.ts:145:34 - -(fail) session diff with missing patch (#26574) > keeps stored turn diffs while compacting later message update events [1424.89ms] - - 2 pass - 1 fail - 7 expect() calls -Ran 3 tests across 1 file. [10.93s] -``` - -## Passing-after evidence - -```text -bun test v1.4.0 (34cbb9a40) - - 3 pass - 0 fail - 9 expect() calls -Ran 3 tests across 1 file. [10.79s] -``` - -## Measured reduction - -The regression test uses a real SQLite event table and a 262,144-byte patch. On unmodified `dev`, the second `message.updated.1` row was **262,515 bytes**. With this change it was **284 bytes**, measured from SQLite `length(event.data)`. That is a reduction of **262,231 bytes (99.89%)** for each later update of that summarized message. - -The first diff-bearing event remains 262,493 bytes in this fixture. This is intentional: it establishes the durable replay and projection source. The defect is the repeated full rows, not the one required diff write. - -## Verification - -- `bun test test/server/session-diff-missing-patch.test.ts` from `packages/opencode`: passed, 3 tests. -- `bun typecheck` from `packages/schema`: passed. -- `bun typecheck` from `packages/core`: passed. -- `bun typecheck` from `packages/opencode`: passed. -- LSP diagnostics were clean for the Core projector and the regression test. The OpenCode service had no errors and three pre-existing unused-import hints. Fresh diagnostics for the large Schema file timed out after 3 seconds; its package typecheck was clean. - -## Residual risk and limits - -- The MessageTable projection still stores the full patch once and rewrites that JSON when another message field changes. This fix removes the dominant append-only EventTable duplication, measured as 98% of event bytes in the production analysis, but does not redesign message projection storage. -- The projector reads the existing projected message only for a compact user update. This is an additional read, but it avoids the multi-hundred-KB append-only event write and remains inside the existing transaction/replay ordering. -- This change was verified with a focused real SQLite integration test. The full package test suites were not run.