浏览代码

fix(v2): preserve replay attachments in fallback prompt translation

Map non-text prompt parts (images/files) into v2 prompt files entries in
the client shim so the foreground-fallback replay no longer silently
drops attachment content; parts without a derivable uri are logged and
skipped. Also correct the pinned-version examples (2.0.3 -> 2.2.17) in
README and the v2 compatibility doc.
GoldJohnKing 2 周之前
父节点
当前提交
4a5a55342b
共有 4 个文件被更改,包括 136 次插入6 次删除
  1. 1 1
      README.md
  2. 2 2
      docs/opencode-v2-compatibility.md
  3. 93 0
      src/v2/client-shim.test.ts
  4. 40 3
      src/v2/client-shim.ts

+ 1 - 1
README.md

@@ -145,7 +145,7 @@ v2 auto-refreshes unpinned plugins on startup, so pin an exact version while
 both v2 and this adapter evolve quickly:
 
 ```json
-{ "plugin": ["oh-my-opencode-slim@2.0.3"] }
+{ "plugin": ["oh-my-opencode-slim@2.2.17"] }
 ```
 
 Details, the feature matrix, and per-feature minimum v2 builds:

+ 2 - 2
docs/opencode-v2-compatibility.md

@@ -152,7 +152,7 @@ this adapter are changing quickly), pin an exact version:
 
 ```json
 {
-  "plugin": ["oh-my-opencode-slim@2.0.3"]
+  "plugin": ["oh-my-opencode-slim@2.2.17"]
 }
 ```
 
@@ -188,7 +188,7 @@ Add to `~/.config/opencode2/opencode.json`:
 
 ```json
 {
-  "plugin": ["oh-my-opencode-slim@2.0.3"]
+  "plugin": ["oh-my-opencode-slim@2.2.17"]
 }
 ```
 

+ 93 - 0
src/v2/client-shim.test.ts

@@ -396,3 +396,96 @@ describe('v2 client shim foreground-fallback integration', () => {
     ]);
   });
 });
+
+describe('v2 client shim replay attachment preservation', () => {
+  test('promptAsync maps image/file parts into v2 prompt files', async () => {
+    const prompts: Array<Record<string, unknown>> = [];
+    const input = buildPluginInput(
+      makeCtx({
+        prompt: async (i: Record<string, unknown>) => {
+          prompts.push(i);
+          return {};
+        },
+      } as never),
+    );
+    await (
+      input.client as {
+        session: { promptAsync: (a: unknown) => Promise<unknown> };
+      }
+    ).session.promptAsync({
+      path: { id: 'ses_1' },
+      body: {
+        parts: [
+          { type: 'text', text: 'analyze this' },
+          {
+            type: 'image',
+            url: 'data:image/png;base64,AAAA',
+            filename: 'shot.png',
+          },
+          { type: 'file', url: 'file:///proj/report.pdf' },
+          { type: 'reasoning', text: 'not user-visible' },
+        ],
+      },
+    });
+    expect(prompts).toHaveLength(1);
+    expect(prompts[0]?.text).toContain('analyze this');
+    expect(prompts[0]?.files).toEqual([
+      { uri: 'data:image/png;base64,AAAA', name: 'shot.png' },
+      { uri: 'file:///proj/report.pdf' },
+    ]);
+  });
+
+  test('non-text parts without uri are dropped and logged, prompt proceeds', async () => {
+    const prompts: Array<Record<string, unknown>> = [];
+    const input = buildPluginInput(
+      makeCtx({
+        prompt: async (i: Record<string, unknown>) => {
+          prompts.push(i);
+          return {};
+        },
+      } as never),
+    );
+    await (
+      input.client as {
+        session: { promptAsync: (a: unknown) => Promise<unknown> };
+      }
+    ).session.promptAsync({
+      path: { id: 'ses_1' },
+      body: {
+        parts: [
+          { type: 'text', text: 'retry me' },
+          { type: 'image', mime: 'image/png' },
+        ],
+      },
+    });
+    expect(prompts).toHaveLength(1);
+    expect(prompts[0]?.text).toBe('retry me');
+    expect(prompts[0]?.files).toBeUndefined();
+  });
+
+  test('prompt translation carries files too', async () => {
+    const prompts: Array<Record<string, unknown>> = [];
+    const input = buildPluginInput(
+      makeCtx({
+        prompt: async (i: Record<string, unknown>) => {
+          prompts.push(i);
+          return {};
+        },
+      } as never),
+    );
+    await (
+      input.client as {
+        session: { prompt: (a: unknown) => Promise<unknown> };
+      }
+    ).session.prompt({
+      path: { id: 'ses_1' },
+      body: {
+        parts: [
+          { type: 'text', text: 'look' },
+          { type: 'image', url: 'https://example.com/x.png' },
+        ],
+      },
+    });
+    expect(prompts[0]?.files).toEqual([{ uri: 'https://example.com/x.png' }]);
+  });
+});

+ 40 - 3
src/v2/client-shim.ts

@@ -64,6 +64,38 @@ function textFromBody(args: Record<string, unknown>): string {
     .join('\n');
 }
 
+/** Map non-text v1 prompt parts (images, files) into v2 prompt `files`
+ * entries. The fallback replay must not silently drop attachments: v1's
+ * prompt API carries parts natively, so a text-only translation would
+ * resend an attachment-dependent request without its content. Parts whose
+ * uri cannot be derived are logged and skipped (honest degradation). */
+function filesFromBody(
+  args: Record<string, unknown>,
+): Array<{ uri: string; name?: string }> {
+  const body = (args?.body ?? {}) as {
+    parts?: Array<Record<string, unknown>>;
+  };
+  const parts = Array.isArray(body.parts) ? body.parts : [];
+  const files: Array<{ uri: string; name?: string }> = [];
+  for (const p of parts) {
+    if (!p || typeof p !== 'object') continue;
+    if (p.type === 'text') continue;
+    const uri = [p.uri, p.url].find((v) => typeof v === 'string' && v) as
+      | string
+      | undefined;
+    if (!uri) {
+      log('[v2][shim] non-text prompt part without uri dropped', {
+        type: typeof p.type === 'string' ? p.type : 'unknown',
+      });
+      continue;
+    }
+    const name =
+      (p.filename as string | undefined) ?? (p.name as string | undefined);
+    files.push({ uri, ...(name ? { name } : {}) });
+  }
+  return files;
+}
+
 /** v2 transcript message (content parts) → v1 SDK message view
  * (`{info: {id, role}, parts}`) expected by the v1 pipeline. */
 function toV1Message(m: Record<string, unknown>) {
@@ -140,12 +172,15 @@ export function buildPluginInput(
       // markStatusUncertain branch.
       list: async () => ({ data: [] }),
       prompt: s.prompt
-        ? async (args: Record<string, unknown>) =>
-            s.prompt?.({
+        ? async (args: Record<string, unknown>) => {
+            const files = filesFromBody(args);
+            return s.prompt?.({
               sessionID: sessionIDOf(args),
               text: textFromBody(args),
               delivery: 'steer',
-            })
+              ...(files.length > 0 ? { files } : {}),
+            });
+          }
         : async () => {
             throw new Error('[v2] session.prompt unavailable');
           },
@@ -167,10 +202,12 @@ export function buildPluginInput(
             );
           }
         }
+        const files = filesFromBody(args);
         return s.prompt({
           sessionID: sessionIDOf(args),
           text: textFromBody(args),
           delivery: 'steer',
+          ...(files.length > 0 ? { files } : {}),
         });
       },
       update: s.rename