mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
fix(runner): stabilize live response handoff
This commit is contained in:
1 parent
fab305db71
commit
c31a345a3d
8 files changed
+162
-40
No files matched your search
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user