mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(core): decode legacy media in compaction checkpoints (#51409)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
@@ -2,7 +2,7 @@ export * as SessionProviderContext from "./provider-context.js"
|
||||
|
||||
import { Message } from "@opencode/ai"
|
||||
import { SessionProviderContext } from "@opencode/schema/session-provider-context"
|
||||
import { Schema } from "effect"
|
||||
import { Predicate, Schema } from "effect"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Hash } from "@opencode/util/hash"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
@@ -54,5 +54,31 @@ export const encode = (provenance: Provenance, replacement: ReadonlyArray<Messag
|
||||
messages: Schema.decodeSync(Schema.fromJsonString(Schema.Json))(JSON.stringify(replacement)),
|
||||
})
|
||||
|
||||
export const decode = (context: Info) => Schema.decodeUnknownSync(messages)(context.messages)
|
||||
export const validate = (context: Info) => Schema.decodeUnknownEffect(messages)(context.messages)
|
||||
export const decode = (context: Info) => Schema.decodeUnknownSync(messages)(upgradeLegacyMedia(context.messages))
|
||||
export const validate = (context: Info) => Schema.decodeUnknownEffect(messages)(upgradeLegacyMedia(context.messages))
|
||||
|
||||
/**
|
||||
* Before 2.0.15, version-1 checkpoints stored mediaType/data directly on media parts. Core only built
|
||||
* those parts from prompt attachments, whose data the Prompt schema guarantees is base64.
|
||||
*/
|
||||
function upgradeLegacyMedia(input: Info["messages"]) {
|
||||
if (!Array.isArray(input)) return input
|
||||
return input.map((message: unknown) => {
|
||||
if (!Predicate.isObject(message) || !Array.isArray(message.content)) return message
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map((part: unknown) => {
|
||||
if (
|
||||
!Predicate.isObject(part) ||
|
||||
part.type !== "media" ||
|
||||
part.media !== undefined ||
|
||||
typeof part.mediaType !== "string" ||
|
||||
typeof part.data !== "string"
|
||||
)
|
||||
return part
|
||||
const { mediaType, data, ...rest } = part
|
||||
return { ...rest, media: { source: { type: "base64", data, mediaType } } }
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "text", "text": "retained attachments" },
|
||||
{
|
||||
"type": "media",
|
||||
"mediaType": "image/png",
|
||||
"data": "AQID",
|
||||
"filename": "attachment.png",
|
||||
"cache": { "type": "ephemeral" },
|
||||
"metadata": { "attachment": "original" },
|
||||
"providerMetadata": { "openai": { "detail": "high" } }
|
||||
},
|
||||
{ "type": "media", "mediaType": "image/jpeg", "data": "BAUG" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{ "type": "compaction", "provider": "openai", "encrypted": "opaque-checkpoint", "id": "cp_legacy" }]
|
||||
}
|
||||
]
|
||||
@@ -26,6 +26,7 @@ import { Model } from "@opencode/schema/model"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import legacyMedia from "./fixture/provider-context/2.0.14-media.json"
|
||||
|
||||
const model = SessionRunnerModel.resolved(
|
||||
LanguageModel.make({ id: "deployment", provider: "openai", route: OpenAIResponses.route }),
|
||||
@@ -46,6 +47,51 @@ const replacement = [
|
||||
const providerContext = SessionProviderContext.encode(target, replacement)
|
||||
const sessionID = SessionSchema.ID.make("ses_provider_context")
|
||||
|
||||
// Frozen serialized data, deliberately not constructed using today's Message API.
|
||||
const legacyContext = Schema.decodeUnknownSync(SessionProviderContext.Info)({
|
||||
...providerContext,
|
||||
messages: legacyMedia,
|
||||
})
|
||||
|
||||
test("reads pre-2.0.15 checkpoint media without rewriting the stored payload", async () => {
|
||||
const before = JSON.stringify(legacyContext)
|
||||
const decoded = SessionProviderContext.decode(legacyContext)
|
||||
expect(await Effect.runPromise(SessionProviderContext.validate(legacyContext))).toEqual(decoded)
|
||||
expect(decoded[0]?.content).toMatchObject([
|
||||
{ type: "text", text: "retained attachments" },
|
||||
{
|
||||
type: "media",
|
||||
media: { source: { type: "base64", data: "AQID", mediaType: "image/png" } },
|
||||
filename: "attachment.png",
|
||||
cache: { type: "ephemeral" },
|
||||
metadata: { attachment: "original" },
|
||||
providerMetadata: { openai: { detail: "high" } },
|
||||
},
|
||||
{ type: "media", media: { source: { type: "base64", data: "BAUG", mediaType: "image/jpeg" } } },
|
||||
])
|
||||
expect(decoded[1]?.content).toMatchObject(legacyMedia[1]?.content ?? [])
|
||||
expect(JSON.stringify(legacyContext)).toBe(before)
|
||||
})
|
||||
|
||||
test("reads mixed legacy and current media without overriding an existing asset", async () => {
|
||||
const current = {
|
||||
type: "media",
|
||||
media: { source: { type: "base64", data: "BAUG", mediaType: "image/jpeg" } },
|
||||
mediaType: "image/png",
|
||||
data: "AQID",
|
||||
}
|
||||
const context = Schema.decodeUnknownSync(SessionProviderContext.Info)({
|
||||
...providerContext,
|
||||
messages: [{ role: "user", content: [...(legacyMedia[0]?.content ?? []), current] }],
|
||||
})
|
||||
const decoded = SessionProviderContext.decode(context)
|
||||
expect(await Effect.runPromise(SessionProviderContext.validate(context))).toEqual(decoded)
|
||||
expect(decoded[0]?.content.at(-1)).toMatchObject({ media: current.media })
|
||||
const invalid = { ...context, messages: [{ role: "user", content: [{ ...current, media: null }] }] }
|
||||
expect(() => SessionProviderContext.decode(invalid)).toThrow()
|
||||
expect(await Effect.runPromise(Effect.isFailure(SessionProviderContext.validate(invalid)))).toBe(true)
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node, SessionStore.node]),
|
||||
@@ -95,6 +141,26 @@ const setup = Effect.gen(function* () {
|
||||
return { db: database.db, bus, state, instructions, prepare, prompt, compact, load }
|
||||
})
|
||||
|
||||
it.effect("loads and replays a persisted pre-2.0.15 media checkpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const s = yield* setup
|
||||
yield* s.prepare
|
||||
yield* s.prompt("original request")
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "" })
|
||||
yield* s.compact(legacyContext)
|
||||
yield* s.prompt("continue")
|
||||
const history = yield* SessionHistory.load(s.db, sessionID, target)
|
||||
expect(toLLMMessages(history, model.ref).slice(0, 2)).toEqual([...SessionProviderContext.decode(legacyContext)])
|
||||
expect(history.some(SessionProviderContext.isCheckpoint)).toBe(true)
|
||||
const local = yield* SessionHistory.load(s.db, sessionID, "local")
|
||||
expect(local.some(SessionProviderContext.isCheckpoint)).toBe(false)
|
||||
expect(toLLMMessages(local, model.ref).map((message) => message.content)).toEqual([
|
||||
[Message.text("original request")],
|
||||
[Message.text("continue")],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
test("canonical provider context round-trips tools, opaque checkpoints and binary media through JSON", () => {
|
||||
const messages = [
|
||||
...replacement,
|
||||
|
||||
Reference in New Issue
Block a user