diff --git a/packages/core/src/session/provider-context.ts b/packages/core/src/session/provider-context.ts index 66874313201..e4f3e0a1033 100644 --- a/packages/core/src/session/provider-context.ts +++ b/packages/core/src/session/provider-context.ts @@ -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 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 } } } + }), + } + }) +} diff --git a/packages/core/test/fixture/provider-context/2.0.14-media.json b/packages/core/test/fixture/provider-context/2.0.14-media.json new file mode 100644 index 00000000000..66b2dd0b1b1 --- /dev/null +++ b/packages/core/test/fixture/provider-context/2.0.14-media.json @@ -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" }] + } +] diff --git a/packages/core/test/session-provider-context.test.ts b/packages/core/test/session-provider-context.test.ts index 555ff0e78b8..1188be85721 100644 --- a/packages/core/test/session-provider-context.test.ts +++ b/packages/core/test/session-provider-context.test.ts @@ -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,