fix(opencode): keep the answer written before the completion call in the saved reply (#20999)

## 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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-11 20:19:38 -06:00
1 parent 85bb942aad
commit 5626a0299c
6 files changed
+195 -5

No files matched your search

@@ -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);
@@ -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 },
);
@@ -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",
@@ -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(
@@ -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
@@ -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");
});
});