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"); + }); +});