فهرست منبع

fix(app): preserve inline file mentions (#38663)

Brendan Allan 1 ماه پیش
والد
کامیت
b62806683e

+ 3 - 0
packages/app/src/components/prompt-input/submit.test.ts

@@ -489,6 +489,9 @@ describe("prompt submit worktree selection", () => {
       agents: [],
     })
     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 () => {

+ 1 - 0
packages/app/src/components/prompt-input/submit.ts

@@ -162,6 +162,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
       agent: input.draft.agent,
       model: input.draft.model,
       variant: input.draft.variant,
+      legacyParts: requestParts,
       text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
       files: requestParts.flatMap((part) => {
         if (part.type !== "file") return []

+ 47 - 3
packages/app/src/utils/server-compat.test.ts

@@ -69,18 +69,62 @@ describe("createCompatibleApi", () => {
     await api.session.prompt({
       sessionID: "ses_1",
       id: "msg_1",
-      text: "hello",
+      text: "hello @src/index.ts",
       agent: "build",
       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(await requests[0]!.json()).toMatchObject({
+    const body = await requests[0]!.json()
+    expect(body).toMatchObject({
       messageID: "msg_1",
       agent: "build",
       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 () => {

+ 11 - 3
packages/app/src/utils/server-compat.ts

@@ -1,6 +1,6 @@
 import type { ServerApi } from "./server"
 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 {
   Project,
   ProjectCurrent,
@@ -43,6 +43,7 @@ type LegacyPrompt = {
   agent?: string
   model?: { providerID: string; modelID: string }
   variant?: string
+  legacyParts?: (TextPartInput | FilePartInput | AgentPartInput)[]
 }
 type LegacyLocation = { directory?: string }
 type CompatibleInput = {
@@ -203,13 +204,20 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
           agent: value.agent,
           model: value.model,
           variant: value.variant,
-          parts: [
+          parts: value.legacyParts ?? [
             { type: "text", text: value.text },
             ...(value.files ?? []).map((file) => ({
               type: "file" as const,
-              mime: mime(file.uri),
+              mime: file.mention ? "text/plain" : mime(file.uri),
               url: file.uri,
               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) => ({
               type: "agent" as const,

+ 17 - 1
packages/app/src/utils/session-message.test.ts

@@ -15,7 +15,7 @@ describe("normalizeSessionMessages", () => {
       {
         id: "msg_3",
         type: "user",
-        text: "inspect this",
+        text: "inspect @src/client.ts",
         files: [
           {
             data: "aGVsbG8=",
@@ -23,6 +23,13 @@ describe("normalizeSessionMessages", () => {
             name: "note.txt",
             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 } }],
         time: { created: 3 },
@@ -76,9 +83,18 @@ describe("normalizeSessionMessages", () => {
     expect(result.parts.get("msg_3")?.map((part) => part.id)).toEqual([
       "msg_3:text:0",
       "msg_3:file:0",
+      "msg_3:file:1",
       "msg_3:agent:0",
       "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")?.[2]).toMatchObject({
       type: "tool",

+ 7 - 0
packages/app/src/utils/session-message.ts

@@ -206,6 +206,13 @@ function userParts(sessionID: string, message: SessionMessageUser): Part[] {
         mime: file.mime,
         filename: file.name,
         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(

+ 11 - 13
packages/session-ui/src/components/message-file.test.ts

@@ -21,7 +21,7 @@ describe("message-file", () => {
     expect(attached(file())).toBe(false)
   })
 
-  test("treats only non-attachment source ranges as inline references", () => {
+  test("keeps data-backed file mentions inline", () => {
     expect(
       inline(
         file({
@@ -34,18 +34,16 @@ describe("message-file", () => {
       ),
     ).toBe(true)
 
-    expect(
-      inline(
-        file({
-          url: "data:text/plain;base64,SGVsbG8=",
-          source: {
-            type: "file",
-            path: "/repo/README.txt",
-            text: { value: "@README.txt", start: 0, end: 11 },
-          },
-        }),
-      ),
-    ).toBe(false)
+    const mentioned = file({
+      url: "data:text/plain;base64,SGVsbG8=",
+      source: {
+        type: "file",
+        path: "/repo/README.txt",
+        text: { value: "@README.txt", start: 0, end: 11 },
+      },
+    })
+    expect(inline(mentioned)).toBe(true)
+    expect(attached(mentioned)).toBe(false)
   })
 
   test("separates image and file attachment kinds", () => {

+ 1 - 2
packages/session-ui/src/components/message-file.ts

@@ -3,11 +3,10 @@ import { getFilename } from "@opencode-ai/core/util/path"
 import type { FilePart } from "@opencode-ai/sdk/v2"
 
 export function attached(part: FilePart) {
-  return part.url.startsWith("data:")
+  return part.url.startsWith("data:") && !inline(part)
 }
 
 export function inline(part: FilePart) {
-  if (attached(part)) return false
   return part.source?.text?.start !== undefined && part.source?.text?.end !== undefined
 }