fix(opencode): remove automatic full session diffs (#30127)
This commit is contained in:
@@ -210,7 +210,8 @@ export function Session() {
|
|||||||
const disabled = createMemo(() => permissions().length > 0 || questions().length > 0)
|
const disabled = createMemo(() => permissions().length > 0 || questions().length > 0)
|
||||||
|
|
||||||
const pending = createMemo(() => {
|
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(() => {
|
const lastAssistant = createMemo(() => {
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { or } from "drizzle-orm"
|
|||||||
import type { SQL } from "drizzle-orm"
|
import type { SQL } from "drizzle-orm"
|
||||||
import { PartTable, SessionTable } from "@opencode-ai/core/session/sql"
|
import { PartTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
import { Storage } from "@/storage/storage"
|
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
import type { InstanceContext } from "../project/instance-context"
|
import type { InstanceContext } from "../project/instance-context"
|
||||||
@@ -536,7 +535,7 @@ export type Patch = Omit<Partial<Info>, "time" | "share" | "summary" | "revert"
|
|||||||
export const layer: Layer.Layer<
|
export const layer: Layer.Layer<
|
||||||
Service,
|
Service,
|
||||||
never,
|
never,
|
||||||
BackgroundJob.Service | Storage.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service
|
BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service
|
||||||
> = Layer.effect(
|
> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -544,7 +543,6 @@ export const layer: Layer.Layer<
|
|||||||
const database = yield* Database.Service
|
const database = yield* Database.Service
|
||||||
const background = yield* BackgroundJob.Service
|
const background = yield* BackgroundJob.Service
|
||||||
const events = yield* EventV2Bridge.Service
|
const events = yield* EventV2Bridge.Service
|
||||||
const storage = yield* Storage.Service
|
|
||||||
const flags = yield* RuntimeFlags.Service
|
const flags = yield* RuntimeFlags.Service
|
||||||
|
|
||||||
const locationForSession = Effect.fnUntraced(function* (sessionID: SessionID) {
|
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) {
|
const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) {
|
||||||
return yield* storage
|
void sessionID
|
||||||
.read<Snapshot.FileDiff[]>(["session_diff", sessionID])
|
return [] as Snapshot.FileDiff[]
|
||||||
.pipe(Effect.orElseSucceed((): Snapshot.FileDiff[] => []))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const messages: Interface["messages"] = Effect.fn("Session.messages")(function* (input) {
|
const messages: Interface["messages"] = Effect.fn("Session.messages")(function* (input) {
|
||||||
@@ -1013,7 +1010,6 @@ export const layer: Layer.Layer<
|
|||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(BackgroundJob.defaultLayer),
|
Layer.provide(BackgroundJob.defaultLayer),
|
||||||
Layer.provide(Storage.defaultLayer),
|
|
||||||
Layer.provide(Database.defaultLayer),
|
Layer.provide(Database.defaultLayer),
|
||||||
Layer.provide(EventV2Bridge.defaultLayer),
|
Layer.provide(EventV2Bridge.defaultLayer),
|
||||||
Layer.provide(SessionV2.defaultLayer),
|
Layer.provide(SessionV2.defaultLayer),
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import { Effect, Layer, Context, Schema } from "effect"
|
|||||||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { Storage } from "@/storage/storage"
|
|
||||||
import * as Session from "./session"
|
import * as Session from "./session"
|
||||||
import { MessageV2 } from "./message-v2"
|
|
||||||
import { SessionID, MessageID } from "./schema"
|
import { SessionID, MessageID } from "./schema"
|
||||||
|
import { Config } from "@/config/config"
|
||||||
|
|
||||||
function unquoteGitPath(input: string) {
|
function unquoteGitPath(input: string) {
|
||||||
if (!input.startsWith('"')) return input
|
if (!input.startsWith('"')) return input
|
||||||
@@ -76,8 +75,8 @@ export const layer = Layer.effect(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const sessions = yield* Session.Service
|
const sessions = yield* Session.Service
|
||||||
const snapshot = yield* Snapshot.Service
|
const snapshot = yield* Snapshot.Service
|
||||||
const storage = yield* Storage.Service
|
|
||||||
const events = yield* EventV2Bridge.Service
|
const events = yield* EventV2Bridge.Service
|
||||||
|
const config = yield* Config.Service
|
||||||
|
|
||||||
const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: {
|
const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: {
|
||||||
messages: SessionLegacy.WithParts[]
|
messages: SessionLegacy.WithParts[]
|
||||||
@@ -105,20 +104,18 @@ export const layer = Layer.effect(
|
|||||||
sessionID: SessionID
|
sessionID: SessionID
|
||||||
messageID: MessageID
|
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({
|
yield* sessions.setSummary({
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
summary: {
|
summary: {
|
||||||
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
additions: 0,
|
||||||
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
deletions: 0,
|
||||||
files: diffs.length,
|
files: 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
|
yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: [] })
|
||||||
yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
|
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(
|
const messages = all.filter(
|
||||||
(m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
|
(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 diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
|
||||||
const diffs = yield* storage
|
if (!input.messageID) return []
|
||||||
.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID])
|
const message = (yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)).find(
|
||||||
.pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[])))
|
(item) => item.info.id === input.messageID,
|
||||||
const next = diffs.map((item) => {
|
)
|
||||||
|
if (!message || message.info.role !== "user") return []
|
||||||
|
const diffs = message.info.summary?.diffs ?? []
|
||||||
|
return diffs.map((item) => {
|
||||||
if (item.file === undefined) return item
|
if (item.file === undefined) return item
|
||||||
const file = unquoteGitPath(item.file)
|
const file = unquoteGitPath(item.file)
|
||||||
if (file === item.file) return item
|
if (file === item.file) return item
|
||||||
return { ...item, file }
|
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 })
|
return Service.of({ summarize, diff, computeDiff })
|
||||||
@@ -153,8 +150,8 @@ export const defaultLayer = Layer.suspend(() =>
|
|||||||
layer.pipe(
|
layer.pipe(
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(Snapshot.defaultLayer),
|
Layer.provide(Snapshot.defaultLayer),
|
||||||
Layer.provide(Storage.defaultLayer),
|
|
||||||
Layer.provide(EventV2Bridge.defaultLayer),
|
Layer.provide(EventV2Bridge.defaultLayer),
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,20 @@
|
|||||||
* the response was Schema-encoded against `Snapshot.FileDiff` with
|
* the response was Schema-encoded against `Snapshot.FileDiff` with
|
||||||
* `patch: Schema.String` (required), so any session whose stored
|
* `patch: Schema.String` (required), so any session whose stored
|
||||||
* `summary_diffs` had a row without `patch` returned HTTP 400 and the
|
* `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
|
* 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.
|
* asserts that GET /session/<id>/diff returns 200 with empty data.
|
||||||
*/
|
*/
|
||||||
import { afterEach, describe, expect } from "bun:test"
|
import { afterEach, describe, expect } from "bun:test"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session"
|
import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session"
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { Storage } from "@/storage/storage"
|
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 { resetDatabase } from "../fixture/db"
|
||||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
@@ -38,7 +42,7 @@ const withSession = (input?: Parameters<Session.Interface["create"]>[0]) =>
|
|||||||
|
|
||||||
describe("session diff with missing patch (#26574)", () => {
|
describe("session diff with missing patch (#26574)", () => {
|
||||||
it.instance(
|
it.instance(
|
||||||
"GET /session/<id>/diff returns 200 when summary_diffs row has no patch",
|
"GET /session/<id>/diff ignores legacy session-level diff storage",
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const test = yield* TestInstance
|
const test = yield* TestInstance
|
||||||
@@ -57,15 +61,37 @@ describe("session diff with missing patch (#26574)", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
const body = (yield* response.json) as Array<{
|
expect(yield* response.json).toEqual([])
|
||||||
file: string
|
}),
|
||||||
patch?: string
|
{ git: true, config: { formatter: false, lsp: false } },
|
||||||
additions: number
|
)
|
||||||
}>
|
|
||||||
expect(body).toHaveLength(1)
|
it.instance(
|
||||||
expect(body[0]?.file).toBe("legacy.txt")
|
"GET /session/<id>/diff returns requested turn diffs",
|
||||||
expect(body[0]?.additions).toBe(1)
|
() =>
|
||||||
expect(body[0]?.patch).toBeUndefined()
|
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 } },
|
{ git: true, config: { formatter: false, lsp: false } },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
// Verify the tool call completed (in the first assistant message)
|
||||||
const allMsgs = yield* MessageV2.filterCompactedEffect(session.id)
|
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
|
const tool = allMsgs
|
||||||
.flatMap((m) => m.parts)
|
.flatMap((m) => m.parts)
|
||||||
.find((p): p is SessionLegacy.ToolPart => p.type === "tool" && p.tool === "bash")
|
.find((p): p is SessionLegacy.ToolPart => p.type === "tool" && p.tool === "bash")
|
||||||
expect(tool?.state.status).toBe("completed")
|
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 }> = []
|
let diff: Array<{ file?: string }> = []
|
||||||
for (let i = 0; i < 50; i++) {
|
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
|
if (diff.length > 0) break
|
||||||
yield* Effect.sleep("100 millis")
|
yield* Effect.sleep("100 millis")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user