mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
Reconcile Pi prerequisites with the integrated runtime while holding admission
This commit is contained in:
1 parent
20ce062701
commit
f620e7157b
313 files changed
+13086
-2187
No files matched your search
@@ -231,4 +231,22 @@ describe("TaskChatProtocolActivityRow", () => {
|
||||
expect(row?.querySelector("details")).toBeNull();
|
||||
expect(row?.querySelector('[aria-expanded]')).toBeNull();
|
||||
});
|
||||
it.each([
|
||||
["info", "informational", "Provider update", "lucide-info"],
|
||||
["warning", "informational", "Warning", "lucide-triangle-alert"],
|
||||
["error", "failed", "Error", "lucide-triangle-alert"],
|
||||
["error", "informational", "Error", "lucide-triangle-alert"],
|
||||
["info", "failed", "Error", "lucide-triangle-alert"],
|
||||
] as const)("renders %s/%s notice severity without hiding its summary", (severity, status, label, icon) => {
|
||||
const summary = "Pi estimates this turn at $0.000617. Billing cost is unverified.";
|
||||
render({
|
||||
id: "notice", kind: "protocol", surface: "provider_activity", family: "provider_notice",
|
||||
eventType: "provider.notice.recorded", status, title: "Provider notice", summary,
|
||||
details: [{ label: "Severity", value: severity }], steps: [], links: [], children: [],
|
||||
});
|
||||
expect(container.textContent).toContain(label);
|
||||
expect(container.querySelector("p")?.textContent).toBe(summary);
|
||||
expect(container.querySelector('[data-testid="task-chat-protocol-activity-icon"]')?.classList.contains(icon)).toBe(true);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
protocolActivityIsRunning,
|
||||
protocolActivityLabel,
|
||||
protocolActivityPresentation,
|
||||
providerNoticeSeverity,
|
||||
} from "./task-chat-activity-presentation";
|
||||
|
||||
const COMPACT_RESEARCH_RESULT_LIMIT = 5;
|
||||
@@ -281,14 +281,16 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt
|
||||
const presentation = protocolActivityPresentation(item);
|
||||
if (!presentation) return null;
|
||||
if (item.surface === "provider_activity" && item.family === "provider_notice") {
|
||||
const NoticeIcon = presentation.icon;
|
||||
const severity = providerNoticeSeverity(item);
|
||||
const summary = item.summary
|
||||
?? item.details.find((entry) => entry.label === "Summary")?.value
|
||||
?? "The provider reported a notice without a message.";
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5 py-1 text-xs" data-testid="task-chat-protocol-activity-row" data-activity-family="provider_notice">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" aria-hidden data-testid="task-chat-protocol-activity-icon" />
|
||||
<span className="font-medium">{item.status === "failed" ? "Error" : item.details.find(detail => detail.label === "Severity")?.value === "info" ? "Provider update" : "Warning"}</span>
|
||||
<NoticeIcon className="h-3.5 w-3.5 shrink-0" aria-hidden data-testid="task-chat-protocol-activity-icon" />
|
||||
<span className="font-medium">{severity === "error" ? "Error" : severity === "info" ? "Provider update" : "Warning"}</span>
|
||||
</div>
|
||||
<p className="min-w-0 whitespace-pre-wrap break-words text-foreground">{summary}</p>
|
||||
{item.details.some(detail => detail.label !== "Summary") ? (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AlertTriangle, Info } from "lucide-react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { completedActivitySummary } from "./completed-activity-summary";
|
||||
import type {
|
||||
@@ -141,4 +142,48 @@ describe("completedActivitySummary", () => {
|
||||
completedActivitySummary([tool("mcp__github__get_pull_request")]).label,
|
||||
).toBe("Used connected tools");
|
||||
});
|
||||
it("shows the preserved pricing estimate as informational, not a billing receipt", () => {
|
||||
const summary = "Pi estimates this turn at $0.000617 from its model prices. Billing cost is unverified.";
|
||||
expect(completedActivitySummary([{
|
||||
...provider("provider_notice", "informational"), summary,
|
||||
details: [{ label: "Severity", value: "info" }],
|
||||
}])).toEqual({ label: summary, fullLabel: summary, icon: Info });
|
||||
});
|
||||
it.each(["warning", "error", undefined])("retains warning/error icons and safe notice text (%s)", (severity) => {
|
||||
const summary = "Provider request needs attention.";
|
||||
const result = completedActivitySummary([{
|
||||
...provider("provider_notice", severity === "error" ? "failed" : "informational"),
|
||||
details: [
|
||||
{ label: "Summary", value: summary },
|
||||
...(severity ? [{ label: "Severity", value: severity }] : []),
|
||||
],
|
||||
}]);
|
||||
expect(result).toEqual({ label: summary, fullLabel: summary, icon: AlertTriangle });
|
||||
});
|
||||
it("uses a generic fallback without promoting arbitrary detail fields", () => {
|
||||
expect(completedActivitySummary([{
|
||||
...provider("provider_notice", "informational"), summary: " ",
|
||||
details: [{ label: "Raw input", value: "private-command-or-path" }],
|
||||
}]).label).toBe("Received a provider update");
|
||||
});
|
||||
it("does not hide a failed notice behind informational metadata", () => {
|
||||
expect(completedActivitySummary([{
|
||||
...provider("provider_notice", "failed"), summary: "Provider failed.",
|
||||
details: [{ label: "Severity", value: "info" }],
|
||||
}]).icon).toBe(AlertTriangle);
|
||||
});
|
||||
|
||||
it("groups distinct notices and keeps a later error visible beside actual work", () => {
|
||||
const notices = ["info", "info", "warning", "error"].map((severity, index) => ({
|
||||
...provider("provider_notice", "informational"),
|
||||
id: `notice-${index}`,
|
||||
summary: `Distinct provider message ${index}`,
|
||||
details: [{ label: "Severity", value: severity }],
|
||||
}));
|
||||
const result = completedActivitySummary([tool("bash"), ...notices]);
|
||||
expect(result.label).toBe("Provider error reported, ran commands");
|
||||
expect(result.fullLabel).toBe(result.label);
|
||||
expect(result.label).not.toContain("Distinct provider message");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type ToolFamily,
|
||||
type ToolIcon,
|
||||
} from "./tool-taxonomy";
|
||||
import { protocolActivityPresentation } from "./task-chat-activity-presentation";
|
||||
import { protocolActivityPresentation, providerNoticeSeverity } from "./task-chat-activity-presentation";
|
||||
|
||||
type Activity = TaskChatActivityPhaseItem["items"][number];
|
||||
const labels: Record<ToolFamily, string> = {
|
||||
@@ -42,6 +42,12 @@ export function completedActivitySummary(items: Activity[]) {
|
||||
completed: boolean;
|
||||
order: number;
|
||||
}> = [];
|
||||
const notices: Array<{
|
||||
summary: string | undefined;
|
||||
severity: "info" | "warning" | "error";
|
||||
icon: ToolIcon;
|
||||
order: number;
|
||||
}> = [];
|
||||
for (const [order, item] of items.entries()) {
|
||||
if (item.kind === "tool") {
|
||||
const p = toolActivityPresentation({
|
||||
@@ -67,6 +73,13 @@ export function completedActivitySummary(items: Activity[]) {
|
||||
} else {
|
||||
const p = protocolActivityPresentation(item);
|
||||
if (!p) continue;
|
||||
if (item.surface === "provider_activity" && item.family === "provider_notice") {
|
||||
// Reuse only the normalized notice text already available in the row.
|
||||
const summary = item.summary?.trim()
|
||||
|| item.details.find((entry) => entry.label === "Summary")?.value.trim();
|
||||
notices.push({ summary, severity: providerNoticeSeverity(item), icon: p.icon, order });
|
||||
continue;
|
||||
}
|
||||
const family =
|
||||
item.surface === "provider_activity" ? item.family : item.surface;
|
||||
const label =
|
||||
@@ -84,7 +97,6 @@ export function completedActivitySummary(items: Activity[]) {
|
||||
safety: "Reviewed safety",
|
||||
terminal: "Ran commands",
|
||||
wait: "Waited",
|
||||
provider_notice: "Received a provider update",
|
||||
workspace_change: "Worked on files",
|
||||
workspace_file: "Referenced files",
|
||||
resource: "Added resources",
|
||||
@@ -94,6 +106,21 @@ export function completedActivitySummary(items: Activity[]) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (notices.length) {
|
||||
const strongest = notices.find((notice) => notice.severity === "error")
|
||||
?? notices.find((notice) => notice.severity === "warning")
|
||||
?? notices[0]!;
|
||||
const label = notices.length === 1
|
||||
? strongest.summary || "Received a provider update"
|
||||
: strongest.severity === "error"
|
||||
? "Provider error reported"
|
||||
: strongest.severity === "warning"
|
||||
? "Provider warning reported"
|
||||
: "Received provider updates";
|
||||
// One notice category leaves room for actual work. Keep warnings and errors
|
||||
// visible when the compact label truncates other activity categories.
|
||||
add(label, strongest.icon, strongest.severity === "info" ? notices[0]!.order : -1);
|
||||
}
|
||||
const completedFamilies = new Set(
|
||||
tools.filter((tool) => tool.completed).map((tool) => tool.family),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FilePenLine,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Info,
|
||||
ListChecks,
|
||||
PackageCheck,
|
||||
Search,
|
||||
@@ -40,6 +41,12 @@ export interface TaskChatActivityPresentation {
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export function providerNoticeSeverity(item: TaskChatProviderActivityItem): "info" | "warning" | "error" {
|
||||
const severity = item.details.find((entry) => entry.label === "Severity")?.value;
|
||||
if (item.status === "failed" || severity === "error") return "error";
|
||||
return severity === "info" ? "info" : "warning";
|
||||
}
|
||||
|
||||
function providerDetail(item: TaskChatProviderActivityItem, ...labels: string[]): string | undefined {
|
||||
return item.details.find((entry) => labels.includes(entry.label))?.value;
|
||||
}
|
||||
@@ -135,7 +142,7 @@ export function providerActivityPresentation(item: TaskChatProviderActivityItem)
|
||||
case "wait":
|
||||
return { icon: Clock3, runningLabel: "Waiting", completedLabel: "Finished waiting", failedLabel: "Wait failed", interruptedLabel: "Wait stopped", detail };
|
||||
case "provider_notice":
|
||||
return { icon: AlertTriangle, runningLabel: "Provider notice", completedLabel: "Provider notice", failedLabel: "Provider error", interruptedLabel: "Provider notice", detail };
|
||||
return { icon: providerNoticeSeverity(item) === "info" ? Info : AlertTriangle, runningLabel: "Provider notice", completedLabel: "Provider notice", failedLabel: "Provider error", interruptedLabel: "Provider notice", detail };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
splitTranscriptAtAnchors,
|
||||
toolDisplayName,
|
||||
transcriptToTaskChatItems,
|
||||
runtimeRequestSegmentContext,
|
||||
reconcileSegmentRuntimeRequests,
|
||||
type SettledTurnMergeMeta,
|
||||
type ThreadBackboneEntry,
|
||||
} from "./transcript-adapter";
|
||||
@@ -32,11 +34,43 @@ import type {
|
||||
TaskChatPlanDocumentItem,
|
||||
TaskChatTurnItem,
|
||||
} from "./task-chat-model";
|
||||
import { latestPendingRuntimeRequest } from "./task-chat-model";
|
||||
import { providerActivityPresentation } from "./task-chat-activity-presentation";
|
||||
import { nativeRunEventsToTranscript } from "../transcript/native-run-events";
|
||||
|
||||
const TS = "2026-07-31T12:00:00.000Z";
|
||||
|
||||
describe("runtime requests across steering sections", () => {
|
||||
it("selects the newer request when an older pending permission crosses steering", () => {
|
||||
const older: TranscriptEntry = { kind: "runtime_request", ts: TS, requestId: "older-permission",
|
||||
requestKind: "permission_approval", turnId: "provider-turn", requestType: "permission", status: "pending",
|
||||
prompt: "Earlier write", choices: [{ key: "decline", label: "Deny" }], fields: [] };
|
||||
const newer: TranscriptEntry = { ...older, ts: "2026-07-31T12:00:02.000Z", requestId: "newer-permission", prompt: "Later write" };
|
||||
const options = { runId: "run-steering", running: true };
|
||||
const context = runtimeRequestSegmentContext([older, newer], options);
|
||||
const tail = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems([newer], options), [newer], context, true);
|
||||
expect(tail.map(item => item.id)).toEqual(["run-steering:runtime-request:older-permission", "run-steering:runtime-request:newer-permission"]);
|
||||
expect(latestPendingRuntimeRequest(tail)?.requestId).toBe("newer-permission");
|
||||
});
|
||||
|
||||
it.each(["pending", "resolved", "cancelled"] as const)("keeps one %s permission under whole-run authority", status => {
|
||||
const request: TranscriptEntry = { kind: "runtime_request", ts: TS, requestId: "permission-1",
|
||||
requestKind: "permission_approval", turnId: "provider-turn", requestType: "permission", status: "pending",
|
||||
prompt: "Pi write", choices: [{ key: "decline", label: "Deny" }], fields: [] };
|
||||
const after: TranscriptEntry[] = [{ kind: "assistant", ts: "2026-07-31T12:00:01.000Z", text: "Continued.", channel: "progress" }];
|
||||
if (status === "resolved") after.push({ ...request, ts: "2026-07-31T12:00:02.000Z", status, resolvedAction: "decline" });
|
||||
const entries = [request, ...after];
|
||||
const options = { runId: "run-steering", running: status !== "cancelled" };
|
||||
const context = runtimeRequestSegmentContext(entries, options);
|
||||
const history = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems([request], { ...options, running: false }), [request], context);
|
||||
const tail = reconcileSegmentRuntimeRequests(transcriptToTaskChatItems(after, options), after, context, options.running);
|
||||
const cards = [...history, ...tail].filter(item => item.kind === "protocol" && item.surface === "runtime_request");
|
||||
expect(cards).toEqual([expect.objectContaining({ requestId: "permission-1", turnId: "provider-turn", status })]);
|
||||
expect(history.filter(item => item.kind === "protocol")).toHaveLength(status === "pending" ? 0 : 1);
|
||||
expect(tail.filter(item => item.kind === "protocol")).toHaveLength(status === "pending" ? 1 : 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("accepted native response-wake answers", () => {
|
||||
const runId = "native-response-wake";
|
||||
const summary =
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
TaskChatRunResultItem,
|
||||
TaskChatMessageItem,
|
||||
TaskChatPlanDocumentItem,
|
||||
TaskChatRuntimeRequestItem,
|
||||
} from "./task-chat-model";
|
||||
import {
|
||||
humanizeToolName,
|
||||
@@ -570,6 +571,52 @@ interface TranscriptAdapterOptions {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
/** Request authority follows the whole run, even when steering splits its display. */
|
||||
export function runtimeRequestSegmentContext(
|
||||
entries: readonly TranscriptEntry[],
|
||||
options: TranscriptAdapterOptions,
|
||||
) {
|
||||
const requests = entries.filter(entry => entry.kind === "runtime_request");
|
||||
const origins = new Map<string, TranscriptEntry>();
|
||||
for (const entry of requests) {
|
||||
if (!origins.has(entry.requestId)) origins.set(entry.requestId, entry);
|
||||
}
|
||||
const context = new Map<string, { item: TaskChatRuntimeRequestItem; origin: TranscriptEntry }>();
|
||||
for (const item of transcriptToTaskChatItems(requests, options)) {
|
||||
if (item.kind === "protocol" && item.surface === "runtime_request") {
|
||||
context.set(item.id, { item, origin: origins.get(item.requestId)! });
|
||||
}
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/** Carry live requests to the tail; retain closed cards at their original position. */
|
||||
export function reconcileSegmentRuntimeRequests(
|
||||
items: readonly TaskChatItem[],
|
||||
entries: readonly TranscriptEntry[],
|
||||
context: ReturnType<typeof runtimeRequestSegmentContext>,
|
||||
carryPending = false,
|
||||
): TaskChatItem[] {
|
||||
const owned = new Set(entries);
|
||||
const retained = new Set<string>();
|
||||
const result = items.flatMap<TaskChatItem>(item => {
|
||||
if (item.kind !== "protocol" || item.surface !== "runtime_request") return [item];
|
||||
const state = context.get(item.id);
|
||||
if (!state || (state.item.status === "pending" ? !carryPending : !owned.has(state.origin))) return [];
|
||||
retained.add(item.id);
|
||||
return [state.item];
|
||||
});
|
||||
const carried: TaskChatItem[] = [];
|
||||
if (carryPending) {
|
||||
for (const { item } of context.values()) {
|
||||
if (item.status === "pending" && !retained.has(item.id)) carried.push(item);
|
||||
}
|
||||
}
|
||||
// A carried request predates this section. Keep newer requests last so the
|
||||
// composer selects the latest input instead of reviving an older one.
|
||||
return [...carried, ...result];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a transcript into ordered items, coalescing consecutive thinking and
|
||||
* assistant deltas and threading tool_result/diff onto their tool_call. Mirrors
|
||||
|
||||
Reference in new issue
Block a user