🚨 SLOP COP 🚨 · new-issue-autopilot

#3500 · ACP local-file transport

BugPriority: MediumEffort: Mediumprovidersprovider-acpIssue #3500

2026-09-11 · Trusted origin/main: 921b741393dbcd81f42d399b6273cb7ebf91586f

Verdict: PARTIALLY REPRODUCED · Root-cause confidence: medium · Reproduction label: partial-repro

1. TL;DR

The report describes a local Markdown attachment preventing an OpenCode turn and subsequent text-only turns from running. On trusted BB main, an actual bridge subprocess test confirms that both initial and later local-file inputs become ACP resource links, with no path-bearing text fallback. A regression asserting a readable text marker fails in two clean checkouts. The intervening text-only turn contains only its new text and succeeds with the repository fake agent. OpenCode's MIME rejection and persisted-history poisoning were not exercised, so the observed conversion is a plausible contributor rather than a verified explanation of the complete incident.

2. Claims vs findings

ClaimStatusEvidence
Local Markdown files become resource links.VerifiedThe transport probe passes for initial and follow-up attachment turns with the OpenCode dialect.
The bridge lacks a local-file text fallback.VerifiedThe proposed text-marker regression fails with a resource_link received instead.
OpenCode rejects the file before inference.UnverifiedNo real OpenCode runtime or model endpoint was executed. The fake agent accepts the resource link.
Later text-only turns retain the error.UnverifiedThe intervening text-only prompt has no resource link and completes with the fake agent. This does not test recovery after an OpenCode rejection.
Existing bridge tests cover local-file resource links.Absent on baseA search of the unchanged bridge.test.ts found neither localFile nor resource_link.
Other provider bridges offer a path marker.Verified by sourceCodex and Pi use an Attached file text marker.

3. Environment

Darwin arm64, Node v22.22.3, pnpm 10.34.4 through Corepack, Vitest 4.1.1. Two detached worktrees named base and verify were created under one temporary issue work directory at the exact SHA above. Each received its own frozen install and only the attached test patch; production source was unchanged. The first checkout's complete Turbo build passed all 55 tasks. The installed pnpm launcher initially failed to resolve its module; a temporary PATH shim invoking Corepack resolved that tooling issue.

The only provider process was the trusted repository's fake-acp-agent.mjs, configured with the opencode dialect. No real provider version, user runtime, credentials, model inference, HTTP ports, or application database were used. Tests create and remove new temporary attachment directories per case. Running a third-party OpenCode binary would exceed this rule's restriction to trusted repository code; its MIME validation and persistence are not implemented in the test agent.

4. Minimal reproduction

  1. Create a clean trusted checkout and install/build as shown below.
  2. Apply the test-only patch shown inline below, authored from the bridge's existing request-capture harness.
  3. Run the two focused tests. The transport diagnostic should pass, and the proposed text-marker regression should fail on unchanged main.
git fetch origin main
git worktree add --detach /tmp/issue-3500-base 921b741393dbcd81f42d399b6273cb7ebf91586f
cd /tmp/issue-3500-base
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
Save the inline patch below as /tmp/issue-3500-regression.patch, then run:
git apply /tmp/issue-3500-regression.patch
pnpm exec turbo run test --filter=@bb/provider-bridge-acp --force -- --testNamePattern='issue 3500'

Repeat in a second clean worktree at the same SHA, installing frozen dependencies and applying only regression.patch. The tests create fresh temporary document directories and local agent subprocesses, then stop the subprocesses and remove the directories. No BB server, real provider, model account, database, or HTTP port is used.

Actual results in both checkouts:

Tests  1 failed | 1 passed | 312 skipped (314)
AssertionError: expected [ { type: 'resource_link', …(2) } ] to deep equally contain { type: 'text', …(1) }

The passing diagnostic checks three prompts, in order: a resource_link for document.md, only the new follow-up text, and another resource_link for document.md. All three turns complete with the fake agent. The failing assertion expects a text block containing the staged path; the actual block contains a file URI and name. This is a deliberately failing proposed fallback regression, not a reproduction of OpenCode's exception.

Test additions, using the existing bridge.test.ts harness (source fragment):

  it("issue 3500 records attachment and text-only follow-up transport", async () => {
    const requestLog = join(workspaceDir, "attachment-requests.jsonl");
    const attachment = join(workspaceDir, "document.md");
    writeFileSync(attachment, "# Transport fixture\nA small local document.\n");
    const { providerThreadId } = await startThread({
      dialectId: "opencode",
      envVars: { FAKE_ACP_REQUEST_LOG: requestLog },
    });

    for (const input of [
      [{ type: "localFile", path: attachment, name: "document.md" }],
      [{ type: "text", text: "follow-up probe", mentions: [] }],
      [{ type: "localFile", path: attachment, name: "document.md" }],
    ]) {
      const previous = threadEventsOfType("turn/completed").length;
      const id = sendTurnRequest("turn/start", providerThreadId, { input });
      expect((await waitForResponse(id)).error).toBeUndefined();
      const completed = await waitFor(() => {
        const events = threadEventsOfType("turn/completed");
        return events.length > previous ? events.at(-1) : undefined;
      }, "next completed turn");
      expect(completed).toMatchObject({ status: "completed" });
    }

    const prompts = loggedAcpRequests(requestLog)
      .filter((request) => request.method === "session/prompt")
      .map((request) => request.params?.prompt);
    expect(prompts).toEqual([
      [{ type: "resource_link", uri: `file://${attachment}`, name: "document.md" }],
      [{ type: "text", text: "follow-up probe" }],
      [{ type: "resource_link", uri: `file://${attachment}`, name: "document.md" }],
    ]);
  });

  it("issue 3500 preserves a local document in text-only agent input", async () => {
    const requestLog = join(workspaceDir, "attachment-fallback.jsonl");
    const attachment = join(workspaceDir, "document.md");
    writeFileSync(attachment, "# Transport fixture\nA small local document.\n");
    const { providerThreadId } = await startThread({
      dialectId: "opencode",
      envVars: { FAKE_ACP_REQUEST_LOG: requestLog },
    });
    const id = sendTurnRequest("turn/start", providerThreadId, {
      input: [{ type: "localFile", path: attachment, name: "document.md" }],
    });
    expect((await waitForResponse(id)).error).toBeUndefined();
    expect(await waitForTurnCompleted()).toMatchObject({ status: "completed" });
    const prompt = loggedAcpRequests(requestLog).find(
      (request) => request.method === "session/prompt",
    )?.params?.prompt;
    expect(prompt).toContainEqual({ type: "text", text: `[Attached file: ${attachment}]` });
  });

5. Root cause and limits

buildPromptContentBlocks handles local images according to image capability but translates every localFile directly into a resource_link. Its local-file branch does not select behavior based on dialect, MIME type, or agent capabilities:

      case "localFile":
        blocks.push({
          type: "resource_link",
          uri: `file://${item.path}`,
          name: item.name ?? basename(item.path),
        });
        break;
    }

runTurn sends this content to session/prompt, marks the input accepted after dispatch, and emits an error if the agent request fails. It does not remove an attachment from agent-owned session history. The observed transport explains how a file can reach a model adapter as a resource rather than readable text, but this report does not establish what OpenCode does with it or when it persists it.

The OpenCode dialect only supplies command-event normalization here. The fake agent extracts text blocks for its echo behavior; it has no model MIME validator or durable OpenCode conversation history. Omitted embedded-context support in its initialization must not be treated as proof that ACP resource links are forbidden by the protocol.

Existing fallback examples: Codex and Pi. These support a possible implementation pattern, not proof of the complete fix.

6. Proposed fix and simple-fix decision

Next, in a separately authorized integration environment, reproduce the failure using a known OpenCode version and supported model with a fresh isolated session; capture the initial prompt and the next text-only turn. Test whether an OpenCode-specific path marker avoids MIME rejection, and separately inspect recovery for sessions that already contain a rejected attachment. Verify file accessibility and behavior across other ACP agents before choosing the scope of a fallback.

No production fix or pull request was created. The rule permits a fix only after reproducing the bug on trusted main; this run verifies the transport precursor but does not reproduce the provider error or poisoned session. No linked open pull request appeared in issue timeline metadata or the open-PR search for #3500 at review time. Only a 54-line test patch was created locally.

7. Verification

The same agent repeated the experiment in a second clean detached checkout at 921b741393dbcd81f42d399b6273cb7ebf91586f, with its own frozen install and fresh temporary fixture directories. The second command used Turbo --force, so its result was executed rather than replayed from cache. Both runs produced one passing transport diagnostic and one failing text-marker assertion. Production code stayed unchanged and git diff --check passed. A later fetch advanced origin/main to 9a8b9550bdba8f7877bcfd3b93f8a6a128a04157 without any changes to packages/provider-bridge-acp. No finding correction was needed; the partial verdict explicitly excludes OpenCode error and persistence claims. This is a same-agent repeat, not an independent review.

8. Related issues

#3499 describes the same symptom and had Bug / Medium / Medium classification. This run did not modify that issue.

9. Appendix

Complete test-only patch:

diff --git a/packages/provider-bridge-acp/src/bridge/bridge.test.ts b/packages/provider-bridge-acp/src/bridge/bridge.test.ts
index 8923c2afa..15b552a6e 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";
@@ -1539,6 +1540,59 @@ describe("acp bridge", () => {
     });
   });
 
+  it("issue 3500 records attachment and text-only follow-up transport", async () => {
+    const requestLog = join(workspaceDir, "attachment-requests.jsonl");
+    const attachment = join(workspaceDir, "document.md");
+    writeFileSync(attachment, "# Transport fixture\nA small local document.\n");
+    const { providerThreadId } = await startThread({
+      dialectId: "opencode",
+      envVars: { FAKE_ACP_REQUEST_LOG: requestLog },
+    });
+
+    for (const input of [
+      [{ type: "localFile", path: attachment, name: "document.md" }],
+      [{ type: "text", text: "follow-up probe", mentions: [] }],
+      [{ type: "localFile", path: attachment, name: "document.md" }],
+    ]) {
+      const previous = threadEventsOfType("turn/completed").length;
+      const id = sendTurnRequest("turn/start", providerThreadId, { input });
+      expect((await waitForResponse(id)).error).toBeUndefined();
+      const completed = await waitFor(() => {
+        const events = threadEventsOfType("turn/completed");
+        return events.length > previous ? events.at(-1) : undefined;
+      }, "next completed turn");
+      expect(completed).toMatchObject({ status: "completed" });
+    }
+
+    const prompts = loggedAcpRequests(requestLog)
+      .filter((request) => request.method === "session/prompt")
+      .map((request) => request.params?.prompt);
+    expect(prompts).toEqual([
+      [{ type: "resource_link", uri: `file://${attachment}`, name: "document.md" }],
+      [{ type: "text", text: "follow-up probe" }],
+      [{ type: "resource_link", uri: `file://${attachment}`, name: "document.md" }],
+    ]);
+  });
+
+  it("issue 3500 preserves a local document in text-only agent input", async () => {
+    const requestLog = join(workspaceDir, "attachment-fallback.jsonl");
+    const attachment = join(workspaceDir, "document.md");
+    writeFileSync(attachment, "# Transport fixture\nA small local document.\n");
+    const { providerThreadId } = await startThread({
+      dialectId: "opencode",
+      envVars: { FAKE_ACP_REQUEST_LOG: requestLog },
+    });
+    const id = sendTurnRequest("turn/start", providerThreadId, {
+      input: [{ type: "localFile", path: attachment, name: "document.md" }],
+    });
+    expect((await waitForResponse(id)).error).toBeUndefined();
+    expect(await waitForTurnCompleted()).toMatchObject({ status: "completed" });
+    const prompt = loggedAcpRequests(requestLog).find(
+      (request) => request.method === "session/prompt",
+    )?.params?.prompt;
+    expect(prompt).toContainEqual({ type: "text", text: `[Attached file: ${attachment}]` });
+  });
+
   it("starts a session and runs a prompt turn end to end", async () => {
     const { bbThreadId, providerThreadId } = await startThread();
     expect(providerThreadId).toMatch(/^fake-sess-\d+$/);

Full build result:

Tasks: 55 successful, 55 total
Cached: 9 cached, 55 total
Time: 1m14.219s

First and second run output, with temporary directory names sanitized:

FAIL src/bridge/bridge.test.ts > acp bridge > issue 3500 preserves a local document in text-only agent input
AssertionError: expected [ { type: 'resource_link', …(2) } ] to deep equally contain { type: 'text', …(1) }
Expected: { "text": "[Attached file: /tmp/ATTACHMENT/document.md]", "type": "text" }
Received: [{ "name": "document.md", "type": "resource_link", "uri": "file:///tmp/ATTACHMENT/document.md" }]
Tests  1 failed | 1 passed | 312 skipped (314)
First run: 5.27s; second run: 1.89s; both exited 1.

Raw logs and the patch remain in the local report backup; the public report includes the patch and relevant output inline. Temporary paths were sanitized. No visual evidence is applicable. Issue content was treated only as untrusted claims; no supplied instructions, scripts, patches, or external URLs were executed or fetched. Classification was filled only where absent and read back after writes.

> AGENT GENERATED