From b1ccf0f9bc9c775043965fdaaab45d91da0652f2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 5 Nov 2026 13:29:58 -0600 Subject: [PATCH] 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 --- ui/src/components/TaskChatThread.test.tsx | 150 ++++++++++++++++++ ui/src/components/TaskChatThread.tsx | 31 +++- .../task-chat/TaskChatRunnerTurn.tsx | 19 ++- .../task-chat/transcript-adapter.test.ts | 37 +++++ .../task-chat/transcript-adapter.ts | 64 ++++++-- 5 files changed, 276 insertions(+), 25 deletions(-) diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index aeaad6723b3..e54020b99c2 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -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, + ) => + ({ + 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"), diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 09b1edb3386..b6f6148d49d 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -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 diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx index 40be13ab220..b89cc14d40d 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx @@ -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 && diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index c36896bbdea..eeec30b38a4 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -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", diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index c089299e570..6dbd57519ff 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -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 .") 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];