Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .specgit.yaml
Original file line number Diff line number Diff line change
@@ -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
14 changes: 2 additions & 12 deletions packages/core/schema.json
Original file line number Diff line number Diff line change
@@ -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": [
{
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/database/migration.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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
1 change: 0 additions & 1 deletion packages/core/src/database/schema.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/session/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 0 additions & 2 deletions packages/core/src/session/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -38,7 +37,6 @@ export const SessionTable = sqliteTable(
summary_additions: integer(),
summary_deletions: integer(),
summary_files: integer(),
summary_diffs: text({ mode: "json" }).$type<Snapshot.LegacyFileDiff[]>(),
metadata: text({ mode: "json" }).$type<Record<string, unknown>>(),
cost: real().notNull().default(0),
tokens_input: integer().notNull().default(0),
Expand Down
34 changes: 34 additions & 0 deletions packages/core/test/database-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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* () {
Expand Down
12 changes: 3 additions & 9 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,33 +68,28 @@ 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<T>(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
? {
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
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 0 additions & 34 deletions packages/opencode/test/server/httpapi-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Session.Info>(response)).summary?.diffs).toEqual([{ additions: 1, deletions: 0 }])
}),
{ git: true, config: { formatter: false, lsp: false } },
)

it.instance(
"serves lifecycle mutation routes",
() =>
Expand Down
110 changes: 25 additions & 85 deletions packages/opencode/test/session/summary-diff-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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",
Expand All @@ -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)
})
})
})
Loading