Skip to content

Commit e928d4f

Browse files
authored
feat(server): add experimental GET /api/experimental/media/:hash and opt-in experimental-media-uri capability (#59103)
1 parent 350dc1a commit e928d4f

30 files changed

Lines changed: 1103 additions & 112 deletions

‎packages/client/src/promise/generated/client.ts‎

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/client/src/promise/generated/types.ts‎

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/client/src/promise/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,8 @@ export type {
2121
SkillApi,
2222
} from "./api.js"
2323
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types.js"
24+
export type {
25+
MediaFileAttachment as PromptMediaFileAttachment,
26+
Attachment as PromptAttachment,
27+
} from "@opencode/schema/prompt"
2428
export type OpenCodeClient = ReturnType<typeof OpenCode.make>

‎packages/client/test/promise.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ test("exposes every standard HTTP API group", () => {
5353
expect(Object.keys(client.vcs)).toEqual(["init", "get", "base", "status", "branch", "diff"])
5454
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
5555
expect(Object.keys(client.pty.connect)).toEqual(["token"])
56-
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
56+
expect(Object.keys(client.experimental).sort()).toEqual(["media", "persistentPty"])
57+
expect(Object.keys(client.experimental.media)).toEqual(["get"])
5758
expect(client.experimental.persistentPty.read).toBeFunction()
5859
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"])
5960
expect(Object.keys(client.project)).toEqual(["list", "update"])

‎packages/core/src/session.ts‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export * as Session from "./session.js"
22
export * from "./session/schema.js"
33

4-
import { DateTime, Effect, Layer, Context, Stream } from "effect"
4+
import { DateTime, Effect, Layer, Context, Option, Stream } from "effect"
55
import { LLMClient } from "@opencode/ai/effect"
66
import { ListAnchor, ListCursor, ListFilter } from "@opencode/schema/session"
77
import { and, desc, eq } from "drizzle-orm"
@@ -16,6 +16,7 @@ import { Instance } from "./instance/service.js"
1616
import { Database } from "./database/database.js"
1717
import { SessionProjector } from "./session/projector.js"
1818
import { SessionMessageTable } from "./session/sql.js"
19+
import { SessionBlob } from "./session/blob.js"
1920
import { SessionSchema } from "./session/schema.js"
2021
import { RelativePath } from "./schema.js"
2122
import { Agent } from "@opencode/schema/agent"
@@ -147,6 +148,15 @@ export interface Interface {
147148
sessionID: SessionSchema.ID
148149
messageID: SessionMessage.ID
149150
}) => Effect.Effect<SessionMessage.Info | undefined>
151+
/** Experimental: {@link Interface.messages} with files stored in `blob` left as `media:` refs when the session's location enables `experimental.media_blob`. */
152+
readonly messagesWithRefs: (
153+
input: SessionStore.MessagesInput,
154+
) => Effect.Effect<SessionMessage.MediaInfo[], NotFoundError | MessageDecodeError>
155+
/** Experimental: {@link Interface.message} with files stored in `blob` left as `media:` refs when the session's location enables `experimental.media_blob`. */
156+
readonly messageWithRefs: (input: {
157+
sessionID: SessionSchema.ID
158+
messageID: SessionMessage.ID
159+
}) => Effect.Effect<SessionMessage.MediaInfo | undefined>
150160
readonly context: (
151161
sessionID: SessionSchema.ID,
152162
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
@@ -166,6 +176,8 @@ export interface Interface {
166176
* unhandled compaction barriers.
167177
*/
168178
readonly inbox: (sessionID: SessionSchema.ID) => Effect.Effect<SessionInbox.Info[], NotFoundError>
179+
/** Experimental: {@link Interface.inbox} with files stored in `blob` left as `media:` refs when the session's location enables `experimental.media_blob`. */
180+
readonly inboxWithRefs: (sessionID: SessionSchema.ID) => Effect.Effect<SessionInbox.MediaInfo[], NotFoundError>
169181
readonly cancelInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
170182
readonly steerInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
171183
readonly queueInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
@@ -268,6 +280,16 @@ const layer = Layer.effect(
268280
const environments = yield* SessionEnvironment.Service
269281
const locations = yield* LocationServiceMap.Service
270282
const sessions = yield* Session.make()
283+
// `experimental.media_blob` as the session's own location configures it, where its media is written.
284+
const keepsMediaRefs = (session: SessionSchema.Info) =>
285+
SessionBlob.enabled.pipe(
286+
instances.provide(session),
287+
Effect.catch((cause) =>
288+
Effect.logWarning("could not resolve experimental.media_blob; serving media inline", { cause }).pipe(
289+
Effect.as(false),
290+
),
291+
),
292+
)
271293
const isDurableSessionEvent = (item: Bus.LogItem): item is SessionEvent.DurableEvent =>
272294
Object.hasOwn(SessionEvent.Durable.cases, item.type)
273295

@@ -413,6 +435,18 @@ const layer = Layer.effect(
413435
return yield* store.messages(input)
414436
}),
415437
message: (input) => sessions.forSession(input.sessionID).message(input.messageID),
438+
messagesWithRefs: Effect.fn("Session.messagesWithRefs")(function* (input) {
439+
const session = yield* result.get(input.sessionID)
440+
if (!(yield* keepsMediaRefs(session))) return yield* store.messages(input)
441+
return yield* store.messagesWithRefs(input)
442+
}),
443+
messageWithRefs: Effect.fn("Session.messageWithRefs")(function* (input) {
444+
const session = yield* result.get(input.sessionID).pipe(Effect.option)
445+
if (Option.isNone(session)) return undefined
446+
if (!(yield* keepsMediaRefs(session.value))) return yield* result.message(input)
447+
const stored = yield* store.messageWithRefs(input.messageID)
448+
return stored?.sessionID === input.sessionID ? stored.message : undefined
449+
}),
416450
context: Effect.fn("Session.context")(function* (sessionID) {
417451
yield* result.get(sessionID)
418452
return yield* store.context(sessionID)
@@ -429,6 +463,11 @@ const layer = Layer.effect(
429463
})
430464
}),
431465
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
466+
inboxWithRefs: Effect.fn("Session.inboxWithRefs")(function* (sessionID) {
467+
const session = yield* result.get(sessionID)
468+
if (!(yield* keepsMediaRefs(session))) return yield* result.inbox(sessionID)
469+
return yield* SessionInbox.listWithRefs(db, sessionID)
470+
}),
432471
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
433472
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
434473
queueInbox: (input) => sessions.forSession(input.sessionID).queueInbox(input.inboxID),

0 commit comments

Comments
 (0)