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
5 changes: 4 additions & 1 deletion packages/opencode/src/project/vcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,10 @@ export type Info = Schema.Schema.Type<typeof Info>

export const FileDiff = Schema.Struct({
file: Schema.String,
patch: Schema.String,
// Mirrors Snapshot.FileDiff (see #26574). The current producer always
// populates patch, but loosening matches the sibling schema so a
// future code path that omits it can't crash /instance/vcs/diff.
patch: Schema.optional(Schema.String),
additions: NonNegativeInt,
deletions: NonNegativeInt,
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/snapshot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export type Patch = typeof Patch.Type

export const FileDiff = Schema.Struct({
file: Schema.String,
// Optional because legacy/imported `summary_diffs` on disk may omit
// the patch text (see #26574). Required-Schema rejected the whole
// /session/<id>/diff response and broke session loading on Desktop.
patch: Schema.optional(Schema.String),
additions: NonNegativeInt,
deletions: NonNegativeInt,
Expand Down
79 changes: 79 additions & 0 deletions packages/opencode/test/server/session-diff-missing-patch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Regression test for the same bug class as #26574 (sibling of #26566 and
* #26553). The Desktop app calls GET /session/<id>/diff; before #26574
* 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.
*
* This test inserts a session row with a missing-patch diff entry and
* asserts that GET /session/<id>/diff returns 200 with the row intact.
*/
import { afterEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { Server } from "@/server/server"
import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session"
import { Storage } from "@/storage/storage"
import { WithInstance } from "@/project/with-instance"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
import * as Log from "@opencode-ai/core/util/log"

void Log.init({ print: false })

afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})

function pathFor(template: string, params: Record<string, string>) {
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), template)
}

describe("session diff with missing patch (#26574)", () => {
it.live("GET /session/<id>/diff returns 200 when summary_diffs row has no patch", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir({ git: true, config: { formatter: false, lsp: false } })),
(t) => Effect.promise(() => t[Symbol.asyncDispose]()),
)

yield* Effect.promise(() =>
WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await Effect.runPromise(
Effect.provide(Session.Service.use((s) => s.create({ title: "missing-patch" })), Session.defaultLayer),
)

// Mimic legacy/imported on-disk shape: a diff entry with no
// `patch` text. Pre-fix the typed response encoder rejects
// this and returns 400.
await Effect.runPromise(
Effect.provide(
Storage.Service.use((s) =>
s.write(["session_diff", session.id], [{ file: "legacy.txt", additions: 1, deletions: 0 }]),
),
Storage.defaultLayer,
),
)

const headers = { "x-opencode-directory": tmp.path }
const response = await Server.Default().app.request(
pathFor(SessionPaths.diff, { sessionID: session.id }),
{ headers },
)
expect(response.status).toBe(200)
const body = (await 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()
},
}),
)
}),
)
})
Loading