fix(core): resolve prompt attachment mime types

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

View file

@ -305,7 +305,6 @@ export type SessionsPromptInput = {
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string
readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
@ -324,7 +323,6 @@ export type SessionsPromptInput = {
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string
readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
@ -343,7 +341,6 @@ export type SessionsPromptInput = {
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string
readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
@ -362,7 +359,6 @@ export type SessionsPromptInput = {
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string
readonly description?: 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 { SessionMessage } from "./session/message"
import { Prompt } from "./session/prompt"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { EventV2 } from "./event"
import { Database } from "./database/database"
import { SessionProjector } from "./session/projector"
@ -32,6 +33,7 @@ import { SessionInput } from "./session/input"
import { Snapshot } from "./snapshot"
import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert"
import { FSUtil } from "./fs-util"
export const RevertState = Revert.State
export type RevertState = Revert.State
@ -137,7 +139,7 @@ export interface Interface {
readonly prompt: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
prompt: Prompt
prompt: PromptInput.Prompt
delivery?: SessionInput.Delivery
resume?: boolean
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
@ -349,13 +351,14 @@ export const layer = Layer.unwrap(
Effect.uninterruptible(
Effect.gen(function* () {
yield* result.get(input.sessionID)
const prompt = resolvePrompt(input.prompt)
const messageID = input.id ?? SessionMessage.ID.create()
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, {
id: messageID,
sessionID: input.sessionID,
prompt: input.prompt,
prompt,
delivery,
}).pipe(
Effect.catchDefect((defect) =>
@ -452,3 +455,17 @@ export const defaultLayer = layer.pipe(
Layer.provide(ProjectV2.defaultLayer),
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", () =>
Effect.gen(function* () {
yield* setup

View file

@ -1,6 +1,6 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
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 { Project } from "@opencode-ai/schema/project"
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 },
payload: Schema.Struct({
id: SessionMessage.ID.pipe(Schema.optional),
prompt: Prompt,
prompt: PromptInput.Prompt,
delivery: SessionInput.Delivery.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 { Workspace } from "./workspace"
export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt"
export { PromptInput } from "./prompt-input"
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,
ProjectUpdateErrors,
ProjectUpdateResponses,
Prompt,
PromptInput,
ProviderAuthErrors,
ProviderAuthResponses,
ProviderListErrors,
@ -5621,7 +5621,7 @@ export class Session3 extends HeyApiClient {
parameters: {
sessionID: string
id?: string
prompt?: Prompt
prompt?: PromptInput
delivery?: "steer" | "queue"
resume?: boolean
},

View file

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