Compare commits

..

3 Commits

Author SHA1 Message Date
Aiden Cline 2c3b65a04e fix(session): respect model media capabilities 2026-08-08 04:47:13 +00:00
opencode-agent[bot] fe82a1b6ca chore: generate 2026-08-08 03:39:08 +00:00
Brendan Allan 2ea728f731 fix(app): populate project picker from home (#41158) 2026-08-08 11:37:30 +08:00
5 changed files with 138 additions and 2 deletions
@@ -139,6 +139,7 @@ test("resolves directory autocomplete from the current browser root", async () =
directories.push(input.location?.directory ?? "")
return Promise.resolve({ data: [] })
},
list: () => Promise.resolve({ data: [] }),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
@@ -152,6 +153,67 @@ test("resolves directory autocomplete from the current browser root", async () =
expect(directories).toEqual(["/repo", "/repo/src"])
})
test("keeps indexed directory results for servers that support empty search", async () => {
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
list: () => Promise.reject(new Error("listing should not run when search returns results")),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
expect(await search("")).toEqual(["/home/luke/projects"])
})
test("lists the default directory when empty search is unsupported", async () => {
const calls: string[] = []
const directories = Array.from({ length: 60 }, (_, index) => ({
path: `project-${index}/`,
type: "directory" as const,
}))
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [] }),
list: (input: { location?: { directory?: string } }) => {
calls.push(input.location?.directory ?? "")
return Promise.resolve({
data: [...directories, { path: "README.md", type: "file" }],
})
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
const results = await search("")
expect(results).toHaveLength(60)
expect(results.at(-1)).toBe("/home/luke/project-59")
expect(calls).toEqual(["/home/luke"])
})
test("matches the default directory listing when typed search is unsupported", async () => {
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [] }),
list: () =>
Promise.resolve({
data: [
{ path: "Documents/", type: "directory" },
{ path: "Downloads/", type: "directory" },
],
}),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
expect(await search("documents")).toEqual(["/home/luke/Documents"])
})
test("searches from an absolute root without a default base", async () => {
const directories: string[] = []
const sdk = {
@@ -379,7 +379,14 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
.then((result) => result.data.map((entry) => entry.path))
.catch(() => [])
if (!active()) return []
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
if (results.length) {
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
}
const fallback = query
? await match(input.directory, query, 50)
: (await directories(input.directory)).map((item) => item.absolute)
if (!active()) return []
return fallback
}
const segments = query.replace(/^\/+/, "").split("/")
const head = segments.slice(0, -1).filter((part) => part && part !== ".")
@@ -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
@@ -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 () => {