fix(task-chat): place a steered yielded reply in its own segment

A steering anchor can split a yielded turn. The authored reply now
renders in the last segment that contains an authored final, not
always in the last populated segment. The settled final-response
choice moves from a nested ternary into a small helper, and the
runner turn's suppressFinal prop is renamed awaitingContinuation,
since it no longer suppresses authored prose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-05 13:52:09 -06:00
1 parent c134d72620
commit 4f300fbb3e
4 files changed
+117 -35

No files matched your search

+64
View File
@@ -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", () => { it("keeps a yielded question turn out of the final-response slot", () => {
nativeTranscriptState.transcriptByRun.set("native-question", [ nativeTranscriptState.transcriptByRun.set("native-question", [
assistantEntry("2026-08-25T18:00:01.000Z", "Preparing the verification question.", "progress"), assistantEntry("2026-08-25T18:00:01.000Z", "Preparing the verification question.", "progress"),
+47 -29
View File
@@ -2108,35 +2108,53 @@ export function TaskChatThread(props: TaskChatThreadProps) {
break; 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()) { for (const [segmentIndex, projected] of projectedSegments.entries()) {
const { segment, parsed } = projected; const { segment, parsed } = projected;
if (segment.entries.length === 0) continue; if (segment.entries.length === 0) continue;
const finalResponse = const finalResponse = settledSegmentFinalResponse(segmentIndex, parsed);
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 runHealth = { const runHealth = {
failures: projectedSegments[segmentIndex]?.mcpStartupFailures ?? [], failures: projectedSegments[segmentIndex]?.mcpStartupFailures ?? [],
rest: partitionRunHealthItems(timelineItemsBySegment[segmentIndex] ?? []).rest, rest: partitionRunHealthItems(timelineItemsBySegment[segmentIndex] ?? []).rest,
@@ -2451,7 +2469,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
? runs.find((run) => run.id === tailRunId) ? runs.find((run) => run.id === tailRunId)
: undefined; : undefined;
const paperclipRunnerTail = isNativePaperclipRunnerRun(tailRunSource); const paperclipRunnerTail = isNativePaperclipRunnerRun(tailRunSource);
const suppressPaperclipRunnerTailFinal = Boolean( const paperclipRunnerTailAwaitingContinuation = Boolean(
paperclipRunnerTail && paperclipRunnerTail &&
(acceptedSemanticResultDisposition( (acceptedSemanticResultDisposition(
tailRunId ? linkedRunMetaById.get(tailRunId)?.resultJson : null, tailRunId ? linkedRunMetaById.get(tailRunId)?.resultJson : null,
@@ -3401,8 +3419,8 @@ export function TaskChatThread(props: TaskChatThreadProps) {
startedAtMs={tailStartedAtMs} startedAtMs={tailStartedAtMs}
finishedAtMs={tailFinishedAtMs} finishedAtMs={tailFinishedAtMs}
activityUnavailable={tailActivityUnavailable} activityUnavailable={tailActivityUnavailable}
suppressFinal={ awaitingContinuation={
suppressPaperclipRunnerTailFinal paperclipRunnerTailAwaitingContinuation
} }
waitSummary={acceptedSemanticResultSummary( waitSummary={acceptedSemanticResultSummary(
tailRunId ? linkedRunMetaById.get(tailRunId)?.resultJson : null, tailRunId ? linkedRunMetaById.get(tailRunId)?.resultJson : null,
@@ -40,7 +40,7 @@ describe("TaskChatRunnerTurn", () => {
item: TaskChatRuntimeRequestItem, item: TaskChatRuntimeRequestItem,
decision: TaskChatRuntimeRequestDecision, decision: TaskChatRuntimeRequestDecision,
) => void, ) => void,
suppressFinal = false, awaitingContinuation = false,
continuedAfterSteering = false, continuedAfterSteering = false,
execution?: ExecutionProjection, execution?: ExecutionProjection,
preparationLabel?: string, preparationLabel?: string,
@@ -58,7 +58,7 @@ describe("TaskChatRunnerTurn", () => {
execution={execution} execution={execution}
preparationLabel={preparationLabel} preparationLabel={preparationLabel}
startedAtMs={Date.now() - 2_000} startedAtMs={Date.now() - 2_000}
suppressFinal={suppressFinal} awaitingContinuation={awaitingContinuation}
continuedAfterSteering={continuedAfterSteering} continuedAfterSteering={continuedAfterSteering}
onRuntimeRequestDecision={onRuntimeRequestDecision} onRuntimeRequestDecision={onRuntimeRequestDecision}
/> />
@@ -158,7 +158,7 @@ export function TaskChatRunnerTurn({
finishedAtMs, finishedAtMs,
activityUnavailable = false, activityUnavailable = false,
allowSummaryFallback = true, allowSummaryFallback = true,
suppressFinal = false, awaitingContinuation = false,
waitSummary = null, waitSummary = null,
continuedAfterSteering = false, continuedAfterSteering = false,
showHeader = true, showHeader = true,
@@ -183,7 +183,7 @@ export function TaskChatRunnerTurn({
* provider final that only restates the wait is not presented as a reply. * provider final that only restates the wait is not presented as a reply.
* Prose the agent wrote for the user stays visible. * Prose the agent wrote for the user stays visible.
*/ */
suppressFinal?: boolean; awaitingContinuation?: boolean;
/** The accepted yielded result's wait summary, when known. */ /** The accepted yielded result's wait summary, when known. */
waitSummary?: string | null; waitSummary?: string | null;
/** The visible tail resumes the same native run after an accepted steer. */ /** The visible tail resumes the same native run after an accepted steer. */
@@ -204,7 +204,7 @@ export function TaskChatRunnerTurn({
item.surface === "run_result" && item.surface === "run_result" &&
item.disposition === "yielded", item.disposition === "yielded",
); );
const observedFinal = paperclipRunnerFinalResponse(turnItems, suppressFinal const observedFinal = paperclipRunnerFinalResponse(turnItems, awaitingContinuation
? { allowFallback: false, yielded: true, waitSummary } ? { allowFallback: false, yielded: true, waitSummary }
: { allowFallback: terminal && allowSummaryFallback }); : { allowFallback: terminal && allowSummaryFallback });
const observedProviderText = Boolean( const observedProviderText = Boolean(
@@ -229,7 +229,7 @@ export function TaskChatRunnerTurn({
// A provider final can arrive before the accepted yielded result. Clear any // A provider final can arrive before the accepted yielded result. Clear any
// replay latch once the control plane establishes that this turn is waiting // replay latch once the control plane establishes that this turn is waiting
// for continuation; only an authored (non-wait) final is re-latched below. // 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 ( if (
observedFinal && observedFinal &&
(!finalRef.current.item || (!finalRef.current.item ||