From 2f2fcc165439aec88a0e8e09c836d89c96977c6b Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 31 May 2026 13:34:14 -0400 Subject: [PATCH] fix(opencode): remove automatic full session diffs (#30127) --- .../src/cli/cmd/tui/routes/session/index.tsx | 3 +- packages/opencode/src/session/session.ts | 10 ++-- packages/opencode/src/session/summary.ts | 37 +++++++------- .../server/session-diff-missing-patch.test.ts | 50 ++++++++++++++----- .../test/session/snapshot-tool-race.test.ts | 6 ++- 5 files changed, 64 insertions(+), 42 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 5c0e5e73f..4fdeb61f7 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -210,7 +210,8 @@ export function Session() { const disabled = createMemo(() => permissions().length > 0 || questions().length > 0) const pending = createMemo(() => { - return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id + const completed = messages().findLast((x) => x.role === "assistant" && x.time.completed)?.id + return messages().findLast((x) => x.role === "assistant" && !x.time.completed && (!completed || x.id > completed))?.id }) const lastAssistant = createMemo(() => { diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index f87a24a74..bb6f1d6a1 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -25,7 +25,6 @@ import { or } from "drizzle-orm" import type { SQL } from "drizzle-orm" import { PartTable, SessionTable } from "@opencode-ai/core/session/sql" import { ProjectTable } from "@opencode-ai/core/project/sql" -import { Storage } from "@/storage/storage" import * as Log from "@opencode-ai/core/util/log" import { MessageV2 } from "./message-v2" import type { InstanceContext } from "../project/instance-context" @@ -536,7 +535,7 @@ export type Patch = Omit, "time" | "share" | "summary" | "revert" export const layer: Layer.Layer< Service, never, - BackgroundJob.Service | Storage.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service + BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -544,7 +543,6 @@ export const layer: Layer.Layer< const database = yield* Database.Service const background = yield* BackgroundJob.Service const events = yield* EventV2Bridge.Service - const storage = yield* Storage.Service const flags = yield* RuntimeFlags.Service const locationForSession = Effect.fnUntraced(function* (sessionID: SessionID) { @@ -887,9 +885,8 @@ export const layer: Layer.Layer< }) const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) { - return yield* storage - .read(["session_diff", sessionID]) - .pipe(Effect.orElseSucceed((): Snapshot.FileDiff[] => [])) + void sessionID + return [] as Snapshot.FileDiff[] }) const messages: Interface["messages"] = Effect.fn("Session.messages")(function* (input) { @@ -1013,7 +1010,6 @@ export const layer: Layer.Layer< export const defaultLayer = layer.pipe( Layer.provide(BackgroundJob.defaultLayer), - Layer.provide(Storage.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(SessionV2.defaultLayer), diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index e32ce9803..7dccec1f2 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -2,10 +2,9 @@ import { Effect, Layer, Context, Schema } from "effect" import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { EventV2Bridge } from "@/event-v2-bridge" import { Snapshot } from "@/snapshot" -import { Storage } from "@/storage/storage" import * as Session from "./session" -import { MessageV2 } from "./message-v2" import { SessionID, MessageID } from "./schema" +import { Config } from "@/config/config" function unquoteGitPath(input: string) { if (!input.startsWith('"')) return input @@ -76,8 +75,8 @@ export const layer = Layer.effect( Effect.gen(function* () { const sessions = yield* Session.Service const snapshot = yield* Snapshot.Service - const storage = yield* Storage.Service const events = yield* EventV2Bridge.Service + const config = yield* Config.Service const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionLegacy.WithParts[] @@ -105,20 +104,18 @@ export const layer = Layer.effect( sessionID: SessionID messageID: MessageID }) { - const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) - if (!all.length) return - - const diffs = yield* computeDiff({ messages: all }) yield* sessions.setSummary({ sessionID: input.sessionID, summary: { - additions: diffs.reduce((sum, x) => sum + x.additions, 0), - deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), - files: diffs.length, + additions: 0, + deletions: 0, + files: 0, }, }) - yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore) - yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) + yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: [] }) + if ((yield* config.get()).snapshot === false) return + const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie) + if (!all.length) return const messages = all.filter( (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), @@ -131,18 +128,18 @@ export const layer = Layer.effect( }) const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { - const diffs = yield* storage - .read(["session_diff", input.sessionID]) - .pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[]))) - const next = diffs.map((item) => { + if (!input.messageID) return [] + const message = (yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)).find( + (item) => item.info.id === input.messageID, + ) + if (!message || message.info.role !== "user") return [] + const diffs = message.info.summary?.diffs ?? [] + return diffs.map((item) => { if (item.file === undefined) return item const file = unquoteGitPath(item.file) if (file === item.file) return item return { ...item, file } }) - const changed = next.some((item, i) => item.file !== diffs[i]?.file) - if (changed) yield* storage.write(["session_diff", input.sessionID], next).pipe(Effect.ignore) - return next }) return Service.of({ summarize, diff, computeDiff }) @@ -153,8 +150,8 @@ export const defaultLayer = Layer.suspend(() => layer.pipe( Layer.provide(Session.defaultLayer), Layer.provide(Snapshot.defaultLayer), - Layer.provide(Storage.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(Config.defaultLayer), ), ) 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 f7f22b432..d77a23380 100644 --- a/packages/opencode/test/server/session-diff-missing-patch.test.ts +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -4,16 +4,20 @@ * the response was Schema-encoded against `Snapshot.FileDiff` with * `patch: Schema.String` (required), so any session whose stored * `summary_diffs` had a row without `patch` returned HTTP 400 and the - * session never loaded. + * session never loaded. Legacy session-level diffs are no longer surfaced, + * but the endpoint remains compatible and must still return successfully. * * This test inserts a session row with a missing-patch diff entry and - * asserts that GET /session//diff returns 200 with the row intact. + * asserts that GET /session//diff returns 200 with empty data. */ import { afterEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" import { Storage } from "@/storage/storage" +import { SessionLegacy } from "@opencode-ai/core/session/legacy" +import { MessageID } from "@/session/schema" +import { ProviderV2 } from "@opencode-ai/core/provider" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -38,7 +42,7 @@ const withSession = (input?: Parameters[0]) => describe("session diff with missing patch (#26574)", () => { it.instance( - "GET /session//diff returns 200 when summary_diffs row has no patch", + "GET /session//diff ignores legacy session-level diff storage", () => Effect.gen(function* () { const test = yield* TestInstance @@ -57,15 +61,37 @@ describe("session diff with missing patch (#26574)", () => { ) expect(response.status).toBe(200) - const body = (yield* response.json) as Array<{ - file: string - patch?: string - additions: number - }> - expect(body).toHaveLength(1) - expect(body[0]?.file).toBe("legacy.txt") - expect(body[0]?.additions).toBe(1) - expect(body[0]?.patch).toBeUndefined() + expect(yield* response.json).toEqual([]) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "GET /session//diff returns requested turn diffs", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "turn-diff" }) + const messageID = MessageID.ascending() + yield* Session.use.updateMessage({ + id: messageID, + sessionID: session.id, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("model") }, + summary: { + diffs: [{ file: "turn.ts", additions: 1, deletions: 0, status: "modified" }], + }, + } satisfies SessionLegacy.User) + + const response = yield* requestInDirectory( + `${pathFor(SessionPaths.diff, { sessionID: session.id })}?messageID=${messageID}`, + test.directory, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual([{ file: "turn.ts", additions: 1, deletions: 0, status: "modified" }]) }), { git: true, config: { formatter: false, lsp: false } }, ) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index b5fed974a..815303a53 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -257,15 +257,17 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => // Verify the tool call completed (in the first assistant message) const allMsgs = yield* MessageV2.filterCompactedEffect(session.id) + const user = allMsgs.find((msg): msg is SessionLegacy.WithParts & { info: SessionLegacy.User } => msg.info.role === "user") const tool = allMsgs .flatMap((m) => m.parts) .find((p): p is SessionLegacy.ToolPart => p.type === "tool" && p.tool === "bash") expect(tool?.state.status).toBe("completed") + if (!user) throw new Error("Expected user message") - // Poll for diff — summarize() is fire-and-forget + // Poll for the turn diff — summarize() is fire-and-forget. let diff: Array<{ file?: string }> = [] for (let i = 0; i < 50; i++) { - diff = yield* summary.diff({ sessionID: session.id }) + diff = yield* summary.diff({ sessionID: session.id, messageID: user.info.id }) if (diff.length > 0) break yield* Effect.sleep("100 millis") }