diff --git a/.specgit.yaml b/.specgit.yaml index 734775fd31..6b3d72ca40 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,8 @@ version: 1 -delivery: auto-close-linked +delivery: summary-diff-continue context: kind: branch - branch: feat/519-auto-close-linked + branch: fix/525-summary-diff-continue issues: - - 519 -pr: 520 + - 525 +pr: 526 diff --git a/packages/core/schema.json b/packages/core/schema.json index 7df86d7742..2afa95c92a 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,9 +1,9 @@ { "version": "7", "dialect": "sqlite", - "id": "874d8e74-d354-4dcb-b98c-c893660c9371", + "id": "abadf28b-1770-46c6-bbf6-b7800a9ca874", "prevIds": [ - "4142b961-0712-4834-b475-16ea4a74c43c" + "874d8e74-d354-4dcb-b98c-c893660c9371" ], "ddl": [ { @@ -1772,16 +1772,6 @@ "entityType": "columns", "table": "session" }, - { - "type": "text", - "notNull": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "summary_diffs", - "entityType": "columns", - "table": "session" - }, { "type": "text", "notNull": false, diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 3d44bcf97d..c141dc82ad 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -56,5 +56,6 @@ export const migrations = ( import("./migration/20260813040429_workflow_directory"), import("./migration/20260815044858_dag_graph_rev_view"), import("./migration/20260815083000_workflow_directory_convergence"), + import("./migration/20260903044702_drop_session_summary_diffs"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260903044702_drop_session_summary_diffs.ts b/packages/core/src/database/migration/20260903044702_drop_session_summary_diffs.ts new file mode 100644 index 0000000000..79c3559aae --- /dev/null +++ b/packages/core/src/database/migration/20260903044702_drop_session_summary_diffs.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260903044702_drop_session_summary_diffs", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`summary_diffs\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 25c9b5657b..851962d2eb 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -274,7 +274,6 @@ export default { \`summary_additions\` integer, \`summary_deletions\` integer, \`summary_files\` integer, - \`summary_diffs\` text, \`metadata\` text, \`cost\` real DEFAULT 0 NOT NULL, \`tokens_input\` integer DEFAULT 0 NOT NULL, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 0064fcb1d6..c47cbce2ec 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -60,7 +60,6 @@ function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInse summary_additions: info.summary?.additions, summary_deletions: info.summary?.deletions, summary_files: info.summary?.files, - summary_diffs: info.summary?.diffs ? [...info.summary.diffs] : undefined, metadata: info.metadata, cost: info.cost ?? 0, tokens_input: (info.tokens ?? { input: 0 }).input, diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index a7ce8df496..abdd603601 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -4,7 +4,6 @@ import { ProjectTable } from "../project/sql" import type { SessionMessage } from "./message" import type { Prompt } from "./prompt" import type { SessionInput } from "./input" -import type { Snapshot } from "../snapshot" import { PermissionV1 } from "../v1/permission" import { ProjectV2 } from "../project" import type { SessionSchema } from "./schema" @@ -38,7 +37,6 @@ export const SessionTable = sqliteTable( summary_additions: integer(), summary_deletions: integer(), summary_files: integer(), - summary_diffs: text({ mode: "json" }).$type(), metadata: text({ mode: "json" }).$type>(), cost: real().notNull().default(0), tokens_input: integer().notNull().default(0), diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 339417fbc8..87da1346bc 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -18,6 +18,7 @@ import simplifySessionInputMigration from "@opencode-ai/core/database/migration/ import capturedOutputMigration from "@opencode-ai/core/database/migration/20260715035022_captured_output" import fearlessCammiMigration from "@opencode-ai/core/database/migration/20260717034735_fearless_cammi" import dagWorkflowNodeIdentityMigration from "@opencode-ai/core/database/migration/20260720013828_dag-workflow-node-identity" +import dropSessionSummaryDiffsMigration from "@opencode-ai/core/database/migration/20260903044702_drop_session_summary_diffs" import { EventV2 } from "@opencode-ai/core/event" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" @@ -100,6 +101,39 @@ describe("DatabaseMigration", () => { ) }) + test("drops the legacy session summary_diffs column", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + // Legacy install: the column still exists with data, and only the drop migration is pending. + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, summary_diffs text)`) + yield* db.run( + sql`INSERT INTO session (id, summary_diffs) VALUES ('ses_legacy', '[{"file":"a.txt","patch":"p","additions":1,"deletions":0,"status":"modified"}]')`, + ) + + yield* DatabaseMigration.applyOnly(db, [dropSessionSummaryDiffsMigration]) + + expect( + yield* db.get(sql`SELECT name FROM pragma_table_info('session') WHERE name = 'summary_diffs'`), + ).toBeUndefined() + expect(yield* db.get(sql`SELECT id FROM session WHERE id = 'ses_legacy'`)).toEqual({ id: "ses_legacy" }) + expect(yield* db.get(sql`SELECT id FROM migration WHERE id = ${dropSessionSummaryDiffsMigration.id}`)).toEqual({ + id: dropSessionSummaryDiffsMigration.id, + }) + }), + ) + + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* DatabaseMigration.apply(db) + expect( + yield* db.get(sql`SELECT name FROM pragma_table_info('session') WHERE name = 'summary_diffs'`), + ).toBeUndefined() + }), + ) + }) + test("upgrades DAG node storage without duplicate columns or cross-workflow collisions", async () => { await run( Effect.gen(function* () { diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 36a6440a08..81c79c0e47 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -68,25 +68,21 @@ export const MAX_SUMMARY_DIFF_BYTES = 256 * 1024 // Byte accounting mirrors the JSON serialization: 2 bytes for the "[]" wrapper, // +1 per comma separator, so kept output never exceeds MAX_SUMMARY_DIFF_BYTES. +// Entries that do not fit are skipped individually so later, smaller entries +// are still kept. export function truncateSummaryDiffs(diffs: Snapshot.FileDiff[] | undefined) { if (!diffs) return undefined let total = 2 const kept: Snapshot.FileDiff[] = [] for (const item of diffs) { const size = Buffer.byteLength(JSON.stringify(item)) + (kept.length > 0 ? 1 : 0) - if (total + size > MAX_SUMMARY_DIFF_BYTES) break + if (total + size > MAX_SUMMARY_DIFF_BYTES) continue total += size kept.push(item) } return kept } -function stripOversizedDiffs(diffs: T[] | null | undefined) { - if (!diffs) return undefined - if (Buffer.byteLength(JSON.stringify(diffs)) > MAX_SUMMARY_DIFF_BYTES) return undefined - return diffs -} - export function fromRow(row: SessionRow): Info { const summary = row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null @@ -94,7 +90,6 @@ export function fromRow(row: SessionRow): Info { additions: row.summary_additions ?? 0, deletions: row.summary_deletions ?? 0, files: row.summary_files ?? 0, - diffs: stripOversizedDiffs(row.summary_diffs), } : undefined const share = row.share_url ? { url: row.share_url } : undefined @@ -165,7 +160,6 @@ export function toRow(info: Info) { summary_additions: info.summary?.additions, summary_deletions: info.summary?.deletions, summary_files: info.summary?.files, - summary_diffs: truncateSummaryDiffs(info.summary?.diffs), metadata: info.metadata, cost: info.cost ?? 0, tokens_input: (info.tokens ?? EmptyTokens).input, diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index ae72bc4d0a..75257a8e12 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -182,22 +182,6 @@ const insertCorruptV2Message = (sessionID: SessionIDType, time = 1) => .pipe(Effect.orDie) }) -const setLegacySummaryDiff = (sessionID: SessionIDType) => - Effect.gen(function* () { - const { db } = yield* Database.Service - yield* db - .update(SessionTable) - .set({ - summary_additions: 1, - summary_deletions: 0, - summary_files: 1, - summary_diffs: [{ additions: 1, deletions: 0 }], - }) - .where(eq(SessionTable.id, sessionID)) - .run() - .pipe(Effect.orDie) - }) - const getWorkspaceID = (sessionID: SessionIDType) => Effect.gen(function* () { const { db } = yield* Database.Service @@ -690,24 +674,6 @@ describe("session HttpApi", () => { { git: true, config: { formatter: false, lsp: false } }, ) - it.instance( - "serves sessions with migrated summary diffs missing file details", - () => - Effect.gen(function* () { - const test = yield* TestInstance - const session = yield* createSession({ title: "legacy diff" }) - yield* setLegacySummaryDiff(session.id) - - const response = yield* request(pathFor(SessionPaths.get, { sessionID: session.id }), { - headers: { "x-opencode-directory": test.directory }, - }) - - expect(response.status).toBe(200) - expect((yield* json(response)).summary?.diffs).toEqual([{ additions: 1, deletions: 0 }]) - }), - { git: true, config: { formatter: false, lsp: false } }, - ) - it.instance( "serves lifecycle mutation routes", () => diff --git a/packages/opencode/test/session/summary-diff-guard.test.ts b/packages/opencode/test/session/summary-diff-guard.test.ts index 7d109b40c6..ae8c2f0383 100644 --- a/packages/opencode/test/session/summary-diff-guard.test.ts +++ b/packages/opencode/test/session/summary-diff-guard.test.ts @@ -3,12 +3,10 @@ import { Database } from "@opencode-ai/core/database/database" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionProjector } from "@opencode-ai/core/session/projector" -import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionV1 } from "@opencode-ai/core/v1/session" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { Effect, Layer } from "effect" -import { eq } from "drizzle-orm" import { Snapshot } from "@/snapshot" import { Session as SessionNs, truncateSummaryDiffs, MAX_SUMMARY_DIFF_BYTES } from "@/session/session" import { SessionSummary } from "@/session/summary" @@ -48,22 +46,6 @@ const giantDiffs = (count: number) => status: "modified" as const, })) -const setSummaryRow = (sessionID: SessionID, summary: { additions: number; deletions: number; files: number; diffs: Snapshot.FileDiff[] }) => - Effect.gen(function* () { - const database = yield* Database.Service - yield* database.db - .update(SessionTable) - .set({ - summary_additions: summary.additions, - summary_deletions: summary.deletions, - summary_files: summary.files, - summary_diffs: summary.diffs, - }) - .where(eq(SessionTable.id, sessionID)) - .run() - .pipe(Effect.orDie) - }) - const seedUserTurn = Effect.fnUntraced(function* (sessionID: SessionID) { const sessions = yield* SessionNs.Service const userMessageID = MessageID.ascending() @@ -140,34 +122,6 @@ describe("summary.diffs source truncation", () => { ) }) -describe("summary_diffs read guard", () => { - it.instance("strips oversized legacy summary_diffs on read and keeps stats", () => - Effect.gen(function* () { - const sessions = yield* SessionNs.Service - const database = yield* Database.Service - const session = yield* sessions.create({ title: "legacy-giant-diffs" }) - - yield* database.db - .update(SessionTable) - .set({ - summary_additions: 12, - summary_deletions: 34, - summary_files: 56, - summary_diffs: giantDiffs(300), - }) - .where(eq(SessionTable.id, session.id)) - .run() - .pipe(Effect.orDie) - - const info = yield* sessions.get(session.id) - expect(info.summary?.additions).toBe(12) - expect(info.summary?.deletions).toBe(34) - expect(info.summary?.files).toBe(56) - expect(info.summary?.diffs).toBeUndefined() - }), - ) -}) - describe("truncateSummaryDiffs boundaries", () => { const item = { file: "a.txt", @@ -194,45 +148,31 @@ describe("truncateSummaryDiffs boundaries", () => { expect(kept?.length).toBe(count) expect(Buffer.byteLength(JSON.stringify(kept))).toBeLessThanOrEqual(MAX_SUMMARY_DIFF_BYTES) }) -}) -describe("summary diffs budget boundary", () => { - it.instance("keeps diffs at just under the budget on write and read", () => - Effect.gen(function* () { - const sessions = yield* SessionNs.Service - const session = yield* sessions.create({ title: "under-budget" }) - const info = yield* sessions.get(session.id) - - const diffs = giantDiffs(100) - expect(Buffer.byteLength(JSON.stringify(diffs))).toBeLessThan(SessionNs.MAX_SUMMARY_DIFF_BYTES) - const row = SessionNs.toRow({ ...info, summary: { additions: 5, deletions: 6, files: 100, diffs } }) - expect(row.summary_diffs).toEqual(diffs) - - yield* setSummaryRow(session.id, { additions: 5, deletions: 6, files: 100, diffs }) - const back = yield* sessions.get(session.id) - expect(back.summary?.diffs).toEqual(diffs) - expect(back.summary?.additions).toBe(5) - expect(back.summary?.deletions).toBe(6) - expect(back.summary?.files).toBe(100) - }), - ) + test("skips an oversized entry and keeps later entries that still fit", () => { + const huge = { ...item, file: "huge.txt", patch: "x".repeat(MAX_SUMMARY_DIFF_BYTES) } + const smallA = { ...item, file: "small-a.txt", patch: "y".repeat(64) } + const smallB = { ...item, file: "small-b.txt", patch: "z".repeat(64) } + const kept = truncateSummaryDiffs([huge, smallA, smallB]) + expect(kept).toEqual([smallA, smallB]) + expect(Buffer.byteLength(JSON.stringify(kept))).toBeLessThanOrEqual(MAX_SUMMARY_DIFF_BYTES) + }) - it.instance("truncates oversized diffs on write within the budget", () => - Effect.gen(function* () { - const sessions = yield* SessionNs.Service - const session = yield* sessions.create({ title: "over-budget-write" }) - const info = yield* sessions.get(session.id) - - const row = SessionNs.toRow({ - ...info, - summary: { additions: 5, deletions: 6, files: 300, diffs: giantDiffs(300) }, - }) - const kept = row.summary_diffs - expect(kept?.length).toBeGreaterThan(0) - expect(kept?.length).toBeLessThan(300) - expect(Buffer.byteLength(JSON.stringify(kept))).toBeLessThanOrEqual(SessionNs.MAX_SUMMARY_DIFF_BYTES) - expect(kept?.[0]?.file).toBe("f000.txt") - expect(kept?.at(-1)?.file).toBe(`f${String((kept?.length ?? 1) - 1).padStart(3, "0")}.txt`) - }), - ) + test("keeps serialized output within the budget for mixed oversized inputs", () => { + const huge = { ...item, patch: "x".repeat(MAX_SUMMARY_DIFF_BYTES) } + const nearBudget = { ...item, patch: "x".repeat(MAX_SUMMARY_DIFF_BYTES - 120) } + const shapes = [ + [huge, item, item], + [item, huge, item], + [item, item, huge], + [nearBudget, item, nearBudget, item], + [huge, nearBudget, huge, item], + [nearBudget, nearBudget], + [item, nearBudget, item, huge, item], + ] + shapes.forEach((shape) => { + const kept = truncateSummaryDiffs(shape) + expect(Buffer.byteLength(JSON.stringify(kept))).toBeLessThanOrEqual(MAX_SUMMARY_DIFF_BYTES) + }) + }) })