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", () => {
nativeTranscriptState.transcriptByRun.set("native-question", [
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;
}
}
// 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,
@@ -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}
/>
@@ -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 ||