From 5626a0299cea1fd0db2afa2ed9199ee401dcc5b4 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:19:38 -0600 Subject: [PATCH] fix(opencode): keep the answer written before the completion call in the saved reply (#20999) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents that use OpenCode models (for example DeepSeek through OpenRouter) reply in chat through the Runner's OpenCode driver > - The driver picks one assistant text part as the turn's final response. Paperclip saves that text as the reply comment and delivers it to other channels > - In a chat eval, the model wrote its full answer with links, called `paperclip_finish`, and then wrote "Answered above — two inbox items need your attention ..." > - The driver saved only the later line. The chat showed both texts from the run stream, but the saved reply, its deliveries and the next turn's history held only a line that pointed at nothing > - This pull request keeps all prose written after the last work tool in the saved reply in that case, and lets the chat show it once > - The benefit is that the saved reply holds the answer the person saw ## Linked Issues or Issue Description **What happened?** An OpenCode chat turn wrote its answer before the completion call, then a short line after it that refers back to the answer. The saved reply comment held only "Answered above — ...". The full answer with its links existed only as progress text in the run stream. **Expected behavior** The saved reply holds the answer. Other channels, notifications and the agent's next turn read the same answer that the person saw in chat. **Steps to reproduce** 1. Use an OpenCode agent. Ask a question in chat that the agent answers with links. 2. The model writes its answer, calls `paperclip_finish`, and then writes a short line that refers to "above". 3. Read the saved reply comment with `GET /api/issues/{id}/comments`. It holds only the short line. **Paperclip version or commit** master at 9dfdeaad86. ## What Changed - `OpenCodeServerDriver` settles the final response from all eligible prose parts when it selects a later message after the completion call and the model also wrote prose after its last work tool and before that call. It joins the parts in order with a blank line. Other selection paths do not change. - The chat transcript adapter removes the latest progress messages when they compose the saved response exactly, so the text does not show twice. - Added a fake-server fixture and a driver test for the "answer, then a line that refers back" turn. Updated the existing post-tool test: the saved reply now keeps the earlier prose too, because structure cannot tell commentary from an answer. ## Verification - `cd packages/paperclip-runner && npx vitest run src/drivers/opencode/opencode-server-driver.test.ts` (80 tests pass). - `cd ui && npx vitest run src/components/task-chat/transcript-adapter.test.ts` (117 tests pass). - Red/green: with the driver change removed, the new test and the updated post-tool test fail. With the change, they pass. - `npx tsc -p packages/paperclip-runner/tsconfig.json --noEmit` passes. ## Risks - A turn that wrote commentary after its last work tool, then its answer after the completion call, now saves both texts. The person already saw both in chat. We accept this. Losing the answer is a worse failure than keeping a short note. - No saved run state, session state or event shape changes. A run that the previous release saved resumes without change; only the text of a newly settled final response differs. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../opencode/opencode-server-driver.test.ts | 43 +++++++++++++- .../opencode/opencode-server-driver.ts | 18 +++++- .../test/fixtures/fake-opencode-server.mjs | 59 +++++++++++++++++++ .../task-chat/transcript-adapter.test.ts | 21 +++++++ .../task-chat/transcript-adapter.ts | 39 +++++++++++- .../transcript/native-run-events.test.ts | 20 ++++++- 6 files changed, 195 insertions(+), 5 deletions(-) diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts index 330a2769552..c9808786052 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -3015,11 +3015,52 @@ describe("OpenCodeServerDriver", () => { event.payload.channel === "final", ); expect(finalMessages).toHaveLength(1); + // Structure cannot tell commentary from an answer, so the saved reply keeps + // all prose written after the last work tool, in order, as the person saw it. expect(finalMessages[0]?.payload.text).toBe( - "This is the complete substantive answer emitted after the accepted completion tool call.", + "This deliberately long pre-tool commentary discusses possible schema corrections, retries, compatibility details, and several implementation alternatives, but it is not the provider's final response.\n\nThis is the complete substantive answer emitted after the accepted completion tool call.", ); await session.close({ reason: "test" }); }); + it("keeps an answer written before the completion call when a later message only refers back to it", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-")); + const workspace = await mkdtemp( + join(tmpdir(), "paperclip-opencode-workspace-"), + ); + roots.push(root, workspace); + const driver = new OpenCodeServerDriver({ + model: "openrouter/deepseek/deepseek-v4-flash-0731", + runtimeDirectory: root, + command: fixture, + environment: { + PATH: process.env.PATH, + OPENROUTER_API_KEY: "fixture-key", + }, + }); + const session = await driver.openSession({ + runId: "run-answer-then-reference", + normalizedSessionId: "answer-then-reference", + workingDirectory: workspace, + }); + await session.startTurn({ + message: { role: "user", text: "answer-before-finish-then-reference" }, + }); + const events = await collectTurnEvents(session.events()); + const finalMessages = events.filter( + (event) => + event.eventType === "item.completed" && + event.payload.kind === "agentMessage" && + event.payload.channel === "final", + ); + expect(finalMessages).toHaveLength(1); + // The saved reply must hold the answer the person saw, not only "Answered above". + expect(finalMessages[0]?.payload.text).toBe( + "Two inbox items need you: [COM-2](/COM/issues/COM-2) is blocked and [COM-1](/COM/issues/COM-1) is waiting for review.\n\nAnswered above: two inbox items need your attention.", + ); + expect(finalMessages[0]?.itemId).toBe("part-reference"); + await session.close({ reason: "test" }); + }); it.each(["session.idle", "session.status", "session.error", "aborted"])("settles accepted completion before a racing %s event", async terminalType => { await chmod(fixture, 0o755); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index 7c7222c14d4..b8177f6ef8d 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -2296,14 +2296,28 @@ class OpenCodeHarnessSession implements HarnessSession { afterResult[0]?.part ?? null; if (!selected) return; + // A later message after the terminal call is only the closing part of the + // reply when the model already wrote prose after its last work tool and + // before that call: the person saw both, and the later message often just + // refers back ("Answered above"). Structure cannot tell an answer from + // commentary, so keep every eligible part rather than drop the answer. + const preface = + this.#semanticResultProviderMessageId !== null && + correlatedAfterResult.length === 0 && + uncorrelatedAfterResult[0]?.part === selected + ? beforeResult.map(({ part }) => part) + : []; + const text = preface.length + ? [...preface, selected].map((part) => part.text.trim()).filter(Boolean).join("\n\n") + : selected.text; this.#emit( "item.completed", { kind: "agentMessage", channel: "final", providerPhase: "final_answer", - text: selected.text, - item: selected.item, + text, + item: preface.length ? bounded({ ...selected.item, text }) : selected.item, }, { turnId, itemId: selected.partId }, ); diff --git a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs index 18b523b00ea..f142d2880de 100644 --- a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs +++ b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs @@ -797,6 +797,9 @@ const server = createServer(async (request, response) => { const correlatedFinal = String(parsedPrompt.message ?? "").includes( "correlated-final-message", ); + const answerBeforeFinishThenReference = String( + parsedPrompt.message ?? "", + ).includes("answer-before-finish-then-reference"); const finalAfterToolCommentary = String( parsedPrompt.message ?? "", ).includes("final-after-tool-commentary"); @@ -998,6 +1001,62 @@ const server = createServer(async (request, response) => { }, }, }); + } else if (answerBeforeFinishThenReference) { + emit({ + type: "message.part.updated", + id: "event-answer-before-finish", + properties: { + sessionID: session.id, + part: { + id: "part-answer-before-finish", + messageID: "message-assistant", + type: "text", + text: "Two inbox items need you: [COM-2](/COM/issues/COM-2) is blocked and [COM-1](/COM/issues/COM-1) is waiting for review.", + time: { start: 1, end: 2 }, + }, + }, + }); + await callTerminalTool(promptPayload); + emit({ + type: "message.part.updated", + id: "event-answer-finish-part", + properties: { + sessionID: session.id, + part: { + id: "part-answer-finish", + messageID: "message-assistant", + type: "tool", + tool: "paperclip_paperclip_finish", + state: { status: "completed", output: "accepted" }, + }, + }, + }); + emit({ + type: "message.updated", + id: "event-reference-message", + properties: { + sessionID: session.id, + info: { + id: "message-reference", + sessionID: session.id, + role: "assistant", + }, + }, + }); + emit({ + type: "message.part.updated", + id: "event-reference", + properties: { + sessionID: session.id, + part: { + id: "part-reference", + messageID: "message-reference", + type: "text", + text: "Answered above: two inbox items need your attention.", + time: { start: 3, end: 4 }, + }, + }, + }); } else if (correlatedFinal) { emit({ type: "message.part.updated", diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index cd8019de60b..e2fc355803a 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -274,6 +274,27 @@ describe("omitProgressRepeatedByResponseAcrossSegments", () => { ).toEqual([[first], []]); }); + it("lets the response own the progress messages it was saved from", () => { + const work = progress("work", "Checking your inbox."); + const answer = progress("answer", "Two items need you: COM-2 and COM-1."); + const coda = progress("coda", "Answered above."); + const segments = [[work, answer], [coda]]; + expect( + omitProgressRepeatedByResponseAcrossSegments(segments, "Two items need you: COM-2 and COM-1.\n\nAnswered above."), + ).toEqual([[work], []]); + // The closing line's own progress is gone once its completion exists; the earlier answer still composes the response. + expect( + omitProgressRepeatedByResponseAcrossSegments([[work, answer]], "Two items need you: COM-2 and COM-1.\n\nAnswered above."), + ).toEqual([[work]]); + // A long unrelated history is not hidden. + const history = [Array.from({ length: 500 }, (_, index) => progress(`p${index}`, `Step ${index} of a long run.`))]; + expect(omitProgressRepeatedByResponseAcrossSegments(history, "Short reply.")).toBe(history); + // A response that only shares a progress message's text does not hide it. + expect( + omitProgressRepeatedByResponseAcrossSegments(segments, "Something else.\n\nTwo items need you: COM-2 and COM-1."), + ).toBe(segments); + }); + it("preserves segment identity when there is no durable-response match", () => { const segments = [[progress("first", "Progress only")]]; expect( diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index 9f04e6f52e4..964bfd91b65 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -1384,7 +1384,7 @@ export function omitProgressRepeatedByResponseAcrossSegments( } } } - if (redundantSegmentIndex < 0) return segments; + if (redundantSegmentIndex < 0) return omitProgressComposingResponse(segments, normalizedResponse); return segments.map((items, segmentIndex) => segmentIndex === redundantSegmentIndex ? items.filter((_, index) => index !== redundantIndex) @@ -1392,6 +1392,43 @@ export function omitProgressRepeatedByResponseAcrossSegments( ); } +/** + * A runner can save one reply from several messages written after the last + * work tool (an answer, then a closing line after the completion call), + * joined by a blank line. The closing message is the response's own item, so + * its progress is already gone; the earlier messages remain as progress. When + * the latest progress messages make up the whole response, or its opening + * paragraphs, the response bubble owns them. One backward walk, bounded by the + * response length. + */ +function omitProgressComposingResponse( + segments: readonly (readonly TaskChatItem[])[], + normalizedResponse: string, +): readonly (readonly TaskChatItem[])[] { + let composed = ""; + let matched = 0; + let count = 0; + const omitted: string[] = []; + walk: for (let segmentIndex = segments.length - 1; segmentIndex >= 0; segmentIndex -= 1) { + const items = segments[segmentIndex]!; + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]!; + const text = item.kind === "message" && item.interstitial && item.channel === "progress" ? item.text.trim() : ""; + if (!text) continue; + composed = composed ? `${text}\n\n${composed}` : text; + if (composed.length > normalizedResponse.length) break walk; + count += 1; + omitted.push(`${segmentIndex}:${index}`); + if ((count > 1 && composed === normalizedResponse) || normalizedResponse.startsWith(`${composed}\n\n`)) matched = count; + } + } + if (!matched) return segments; + const hidden = new Set(omitted.slice(0, matched)); + return segments.map((items, segmentIndex) => + items.filter((_, index) => !hidden.has(`${segmentIndex}:${index}`)), + ); +} + /** * Keep the paperclip runner's expanded activity history focused on work the * user can act on. The normalized transcript remains lossless; this is only a diff --git a/ui/src/components/transcript/native-run-events.test.ts b/ui/src/components/transcript/native-run-events.test.ts index 379045b632d..46709371cb2 100644 --- a/ui/src/components/transcript/native-run-events.test.ts +++ b/ui/src/components/transcript/native-run-events.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import type { HeartbeatRunEvent } from "@paperclipai/shared"; import { nativeRunEventsToTranscript } from "./native-run-events"; -import { buildTurnTimelineRows, paperclipRunnerFinalResponse, paperclipRunnerTimelineItems, settledRunChildren, transcriptToTaskChatItems } from "../task-chat/transcript-adapter"; +import { buildTurnTimelineRows, omitProgressRepeatedByResponse, paperclipRunnerFinalResponse, paperclipRunnerTimelineItems, settledRunChildren, transcriptToTaskChatItems } from "../task-chat/transcript-adapter"; const RUN_ID = "10000000-0000-4000-8000-000000000001"; @@ -1522,3 +1522,21 @@ describe("edit diffs", () => { expect(items.filter((item) => item.kind === "protocol" && item.surface === "workspace_change")).toHaveLength(1); }); }); + +describe("an OpenCode reply settled from an answer and a closing line", () => { + it("shows the saved reply once, without the answer repeated as progress", () => { + const answer = "Two inbox items need you: [COM-2](/COM/issues/COM-2) and [COM-1](/COM/issues/COM-1)."; + const coda = "Answered above."; + const progress = (text: string) => ({ kind: "agentMessage", channel: "progress", providerPhase: "commentary", text }); + const transcript = nativeRunEventsToTranscript([ + itemEvent(1, "item.delta", "part-answer", progress(answer)), + itemEvent(2, "item.delta", "part-coda", progress(coda)), + itemEvent(3, "item.completed", "part-coda", { kind: "agentMessage", channel: "final", providerPhase: "final_answer", text: `${answer}\n\n${coda}` }), + ]); + const items = transcriptToTaskChatItems(transcript, { runId: RUN_ID, running: false }); + const response = paperclipRunnerFinalResponse(items, { runId: RUN_ID }); + expect(response?.text).toBe(`${answer}\n\n${coda}`); + const timeline = omitProgressRepeatedByResponse(paperclipRunnerTimelineItems(items), response?.text); + expect(JSON.stringify(timeline)).not.toContain("Two inbox items need you"); + }); +});