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

@@ -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];