fix(runner): stabilize live response handoff

This commit is contained in:
Dotta committed 2026-08-21 15:56:15 -05:00
1 parent fab305db71
commit c31a345a3d
8 files changed
+162 -40

No files matched your search

+13 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { buildTranscript, type RunLogChunk } from "./transcript";
import { appendTranscriptEntry, buildTranscript, type RunLogChunk } from "./transcript";
import { grokLocalUIAdapter } from "./grok-local";
import type { UIAdapterModule } from "./types";
import type { TranscriptEntry, UIAdapterModule } from "./types";
describe("buildTranscript", () => {
const ts = "2026-03-20T13:00:00.000Z";
@@ -199,4 +199,15 @@ describe("buildTranscript", () => {
{ kind: "system", ts, text: "stop_reason=EndTurn session=sess-1" },
]);
});
it("never coalesces adjacent assistant deltas across progress and final channels", () => {
const entries: TranscriptEntry[] = [];
appendTranscriptEntry(entries, { kind: "assistant", ts, text: "Checking.", delta: true, channel: "progress" });
appendTranscriptEntry(entries, { kind: "assistant", ts, text: "Done.", delta: true, channel: "final" });
expect(entries).toEqual([
{ kind: "assistant", ts, text: "Checking.", delta: true, channel: "progress" },
{ kind: "assistant", ts, text: "Done.", delta: true, channel: "final" },
]);
});
});
+6 -1
View File
@@ -19,7 +19,12 @@ function resolveStdoutParser(source: StdoutLineParser | TranscriptParserSource)
export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) {
if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) {
const last = entries[entries.length - 1];
if (last && last.kind === entry.kind && last.delta) {
if (
last &&
last.kind === entry.kind &&
last.delta &&
last.channel === entry.channel
) {
last.text += entry.text;
last.ts = entry.ts;
return;
+50
View File
@@ -580,6 +580,56 @@ describe("TaskChatThread mobile composer dock (PAP-495)", () => {
});
describe("TaskChatThread live transcript", () => {
it("keeps a persisted runner reply hidden while the live turn still owns that response", () => {
transcriptState.transcriptByRun.set("run-runner", [
{
kind: "assistant",
ts: "2026-08-21T15:44:20.000Z",
text: "Completed the requested streaming test.",
channel: "final",
delta: true,
},
]);
const comment = {
id: "comment-runner",
companyId: "company-1",
issueId: "issue-1",
authorType: "agent" as const,
authorAgentId: "agent-1",
authorUserId: null,
body: "Completed the requested streaming test.",
presentation: null,
metadata: null,
runId: "run-runner",
createdAt: new Date("2026-08-21T15:44:22.000Z"),
updatedAt: new Date("2026-08-21T15:44:22.000Z"),
};
render(
<TaskChatThread
comments={[comment]}
onAdd={async () => {}}
issueStatus="in_progress"
activeRun={{
id: "run-runner",
status: "running",
invocationSource: "issue",
triggerDetail: null,
startedAt: "2026-08-21T15:44:00.000Z",
finishedAt: null,
createdAt: "2026-08-21T15:44:00.000Z",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
}}
/>,
);
expect(container.textContent?.match(/Completed the requested streaming test\./g)).toHaveLength(1);
expect(container.querySelectorAll('[data-testid="task-chat-agent-bubble"]')).toHaveLength(1);
expect(container.querySelector('[data-testid="task-chat-live-transcript"]')).not.toBeNull();
});
it("renders in-flight output through TaskChatLiveTail, dropping the debug plumbing (PAP-463 C1)", () => {
// Interleave the exact noise the old RunTranscriptView tail surfaced (init
// row, stdout/stderr/system dumps) with real content. Only the streamed
+36 -28
View File
@@ -250,6 +250,33 @@ export function TaskChatThread(props: TaskChatThreadProps) {
return (liveRuns ?? []).find((r) => isLiveIssueRun(r, issueStatus)) ?? null;
}, [activeRun, issueStatus, liveRuns]);
// The live runner turn remains the sole owner of its response until the
// canonical comment and settled activity are both ready. This state is
// established in a layout effect so the live lane never disappears for a
// paint between `liveRun` ending and the persistence handoff beginning.
const [settlingRun, setSettlingRun] = useState<{ id: string; startedAtMs: number | null } | null>(null);
const prevLiveRunRef = useRef<typeof liveRun>(null);
useLayoutEffect(() => {
if (liveRun) {
prevLiveRunRef.current = liveRun;
setSettlingRun((current) => (current && current.id !== liveRun.id ? null : current));
return;
}
const prev = prevLiveRunRef.current;
prevLiveRunRef.current = null;
if (!prev) return;
setSettlingRun({
id: prev.id,
startedAtMs: (prev.startedAt ? toMs(prev.startedAt) : null) ?? toMs(prev.createdAt),
});
}, [liveRun]);
const heldPaperclipRunnerRunId = liveRun?.adapterType === "paperclip_runner"
? liveRun.id
: settlingRun && runs.find((run) => run.id === settlingRun.id)?.adapterType === "paperclip_runner"
? settlingRun.id
: null;
// Runs observed non-terminal while mounted: their turns ANIMATE the fold when
// they settle. Runs already terminal at mount collapse instantly.
const liveSeenRef = useRef<Set<string>>(new Set());
@@ -283,6 +310,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
// two lists stay index-aligned.
const visibleComments = comments.filter((comment) => !comment.deletedAt);
visibleComments.forEach((comment, index) => {
// The live/settling runner lane already renders this run's final answer.
// Keep a just-persisted duplicate out of the backbone until the complete
// canonical bubble + Worked header can replace that lane atomically.
if (comment.runId === heldPaperclipRunnerRunId) return;
const item = commentItems[index];
if (!item) return;
entries.push({ ms: toMs(comment.createdAt), order: 1, id: item.id, item });
@@ -339,7 +370,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
return entries.sort(
(a, b) => a.ms - b.ms || a.order - b.order || a.id.localeCompare(b.id),
);
}, [comments, commentItems, interactions, timelineEvents, linkedRuns, liveRuns, planDocument]);
}, [comments, commentItems, interactions, timelineEvents, linkedRuns, liveRuns, planDocument, heldPaperclipRunnerRunId]);
// Boolean gate (stable across the host's per-render brief objects) so the
// heavy assembly memo doesn't recompute on every parent render.
@@ -475,32 +506,6 @@ export function TaskChatThread(props: TaskChatThreadProps) {
};
}, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun, hasBrief]);
// PAP-462 B4: the moment a run settles, `liveRun` flips to null — but its
// settled turn (or reply comment) can take seconds to arrive on the next
// comment refetch. Without a handoff the whole transcript unmounts that frame,
// blinking out already-streamed output before its settled form renders. Snapshot
// the just-settled run so its now-static transcript stays mounted through the gap.
const [settlingRun, setSettlingRun] = useState<{ id: string; startedAtMs: number | null } | null>(null);
const prevLiveRunRef = useRef<typeof liveRun>(null);
// Layout effect (not passive): snapshot the settled run before the browser
// paints the `liveRun === null` frame, so the tail never blinks empty for a
// frame between the run stopping and this snapshot committing.
useLayoutEffect(() => {
if (liveRun) {
prevLiveRunRef.current = liveRun;
// A fresh live run supersedes any lingering settled remnant.
setSettlingRun((current) => (current && current.id !== liveRun.id ? null : current));
return;
}
const prev = prevLiveRunRef.current;
prevLiveRunRef.current = null;
if (!prev) return;
setSettlingRun({
id: prev.id,
startedAtMs: (prev.startedAt ? toMs(prev.startedAt) : null) ?? toMs(prev.createdAt),
});
}, [liveRun]);
// Hand off once the settled turn or its reply comment is in the thread; a
// stopped run that yields neither is released by the backstop timeout so the
// tail never lingers indefinitely.
@@ -509,7 +514,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
const settlingIsPaperclipRunner = settlingRun != null &&
runs.find((run) => run.id === settlingRun.id)?.adapterType === "paperclip_runner";
const settledRunRendered = settlingRun != null &&
(settlingHasComment || (!settlingIsPaperclipRunner && settledRunIds.has(settlingRun.id)));
(settlingIsPaperclipRunner
? settlingHasComment && settledRunIds.has(settlingRun.id)
: settlingHasComment || settledRunIds.has(settlingRun.id));
useEffect(() => {
if (!settlingRun) return;
if (settledRunRendered) {
@@ -756,6 +763,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
<div data-testid="task-chat-live-transcript">
{paperclipRunnerTail || optimisticRunnerStartup ? (
<TaskChatRunnerTurn
runId={tailRunId}
items={tailItems}
status={optimisticRunnerStartup ? "queued" : tailStatus}
startedAtMs={tailStartedAtMs}
+11 -2
View File
@@ -20,6 +20,8 @@ import type { TaskChatMessageItem } from "./task-chat-model";
interface TaskChatBubbleProps {
item: TaskChatMessageItem;
/** Disable the entrance animation when replacing an already-visible live response. */
animateEntry?: boolean;
/** Action shown beside the queued state for an interruptible message. */
queuedAction?: ReactNode;
/**
@@ -65,7 +67,14 @@ function galleryItemForImage(src: string, name?: string): GalleryMediaItem {
};
}
export function TaskChatBubble({ item, queuedAction, attachedTurn, beforeTurn, actions }: TaskChatBubbleProps) {
export function TaskChatBubble({
item,
animateEntry = true,
queuedAction,
attachedTurn,
beforeTurn,
actions,
}: TaskChatBubbleProps) {
// Clicking an embedded image opens the full-screen lightbox (with download);
// arrow keys walk across the other images in the same bubble.
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
@@ -97,7 +106,7 @@ export function TaskChatBubble({ item, queuedAction, attachedTurn, beforeTurn, a
? -1
: Math.max(0, galleryItems.findIndex((galleryItem) => galleryItem.contentPath === lightboxSrc));
return (
<div className={cn("tc-enter-bubble flex w-full flex-col gap-1", isHuman ? "items-end" : "items-start")}>
<div className={cn("flex w-full flex-col gap-1", animateEntry && "tc-enter-bubble", isHuman ? "items-end" : "items-start")}>
{beforeTurn ? <div className="w-full pb-1">{beforeTurn}</div> : null}
{!isHuman && item.authorName ? (
<span className="flex items-center gap-2 px-1">
@@ -22,9 +22,9 @@ describe("TaskChatRunnerTurn", () => {
container.remove();
});
const render = (items: TaskChatItem[], status = "running") => act(() => root.render(
const render = (items: TaskChatItem[], status = "running", runId = "run-1") => act(() => root.render(
<ThemeProvider>
<TaskChatRunnerTurn items={items} status={status} startedAtMs={Date.now() - 2_000} toolSummary={null} />
<TaskChatRunnerTurn runId={runId} items={items} status={status} startedAtMs={Date.now() - 2_000} toolSummary={null} />
</ThemeProvider>,
));
@@ -48,12 +48,35 @@ describe("TaskChatRunnerTurn", () => {
it("streams the final response in its durable slot and hides current activity", () => {
render([
{ id: "p1", kind: "message", author: "agent", text: "Checking.", interstitial: true, channel: "progress" },
{ id: "f1", kind: "message", author: "agent", text: "Completed successfully.", channel: "final", streaming: true },
{ id: "f1", kind: "message", author: "agent", authorName: "Runner", text: "Completed successfully.", channel: "final", streaming: true },
]);
expect(container.querySelector('[data-testid="task-chat-final-response"]')?.textContent).toContain("Completed successfully.");
expect(container.querySelector('[data-testid="task-chat-agent-avatar"]')).not.toBeNull();
expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
});
it("keeps final text mounted through a transient replay gap", () => {
render([
{ id: "f1", kind: "message", author: "agent", authorName: "Runner", text: "Completed successfully.", channel: "final", streaming: true },
]);
render([{ id: "t1", kind: "tool", name: "Paperclip_finish", status: "completed" }]);
expect(container.querySelector('[data-testid="task-chat-final-response"]')?.textContent)
.toContain("Completed successfully.");
expect(container.querySelectorAll('[data-testid="task-chat-final-response"]')).toHaveLength(1);
});
it("clears replay-latched final text when the next run takes over the lane", () => {
render([
{ id: "f1", kind: "message", author: "agent", authorName: "Runner", text: "First answer.", channel: "final" },
], "running", "run-1");
render([], "running", "run-2");
expect(container.textContent).not.toContain("First answer.");
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.textContent)
.toContain("Thinking");
});
it("omits runner lifecycle and token noise while preserving useful history", () => {
render([
{ id: "session", kind: "marker", variant: "session_start", label: "Session started", detail: "session-id" },
@@ -1,8 +1,9 @@
import { useState } from "react";
import { useRef, useState } from "react";
import { Brain } from "lucide-react";
import { MarkdownBody } from "@/components/MarkdownBody";
import { cn } from "@/lib/utils";
import type { TaskChatItem, TaskChatMessageItem, TaskChatThinkingItem, TaskChatToolItem } from "./task-chat-model";
import { TaskChatBubble } from "./TaskChatBubble";
import { TaskChatLiveRunPill } from "./TaskChatLiveRunPill";
import { TaskChatLiveTail } from "./TaskChatLiveTail";
import { paperclipRunnerHistoryItems } from "./transcript-adapter";
@@ -42,12 +43,15 @@ function CurrentActivity({ items }: { items: readonly TaskChatItem[] }) {
}
export function TaskChatRunnerTurn({
runId,
items,
status,
startedAtMs,
finishedAtMs,
toolSummary,
}: {
/** Stable identity used to clear replay-latched final text for the next turn. */
runId?: string | null;
items: readonly TaskChatItem[];
status: string;
startedAtMs: number | null;
@@ -57,7 +61,18 @@ export function TaskChatRunnerTurn({
const [open, setOpen] = useState(false);
const historyItems = paperclipRunnerHistoryItems(items);
const progress = lastOf<TaskChatMessageItem>(items, (item): item is TaskChatMessageItem => item.kind === "message" && Boolean(item.interstitial));
const final = lastOf<TaskChatMessageItem>(items, (item): item is TaskChatMessageItem => item.kind === "message" && item.channel === "final");
const observedFinal = lastOf<TaskChatMessageItem>(items, (item): item is TaskChatMessageItem => item.kind === "message" && item.channel === "final");
// A reconnect/replay can briefly rebuild the transcript without the final
// item (or with an earlier, shorter prefix). Once durable answer text is on
// screen it must be monotonic for the lifetime of this mounted turn.
const finalRef = useRef<{ runId?: string | null; item?: TaskChatMessageItem }>({ runId });
if (finalRef.current.runId !== runId) {
finalRef.current = { runId };
}
if (observedFinal && (!finalRef.current.item || observedFinal.text.length >= finalRef.current.item.text.length)) {
finalRef.current.item = observedFinal;
}
const final = finalRef.current.item;
if (status === "queued") {
return (
@@ -96,8 +111,8 @@ export function TaskChatRunnerTurn({
</div>
) : null}
{final ? (
<div className="tc-enter-bubble px-1 py-2 text-sm text-foreground" data-testid="task-chat-final-response">
<MarkdownBody softBreaks linkIssueReferences>{final.text}</MarkdownBody>
<div data-testid="task-chat-final-response">
<TaskChatBubble item={final} />
</div>
) : null}
</div>
@@ -80,6 +80,7 @@ function renderItem(
return (
<TaskChatBubble
item={item}
animateEntry={!item.attachedTurn?.standaloneHeader}
actions={item.attachedTurn?.standaloneHeader ? actions : item.attachedTurn ? undefined : actions}
queuedAction={renderQueuedAction?.(item)}
beforeTurn={item.attachedTurn?.standaloneHeader ? turn : undefined}