mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
feat(runner): add OpenCode and durable runner UX
This commit is contained in:
1 parent
f34591016e
commit
4c3e30cdad
100 files changed
+7156
-412
No files matched your search
@@ -29,6 +29,8 @@ interface TaskChatBubbleProps {
|
||||
* Supplied by TaskChatThreadView when `item.attachedTurn` is set.
|
||||
*/
|
||||
attachedTurn?: ReactNode;
|
||||
/** New-runner Worked header, rendered above the durable final response. */
|
||||
beforeTurn?: ReactNode;
|
||||
/**
|
||||
* copy · 👍 · 👎 controls for an agent bubble's footer line (PAP-413).
|
||||
* Rendered here only for a runless reply (leading the bare timestamp); when
|
||||
@@ -63,7 +65,7 @@ function galleryItemForImage(src: string, name?: string): GalleryMediaItem {
|
||||
};
|
||||
}
|
||||
|
||||
export function TaskChatBubble({ item, queuedAction, attachedTurn, actions }: TaskChatBubbleProps) {
|
||||
export function TaskChatBubble({ item, 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);
|
||||
@@ -96,6 +98,7 @@ export function TaskChatBubble({ item, queuedAction, attachedTurn, actions }: Ta
|
||||
: 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")}>
|
||||
{beforeTurn ? <div className="w-full pb-1">{beforeTurn}</div> : null}
|
||||
{!isHuman && item.authorName ? (
|
||||
<span className="flex items-center gap-2 px-1">
|
||||
<Avatar size="sm" className="shrink-0" data-testid="task-chat-agent-avatar">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ChevronRight, Loader2 } from "lucide-react";
|
||||
import type { TranscriptEntry } from "../../adapters";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSecondTick } from "@/hooks/useSecondTick";
|
||||
@@ -47,6 +47,9 @@ export function TaskChatLiveRunPill({
|
||||
startedAtMs,
|
||||
finishedAtMs,
|
||||
toolSummary,
|
||||
runnerStyle = false,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
status: string;
|
||||
/** Run start (startedAt, falling back to createdAt) in ms, or null if unknown. */
|
||||
@@ -54,6 +57,10 @@ export function TaskChatLiveRunPill({
|
||||
/** Run finish in ms once terminal; drives the settled elapsed readout. */
|
||||
finishedAtMs?: number | null;
|
||||
toolSummary: string | null;
|
||||
/** Codex-style new-runner header: no spinner, whole-second compact duration. */
|
||||
runnerStyle?: boolean;
|
||||
expanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
}) {
|
||||
const active = !isTerminalRunStatus(status);
|
||||
// One shared page-wide ticker drives the live elapsed readout, matching the
|
||||
@@ -62,10 +69,42 @@ export function TaskChatLiveRunPill({
|
||||
|
||||
const elapsedMs =
|
||||
startedAtMs == null ? null : (active ? Date.now() : finishedAtMs ?? Date.now()) - startedAtMs;
|
||||
const elapsed = elapsedMs != null ? formatDurationWords(elapsedMs) : null;
|
||||
const elapsed = elapsedMs != null
|
||||
? runnerStyle
|
||||
? `${Math.max(0, Math.floor(elapsedMs / 1000))}s`
|
||||
: formatDurationWords(elapsedMs)
|
||||
: null;
|
||||
const verb = active ? "Working" : "Worked";
|
||||
const suffix = elapsed ? `for ${elapsed}` : null;
|
||||
|
||||
if (runnerStyle) {
|
||||
const content = (
|
||||
<>
|
||||
<span className={cn("text-sm font-medium text-foreground/80", active && "shimmer-text")}>{verb}</span>
|
||||
{suffix ? <span className="text-sm text-muted-foreground">{suffix}</span> : null}
|
||||
<ChevronRight
|
||||
className={cn("ml-auto h-3.5 w-3.5 text-muted-foreground transition-transform", expanded && "rotate-90")}
|
||||
aria-hidden
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return onToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full min-w-0 items-center gap-1.5 border-b border-border/70 px-1 py-2 text-left transition-colors hover:text-foreground"
|
||||
data-testid="task-chat-live-run-pill"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-1.5 border-b border-border/70 px-1 py-2" data-testid="task-chat-live-run-pill">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-2.5 px-1 py-2"
|
||||
|
||||
@@ -29,12 +29,18 @@ import { buildActivityPhases } from "./transcript-adapter";
|
||||
export function TaskChatLiveTail({
|
||||
items,
|
||||
emptyMessage,
|
||||
excludeFinal = false,
|
||||
}: {
|
||||
items: readonly TaskChatItem[];
|
||||
/** Shown when nothing renderable has streamed yet (queued / pre-first-token). */
|
||||
emptyMessage?: string;
|
||||
/** New-runner turn renders final-answer messages in its dedicated response slot. */
|
||||
excludeFinal?: boolean;
|
||||
}) {
|
||||
const rows = buildActivityPhases(items, true)
|
||||
const visibleItems = excludeFinal
|
||||
? items.filter((item) => item.kind !== "message" || item.interstitial)
|
||||
: items;
|
||||
const rows = buildActivityPhases(visibleItems, true)
|
||||
.map((item) => renderTailRow(item))
|
||||
.filter((row): row is ReactElement => row != null);
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { TaskChatRunnerTurn } from "./TaskChatRunnerTurn";
|
||||
import type { TaskChatItem } from "./task-chat-model";
|
||||
|
||||
describe("TaskChatRunnerTurn", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = (items: TaskChatItem[], status = "running") => act(() => root.render(
|
||||
<ThemeProvider>
|
||||
<TaskChatRunnerTurn items={items} status={status} startedAtMs={Date.now() - 2_000} toolSummary={null} />
|
||||
</ThemeProvider>,
|
||||
));
|
||||
|
||||
it("shows immediate Thinking before the first runner event", () => {
|
||||
render([], "queued");
|
||||
expect(container.textContent).toContain("Thinking");
|
||||
expect(container.textContent).not.toContain("Working");
|
||||
expect(container.textContent).not.toContain("Waiting for transcript");
|
||||
});
|
||||
|
||||
it("keeps progress separate from the replace-in-place current command", () => {
|
||||
render([
|
||||
{ id: "p1", kind: "message", author: "agent", text: "Running the exact command now.", interstitial: true, channel: "progress", streaming: true },
|
||||
{ id: "t1", kind: "tool", name: "command", rawName: "Bash", target: "for i in 1 2 3 4; do echo STREAM-$i; done", status: "in_progress", detail: "STREAM-1\n" },
|
||||
]);
|
||||
expect(container.querySelector('[data-testid="task-chat-progress-update"]')?.textContent).toContain("Running the exact command now.");
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.textContent).toContain("Running a command");
|
||||
expect(container.textContent).toContain("STREAM-$i");
|
||||
});
|
||||
|
||||
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 },
|
||||
]);
|
||||
expect(container.querySelector('[data-testid="task-chat-final-response"]')?.textContent).toContain("Completed successfully.");
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { 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 { TaskChatLiveRunPill } from "./TaskChatLiveRunPill";
|
||||
import { TaskChatLiveTail } from "./TaskChatLiveTail";
|
||||
import { toolTaxonomy } from "./tool-taxonomy";
|
||||
|
||||
function lastOf<T extends TaskChatItem>(items: readonly TaskChatItem[], predicate: (item: TaskChatItem) => item is T): T | undefined {
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (predicate(item)) return item;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function CurrentActivity({ items }: { items: readonly TaskChatItem[] }) {
|
||||
const activity = lastOf<TaskChatThinkingItem | TaskChatToolItem>(
|
||||
items,
|
||||
(item): item is TaskChatThinkingItem | TaskChatToolItem => item.kind === "thinking" || item.kind === "tool",
|
||||
);
|
||||
if (!activity || activity.kind === "thinking") {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-sm text-muted-foreground" data-testid="task-chat-current-activity">
|
||||
<Brain className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span className="shimmer-text shimmer-text-muted">Thinking</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const taxonomy = toolTaxonomy(activity.rawName ?? activity.name);
|
||||
const Icon = taxonomy.icon;
|
||||
const active = activity.status === "pending" || activity.status === "in_progress";
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 px-1 py-1.5 text-sm text-muted-foreground" data-testid="task-chat-current-activity">
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span className={cn("shrink-0", active && "shimmer-text shimmer-text-muted")}>{taxonomy.verbLabel}</span>
|
||||
{activity.target ? <span className="min-w-0 truncate font-mono text-xs">{activity.target}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskChatRunnerTurn({
|
||||
items,
|
||||
status,
|
||||
startedAtMs,
|
||||
finishedAtMs,
|
||||
toolSummary,
|
||||
}: {
|
||||
items: readonly TaskChatItem[];
|
||||
status: string;
|
||||
startedAtMs: number | null;
|
||||
finishedAtMs?: number | null;
|
||||
toolSummary: string | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
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");
|
||||
|
||||
if (status === "queued") {
|
||||
return (
|
||||
<div className="py-1" data-testid="task-chat-runner-turn" data-phase="startup">
|
||||
<CurrentActivity items={items} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col" data-testid="task-chat-runner-turn">
|
||||
<TaskChatLiveRunPill
|
||||
status={status}
|
||||
startedAtMs={startedAtMs}
|
||||
finishedAtMs={finishedAtMs}
|
||||
toolSummary={toolSummary}
|
||||
runnerStyle
|
||||
expanded={open}
|
||||
onToggle={() => setOpen((value) => !value)}
|
||||
/>
|
||||
<div className="tc-turn-fold" data-folded={open ? "false" : "true"} aria-hidden={!open}>
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 py-2">
|
||||
<TaskChatLiveTail items={items as TaskChatItem[]} excludeFinal />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!open ? (
|
||||
<div className="flex min-w-0 flex-col py-1">
|
||||
{progress ? (
|
||||
<div className="tc-enter-cot-line min-w-0 px-1 py-1.5 text-sm text-foreground/90" data-testid="task-chat-progress-update">
|
||||
<MarkdownBody softBreaks linkIssueReferences>{progress.text}</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
{!final ? <CurrentActivity items={items} /> : null}
|
||||
</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>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,8 @@ export function TaskChatThinking({ item }: { item: TaskChatThinkingItem }) {
|
||||
const [open, setOpen] = useState(item.streaming || !item.collapsed);
|
||||
const body = item.lines.join("\n").trim();
|
||||
if (!body) return null;
|
||||
const label = item.streaming ? "Reasoning…" : item.summaryLabel ?? "Reasoning";
|
||||
const baseLabel = item.channel === "detail" ? "Reasoning detail" : "Reasoning";
|
||||
const label = item.streaming ? `${baseLabel}…` : item.summaryLabel ?? baseLabel;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col text-xs" data-testid="task-chat-thinking">
|
||||
|
||||
@@ -69,21 +69,21 @@ function renderItem(
|
||||
// put when the tool history expands (PAP-413). The two paths are mutually
|
||||
// exclusive at runtime, so only one host ever mounts the node.
|
||||
const actions = renderMessageActions?.(item);
|
||||
const turn = item.attachedTurn ? (
|
||||
<TaskChatTurn
|
||||
item={item.attachedTurn}
|
||||
timestampPrefix={item.attachedTurn.standaloneHeader ? undefined : item.timestamp}
|
||||
leading={item.attachedTurn.standaloneHeader ? undefined : actions}
|
||||
renderChild={(child) => renderItem(child, onApprovalDecision)}
|
||||
/>
|
||||
) : undefined;
|
||||
return (
|
||||
<TaskChatBubble
|
||||
item={item}
|
||||
actions={actions}
|
||||
actions={item.attachedTurn?.standaloneHeader ? actions : item.attachedTurn ? undefined : actions}
|
||||
queuedAction={renderQueuedAction?.(item)}
|
||||
attachedTurn={
|
||||
item.attachedTurn ? (
|
||||
<TaskChatTurn
|
||||
item={item.attachedTurn}
|
||||
timestampPrefix={item.timestamp}
|
||||
leading={actions}
|
||||
renderChild={(child) => renderItem(child, onApprovalDecision)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
beforeTurn={item.attachedTurn?.standaloneHeader ? turn : undefined}
|
||||
attachedTurn={item.attachedTurn?.standaloneHeader ? undefined : turn}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,6 +124,13 @@ describe("TaskChatTurn", () => {
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders the new-runner settled header as Worked for N above the response", () => {
|
||||
renderTurn({ ...SETTLED, standaloneHeader: true });
|
||||
expect(summaryBtn()?.textContent).toContain("Worked for 38s");
|
||||
expect(summaryBtn()?.textContent).not.toContain("3 tools");
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("true");
|
||||
});
|
||||
|
||||
it("toggles open on summary click", () => {
|
||||
renderTurn(SETTLED);
|
||||
flushSync(() => summaryBtn()!.click());
|
||||
|
||||
@@ -85,7 +85,10 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: Ta
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="group flex items-center gap-2 px-1 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2 px-1 text-muted-foreground transition-colors hover:text-foreground",
|
||||
item.standaloneHeader ? "border-b border-border/70 py-2 text-sm" : "py-0.5 text-xs",
|
||||
)}
|
||||
data-testid="task-chat-turn-summary"
|
||||
>
|
||||
{timestampPrefix ? (
|
||||
@@ -94,9 +97,13 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: Ta
|
||||
<span aria-hidden className="text-(length:--text-micro)">·</span>
|
||||
</>
|
||||
) : null}
|
||||
<SummaryIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{item.summary.failed ? "Stopped" : "Worked"}</span>
|
||||
{turnSummaryMetrics(item.summary) ? (
|
||||
{!item.standaloneHeader ? <SummaryIcon className="h-3.5 w-3.5 shrink-0" /> : null}
|
||||
<span>
|
||||
{item.standaloneHeader && item.summary.durationLabel
|
||||
? `${item.summary.failed ? "Stopped" : "Worked"} for ${item.summary.durationLabel}`
|
||||
: item.summary.failed ? "Stopped" : "Worked"}
|
||||
</span>
|
||||
{!item.standaloneHeader && turnSummaryMetrics(item.summary) ? (
|
||||
// Time/tools/tokens is demoted, not deleted (PAP-502): it stays in the
|
||||
// DOM (and the accessible tree) but fades in only on hover/focus so the
|
||||
// settled line reads as "2:34 PM · ✓ Worked" at rest. Revealed too when
|
||||
@@ -110,7 +117,7 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: Ta
|
||||
{turnSummaryMetrics(item.summary)}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open ? "rotate-90" : null)} />
|
||||
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", item.standaloneHeader && "ml-auto", open ? "rotate-90" : null)} />
|
||||
</button>
|
||||
) : parentRow ? (
|
||||
// The pill renders the expand button itself, wrapped around only the
|
||||
|
||||
@@ -80,14 +80,10 @@ export interface TaskChatMessageItem {
|
||||
* "for {user}" chip beside the author name (the open cross-task write design (attribution)).
|
||||
*/
|
||||
onBehalfOfUserName?: string;
|
||||
/**
|
||||
* Agent text streamed inside a run turn (interstitial updates between tool
|
||||
* calls). Ephemeral in the redesigned view (PAP-361): while streaming it
|
||||
* takes the live parent row's line (TaskChatStatusItem.selfTalk); once
|
||||
* finished it renders nowhere — the run log / classic transcript remain the
|
||||
* archive. Tagged for live and settled transcripts alike.
|
||||
*/
|
||||
/** A transient provider progress update, distinct from the durable final response. */
|
||||
interstitial?: boolean;
|
||||
/** Normalized runner message channel. Only paperclip_runner currently emits it. */
|
||||
channel?: "progress" | "final" | "unknown";
|
||||
/** Epoch ms of the message's first streamed chunk. */
|
||||
atMs?: number;
|
||||
/**
|
||||
@@ -121,6 +117,8 @@ export interface TaskChatThinkingItem {
|
||||
collapsed?: boolean;
|
||||
/** Human-readable elapsed label for the collapsed header. */
|
||||
summaryLabel?: string;
|
||||
/** Provider-emitted reasoning surface; never synthesized by the UI. */
|
||||
channel?: "summary" | "detail" | "unknown";
|
||||
}
|
||||
|
||||
/** A tool invocation row (ACP tool_call / tool_call_update). */
|
||||
@@ -267,6 +265,8 @@ export interface TaskChatTurnItem {
|
||||
liveStatus?: TaskChatStatusItem;
|
||||
/** Animate the fold when settling (false = collapse instantly, e.g. history). */
|
||||
animateFold?: boolean;
|
||||
/** New-runner turns keep the Worked header standalone above the final response. */
|
||||
standaloneHeader?: boolean;
|
||||
summary: {
|
||||
/** e.g. "38s" — omitted when unknown. */
|
||||
durationLabel?: string;
|
||||
|
||||
@@ -64,10 +64,10 @@ export const TASK_CHAT_STATE_META: Record<TaskChatStateId, TaskChatStateMeta> =
|
||||
},
|
||||
"agent-message": {
|
||||
id: "agent-message",
|
||||
label: "Agent message",
|
||||
label: "Final response",
|
||||
tier: "live",
|
||||
surface: "thread",
|
||||
protocol: "text_delta stream:output (ACP agent_message_chunk)",
|
||||
protocol: 'PRP item.delta kind:"agentMessage" channel:"final"',
|
||||
},
|
||||
thinking: {
|
||||
id: "thinking",
|
||||
@@ -78,14 +78,14 @@ export const TASK_CHAT_STATE_META: Record<TaskChatStateId, TaskChatStateMeta> =
|
||||
},
|
||||
responding: {
|
||||
id: "responding",
|
||||
label: "Responding (streaming)",
|
||||
label: "Progress update (streaming)",
|
||||
tier: "live",
|
||||
surface: "thread",
|
||||
protocol: "text_delta stream:output, streaming",
|
||||
protocol: 'PRP item.delta kind:"agentMessage" channel:"progress"',
|
||||
},
|
||||
"responding-burst": {
|
||||
id: "responding-burst",
|
||||
label: "Responding (update burst)",
|
||||
label: "Progress update burst",
|
||||
tier: "live",
|
||||
surface: "thread",
|
||||
protocol: "text_delta stream:output ×N, tool calls between (PAP-368 dwell)",
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("toolTaxonomy", () => {
|
||||
verbLabel: "Running a command",
|
||||
});
|
||||
expect(toolTaxonomy("Shell").family).toBe("terminal");
|
||||
expect(toolTaxonomy("command").family).toBe("terminal");
|
||||
|
||||
expect(toolTaxonomy("Grep")).toEqual({
|
||||
family: "grep",
|
||||
|
||||
@@ -88,7 +88,7 @@ function classify(n: string): Exclude<ToolFamily, "mcp"> {
|
||||
if (first === "edit" || first === "write" || n === "multiedit" || n === "notebookedit") {
|
||||
return "edit";
|
||||
}
|
||||
if (first === "bash" || first === "shell" || first === "run" || n.includes("terminal")) {
|
||||
if (first === "bash" || first === "shell" || first === "run" || first === "command" || n.includes("terminal")) {
|
||||
return "terminal";
|
||||
}
|
||||
// Content grep is its own action (search-code glyph); file-name globbing and
|
||||
|
||||
@@ -407,6 +407,24 @@ describe("settledRunChildren (PAP-361)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("paperclip runner semantic channels", () => {
|
||||
it("keeps progress/final messages and reasoning summary/detail in distinct items", () => {
|
||||
const parsed = transcriptToTaskChatItems([
|
||||
{ kind: "assistant", ts: TS, text: "Checking.", channel: "progress", delta: true },
|
||||
{ kind: "thinking", ts: TS, text: "Summary", channel: "summary", delta: true },
|
||||
{ kind: "thinking", ts: TS, text: "Detail", channel: "detail", delta: true },
|
||||
{ kind: "assistant", ts: TS, text: "Done.", channel: "final", delta: true },
|
||||
], { runId: "run-channels", running: true });
|
||||
expect(parsed.map((item) => item.kind)).toEqual(["message", "thinking", "thinking", "message"]);
|
||||
expect(parsed[0]).toMatchObject({ interstitial: true, channel: "progress" });
|
||||
expect(parsed[1]).toMatchObject({ channel: "summary" });
|
||||
expect(parsed[2]).toMatchObject({ channel: "detail" });
|
||||
expect(parsed[3]).toMatchObject({ interstitial: false, channel: "final" });
|
||||
const history = settledRunChildren(parsed);
|
||||
expect(JSON.stringify(history)).not.toContain("Done.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMergedTurnSummary (PAP-362)", () => {
|
||||
const runA: TranscriptEntry[] = [
|
||||
{ kind: "tool_call", ts: "2026-07-31T12:00:00.000Z", name: "Read", toolUseId: "a-1" } as TranscriptEntry,
|
||||
|
||||
@@ -168,18 +168,22 @@ export function transcriptToTaskChatItems(
|
||||
const thinkingStartTs = new Map<number, string>();
|
||||
let lastToolIndex = -1;
|
||||
let thinkingIndex = -1;
|
||||
let thinkingChannel: "summary" | "detail" | "unknown" | undefined;
|
||||
let messageIndex = -1;
|
||||
let messageChannel: "progress" | "final" | "unknown" | undefined;
|
||||
|
||||
const resetInline = () => {
|
||||
thinkingIndex = -1;
|
||||
thinkingChannel = undefined;
|
||||
messageIndex = -1;
|
||||
messageChannel = undefined;
|
||||
};
|
||||
|
||||
for (const [i, entry] of entries.entries()) {
|
||||
switch (entry.kind) {
|
||||
case "thinking": {
|
||||
if (!entry.text) break;
|
||||
if (thinkingIndex >= 0) {
|
||||
if (thinkingIndex >= 0 && thinkingChannel === entry.channel) {
|
||||
const it = items[thinkingIndex];
|
||||
if (it.kind === "thinking") {
|
||||
it.lines.push(...entry.text.split("\n"));
|
||||
@@ -196,16 +200,20 @@ export function transcriptToTaskChatItems(
|
||||
// Settled history folds its thinking behind the header (v7);
|
||||
// the in-flight run streams it expanded.
|
||||
collapsed: !running,
|
||||
channel: entry.channel,
|
||||
});
|
||||
thinkingIndex = items.length - 1;
|
||||
thinkingChannel = entry.channel;
|
||||
thinkingStartTs.set(thinkingIndex, entry.ts);
|
||||
messageIndex = -1;
|
||||
messageChannel = undefined;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "assistant": {
|
||||
if (!entry.text) break;
|
||||
if (messageIndex >= 0) {
|
||||
const channel = entry.channel;
|
||||
if (messageIndex >= 0 && messageChannel === channel) {
|
||||
const it = items[messageIndex];
|
||||
if (it.kind === "message") it.text += entry.text;
|
||||
} else {
|
||||
@@ -217,12 +225,15 @@ export function transcriptToTaskChatItems(
|
||||
authorName: agentName,
|
||||
text: entry.text,
|
||||
streaming: running,
|
||||
// Everything the agent says inside a run turn is self-talk until it
|
||||
// lands as the posted comment — live and history tag it alike.
|
||||
interstitial: true,
|
||||
// Explicit final-answer items stream durably. Unclassified text
|
||||
// remains transient for backwards compatibility and fail-closed
|
||||
// handling of a delta whose start metadata was missed.
|
||||
interstitial: channel !== "final",
|
||||
channel,
|
||||
atMs: Number.isFinite(atMs) ? atMs : undefined,
|
||||
});
|
||||
messageIndex = items.length - 1;
|
||||
messageChannel = channel;
|
||||
thinkingIndex = -1;
|
||||
}
|
||||
break;
|
||||
@@ -440,6 +451,9 @@ export function buildActivityPhases(
|
||||
const lastVisible = [...parsed].reverse().find((item) => item.kind !== "thinking");
|
||||
for (const item of parsed) {
|
||||
if (item.kind === "message") {
|
||||
// Durable final-answer text is rendered beside the Worked header, never
|
||||
// duplicated inside its expandable activity history.
|
||||
if (!item.interstitial) continue;
|
||||
// A settled transcript's trailing assistant text is the posted reply.
|
||||
// Live/settle-gap tails keep it visible until that canonical reply lands.
|
||||
if (!running && item === lastVisible) continue;
|
||||
|
||||
Reference in new issue
Block a user