#3499 · ACP file input lacks text fallback

Bug · Medium priority · Medium effort · providers / provider-acp / opencode
2026-09-11 · base 921b741393dbcd81f42d399b6273cb7ebf91586f · Issue

Verdict: PARTIALLY REPRODUCED · Root-cause confidence: medium (high for the bridge transformation).

1. TL;DR

The bridge sends a Markdown file as an ACP resource link without a readable file-path text fallback. Two focused assertions fail on unchanged main production code, for an initial attachment and an attachment following a successful text turn. The tests use the repository fake agent with the OpenCode dialect; they do not execute OpenCode inference. The exact provider error and persistent failure of later text-only turns remain unverified.

2. Claims vs findings

ClaimFinding
Local files become resource links without capability handling.Verified by captured session/prompt requests and source.
A Markdown attachment fails real OpenCode inference.Unverified; OpenCode was unavailable on PATH. The fake agent accepts the link.
Later text-only turns retain the failure.Unverified; this test does not simulate provider-side persisted history.
Existing bridge tests omit local-file coverage.No localFile/resource_link matches existed in bridge.test.ts before these probes.

3. Environment

Darwin 25.6.0 arm64; Node v22.22.3; pnpm 9.15.0 via Corepack; Vitest 4.1.1. No live BB instance, account, network listener, or production data directory was used. Each test creates and removes a separate temporary workspace. The first checkout was detached at trusted main; the second was a newly created detached worktree at the same commit.

4. Minimal reproduction

  1. Check out the recorded base commit in get-bb/bb.
  2. Run corepack pnpm install --frozen-lockfile --prefer-offline. Ensure pnpm on PATH resolves to that Corepack version for Turbo children.
  3. Save the following test-only patch as repro.patch and run git apply repro.patch. It writes a real Markdown file and uses existing fake-agent request logging.
  4. Run pnpm exec turbo run test --filter=@bb/provider-bridge-acp -- --testNamePattern="issue 3499".

Expected: a text block containing the staged filename. Actual: both assertions fail with resource_link output. The fake-agent turns complete successfully; this is a fallback contract probe, not a reproduction of the real inference error.

diff --git a/packages/provider-bridge-acp/src/bridge/bridge.test.ts b/packages/provider-bridge-acp/src/bridge/bridge.test.ts
index 8923c2afa..303feed81 100644
--- a/packages/provider-bridge-acp/src/bridge/bridge.test.ts
+++ b/packages/provider-bridge-acp/src/bridge/bridge.test.ts
@@ -5,6 +5,7 @@ import {
   readFileSync,
   rmSync,
   symlinkSync,
+  writeFileSync,
 } from "node:fs";
 import { createConnection } from "node:net";
 import { tmpdir } from "node:os";
@@ -2363,6 +2364,35 @@ describe("acp bridge", () => {
     expect(threadEventsOfType("turn/completed")).toHaveLength(1);
   });
 
+  it.each([false, true])("issue 3499 exposes a file path as text after earlier turn=%s", async (followup) => {
+    writeFileSync(join(workspaceDir, "notes.md"), "# Sample\nA small attachment.\n");
+    const requestLog = join(workspaceDir, "attachment-requests.jsonl");
+    const { providerThreadId } = await startThread({
+      dialectId: "opencode",
+      envVars: { FAKE_ACP_REQUEST_LOG: requestLog },
+    });
+    if (followup) {
+      const first = sendTurnRequest("turn/start", providerThreadId, {
+        input: [{ type: "text", text: "hello", mentions: [] }],
+      });
+      expect((await waitForResponse(first)).error).toBeUndefined();
+      await waitForTurnCompleted();
+    }
+    const completedBefore = threadEventsOfType("turn/completed").length;
+    const attachment = sendTurnRequest("turn/start", providerThreadId, {
+      input: [
+        { type: "text", text: "read attachment", mentions: [] },
+        { type: "localFile", path: join(workspaceDir, "notes.md"), name: "notes.md" },
+      ],
+    });
+    expect((await waitForResponse(attachment)).error).toBeUndefined();
+    await waitFor(() => threadEventsOfType("turn/completed").length > completedBefore ? true : undefined, "attachment completion");
+    const prompts = loggedAcpRequests(requestLog).filter((request) => request.method === "session/prompt");
+    expect(prompts.at(-1)?.params?.prompt).toEqual(expect.arrayContaining([
+      expect.objectContaining({ type: "text", text: expect.stringContaining("notes.md") }),
+    ]));
+  });
+
   it("runs the builtin /compact command as compaction, not as a prompt", async () => {
     const promptLog = join(workspaceDir, "compact-prompt-log.jsonl");
     const { providerThreadId } = await startThread({

Observed output (temporary workspace identifiers normalized)

 FAIL  |@bb/provider-bridge-acp:isolated| src/bridge/bridge.test.ts > acp bridge > issue 3499 exposes a file path as text after earlier turn=false
AssertionError: expected [ { type: 'text', …(1) }, …(1) ] to deeply equal ArrayContaining{…}

- Expected
+ Received

- ArrayContaining [
-   ObjectContaining {
-     "text": StringContaining "notes.md",
+ [
+   {
+     "text": "read attachment",
      "type": "text",
+   },
+   {
+     "name": "notes.md",
+     "type": "resource_link",
+     "uri": "file:///tmp/TEST_WORKSPACE/notes.md",
    },
  ]

 ❯ src/bridge/bridge.test.ts:2391:44
    2389|     await waitFor(() => threadEventsOfType("turn/completed").length > …
    2390|     const prompts = loggedAcpRequests(requestLog).filter((request) => …
    2391|     expect(prompts.at(-1)?.params?.prompt).toEqual(expect.arrayContain…
       |                                            ^
    2392|       expect.objectContaining({ type: "text", text: expect.stringConta…
    2393|     ]));

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯

 FAIL  |@bb/provider-bridge-acp:isolated| src/bridge/bridge.test.ts > acp bridge > issue 3499 exposes a file path as text after earlier turn=true
AssertionError: expected [ { type: 'text', …(1) }, …(1) ] to deeply equal ArrayContaining{…}

- Expected
+ Received

- ArrayContaining [
-   ObjectContaining {
-     "text": StringContaining "notes.md",
+ [
+   {
+     "text": "read attachment",
      "type": "text",
+   },
+   {
+     "name": "notes.md",
+     "type": "resource_link",
+     "uri": "file:///tmp/TEST_WORKSPACE/notes.md",
    },
  ]

 ❯ src/bridge/bridge.test.ts:2391:44
    2389|     await waitFor(() => threadEventsOfType("turn/completed").length > …
    2390|     const prompts = loggedAcpRequests(requestLog).filter((request) => …
    2391|     expect(prompts.at(-1)?.params?.prompt).toEqual(expect.arrayContain…
       |                                            ^
    2392|       expect.objectContaining({ type: "text", text: expect.stringConta…
    2393|     ]));

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯


 Test Files  1 failed | 17 skipped (18)
      Tests  2 failed | 312 skipped (314)
   

5. Root cause

buildPromptContentBlocks checks image support for local images but unconditionally constructs a resource_link for localFile. Session initialization retains image capability only. The fake agent advertises image:false and no file-input capability, yet receives the resource link with no text fallback.

Prompt dispatch and error handling sends current pending input and clears active prompt state on rejection. This alone does not establish where a rejected attachment might persist in OpenCode. The cause of later failures remains unverified.

6. Proposed fix and safety decision

Verify the captured request against an installed supported OpenCode version in an isolated session, including a failed attachment followed by a fresh text-only turn. A staged file-path text fallback is a plausible small fix. No PR was opened because partial reproduction does not meet the rule's reproduced-bug gate. No production code was changed, committed, or pushed.

7. Verification

The same agent repeated the test in a second clean temporary checkout at the full base SHA, with a separate frozen dependency install and new temporary test workspaces. Only the test patch was applied. The same Turbo command exited 1 with 2 failed assertions and 312 skipped tests. Both failures show resource-link output. This supports the narrow bridge finding, so the report limits the verdict to partial reproduction. No independent verifier is claimed.

8. Related issues

Metadata search also found issue #3500 covering the same reported topic. No open pull request was linked in #3499 timeline metadata or found by issue-number PR search during this investigation.

9. Appendix

The default host pnpm launcher pointed to a missing installation. Corepack supplied the repository-pinned version; a temporary PATH shim allowed Turbo child tasks to run. Frozen installs succeeded. The full pnpm exec turbo run build completed with 55 successful tasks. Focused tests ran through Turbo and reached their intended assertions. Raw logs remain local; this report embeds only the test patch and sanitized failure output. Issue content was treated as untrusted evidence; no issue-provided scripts, patches, commands, or links were executed. No runtime processes or data stores were started.