fix(task-chat): keep a yielded turn's streamed reply visible

The first-task interview agent streams a reply, posts a question card,
and settles with reportedWorkDisposition "yielded". The live runner
tail hid that reply as soon as the card's pending interaction arrived,
and the settled turn hid it again because yielded turns never fill the
final-response slot. The reply stays in run history, so this was a
rendering regression, not data loss.

Yielded turns now hide only the control-plane wait stub (text that
echoes the yielded result summary or a single-line "Waiting for ...").
Prose that the agent wrote for the user stays in place through live,
settling, and settled states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-05 13:29:58 -06:00
1 parent 03eb49053f
commit b1ccf0f9bc
5 files changed
+276 -25

No files matched your search

+150
View File
@@ -3094,6 +3094,156 @@ describe("TaskChatThread runtime transcript selection", () => {
);
});
it("keeps a streamed interview reply visible after its run yields to a question card", () => {
// Mirrors a staging onboarding interview: the first-task agent streams a
// user-facing reply, posts an interview question card, and settles with
// reportedWorkDisposition "yielded". The reply must not vanish on settle.
const runId = "interview-run";
const reply =
"I've posted a question card on this task that asks four things. When you answer, I'll save a written plan.";
const event = (
seq: number,
eventType: string,
payload: Record<string, unknown>,
) =>
({
id: seq,
companyId: "company-1",
agentId: "agent-1",
stream: "system",
level: "info",
color: null,
message: null,
runId,
seq,
eventType,
createdAt: new Date("2026-08-25T18:00:00Z"),
payload: {
prpEvent: {
schema: "paperclip.prp.event.v1",
schemaVersion: 1,
runId,
eventType,
sourceEventId: `interview-${seq}`,
sourceKind:
eventType === "run.result.accepted" || eventType === "run.terminal"
? "control_plane"
: "runner",
sourceInstanceId: "runner-1",
sourceSeq: seq,
normalizedSessionId: "session-1",
emittedAt: `2026-08-25T18:00:${String(seq).padStart(2, "0")}Z`,
payload,
},
},
}) satisfies HeartbeatRunEvent;
const result = {
schema: "paperclip.run_result.v1",
reportedWorkDisposition: "yielded",
summary: "Waiting for user answers on the interview question card.",
continuation: {
kind: "response_wake",
idempotencyKey: "first-task-interview-wait-v1",
},
completionClaim: { objectiveSatisfied: false, remainingWork: [] },
attentionRequests: [],
evidence: [],
verification: [],
artifacts: [],
};
const streamed = event(2, "item.delta", {
kind: "agentMessage",
channel: "final",
text: reply,
item: { id: "reply", type: "agentMessage", channel: "final" },
});
const completed = event(3, "item.completed", {
kind: "agentMessage",
channel: "final",
text: reply,
item: { id: "reply", type: "agentMessage", channel: "final", text: reply },
});
const question = {
...questionInteraction(
"interview-questions",
"What does your organization do?",
"2026-08-25T18:00:04.000Z",
),
sourceRunId: runId,
} as IssueThreadInteraction;
const linkedRun = (status: "running" | "succeeded") => ({
runId,
runtimeMode: "native" as const,
status,
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
createdAt: "2026-08-25T18:00:00Z",
startedAt: "2026-08-25T18:00:00Z",
finishedAt: status === "running" ? null : "2026-08-25T18:00:30Z",
resultJson: status === "running" ? null : { nativeResult: result },
});
// 1. Live: the reply streams in and is visible.
nativeTranscriptState.transcriptByRun.set(
runId,
nativeRunEventsToTranscript([streamed]),
);
renderThread({
comments: [],
onAdd: async () => {},
issueStatus: "in_progress",
interactions: [],
activeRun: { ...nativeRunningRun(), id: runId },
linkedRuns: [linkedRun("running")],
});
expect(container.textContent).toContain("I've posted a question card");
// 2. Still live: the question card the reply describes lands first.
nativeTranscriptState.transcriptByRun.set(
runId,
nativeRunEventsToTranscript([streamed, completed]),
);
renderThread({
comments: [],
onAdd: async () => {},
issueStatus: "in_progress",
interactions: [question],
activeRun: { ...nativeRunningRun(), id: runId },
linkedRuns: [linkedRun("running")],
});
expect(container.textContent).toContain("I've posted a question card");
// 3. Settled: the run yielded to the question card it posted.
nativeTranscriptState.transcriptByRun.set(
runId,
nativeRunEventsToTranscript([
streamed,
completed,
event(4, "run.result.proposed", result),
event(5, "run.result.accepted", { result }),
event(6, "run.terminal", {
schema: "paperclip.prp.terminal.v1",
turnTerminalState: "completed",
runTerminalState: "succeeded",
reportedWorkDisposition: "yielded",
}),
]),
);
renderThread({
comments: [],
onAdd: async () => {},
issueStatus: "in_progress",
interactions: [question],
activeRun: undefined,
linkedRuns: [linkedRun("succeeded")],
});
expect(container.textContent).toContain("I've posted a question card");
expect(container.textContent).not.toContain(
"Waiting for user answers on the interview question card.",
);
});
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"),
+26 -5
View File
@@ -1763,10 +1763,23 @@ export function TaskChatThread(props: TaskChatThreadProps) {
(comment) => comment.id === sourcePresentationCommentId,
)?.body ?? null)
: null;
// A yielded wait is not an answer, but prose the agent wrote before it
// yielded (explaining the question card it posted, say) is. It streamed
// into view live, so the settled turn keeps it in place.
const sourceYieldedResponse =
sourceIsPaperclipRunner && sourceYielded && !sourceHasPresentationComment
? paperclipRunnerFinalResponse(parsedSource, {
runId: source.id,
agentName: meta?.agentName,
allowFallback: false,
yielded: true,
waitSummary: acceptedSummary,
})
: undefined;
const sourceHasNativeResponse =
sourceIsPaperclipRunner &&
!sourceYielded &&
(sourceHasPresentationComment ||
(Boolean(sourceYieldedResponse) || !sourceYielded) &&
(sourceHasPresentationComment || Boolean(sourceYieldedResponse) ||
Boolean(acceptedSummary) ||
entries.some(
(entry) =>
@@ -2068,7 +2081,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
};
});
const sourceResponseText =
sourceIsPaperclipRunner && !sourceYielded
sourceIsPaperclipRunner && sourceYielded
? sourceYieldedResponse?.text
: sourceIsPaperclipRunner
? (sourcePresentationText ??
paperclipRunnerFinalResponse(parsedSource, {
runId: source.id,
@@ -2094,8 +2109,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
const { segment, parsed } = projected;
if (segment.entries.length === 0) continue;
const finalResponse =
sourceIsPaperclipRunner &&
!sourceYielded &&
sourceIsPaperclipRunner && sourceYielded
? segmentIndex === lastPopulatedSegmentIndex
? sourceYieldedResponse
: undefined
: sourceIsPaperclipRunner &&
!sourceHasPresentationComment
? sourceAcceptedResponseWake
? // A steering anchor may split acceptance from its terminal.
@@ -3383,6 +3401,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
suppressFinal={
suppressPaperclipRunnerTailFinal
}
waitSummary={acceptedSemanticResultSummary(
tailRunId ? linkedRunMetaById.get(tailRunId)?.resultJson : null,
)}
continuedAfterSteering={
paperclipRunnerTail &&
tailTimelineAnchors.length > 0
@@ -159,6 +159,7 @@ export function TaskChatRunnerTurn({
activityUnavailable = false,
allowSummaryFallback = true,
suppressFinal = false,
waitSummary = null,
continuedAfterSteering = false,
showHeader = true,
onRuntimeRequestDecision,
@@ -177,8 +178,14 @@ export function TaskChatRunnerTurn({
finishedAtMs?: number | null;
activityUnavailable?: boolean;
allowSummaryFallback?: boolean;
/** Accepted wait/interaction authority overrides an early provider final. */
/**
* Accepted wait/interaction authority: the turn is waiting, so an early
* provider final that only restates the wait is not presented as a reply.
* Prose the agent wrote for the user stays visible.
*/
suppressFinal?: 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. */
continuedAfterSteering?: boolean;
/** The task thread keeps a shared header across native and legacy discovery. */
@@ -197,11 +204,9 @@ export function TaskChatRunnerTurn({
item.surface === "run_result" &&
item.disposition === "yielded",
);
const observedFinal = suppressFinal
? undefined
: paperclipRunnerFinalResponse(turnItems, {
allowFallback: terminal && allowSummaryFallback,
});
const observedFinal = paperclipRunnerFinalResponse(turnItems, suppressFinal
? { allowFallback: false, yielded: true, waitSummary }
: { allowFallback: terminal && allowSummaryFallback });
const observedProviderText = Boolean(
observedFinal &&
turnItems.some(
@@ -223,7 +228,7 @@ export function TaskChatRunnerTurn({
if (finalRef.current.runId !== runId) finalRef.current = { runId };
// 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 rather than presenting a durable assistant reply.
// for continuation; only an authored (non-wait) final is re-latched below.
if (yielded || suppressFinal) finalRef.current = { runId };
if (
observedFinal &&
@@ -1214,6 +1214,43 @@ describe("buildTurnTimelineRows (DOT-217)", () => {
).toBeUndefined();
});
it("keeps authored prose from a yielded turn but not its wait stub", () => {
const result = {
id: "result",
kind: "protocol",
surface: "run_result",
disposition: "yielded",
summary: "Waiting for user answers on the interview card.",
objectiveSatisfied: false,
verification: [],
remainingWork: [],
blocker: null,
artifacts: [],
} satisfies TaskChatItem;
const reply = {
id: "reply",
kind: "message",
author: "agent",
text: "I've posted a question card that asks four things.\n- what you do\n- what done looks like",
channel: "final",
streaming: false,
} satisfies TaskChatItem;
expect(paperclipRunnerFinalResponse([reply, result])).toBe(reply);
expect(paperclipRunnerFinalResponse([reply], { yielded: true })).toBe(reply);
expect(
paperclipRunnerFinalResponse(
[{ ...reply, text: "Waiting for Which tools should the storefront use?." }],
{ yielded: true },
),
).toBeUndefined();
expect(
paperclipRunnerFinalResponse([{ ...reply, text: result.summary }], {
yielded: true,
waitSummary: result.summary,
}),
).toBeUndefined();
});
it("keeps a saved plan at its write_document boundary", () => {
const plan: TaskChatPlanDocumentItem = {
id: "plan-doc:revision-1",
@@ -1422,6 +1422,24 @@ export function paperclipRunnerAcceptedResponseWake(
) ? result : undefined;
}
function normalizedWaitText(value: string) {
return value.replace(/\s+/g, " ").trim().toLowerCase();
}
/**
* A yielded turn's control-plane wait label ("Waiting for <card>.") that some
* providers echo as final prose. It duplicates the card, so it is not a reply.
*/
export function isYieldedWaitStub(
text: string,
waitSummaries: readonly (string | null | undefined)[] = [],
): boolean {
const normalized = normalizedWaitText(text);
if (!normalized) return true;
if (waitSummaries.some((summary) => summary && normalizedWaitText(summary) === normalized)) return true;
return !text.trim().includes("\n") && /^waiting for\b/.test(normalized);
}
/** Resolve a terminal reply without presenting ordinary yielded waits as answers. */
export function paperclipRunnerFinalResponse(
parsed: readonly TaskChatItem[],
@@ -1431,32 +1449,52 @@ export function paperclipRunnerFinalResponse(
fallbackSummary?: string | null;
/** Structured summaries are terminal fallbacks, never live partial replies. */
allowFallback?: boolean;
/** Run metadata established a yielded wait even if the transcript lacks its result. */
yielded?: boolean;
/** The yielded result's wait summary, when known outside the transcript. */
waitSummary?: string | null;
},
): TaskChatMessageItem | undefined {
// A yielded result is a control-plane wait boundary, not an assistant reply.
// Some providers historically labeled their "waiting for input" prose as a
// final message, so disposition must take precedence over message channel.
const yielded = parsed.some(
(item) =>
const yieldedResults = parsed.filter(
(item): item is TaskChatRunResultItem =>
item.kind === "protocol" &&
item.surface === "run_result" &&
item.disposition === "yielded",
);
if (yielded) {
if (yieldedResults.length > 0 || options?.yielded) {
const accepted =
options?.allowFallback === false
? undefined
: paperclipRunnerAcceptedResponseWake(parsed, options?.runId);
if (!accepted) return undefined;
return {
id: `${accepted.id}:final-response`,
kind: "message",
author: "agent",
authorName: options?.agentName,
text: accepted.summary.trim(),
channel: "final",
streaming: false,
};
if (accepted) {
return {
id: `${accepted.id}:final-response`,
kind: "message",
author: "agent",
authorName: options?.agentName,
text: accepted.summary.trim(),
channel: "final",
streaming: false,
};
}
// The wait itself is not an answer, but prose the agent wrote for the user
// before yielding (e.g. explaining the question card it just posted) is.
// It streamed into view while the turn was live; dropping it on settle
// makes the conversation flash and lose that reply.
const waitSummaries = [
options?.waitSummary,
...yieldedResults.map((result) => result.summary),
];
for (let index = parsed.length - 1; index >= 0; index -= 1) {
const item = parsed[index];
if (item.kind !== "message" || item.channel !== "final") continue;
if (isYieldedWaitStub(item.text, waitSummaries)) return undefined;
return item;
}
return undefined;
}
for (let index = parsed.length - 1; index >= 0; index -= 1) {
const item = parsed[index];