fix(app): preserve inline file mentions (#38663)
This commit is contained in:
parent
ae4be983cb
commit
b62806683e
8 changed files with 98 additions and 22 deletions
|
|
@ -489,6 +489,9 @@ describe("prompt submit worktree selection", () => {
|
||||||
agents: [],
|
agents: [],
|
||||||
})
|
})
|
||||||
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
|
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
|
||||||
|
expect((promptInputs[0] as { legacyParts?: { id: string; type: string; text?: string }[] }).legacyParts).toEqual([
|
||||||
|
{ id: expect.stringMatching(/^prt_/), type: "text", text: "ls" },
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("submits slash commands through the current session API", async () => {
|
test("submits slash commands through the current session API", async () => {
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||||
agent: input.draft.agent,
|
agent: input.draft.agent,
|
||||||
model: input.draft.model,
|
model: input.draft.model,
|
||||||
variant: input.draft.variant,
|
variant: input.draft.variant,
|
||||||
|
legacyParts: requestParts,
|
||||||
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||||
files: requestParts.flatMap((part) => {
|
files: requestParts.flatMap((part) => {
|
||||||
if (part.type !== "file") return []
|
if (part.type !== "file") return []
|
||||||
|
|
|
||||||
|
|
@ -69,18 +69,62 @@ describe("createCompatibleApi", () => {
|
||||||
await api.session.prompt({
|
await api.session.prompt({
|
||||||
sessionID: "ses_1",
|
sessionID: "ses_1",
|
||||||
id: "msg_1",
|
id: "msg_1",
|
||||||
text: "hello",
|
text: "hello @src/index.ts",
|
||||||
agent: "build",
|
agent: "build",
|
||||||
model: { providerID: "provider", modelID: "model" },
|
model: { providerID: "provider", modelID: "model" },
|
||||||
|
files: [
|
||||||
|
{ uri: "file:///repo/src/index.ts", name: "index.ts", mention: { text: "@src/index.ts", start: 6, end: 19 } },
|
||||||
|
{ uri: "data:text/plain;base64,aGVsbG8=", name: "notes.txt" },
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async")
|
expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async")
|
||||||
expect(await requests[0]!.json()).toMatchObject({
|
const body = await requests[0]!.json()
|
||||||
|
expect(body).toMatchObject({
|
||||||
messageID: "msg_1",
|
messageID: "msg_1",
|
||||||
agent: "build",
|
agent: "build",
|
||||||
model: { providerID: "provider", modelID: "model" },
|
model: { providerID: "provider", modelID: "model" },
|
||||||
parts: [{ type: "text", text: "hello" }],
|
parts: [
|
||||||
|
{ type: "text", text: "hello @src/index.ts" },
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
mime: "text/plain",
|
||||||
|
url: "file:///repo/src/index.ts",
|
||||||
|
filename: "index.ts",
|
||||||
|
source: {
|
||||||
|
type: "file",
|
||||||
|
text: { value: "@src/index.ts", start: 6, end: 19 },
|
||||||
|
path: "file:///repo/src/index.ts",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
mime: "text/plain",
|
||||||
|
url: "data:text/plain;base64,aGVsbG8=",
|
||||||
|
filename: "notes.txt",
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
expect(body.parts[2]).not.toHaveProperty("source")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves original parts for V1 optimistic reconciliation", async () => {
|
||||||
|
const { api, requests } = setup("v1")
|
||||||
|
await api.session.prompt({
|
||||||
|
sessionID: "ses_1",
|
||||||
|
id: "msg_1",
|
||||||
|
text: "look",
|
||||||
|
files: [{ uri: "data:image/png;base64,AAAA", name: "image.png" }],
|
||||||
|
legacyParts: [
|
||||||
|
{ id: "prt_text", type: "text", text: "look" },
|
||||||
|
{ id: "prt_image", type: "file", mime: "image/png", url: "data:image/png;base64,AAAA", filename: "image.png" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect((await requests[0]!.json()).parts).toEqual([
|
||||||
|
{ id: "prt_text", type: "text", text: "look" },
|
||||||
|
{ id: "prt_image", type: "file", mime: "image/png", url: "data:image/png;base64,AAAA", filename: "image.png" },
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps V2 session actions on the current API", async () => {
|
test("keeps V2 session actions on the current API", async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { ServerApi } from "./server"
|
import type { ServerApi } from "./server"
|
||||||
import type { ServerProtocol } from "./server-protocol"
|
import type { ServerProtocol } from "./server-protocol"
|
||||||
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
|
import type { AgentPartInput, FilePartInput, OpencodeClient, Session, TextPartInput } from "@opencode-ai/sdk/v2/client"
|
||||||
import type {
|
import type {
|
||||||
Project,
|
Project,
|
||||||
ProjectCurrent,
|
ProjectCurrent,
|
||||||
|
|
@ -43,6 +43,7 @@ type LegacyPrompt = {
|
||||||
agent?: string
|
agent?: string
|
||||||
model?: { providerID: string; modelID: string }
|
model?: { providerID: string; modelID: string }
|
||||||
variant?: string
|
variant?: string
|
||||||
|
legacyParts?: (TextPartInput | FilePartInput | AgentPartInput)[]
|
||||||
}
|
}
|
||||||
type LegacyLocation = { directory?: string }
|
type LegacyLocation = { directory?: string }
|
||||||
type CompatibleInput = {
|
type CompatibleInput = {
|
||||||
|
|
@ -203,13 +204,20 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||||
agent: value.agent,
|
agent: value.agent,
|
||||||
model: value.model,
|
model: value.model,
|
||||||
variant: value.variant,
|
variant: value.variant,
|
||||||
parts: [
|
parts: value.legacyParts ?? [
|
||||||
{ type: "text", text: value.text },
|
{ type: "text", text: value.text },
|
||||||
...(value.files ?? []).map((file) => ({
|
...(value.files ?? []).map((file) => ({
|
||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
mime: mime(file.uri),
|
mime: file.mention ? "text/plain" : mime(file.uri),
|
||||||
url: file.uri,
|
url: file.uri,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
|
source: file.mention
|
||||||
|
? {
|
||||||
|
type: "file" as const,
|
||||||
|
text: { value: file.mention.text, start: file.mention.start, end: file.mention.end },
|
||||||
|
path: file.uri,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
})),
|
})),
|
||||||
...(value.agents ?? []).map((agent) => ({
|
...(value.agents ?? []).map((agent) => ({
|
||||||
type: "agent" as const,
|
type: "agent" as const,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ describe("normalizeSessionMessages", () => {
|
||||||
{
|
{
|
||||||
id: "msg_3",
|
id: "msg_3",
|
||||||
type: "user",
|
type: "user",
|
||||||
text: "inspect this",
|
text: "inspect @src/client.ts",
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
data: "aGVsbG8=",
|
data: "aGVsbG8=",
|
||||||
|
|
@ -23,6 +23,13 @@ describe("normalizeSessionMessages", () => {
|
||||||
name: "note.txt",
|
name: "note.txt",
|
||||||
source: { type: "inline" },
|
source: { type: "inline" },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
data: "ZXhwb3J0IHt9",
|
||||||
|
mime: "text/plain",
|
||||||
|
name: "client.ts",
|
||||||
|
source: { type: "inline" },
|
||||||
|
mention: { text: "@src/client.ts", start: 8, end: 22 },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }],
|
agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }],
|
||||||
time: { created: 3 },
|
time: { created: 3 },
|
||||||
|
|
@ -76,9 +83,18 @@ describe("normalizeSessionMessages", () => {
|
||||||
expect(result.parts.get("msg_3")?.map((part) => part.id)).toEqual([
|
expect(result.parts.get("msg_3")?.map((part) => part.id)).toEqual([
|
||||||
"msg_3:text:0",
|
"msg_3:text:0",
|
||||||
"msg_3:file:0",
|
"msg_3:file:0",
|
||||||
|
"msg_3:file:1",
|
||||||
"msg_3:agent:0",
|
"msg_3:agent:0",
|
||||||
"msg_5:compaction",
|
"msg_5:compaction",
|
||||||
])
|
])
|
||||||
|
expect(result.parts.get("msg_3")?.[2]).toMatchObject({
|
||||||
|
type: "file",
|
||||||
|
source: {
|
||||||
|
type: "file",
|
||||||
|
path: "src/client.ts",
|
||||||
|
text: { value: "@src/client.ts", start: 8, end: 22 },
|
||||||
|
},
|
||||||
|
})
|
||||||
expect(result.parts.get("msg_4")?.map((part) => part.id)).toEqual(["msg_4:reasoning:0", "msg_4:text:0", "call_1"])
|
expect(result.parts.get("msg_4")?.map((part) => part.id)).toEqual(["msg_4:reasoning:0", "msg_4:text:0", "call_1"])
|
||||||
expect(result.parts.get("msg_4")?.[2]).toMatchObject({
|
expect(result.parts.get("msg_4")?.[2]).toMatchObject({
|
||||||
type: "tool",
|
type: "tool",
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,13 @@ function userParts(sessionID: string, message: SessionMessageUser): Part[] {
|
||||||
mime: file.mime,
|
mime: file.mime,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||||
|
source: file.mention
|
||||||
|
? {
|
||||||
|
type: "file",
|
||||||
|
text: { value: file.mention.text, start: file.mention.start, end: file.mention.end },
|
||||||
|
path: file.mention.text.startsWith("@") ? file.mention.text.slice(1) : (file.name ?? file.mention.text),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
...(message.agents ?? []).map(
|
...(message.agents ?? []).map(
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ describe("message-file", () => {
|
||||||
expect(attached(file())).toBe(false)
|
expect(attached(file())).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("treats only non-attachment source ranges as inline references", () => {
|
test("keeps data-backed file mentions inline", () => {
|
||||||
expect(
|
expect(
|
||||||
inline(
|
inline(
|
||||||
file({
|
file({
|
||||||
|
|
@ -34,18 +34,16 @@ describe("message-file", () => {
|
||||||
),
|
),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
|
|
||||||
expect(
|
const mentioned = file({
|
||||||
inline(
|
url: "data:text/plain;base64,SGVsbG8=",
|
||||||
file({
|
source: {
|
||||||
url: "data:text/plain;base64,SGVsbG8=",
|
type: "file",
|
||||||
source: {
|
path: "/repo/README.txt",
|
||||||
type: "file",
|
text: { value: "@README.txt", start: 0, end: 11 },
|
||||||
path: "/repo/README.txt",
|
},
|
||||||
text: { value: "@README.txt", start: 0, end: 11 },
|
})
|
||||||
},
|
expect(inline(mentioned)).toBe(true)
|
||||||
}),
|
expect(attached(mentioned)).toBe(false)
|
||||||
),
|
|
||||||
).toBe(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("separates image and file attachment kinds", () => {
|
test("separates image and file attachment kinds", () => {
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,10 @@ import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import type { FilePart } from "@opencode-ai/sdk/v2"
|
import type { FilePart } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
export function attached(part: FilePart) {
|
export function attached(part: FilePart) {
|
||||||
return part.url.startsWith("data:")
|
return part.url.startsWith("data:") && !inline(part)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inline(part: FilePart) {
|
export function inline(part: FilePart) {
|
||||||
if (attached(part)) return false
|
|
||||||
return part.source?.text?.start !== undefined && part.source?.text?.end !== undefined
|
return part.source?.text?.start !== undefined && part.source?.text?.end !== undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue