Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/opencode/src/server/routes/instance/httpapi/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,23 @@ export class ApiNotFoundError extends Schema.ErrorClass<ApiNotFoundError>("NotFo
{ httpApiStatus: 404 },
) {}

export class ApiBadRequestError extends Schema.ErrorClass<ApiBadRequestError>("BadRequestError")(
{
name: Schema.Literal("BadRequest"),
data: Schema.Struct({
message: Schema.String,
}),
},
{ httpApiStatus: 400 },
) {}

export function badRequest(message: string) {
return new ApiBadRequestError({
name: "BadRequest",
data: { message },
})
}

export function notFound(message: string) {
return new ApiNotFoundError({
name: "NotFoundError",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { ApiNotFoundError, PermissionNotFoundError, SessionBusyError } from "../errors"
import { ApiBadRequestError, ApiNotFoundError, PermissionNotFoundError, SessionBusyError } from "../errors"
import { described } from "./metadata"
import { QueryBoolean } from "./query"
import { ProviderV2 } from "@opencode-ai/core/provider"
Expand Down Expand Up @@ -318,7 +318,7 @@ export const SessionApi = HttpApi.make("session")
query: WorkspaceRoutingQuery,
payload: PromptPayload,
success: described(SessionV1.WithParts, "Created message"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
error: [HttpApiError.BadRequest, ApiBadRequestError, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.prompt",
Expand All @@ -345,7 +345,7 @@ export const SessionApi = HttpApi.make("session")
query: WorkspaceRoutingQuery,
payload: CommandPayload,
success: described(SessionV1.WithParts, "Created message"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
error: [HttpApiError.BadRequest, ApiBadRequestError, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.command",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,13 @@ import {
SummarizePayload,
UpdatePayload,
} from "../groups/session"
import { PermissionNotFoundError } from "../errors"
import { badRequest, PermissionNotFoundError } from "../errors"
import * as SessionError from "./session-errors"

// Callers need the valid values to fix a bad variant; other prompt failures keep their existing empty body.
const toPromptError = (error: unknown) =>
error instanceof SessionPrompt.VariantNotFoundError ? badRequest(error.message) : new HttpApiError.BadRequest({})

const tryParseJson = (text: string) =>
Effect.try({
try: () => JSON.parse(text) as unknown,
Expand Down Expand Up @@ -302,7 +306,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
...ctx.payload,
sessionID: ctx.params.sessionID,
})
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
.pipe(Effect.mapError(toPromptError))
return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), {
contentType: "application/json",
})
Expand Down Expand Up @@ -335,7 +339,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
yield* requireSession(ctx.params.sessionID)
return yield* promptSvc
.command({ ...ctx.payload, sessionID: ctx.params.sessionID })
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
.pipe(Effect.mapError(toPromptError))
})

const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: {
Expand Down
116 changes: 95 additions & 21 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,31 @@ function isOrphanedInterruptedTool(part: SessionV1.ToolPart) {
return part.state.status === "error" && part.state.metadata?.interrupted === true
}

export class VariantNotFoundError extends Schema.TaggedErrorClass<VariantNotFoundError>()(
"SessionVariantNotFoundError",
{
providerID: Schema.String,
modelID: Schema.String,
variant: Schema.String,
available: Schema.Array(Schema.String),
},
) {
override get message() {
const hint = this.available.length
? ` Available variants: ${this.available.join(", ")}`
: " This model has no variants."
return `Variant not found: "${this.variant}" for ${this.providerID}/${this.modelID}.${hint}`
}
}

export type PromptError = Image.Error | VariantNotFoundError

export interface Interface {
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
readonly prompt: (input: PromptInput) => Effect.Effect<SessionV1.WithParts, Image.Error>
readonly prompt: (input: PromptInput) => Effect.Effect<SessionV1.WithParts, PromptError>
readonly loop: (input: LoopInput) => Effect.Effect<SessionV1.WithParts>
readonly shell: (input: ShellInput) => Effect.Effect<SessionV1.WithParts, Session.BusyError>
readonly command: (input: CommandInput) => Effect.Effect<SessionV1.WithParts, Image.Error>
readonly command: (input: CommandInput) => Effect.Effect<SessionV1.WithParts, PromptError>
readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]>
}

Expand Down Expand Up @@ -613,7 +632,7 @@ const layer = Layer.effect(

const currentModel = Effect.fnUntraced(function* (sessionID: SessionID) {
const current = yield* db
.select({ model: SessionTable.model })
.select({ model: SessionTable.model, revert: SessionTable.revert })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
Expand All @@ -625,14 +644,27 @@ const layer = Layer.effect(
...(current.model.variant && current.model.variant !== "default" ? { variant: current.model.variant } : {}),
}
}
const match = yield* sessions
.findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model)
.pipe(Effect.orDie)
// Undone messages are dropped by the next prompt, so they must not decide the model it runs on.
const pending = current?.revert
const match = pending
? yield* sessions.messages({ sessionID }).pipe(
Effect.orDie,
Effect.map((msgs) =>
Option.fromNullishOr(
msgs
.slice(0, SessionRevert.cutoff(msgs, pending))
.findLast((m) => m.info.role === "user" && !!m.info.model),
),
),
)
: yield* sessions.findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model).pipe(Effect.orDie)
if (Option.isSome(match) && match.value.info.role === "user") return match.value.info.model
return yield* provider.defaultModel().pipe(Effect.orDie)
})

const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {
// Resolves the agent, model and variant a prompt is recorded with. It does not write to the session, so a
// prompt can be checked before anything destructive happens.
const resolveSelection = Effect.fn("SessionPrompt.resolveSelection")(function* (input: PromptInput) {
const agentName = input.agent
const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()
if (!ag) {
Expand All @@ -645,13 +677,38 @@ const layer = Layer.effect(

const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID))
const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID
const explicit = input.variant !== undefined && input.variant !== "default" ? input.variant : undefined
const full =
!input.variant && ag.variant && same
explicit !== undefined || (!input.variant && ag.variant && same)
? yield* provider
.getModel(model.providerID, model.modelID)
.pipe(Effect.catchIf(Provider.ModelNotFoundError.isInstance, () => Effect.succeed(undefined)))
: undefined
const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)
// An unknown variant would be dropped when the request is built while still being recorded on the
// session, so reject it before anything is persisted.
if (explicit !== undefined && full && !Object.hasOwn(full.variants ?? {}, explicit)) {
const error = new VariantNotFoundError({
providerID: model.providerID,
modelID: model.modelID,
variant: explicit,
available: Object.keys(full.variants ?? {}),
})
yield* events.publish(Session.Event.Error, {
sessionID: input.sessionID,
error: new NamedError.Unknown({ message: error.message }).toObject(),
})
return yield* error
}
const variant =
input.variant ?? (ag.variant && Object.hasOwn(full?.variants ?? {}, ag.variant) ? ag.variant : undefined)
return { ag, model, variant }
})

const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (
input: PromptInput,
selection: Effect.Success<ReturnType<typeof resolveSelection>>,
) {
const { ag, model, variant } = selection

const info: SessionV1.User = {
id: input.messageID ?? MessageID.ascending(),
Expand Down Expand Up @@ -1049,12 +1106,15 @@ const layer = Layer.effect(
return { info, parts }
}, Effect.scoped)

const prompt: (input: PromptInput) => Effect.Effect<SessionV1.WithParts, Image.Error> = Effect.fn(
const prompt: (input: PromptInput) => Effect.Effect<SessionV1.WithParts, PromptError> = Effect.fn(
"SessionPrompt.prompt",
)(function* (input: PromptInput) {
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
// Cleanup permanently drops the undone messages, so a prompt that is rejected must fail before it. The
// selection is resolved once and recorded as is, so what was checked is what the message is created with.
const selection = yield* resolveSelection(input)
yield* revert.cleanup(session)
const message = yield* createUserMessage(input)
const message = yield* createUserMessage(input, selection)
yield* sessions.touch(input.sessionID)

const permissions: PermissionV1.Rule[] = []
Expand Down Expand Up @@ -1408,17 +1468,21 @@ const layer = Layer.effect(
}
template = template.trim()

const taskModel = yield* Effect.gen(function* () {
const callerModel = Effect.fnUntraced(function* () {
if (input.model) return Provider.parseModel(input.model)
return yield* currentModel(input.sessionID)
})
const pinnedModel = yield* Effect.gen(function* () {
if (cmd.model) return Provider.parseModel(cmd.model)
if (cmd.agent) {
const cmdAgent = yield* agents.get(cmd.agent)
if (cmdAgent?.model) return cmdAgent.model
}
if (input.model) return Provider.parseModel(input.model)
return yield* currentModel(input.sessionID)
return undefined
})
const taskModel = pinnedModel ?? (yield* callerModel())

yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID)
const resolvedTaskModel = yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID)

const agent = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()
if (!agent) {
Expand Down Expand Up @@ -1451,11 +1515,21 @@ const layer = Layer.effect(
: [...uniqueTemplateParts, ...(input.parts ?? [])]

const userAgent = isSubtask ? (input.agent ?? (yield* agents.defaultInfo()).name) : agent.name
const userModel = isSubtask
? input.model
? Provider.parseModel(input.model)
: yield* currentModel(input.sessionID)
: taskModel
const userModel = isSubtask ? yield* callerModel() : taskModel
// The caller picked its variant for its own model. When the command pins a different model that does
// not offer that variant, run it on that model's default instead of failing the command. A value the
// caller's model does not declare either is left in place so it is rejected like any other prompt.
const inherited = yield* Effect.gen(function* () {
if (isSubtask || !pinnedModel || input.variant === undefined || input.variant === "default") return false
if (Object.hasOwn(resolvedTaskModel.variants ?? {}, input.variant)) return false
const caller = yield* callerModel()
if (caller.providerID === pinnedModel.providerID && caller.modelID === pinnedModel.modelID) return false
const callerInfo = yield* provider
.getModel(caller.providerID, caller.modelID)
.pipe(Effect.catchIf(Provider.ModelNotFoundError.isInstance, () => Effect.succeed(undefined)))
return Object.hasOwn(callerInfo?.variants ?? {}, input.variant)
})
const variant = inherited ? undefined : input.variant

yield* plugin.trigger(
"command.execute.before",
Expand All @@ -1469,7 +1543,7 @@ const layer = Layer.effect(
model: userModel,
agent: userAgent,
parts,
variant: input.variant,
variant,
})
yield* events.publish(Command.Event.Executed, {
name: input.command,
Expand Down
11 changes: 8 additions & 3 deletions packages/opencode/src/session/revert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export const RevertInput = Schema.Struct({
})
export type RevertInput = Schema.Schema.Type<typeof RevertInput>

// Index of the first message that cleanup drops for a pending revert; the messages before it are kept.
export function cutoff(msgs: SessionV1.WithParts[], revert: { messageID: string; partID?: string }) {
const index = msgs.findIndex((msg) => msg.info.id === revert.messageID)
return index < 0 ? msgs.length : index + (revert.partID ? 1 : 0)
}

export interface Interface {
readonly revert: (input: RevertInput) => Effect.Effect<Session.Info, Session.BusyError>
readonly unrevert: (input: { sessionID: SessionID }) => Effect.Effect<Session.Info, Session.BusyError>
Expand Down Expand Up @@ -103,9 +109,8 @@ const layer = Layer.effect(
const sessionID = session.id
const msgs = yield* sessions.messages({ sessionID }).pipe(Effect.orDie)
const messageID = session.revert.messageID
const index = msgs.findIndex((msg) => msg.info.id === messageID)
const target = index < 0 ? undefined : msgs[index]
const remove = index < 0 ? [] : msgs.slice(index + (session.revert.partID ? 1 : 0))
const target = msgs.find((msg) => msg.info.id === messageID)
const remove = msgs.slice(cutoff(msgs, session.revert))
for (const msg of remove) {
yield* sessions.removeMessage({ sessionID, messageID: msg.info.id })
}
Expand Down
9 changes: 8 additions & 1 deletion packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,14 @@ export const TaskTool = Tool.define(
.prompt({
sessionID: ctx.sessionID,
agent: currentParent.agent ?? ctx.agent,
variant,
// Follow the parent's current selection; the user may have switched model or variant meanwhile.
// The variant only applies to the model it was picked for, so pass both.
...(currentParent.model
? {
model: { providerID: currentParent.model.providerID, modelID: currentParent.model.id },
variant: currentParent.model.variant,
}
: { variant }),
parts: [
{
type: "text",
Expand Down
Loading
Loading