Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline 2c3b65a04e fix(session): respect model media capabilities 2026-08-08 04:47:13 +00:00
6 changed files with 96 additions and 19 deletions
+1 -6
View File
@@ -224,12 +224,7 @@ const layer = Layer.effect(
),
)
const parsed = ConfigParse.jsonc(expanded, source)
const normalized = normalizeLoadedConfig(parsed)
const unrecognized = ConfigParse.unrecognizedKeys(ConfigV1.Info, normalized)
if (unrecognized.length) {
yield* Effect.logWarning("ignoring unrecognized config fields", { source, fields: unrecognized })
}
const data = ConfigParse.schema(ConfigV1.Info, normalized, source)
const data = ConfigParse.schema(ConfigV1.Info, normalizeLoadedConfig(parsed), source)
if (!("path" in options)) return data
yield* Effect.promise(() => resolveLoadedPlugins(data, options.path))
+17 -6
View File
@@ -37,11 +37,22 @@ export function schema<S extends EffectSchema.Decoder<unknown, never>>(
data: unknown,
source: string,
): DeepMutable<S["Type"]> {
const decoded = EffectSchema.decodeUnknownExit(schema)(data, {
errors: "all",
onExcessProperty: "ignore",
propertyOrder: "original",
})
const extra = topLevelExtraKeys(schema, data)
if (extra.length) {
throw new InvalidError({
path: source,
issues: [
{
code: "unrecognized_keys",
keys: extra,
path: [],
message: `Unrecognized key${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}`,
},
],
})
}
const decoded = EffectSchema.decodeUnknownExit(schema)(data, { errors: "all", propertyOrder: "original" })
if (Exit.isSuccess(decoded)) return decoded.value as DeepMutable<S["Type"]>
const error = Cause.squash(decoded.cause)
@@ -60,7 +71,7 @@ export function schema<S extends EffectSchema.Decoder<unknown, never>>(
)
}
export function unrecognizedKeys(schema: EffectSchema.Top, data: unknown) {
function topLevelExtraKeys(schema: EffectSchema.Top, data: unknown) {
if (typeof data !== "object" || data === null || Array.isArray(data)) return []
if (schema.ast._tag !== "Objects" || schema.ast.indexSignatures.length > 0) return []
const known = new Set(schema.ast.propertySignatures.map((item) => String(item.name)))
@@ -431,6 +431,7 @@ function unsupportedParts(msgs: ModelMessage[], model: Provider.Model): ModelMes
const modality = mimeToModality(mime)
if (!modality) return part
if (model.capabilities.input[modality]) return part
if ((modality === "image" || modality === "pdf") && model.capabilities.attachment) return part
const name = filename ? `"${filename}"` : modality
return {
@@ -145,6 +145,10 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
// Only apply this workaround if the model actually supports that media input -
// otherwise unsupportedParts() will turn it into a user-visible error.
const supportsMediaInToolResult = (attachment: { mime: string }) => {
if (attachment.mime.startsWith("image/") && !model.capabilities.attachment && !model.capabilities.input.image)
return false
if (attachment.mime === "application/pdf" && !model.capabilities.attachment && !model.capabilities.input.pdf)
return false
if (model.api.npm === "@ai-sdk/anthropic") return true
if (model.api.npm === "@ai-sdk/openai") return true
if (model.api.npm === "@ai-sdk/amazon-bedrock/mantle") return true
+10 -6
View File
@@ -597,12 +597,12 @@ accountTokenIt.instance("resolves env templates in account config with account t
}),
)
it.instance("validates config schema and throws on invalid values", () =>
it.instance("validates config schema and throws on invalid fields", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json",
model: 42,
invalid_field: "should cause error",
})
const exit = yield* Config.use.get().pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
@@ -1331,7 +1331,7 @@ it.instance("permission config preserves user key order", () =>
}),
)
test("config parser preserves permission order while ignoring unknown top-level keys", () => {
test("config parser preserves permission order while rejecting unknown top-level keys", () => {
const config = ConfigParse.schema(
ConfigV1.Info,
{
@@ -1340,14 +1340,18 @@ test("config parser preserves permission order while ignoring unknown top-level
"*": "deny",
edit: "ask",
},
plugins: ["example"],
},
"test",
)
expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"])
expect(config).not.toHaveProperty("plugins")
expect(ConfigParse.unrecognizedKeys(ConfigV1.Info, { plugins: ["example"] })).toEqual(["plugins"])
try {
ConfigParse.schema(ConfigV1.Info, { invalid_field: true }, "test")
throw new Error("expected config parse to fail")
} catch (err) {
const error = err as { data?: { issues?: Array<{ code?: string; keys?: string[]; path?: string[] }> } }
expect(error.data?.issues?.[0]).toMatchObject({ code: "unrecognized_keys", keys: ["invalid_field"], path: [] })
}
})
// MCP config merging tests
@@ -322,6 +322,16 @@ describe("session.message-v2.toModelMessage", () => {
test("converts assistant tool completion into tool-call + tool-result messages with attachments", async () => {
const userID = "m-user"
const assistantID = "m-assistant"
const imageModel: Provider.Model = {
...model,
capabilities: {
...model.capabilities,
input: {
...model.capabilities.input,
image: true,
},
},
}
const input: SessionV1.WithParts[] = [
{
@@ -371,7 +381,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
expect(await MessageV2.toModelMessages(input, imageModel)).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "run tool" }],
@@ -409,6 +419,58 @@ describe("session.message-v2.toModelMessage", () => {
],
},
])
const unsupported = await MessageV2.toModelMessages(input, model)
expect(unsupported).toMatchObject([
{ role: "user" },
{ role: "assistant" },
{
role: "tool",
content: [{ output: { type: "text", value: "ok" } }],
},
{
role: "user",
content: [
{ type: "text", text: MessageV2.SYNTHETIC_ATTACHMENT_PROMPT },
{ type: "file", mediaType: "image/png" },
],
},
])
expect(ProviderTransform.message(unsupported, model, {})).toMatchObject([
{ role: "user" },
{ role: "assistant" },
{ role: "tool" },
{
role: "user",
content: [
{ type: "text", text: MessageV2.SYNTHETIC_ATTACHMENT_PROMPT },
{
type: "text",
text: 'ERROR: Cannot read "attachment.png" (this model does not support image input). Inform the user.',
},
],
},
])
const legacyModel: Provider.Model = {
...model,
api: { ...model.api, npm: "@ai-sdk/openai-compatible" },
capabilities: { ...model.capabilities, attachment: true },
}
expect(
ProviderTransform.message(await MessageV2.toModelMessages(input, legacyModel), legacyModel, {}),
).toMatchObject([
{ role: "user" },
{ role: "assistant" },
{ role: "tool" },
{
role: "user",
content: [
{ type: "text", text: MessageV2.SYNTHETIC_ATTACHMENT_PROMPT },
{ type: "file", mediaType: "image/png" },
],
},
])
})
test("preserves jpeg tool-result media for anthropic models", async () => {