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