diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 6f89ef6c6a5..64d7d1259d5 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -3267,6 +3267,70 @@ describe("TaskChatThread runtime transcript selection", () => { ); }); + it("keeps a steered yielded turn's reply in the segment that wrote it", () => { + const runId = "steered-interview"; + nativeTranscriptState.transcriptByRun.set(runId, [ + assistantEntry("2026-08-25T18:00:02.000Z", "Here is the question card I just posted for you.", "final"), + assistantEntry("2026-08-25T18:00:10.000Z", "Folding in the steering note.", "progress"), + ]); + renderThread({ + comments: [ + { + id: "steer", + companyId: "company-1", + issueId: "issue-1", + authorType: "user", + authorAgentId: null, + authorUserId: "user-1", + body: "Steering note from the user.", + presentation: null, + metadata: null, + runId: null, + consumedByRunId: runId, + steeredIntoRunId: runId, + conversationAnchorAt: new Date("2026-08-25T18:00:05.000Z"), + createdAt: new Date("2026-08-25T18:00:05.000Z"), + updatedAt: new Date("2026-08-25T18:00:05.000Z"), + }, + ], + onAdd: async () => {}, + issueStatus: "in_progress", + interactions: [ + { + ...questionInteraction("steered-card", "Pick one", "2026-08-25T18:00:03.000Z"), + sourceRunId: runId, + } as IssueThreadInteraction, + ], + linkedRuns: [ + { + runId, + runtimeMode: "native", + status: "succeeded", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:00:00.000Z", + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:20.000Z", + resultJson: { + nativeResult: { + schema: "paperclip.run_result.v1", + reportedWorkDisposition: "yielded", + summary: "Waiting for Pick one.", + }, + }, + }, + ], + }); + const text = container.textContent ?? ""; + const replyAt = text.indexOf("Here is the question card I just posted"); + const steerAt = text.indexOf("Steering note from the user."); + expect(replyAt).toBeGreaterThanOrEqual(0); + expect(steerAt).toBeGreaterThanOrEqual(0); + expect(replyAt).toBeLessThan(steerAt); + expect(text.split("Here is the question card I just posted")).toHaveLength(2); + }); + it("keeps a yielded question turn out of the final-response slot", () => { nativeTranscriptState.transcriptByRun.set("native-question", [ assistantEntry("2026-08-25T18:00:01.000Z", "Preparing the verification question.", "progress"), diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 603ee5d962a..b626f43977f 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -2108,35 +2108,53 @@ export function TaskChatThread(props: TaskChatThreadProps) { break; } } + // A yielded turn's authored reply renders once, in the segment that + // contains it: the last segment with an authored final, which is where + // the whole-run scan found it. A steering anchor can split the turn, so + // the last populated segment is not always the right one. + const yieldedOptions = { + runId: source.id, + agentName: meta?.agentName, + allowFallback: false, + yielded: true, + waitSummary: acceptedSummary, + }; + const yieldedResponseSegmentIndex = sourceYieldedResponse + ? projectedSegments.findLastIndex(({ parsed }) => + Boolean(paperclipRunnerFinalResponse(parsed, yieldedOptions))) + : -1; + const settledSegmentFinalResponse = ( + segmentIndex: number, + parsed: readonly TaskChatItem[], + ): TaskChatMessageItem | undefined => { + if (!sourceIsPaperclipRunner) return undefined; + const lastSegment = segmentIndex === lastPopulatedSegmentIndex; + if (sourceYielded) { + if (!sourceYieldedResponse) return undefined; + if (yieldedResponseSegmentIndex === -1) return lastSegment ? sourceYieldedResponse : undefined; + return segmentIndex === yieldedResponseSegmentIndex + ? paperclipRunnerFinalResponse(parsed, yieldedOptions) + : undefined; + } + if (sourceHasPresentationComment) return undefined; + // A steering anchor may split acceptance from its terminal. Keep the + // whole-run proof and render its answer exactly once. + if (sourceAcceptedResponseWake) { + return lastSegment + ? paperclipRunnerFinalResponse(parsedSource, { runId: source.id, agentName: meta?.agentName }) + : undefined; + } + return paperclipRunnerFinalResponse(parsed, { + runId: source.id, + agentName: meta?.agentName, + fallbackSummary: lastSegment ? acceptedSummary : undefined, + allowFallback: sourceAllowSummaryFallback, + }); + }; for (const [segmentIndex, projected] of projectedSegments.entries()) { const { segment, parsed } = projected; if (segment.entries.length === 0) continue; - const finalResponse = - sourceIsPaperclipRunner && sourceYielded - ? segmentIndex === lastPopulatedSegmentIndex - ? sourceYieldedResponse - : undefined - : sourceIsPaperclipRunner && - !sourceHasPresentationComment - ? sourceAcceptedResponseWake - ? // A steering anchor may split acceptance from its terminal. - // Keep the whole-run proof and render its answer exactly once. - segmentIndex === lastPopulatedSegmentIndex - ? paperclipRunnerFinalResponse(parsedSource, { - runId: source.id, - agentName: meta?.agentName, - }) - : undefined - : paperclipRunnerFinalResponse(parsed, { - runId: source.id, - agentName: meta?.agentName, - fallbackSummary: - segmentIndex === lastPopulatedSegmentIndex - ? acceptedSummary - : undefined, - allowFallback: sourceAllowSummaryFallback, - }) - : undefined; + const finalResponse = settledSegmentFinalResponse(segmentIndex, parsed); const runHealth = { failures: projectedSegments[segmentIndex]?.mcpStartupFailures ?? [], rest: partitionRunHealthItems(timelineItemsBySegment[segmentIndex] ?? []).rest, @@ -2451,7 +2469,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { ? runs.find((run) => run.id === tailRunId) : undefined; const paperclipRunnerTail = isNativePaperclipRunnerRun(tailRunSource); - const suppressPaperclipRunnerTailFinal = Boolean( + const paperclipRunnerTailAwaitingContinuation = Boolean( paperclipRunnerTail && (acceptedSemanticResultDisposition( tailRunId ? linkedRunMetaById.get(tailRunId)?.resultJson : null, @@ -3401,8 +3419,8 @@ export function TaskChatThread(props: TaskChatThreadProps) { startedAtMs={tailStartedAtMs} finishedAtMs={tailFinishedAtMs} activityUnavailable={tailActivityUnavailable} - suppressFinal={ - suppressPaperclipRunnerTailFinal + awaitingContinuation={ + paperclipRunnerTailAwaitingContinuation } waitSummary={acceptedSemanticResultSummary( tailRunId ? linkedRunMetaById.get(tailRunId)?.resultJson : null, diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx index a93dd6a4565..70005ac71e9 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx @@ -40,7 +40,7 @@ describe("TaskChatRunnerTurn", () => { item: TaskChatRuntimeRequestItem, decision: TaskChatRuntimeRequestDecision, ) => void, - suppressFinal = false, + awaitingContinuation = false, continuedAfterSteering = false, execution?: ExecutionProjection, preparationLabel?: string, @@ -58,7 +58,7 @@ describe("TaskChatRunnerTurn", () => { execution={execution} preparationLabel={preparationLabel} startedAtMs={Date.now() - 2_000} - suppressFinal={suppressFinal} + awaitingContinuation={awaitingContinuation} continuedAfterSteering={continuedAfterSteering} onRuntimeRequestDecision={onRuntimeRequestDecision} /> diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx index b89cc14d40d..1d556e28a35 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx @@ -158,7 +158,7 @@ export function TaskChatRunnerTurn({ finishedAtMs, activityUnavailable = false, allowSummaryFallback = true, - suppressFinal = false, + awaitingContinuation = false, waitSummary = null, continuedAfterSteering = false, showHeader = true, @@ -183,7 +183,7 @@ export function TaskChatRunnerTurn({ * provider final that only restates the wait is not presented as a reply. * Prose the agent wrote for the user stays visible. */ - suppressFinal?: boolean; + awaitingContinuation?: boolean; /** The accepted yielded result's wait summary, when known. */ waitSummary?: string | null; /** The visible tail resumes the same native run after an accepted steer. */ @@ -204,7 +204,7 @@ export function TaskChatRunnerTurn({ item.surface === "run_result" && item.disposition === "yielded", ); - const observedFinal = paperclipRunnerFinalResponse(turnItems, suppressFinal + const observedFinal = paperclipRunnerFinalResponse(turnItems, awaitingContinuation ? { allowFallback: false, yielded: true, waitSummary } : { allowFallback: terminal && allowSummaryFallback }); const observedProviderText = Boolean( @@ -229,7 +229,7 @@ export function TaskChatRunnerTurn({ // A provider final can arrive before the accepted yielded result. Clear any // replay latch once the control plane establishes that this turn is waiting // for continuation; only an authored (non-wait) final is re-latched below. - if (yielded || suppressFinal) finalRef.current = { runId }; + if (yielded || awaitingContinuation) finalRef.current = { runId }; if ( observedFinal && (!finalRef.current.item ||