fix(core): resolve prompt attachment mime types

This commit is contained in:
Dax Raad 2026-06-26 13:50:39 -04:00
commit 850a0dfe7c
8 changed files with 86 additions and 12 deletions

View file

@ -305,7 +305,6 @@ export type SessionsPromptInput = {
readonly text: string readonly text: string
readonly files?: ReadonlyArray<{ readonly files?: ReadonlyArray<{
readonly uri: string readonly uri: string
readonly mime: string
readonly name?: string readonly name?: string
readonly description?: string readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string } readonly source?: { readonly start: number; readonly end: number; readonly text: string }
@ -324,7 +323,6 @@ export type SessionsPromptInput = {
readonly text: string readonly text: string
readonly files?: ReadonlyArray<{ readonly files?: ReadonlyArray<{
readonly uri: string readonly uri: string
readonly mime: string
readonly name?: string readonly name?: string
readonly description?: string readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string } readonly source?: { readonly start: number; readonly end: number; readonly text: string }
@ -343,7 +341,6 @@ export type SessionsPromptInput = {
readonly text: string readonly text: string
readonly files?: ReadonlyArray<{ readonly files?: ReadonlyArray<{
readonly uri: string readonly uri: string
readonly mime: string
readonly name?: string readonly name?: string
readonly description?: string readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string } readonly source?: { readonly start: number; readonly end: number; readonly text: string }
@ -362,7 +359,6 @@ export type SessionsPromptInput = {
readonly text: string readonly text: string
readonly files?: ReadonlyArray<{ readonly files?: ReadonlyArray<{
readonly uri: string readonly uri: string
readonly mime: string
readonly name?: string readonly name?: string
readonly description?: string readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string } readonly source?: { readonly start: number; readonly end: number; readonly text: string }

View file

@ -10,6 +10,7 @@ import { ModelV2 } from "./model"
import { Location } from "./location" import { Location } from "./location"
import { SessionMessage } from "./session/message" import { SessionMessage } from "./session/message"
import { Prompt } from "./session/prompt" import { Prompt } from "./session/prompt"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { EventV2 } from "./event" import { EventV2 } from "./event"
import { Database } from "./database/database" import { Database } from "./database/database"
import { SessionProjector } from "./session/projector" import { SessionProjector } from "./session/projector"
@ -32,6 +33,7 @@ import { SessionInput } from "./session/input"
import { Snapshot } from "./snapshot" import { Snapshot } from "./snapshot"
import { SessionRevert } from "./session/revert" import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert" import { Revert } from "@opencode-ai/schema/revert"
import { FSUtil } from "./fs-util"
export const RevertState = Revert.State export const RevertState = Revert.State
export type RevertState = Revert.State export type RevertState = Revert.State
@ -137,7 +139,7 @@ export interface Interface {
readonly prompt: (input: { readonly prompt: (input: {
id?: SessionMessage.ID id?: SessionMessage.ID
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
prompt: Prompt prompt: PromptInput.Prompt
delivery?: SessionInput.Delivery delivery?: SessionInput.Delivery
resume?: boolean resume?: boolean
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError> }) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
@ -349,13 +351,14 @@ export const layer = Layer.unwrap(
Effect.uninterruptible( Effect.uninterruptible(
Effect.gen(function* () { Effect.gen(function* () {
yield* result.get(input.sessionID) yield* result.get(input.sessionID)
const prompt = resolvePrompt(input.prompt)
const messageID = input.id ?? SessionMessage.ID.create() const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer" const delivery = input.delivery ?? "steer"
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery } const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
const admitted = yield* SessionInput.admit(db, events, { const admitted = yield* SessionInput.admit(db, events, {
id: messageID, id: messageID,
sessionID: input.sessionID, sessionID: input.sessionID,
prompt: input.prompt, prompt,
delivery, delivery,
}).pipe( }).pipe(
Effect.catchDefect((defect) => Effect.catchDefect((defect) =>
@ -452,3 +455,17 @@ export const defaultLayer = layer.pipe(
Layer.provide(ProjectV2.defaultLayer), Layer.provide(ProjectV2.defaultLayer),
Layer.orDie, Layer.orDie,
) )
const resolvePrompt = (input: PromptInput.Prompt) =>
Prompt.make({
text: input.text,
agents: input.agents,
files: input.files?.map((file) => {
const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1]
const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri)
return {
...file,
mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)),
}
}),
})

View file

@ -173,6 +173,27 @@ describe("SessionV2.prompt", () => {
}), }),
) )
it.effect("resolves attachment MIME before admission", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const message = yield* session.prompt({
sessionID,
prompt: {
text: "Inspect this image",
files: [{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png" }],
},
resume: false,
})
expect(message.prompt.files).toEqual([
{ uri: "data:image/png;base64,aGVsbG8=", name: "image.png", mime: "image/png" },
])
expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files)
}),
)
it.effect("streams durable Session events after an aggregate sequence", () => it.effect("streams durable Session events after an aggregate sequence", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* setup yield* setup

View file

@ -1,6 +1,6 @@
import { SessionMessage } from "@opencode-ai/schema/session-message" import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionInput } from "@opencode-ai/schema/session-input" import { SessionInput } from "@opencode-ai/schema/session-input"
import { Prompt } from "@opencode-ai/schema/prompt" import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Session } from "@opencode-ai/schema/session" import { Session } from "@opencode-ai/schema/session"
import { Project } from "@opencode-ai/schema/project" import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema" import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
@ -192,7 +192,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
params: { sessionID: Session.ID }, params: { sessionID: Session.ID },
payload: Schema.Struct({ payload: Schema.Struct({
id: SessionMessage.ID.pipe(Schema.optional), id: SessionMessage.ID.pipe(Schema.optional),
prompt: Prompt, prompt: PromptInput.Prompt,
delivery: SessionInput.Delivery.pipe(Schema.optional), delivery: SessionInput.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional), resume: Schema.Boolean.pipe(Schema.optional),
}), }),

View file

@ -24,4 +24,5 @@ export { PtyTicket } from "./pty-ticket"
export { Question } from "./question" export { Question } from "./question"
export { Workspace } from "./workspace" export { Workspace } from "./workspace"
export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt" export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt"
export { PromptInput } from "./prompt-input"
export * from "./schema" export * from "./schema"

View file

@ -0,0 +1,26 @@
export * as PromptInput from "./prompt-input"
import { Schema } from "effect"
import { AgentAttachment, Source } from "./prompt"
import { optional, statics } from "./schema"
export interface FileAttachment extends Schema.Schema.Type<typeof FileAttachment> {}
export const FileAttachment = Schema.Struct({
uri: Schema.String,
name: Schema.String.pipe(optional),
description: Schema.String.pipe(optional),
source: Source.pipe(optional),
})
.annotate({ identifier: "PromptInput.FileAttachment" })
.pipe(
statics((schema) => ({
create: (input: FileAttachment) => schema.make(input),
})),
)
export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
export const Prompt = Schema.Struct({
text: Schema.String,
files: Schema.Array(FileAttachment).pipe(optional),
agents: Schema.Array(AgentAttachment).pipe(optional),
}).annotate({ identifier: "PromptInput" })

View file

@ -142,7 +142,7 @@ import type {
ProjectListResponses, ProjectListResponses,
ProjectUpdateErrors, ProjectUpdateErrors,
ProjectUpdateResponses, ProjectUpdateResponses,
Prompt, PromptInput,
ProviderAuthErrors, ProviderAuthErrors,
ProviderAuthResponses, ProviderAuthResponses,
ProviderListErrors, ProviderListErrors,
@ -5621,7 +5621,7 @@ export class Session3 extends HeyApiClient {
parameters: { parameters: {
sessionID: string sessionID: string
id?: string id?: string
prompt?: Prompt prompt?: PromptInput
delivery?: "steer" | "queue" delivery?: "steer" | "queue"
resume?: boolean resume?: boolean
}, },

View file

@ -2700,6 +2700,12 @@ export type SessionNotFoundError = {
message: string message: string
} }
export type PromptInput = {
text: string
files?: Array<PromptInputFileAttachment>
agents?: Array<PromptAgentAttachment>
}
export type ConflictError = { export type ConflictError = {
_tag: "ConflictError" _tag: "ConflictError"
message: string message: string
@ -3814,6 +3820,13 @@ export type SessionV2Info = {
revert?: RevertState revert?: RevertState
} }
export type PromptInputFileAttachment = {
uri: string
name?: string
description?: string
source?: PromptSource
}
export type SessionInputAdmitted = { export type SessionInputAdmitted = {
admittedSeq: number admittedSeq: number
id: string id: string
@ -12091,7 +12104,7 @@ export type V2SessionSwitchModelResponse = V2SessionSwitchModelResponses[keyof V
export type V2SessionPromptData = { export type V2SessionPromptData = {
body: { body: {
id?: string id?: string
prompt: Prompt prompt: PromptInput
delivery?: "steer" | "queue" delivery?: "steer" | "queue"
resume?: boolean resume?: boolean
} }