From 0d864a26cf32725c0298f27e9d92eeba54a40f3a Mon Sep 17 00:00:00 2001 From: ergs0204 Date: Sat, 18 Jul 2026 02:29:55 +0800 Subject: [PATCH] fix(opencode): restore session diff summary --- packages/opencode/src/session/summary.ts | 40 ++++-- .../opencode/test/session/summary.test.ts | 129 ++++++++++++++++++ 2 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 packages/opencode/test/session/summary.test.ts diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 6484730d0bd5..ac5a97e17ad2 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -103,16 +103,14 @@ const layer = Layer.effect( sessionID: SessionID messageID: MessageID }) { - yield* sessions.setSummary({ - sessionID: input.sessionID, - summary: { - additions: 0, - deletions: 0, - files: 0, - }, - }) - yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: [] }) - if ((yield* config.get()).snapshot === false) return + if ((yield* config.get()).snapshot === false) { + yield* sessions.setSummary({ + sessionID: input.sessionID, + summary: { additions: 0, deletions: 0, files: 0 }, + }) + yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: [] }) + return + } const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) if (!all.length) return @@ -124,6 +122,28 @@ const layer = Layer.effect( const msgDiffs = yield* computeDiff({ messages }) target.info.summary = { ...target.info.summary, diffs: msgDiffs } yield* sessions.updateMessage(target.info) + + // Rebuild the session projection from stored turn diffs without diffing the full snapshot history. + const diffs = Array.from( + new Map( + all + .filter( + (message): message is SessionV1.WithParts & { info: SessionV1.User } => message.info.role === "user", + ) + .flatMap((message) => (message.info.summary ? message.info.summary.diffs : [])) + .map((item, index) => [item.file ?? index, item] as const), + ).values(), + ) + yield* sessions.setSummary({ + sessionID: input.sessionID, + summary: { + additions: diffs.reduce((sum, item) => sum + item.additions, 0), + deletions: diffs.reduce((sum, item) => sum + item.deletions, 0), + files: diffs.length, + diffs, + }, + }) + yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) }) const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { diff --git a/packages/opencode/test/session/summary.test.ts b/packages/opencode/test/session/summary.test.ts new file mode 100644 index 000000000000..74aa13213e45 --- /dev/null +++ b/packages/opencode/test/session/summary.test.ts @@ -0,0 +1,129 @@ +import { expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { Effect, Layer } from "effect" +import { Config } from "@/config/config" +import { EventV2Bridge } from "@/event-v2-bridge" +import { Session } from "@/session/session" +import { MessageID, PartID } from "@/session/schema" +import { SessionSummary } from "@/session/summary" +import { Snapshot } from "@/snapshot" +import { testEffect } from "../lib/effect" + +const calls: Array<[string, string]> = [] +const secondDiff = { + file: "second.ts", + patch: "second patch", + additions: 3, + deletions: 4, + status: "modified" as const, +} + +const it = testEffect( + LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node, SessionSummary.node, EventV2Bridge.node]), [ + [ + Snapshot.node, + Layer.mock(Snapshot.Service, { + diffFull: (from, to) => + Effect.sync(() => { + calls.push([from, to]) + return [secondDiff] + }), + }), + ], + [ + Config.node, + Layer.mock(Config.Service, { + get: () => Effect.succeed({ snapshot: true }), + }), + ], + ]), +) + +it.instance("aggregates stored turn diffs without recomputing the full session diff", () => + Effect.gen(function* () { + calls.length = 0 + const sessions = yield* Session.Service + const summary = yield* SessionSummary.Service + const events = yield* EventV2Bridge.Service + const session = yield* sessions.create({}) + const firstDiff = { + file: "first.ts", + patch: "first patch", + additions: 2, + deletions: 1, + status: "modified" as const, + } + + const turn = Effect.fn("test.turn")(function* (input: { + before: string + after: string + diffs?: (typeof firstDiff)[] + }) { + const user = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: session.id, + agent: "build", + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") }, + time: { created: Date.now() }, + summary: input.diffs ? { diffs: input.diffs } : undefined, + }) + const assistant = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + sessionID: session.id, + parentID: user.id, + mode: "build", + agent: "build", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ModelV2.ID.make("test"), + providerID: ProviderV2.ID.make("test"), + time: { created: Date.now() }, + }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "step-start", + snapshot: input.before, + }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID: session.id, + type: "step-finish", + reason: "stop", + snapshot: input.after, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + return user + }) + + yield* turn({ before: "turn-1-before", after: "turn-1-after", diffs: [firstDiff] }) + const second = yield* turn({ before: "turn-2-before", after: "turn-2-after" }) + const seen: (typeof firstDiff)[][] = [] + const off = yield* events.listen((event) => { + if (event.type === Session.Event.Diff.type) seen.push((event.data as { diff: (typeof firstDiff)[] }).diff) + return Effect.void + }) + + yield* summary.summarize({ sessionID: session.id, messageID: second.id }) + yield* off + + expect(calls).toEqual([["turn-2-before", "turn-2-after"]]) + expect((yield* sessions.get(session.id)).summary).toEqual({ + additions: 5, + deletions: 5, + files: 2, + diffs: [firstDiff, secondDiff], + }) + expect(seen).toEqual([[firstDiff, secondDiff]]) + expect(yield* summary.diff({ sessionID: session.id, messageID: second.id })).toEqual([secondDiff]) + }), +)