mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
feat(runner): stabilize native planning and review
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7e9d93f1d2
commit
edde864925
10 files changed
+460
-25
No files matched your search
@@ -253,7 +253,25 @@ pub fn run_durable_runner(
|
||||
)?;
|
||||
}
|
||||
if provider.is_some() {
|
||||
send_outbox(&mut client, &state, &mut sent_source_seq)?;
|
||||
if let Err(error) = send_outbox(&mut client, &state, &mut sent_source_seq) {
|
||||
// A run attachment deliberately rotates the immutable run binding and
|
||||
// makes the control plane close the old authenticated connection. The
|
||||
// command-result write can still succeed locally after the peer has
|
||||
// closed, so the first observable failure may be this subsequent outbox
|
||||
// flush. Treat every steady-state write failure like a receive-side
|
||||
// disconnect: preserve the durable suffix and reconnect under the
|
||||
// rotated lease identity instead of terminating runnerd.
|
||||
state.record_diagnostic(error.to_string());
|
||||
if state.lifecycle == "revoked" {
|
||||
store.save(&state)?;
|
||||
connection_lease_token.take();
|
||||
bootstrap_ticket.take();
|
||||
return Ok(());
|
||||
}
|
||||
state.reconnect_count += 1;
|
||||
store.save(&state)?;
|
||||
break true;
|
||||
}
|
||||
}
|
||||
if (state.stop_after_flush || state.lifecycle == "revoked")
|
||||
&& state.outbox.is_empty()
|
||||
@@ -617,6 +635,7 @@ fn provider_descriptor(
|
||||
Some(crate::codex_provider::ProviderRuntimeIdentity::LocalProcess { process_id, .. }) => Some(process_id),
|
||||
_ => None,
|
||||
},
|
||||
"agentProcessId": runtime.and_then(|item| item.agent_process_id()),
|
||||
}),
|
||||
Some(crate::codex_provider::ProviderConfig::Local(value)) => json!({
|
||||
"provider": provider_kind_name(value.kind),
|
||||
|
||||
@@ -95,6 +95,63 @@ describe("provider plan synchronization", () => {
|
||||
})).toBe("# Health check\n\n1. Add endpoint\n2. Verify it");
|
||||
});
|
||||
|
||||
it("decodes the native provider's compact plan reference into readable Markdown", () => {
|
||||
expect(semanticProviderPlanMarkdown({
|
||||
artifacts: [{
|
||||
kind: "native_provider_plan",
|
||||
ref: "native-provider-plan:health-check-endpoint-v1#1-register-GET-health-return-200-json-status-ok;2-add-API-tests",
|
||||
}],
|
||||
})).toBe([
|
||||
"# Health check endpoint",
|
||||
"",
|
||||
"1. Register GET /health return 200 JSON status ok",
|
||||
"2. Add API tests",
|
||||
].join("\n"));
|
||||
});
|
||||
|
||||
it("decodes a task-scoped native plan URI", () => {
|
||||
expect(semanticProviderPlanMarkdown({
|
||||
artifacts: [{
|
||||
kind: "native_provider_plan",
|
||||
ref: "native-plan://DOT-13/health-check#1-add-GET-health;2-add-tests",
|
||||
}],
|
||||
})).toBe("# Health check\n\n1. Add GET /health\n2. Add tests");
|
||||
});
|
||||
|
||||
it("retains readable Markdown embedded after a native provider plan reference", () => {
|
||||
expect(semanticProviderPlanMarkdown({
|
||||
artifacts: [{
|
||||
kind: "native_provider_plan",
|
||||
ref: "native-provider-plan:DOT-14-health-check-v1\n1. Add `GET /health`.\n2. Add tests.",
|
||||
}],
|
||||
})).toBe("# Health check\n\n1. Add `GET /health`.\n2. Add tests.");
|
||||
});
|
||||
|
||||
it("normalizes a plain numbered native provider plan", () => {
|
||||
expect(semanticProviderPlanMarkdown({
|
||||
artifacts: [{
|
||||
kind: "native_provider_plan",
|
||||
ref: "1. Add GET /health. | 2. Add tests. | 3. Document it.",
|
||||
}],
|
||||
})).toBe("# Plan\n\n1. Add GET /health.\n2. Add tests.\n3. Document it.");
|
||||
});
|
||||
|
||||
it("normalizes a task-labelled inline numbered plan", () => {
|
||||
expect(semanticProviderPlanMarkdown({
|
||||
artifacts: [{
|
||||
kind: "native_provider_plan",
|
||||
ref: "DOT-16 plan: (1) add GET /health; (2) add tests; (3) document it.",
|
||||
}],
|
||||
})).toBe("# Plan\n\n1. add GET /health\n2. add tests\n3. document it.");
|
||||
});
|
||||
|
||||
it("uses an explicitly numbered semantic summary when the artifact is opaque", () => {
|
||||
expect(semanticProviderPlanMarkdown({
|
||||
summary: "Native provider plan completed: 1) add GET /health; 2) add tests; 3) document it.",
|
||||
artifacts: [{ kind: "native_provider_plan", ref: "native-provider-plan:DOT-18:health-check" }],
|
||||
})).toBe("# Plan\n\n1. add GET /health\n2. add tests\n3. document it.");
|
||||
});
|
||||
|
||||
it("renders a bounded Markdown checklist without embedding provenance", () => {
|
||||
const markdown = providerPlanMarkdown({
|
||||
explanation: "Release safely",
|
||||
|
||||
@@ -123,8 +123,76 @@ export function semanticProviderPlanMarkdown(result: Record<string, unknown>): s
|
||||
const artifact = record(value);
|
||||
if (artifact.kind !== "native_provider_plan" || typeof artifact.ref !== "string") continue;
|
||||
const match = artifact.ref.match(/<proposed_plan>\s*([\s\S]*?)\s*<\/proposed_plan>/i);
|
||||
const markdown = (match?.[1] ?? artifact.ref).trim();
|
||||
if (markdown) return markdown.slice(0, 256_000);
|
||||
const completedMarkdown = match?.[1]?.trim();
|
||||
if (completedMarkdown) return completedMarkdown.slice(0, 256_000);
|
||||
|
||||
const embedded = artifact.ref.match(/^native-provider-plan:([^\n]+)\n([\s\S]+)$/i);
|
||||
if (embedded) {
|
||||
const title = embedded[1]!
|
||||
.replace(/^(?:DOT-\d+-)?/i, "")
|
||||
.replace(/-v\d+$/i, "")
|
||||
.replace(/-/g, " ")
|
||||
.trim();
|
||||
const body = embedded[2]!.trim();
|
||||
if (title && body) {
|
||||
return [`# ${title.charAt(0).toUpperCase()}${title.slice(1)}`, "", body]
|
||||
.join("\n")
|
||||
.slice(0, 256_000);
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\s*1\.\s+/.test(artifact.ref)) {
|
||||
const numberedPlan = artifact.ref
|
||||
.split(/\s+\|\s+(?=\d+\.\s+)/)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (numberedPlan) return `# Plan\n\n${numberedPlan}`.slice(0, 256_000);
|
||||
}
|
||||
|
||||
// Some qualified Codex builds use the artifact reference itself as a
|
||||
// compact, human-readable plan. Accept only an explicitly numbered form;
|
||||
// arbitrary opaque artifact references must never become plan documents.
|
||||
const inlineNumbered = artifact.ref.trim();
|
||||
if (/\(1\)\s+.+\(2\)\s+/s.test(inlineNumbered)) {
|
||||
const body = inlineNumbered
|
||||
.replace(/^DOT-\d+\s+plan:\s*/i, "")
|
||||
.replace(/^\(1\)\s*/, "1. ")
|
||||
.replace(/;\s*\((\d+)\)\s*/g, "\n$1. ")
|
||||
.trim();
|
||||
if (body) return `# Plan\n\n${body}`.slice(0, 256_000);
|
||||
}
|
||||
|
||||
const compact = artifact.ref.match(/^native-provider-plan:([^#]+)#(.+)$/i)
|
||||
?? artifact.ref.match(/^native-plan:\/\/[^/]+\/([^#]+)#(.+)$/i);
|
||||
if (!compact) continue;
|
||||
const humanize = (slug: string) => slug
|
||||
.replace(/\b(GET|POST|PUT|PATCH|DELETE)-([a-z0-9][a-z0-9-]*)/gi, (_whole, method: string, path: string) => `${method.toUpperCase()} /${path}`)
|
||||
.replace(/-/g, " ")
|
||||
.replace(/\bjson\b/gi, "JSON")
|
||||
.replace(/\bapi\b/gi, "API")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const title = humanize(compact[1]!.replace(/-v\d+$/i, ""));
|
||||
const steps = compact[2]!.split(";").flatMap((encoded) => {
|
||||
const parsed = encoded.match(/^\d+-(.+)$/);
|
||||
const sentence = humanize(parsed?.[1] ?? encoded);
|
||||
return sentence ? [sentence.charAt(0).toUpperCase() + sentence.slice(1)] : [];
|
||||
});
|
||||
if (!title || steps.length === 0) continue;
|
||||
return [`# ${title.charAt(0).toUpperCase()}${title.slice(1)}`, "", ...steps.map((step, index) => `${index + 1}. ${step}`)]
|
||||
.join("\n")
|
||||
.slice(0, 256_000);
|
||||
|
||||
}
|
||||
const hasNativePlanArtifact = artifacts.some((value) => record(value).kind === "native_provider_plan");
|
||||
const summary = typeof result.summary === "string" ? result.summary.trim() : "";
|
||||
const summaryPlan = hasNativePlanArtifact ? summary.match(/(?:^|:\s*)(1\)\s+[\s\S]+;\s*2\)\s+[\s\S]+;\s*3\)\s+[\s\S]+)$/) : null;
|
||||
if (summaryPlan) {
|
||||
const body = summaryPlan[1]!
|
||||
.replace(/^1\)\s*/, "1. ")
|
||||
.replace(/;\s*(\d+)\)\s*/g, "\n$1. ")
|
||||
.trim();
|
||||
if (body) return `# Plan\n\n${body}`.slice(0, 256_000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -194,6 +262,7 @@ export async function synchronizeCompletedProviderPlan(input: {
|
||||
if (!current || !revision?.id || current.latestRevisionId !== revision.id) {
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "conflict", baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: current?.latestRevisionNumber ?? null, currentRevisionId: current?.latestRevisionId ?? null, confirmationId: null };
|
||||
}
|
||||
let confirmationId: string;
|
||||
try {
|
||||
const confirmation = await issueThreadInteractionService(input.db).create(
|
||||
{ id: input.execution.binding.issueId, companyId: input.execution.binding.companyId },
|
||||
@@ -203,7 +272,7 @@ export async function synchronizeCompletedProviderPlan(input: {
|
||||
sourceRunId: input.execution.binding.runId,
|
||||
title: `Review plan revision ${revision.revisionNumber}`,
|
||||
summary: "Review the synchronized Paperclip plan.",
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: `Approve plan revision ${revision.revisionNumber}?`,
|
||||
@@ -225,19 +294,29 @@ export async function synchronizeCompletedProviderPlan(input: {
|
||||
} as never,
|
||||
{ agentId: input.execution.binding.agentId, runId: input.execution.binding.runId },
|
||||
);
|
||||
const currentIssue = await input.db.select({ status: issues.status }).from(issues)
|
||||
.where(eq(issues.id, input.execution.binding.issueId))
|
||||
.limit(1).then((rows) => rows[0] ?? null);
|
||||
if (currentIssue && currentIssue.status !== "review") {
|
||||
await issueService(input.db).update(input.execution.binding.issueId, {
|
||||
status: "review",
|
||||
actorAgentId: input.execution.binding.agentId,
|
||||
});
|
||||
}
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status, baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: revision.revisionNumber, currentRevisionId: revision.id, confirmationId: confirmation.id };
|
||||
confirmationId = confirmation.id;
|
||||
} catch {
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "approval_failed", baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: revision.revisionNumber, currentRevisionId: revision.id, confirmationId: null };
|
||||
}
|
||||
try {
|
||||
const currentIssue = await input.db.select({ status: issues.status }).from(issues)
|
||||
.where(eq(issues.id, input.execution.binding.issueId))
|
||||
.limit(1).then((rows) => rows[0] ?? null);
|
||||
if (currentIssue && currentIssue.status !== "in_review") {
|
||||
await issueService(input.db).update(input.execution.binding.issueId, {
|
||||
status: "in_review",
|
||||
actorAgentId: input.execution.binding.agentId,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
const settledIssue = await input.db.select({ status: issues.status }).from(issues)
|
||||
.where(eq(issues.id, input.execution.binding.issueId))
|
||||
.limit(1).then((rows) => rows[0] ?? null);
|
||||
if (settledIssue?.status !== "in_review") {
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "approval_failed", baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: revision.revisionNumber, currentRevisionId: revision.id, confirmationId };
|
||||
}
|
||||
}
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status, baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: revision.revisionNumber, currentRevisionId: revision.id, confirmationId };
|
||||
}
|
||||
|
||||
class SessionToolAuthorityRouter {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
@@ -24,6 +25,7 @@ describe("paperclip-runner real server vertical slice", () => {
|
||||
const agentId = "00000000-0000-4000-8000-000000000702";
|
||||
const issueId = "00000000-0000-4000-8000-000000000703";
|
||||
const runId = "00000000-0000-4000-8000-000000000704";
|
||||
const resumedRunId = "00000000-0000-4000-8000-000000000705";
|
||||
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("paperclip-runner-real-server-");
|
||||
@@ -73,10 +75,13 @@ describe("paperclip-runner real server vertical slice", () => {
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") throw new Error("Expected a TCP listener.");
|
||||
setupRunnerPrpWebSocketServer(server, { port: address.port });
|
||||
const stateDirectory = await mkdtemp(resolve(tmpdir(), "paperclip-runner-real-resume-"));
|
||||
const bundle = createRunnerdCodexTransport({
|
||||
runnerBinary: defaultCapabilityRunnerdBinary(),
|
||||
codexCommand: resolve(import.meta.dirname, "../../../../packages/paperclip-runner/runner/target/debug/fake-codex-app-server"),
|
||||
codexArgs: [],
|
||||
stateDirectory,
|
||||
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
|
||||
prpIdentity: {
|
||||
runnerInstanceId: "runner-real-server",
|
||||
environmentLeaseId: "lease-real-server",
|
||||
@@ -121,8 +126,77 @@ describe("paperclip-runner real server vertical slice", () => {
|
||||
run: { id: runId },
|
||||
});
|
||||
expect(bundle.evidence().diagnostics).toContain("runnerd authenticated to the durable PRP control plane");
|
||||
|
||||
await bundle.transport.close();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: resumedRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
runtimeMode: "native",
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "system",
|
||||
contextSnapshot: { issueId },
|
||||
});
|
||||
await db.update(issues).set({ executionRunId: resumedRunId }).where(eq(issues.id, issueId));
|
||||
const resumedAuthority = new PaperclipRunnerToolAuthority(db, {
|
||||
companyId,
|
||||
agentId,
|
||||
issueId,
|
||||
runId: resumedRunId,
|
||||
});
|
||||
const restored = createRunnerdCodexTransport({
|
||||
runnerBinary: defaultCapabilityRunnerdBinary(),
|
||||
codexCommand: resolve(import.meta.dirname, "../../../../packages/paperclip-runner/runner/target/debug/fake-codex-app-server"),
|
||||
codexArgs: [],
|
||||
stateDirectory,
|
||||
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
|
||||
resumeDynamicTools: resumedAuthority.definitions(),
|
||||
prpIdentity: {
|
||||
runnerInstanceId: "runner-real-server",
|
||||
environmentLeaseId: "lease-real-server",
|
||||
runId: resumedRunId,
|
||||
normalizedSessionId: "session-real-server",
|
||||
turnId: "turn-real-server-resumed",
|
||||
itemId: "item-real-server-resumed",
|
||||
},
|
||||
controlPlaneRegistration: (prp) => registerRunnerPrpAuthority({ runId: resumedRunId, authority: prp }),
|
||||
});
|
||||
restored.transport.setServerRequestHandler(async (request) => {
|
||||
const params = request.params as Record<string, unknown>;
|
||||
const result = await resumedAuthority.execute({
|
||||
tool: String(params.tool),
|
||||
callId: String(params.callId),
|
||||
arguments: params.arguments,
|
||||
});
|
||||
observedResults.push(result);
|
||||
return {
|
||||
success: true,
|
||||
contentItems: [{ type: "inputText", text: JSON.stringify({ ok: true, result }) }],
|
||||
};
|
||||
});
|
||||
try {
|
||||
await restored.transport.request("thread/read", {});
|
||||
await restored.transport.request("turn/start", {
|
||||
input: [{ type: "text", text: "Read the same task in a resumed process." }],
|
||||
});
|
||||
for await (const notification of restored.transport.notifications()) {
|
||||
if (notification.method === "turn/completed") break;
|
||||
}
|
||||
expect(observedResults).toHaveLength(2);
|
||||
expect(observedResults[1]).toMatchObject({
|
||||
activeTask: { id: issueId, identifier: "RRS-1" },
|
||||
run: { id: resumedRunId },
|
||||
});
|
||||
expect(restored.evidence().diagnostics).toContain(
|
||||
"runnerd restored its durable PRP session and provider thread",
|
||||
);
|
||||
} finally {
|
||||
await restored.transport.close();
|
||||
}
|
||||
} finally {
|
||||
await bundle.transport.close();
|
||||
await rm(stateDirectory, { recursive: true, force: true });
|
||||
server.closeAllConnections();
|
||||
server.close();
|
||||
}
|
||||
|
||||
@@ -874,6 +874,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
(item: TaskChatInteractionItem) => (
|
||||
<TaskChatInteractionCard
|
||||
item={item}
|
||||
planDocument={planDocument}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
@@ -897,6 +898,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
onSubmitInteractionVerdicts,
|
||||
imageUploadHandler,
|
||||
externalReferences,
|
||||
planDocument,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -14,11 +14,14 @@ import {
|
||||
Circle,
|
||||
CircleHelp,
|
||||
GitBranch,
|
||||
Lightbulb,
|
||||
ListChecks,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
MessageSquareQuote,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { IssueDocument } from "@paperclipai/shared";
|
||||
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -53,6 +56,7 @@ type SharedInteractionProps = Omit<
|
||||
|
||||
export interface TaskChatCompactInteractionCardProps extends SharedInteractionProps {
|
||||
interaction: IssueThreadInteraction;
|
||||
planDocument?: IssueDocument | null;
|
||||
}
|
||||
|
||||
const OTHER_ANSWER_ID = "__paperclip_other__";
|
||||
@@ -182,6 +186,86 @@ function CompactTarget({ interaction }: { interaction: RequestConfirmationIntera
|
||||
return <span className="inline-flex rounded-sm border border-border px-2 py-0.5 font-mono text-xs text-muted-foreground">{label}</span>;
|
||||
}
|
||||
|
||||
function planPreviewContent(markdown: string) {
|
||||
const lines = markdown
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const headingIndex = lines.findIndex((line) => /^#\s+/.test(line));
|
||||
const title = headingIndex >= 0
|
||||
? lines[headingIndex]!.replace(/^#\s+/, "").trim()
|
||||
: "Plan";
|
||||
const preview = lines
|
||||
.filter((_, index) => index !== headingIndex)
|
||||
.map((line) => line
|
||||
.replace(/^#{1,6}\s+/, "")
|
||||
.replace(/^[-*+]\s+/, "")
|
||||
.replace(/^\d+[.)]\s+/, "")
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||||
.replace(/\[([^\]]+)]\([^)]*\)/g, "$1")
|
||||
.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
return { title, preview };
|
||||
}
|
||||
|
||||
function PlanReviewPreview({
|
||||
interaction,
|
||||
planDocument,
|
||||
}: {
|
||||
interaction: RequestConfirmationInteraction;
|
||||
planDocument?: IssueDocument | null;
|
||||
}) {
|
||||
const target = interaction.payload.target;
|
||||
if (target?.type !== "issue_document" || target.key !== "plan") return null;
|
||||
|
||||
const targetRevision = target.revisionNumber ?? null;
|
||||
const documentMatchesTarget = Boolean(
|
||||
planDocument
|
||||
&& (targetRevision == null || planDocument.latestRevisionNumber === targetRevision),
|
||||
);
|
||||
const content = documentMatchesTarget && planDocument
|
||||
? planPreviewContent(planDocument.body)
|
||||
: { title: target.label ?? "Plan", preview: [] as string[] };
|
||||
const revisionLabel = targetRevision == null ? null : `v${targetRevision}`;
|
||||
|
||||
return (
|
||||
<a
|
||||
href="#document-plan"
|
||||
data-testid="plan-review-preview"
|
||||
aria-label={`Open ${target.label ?? revisionLabel ?? "plan"}`}
|
||||
className="group block overflow-hidden rounded-md border border-border bg-muted/25 text-left transition-colors hover:border-foreground/25 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border/70 px-3 py-2.5 text-sm text-muted-foreground">
|
||||
<Lightbulb aria-hidden className="h-4 w-4 shrink-0" />
|
||||
<span className="font-medium">Plan</span>
|
||||
{revisionLabel ? <span className="text-xs">{revisionLabel}</span> : null}
|
||||
<Maximize2
|
||||
aria-hidden
|
||||
className="ml-auto h-4 w-4 transition-transform group-hover:scale-105 group-hover:text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative max-h-36 overflow-hidden px-4 py-3">
|
||||
<h3 className="text-base font-semibold leading-6 text-foreground">{content.title}</h3>
|
||||
{content.preview.length > 0 ? (
|
||||
<ul className="mt-2 space-y-1.5 text-sm leading-5 text-muted-foreground">
|
||||
{content.preview.map((line, index) => (
|
||||
<li key={`${index}-${line}`} className="flex gap-2">
|
||||
<span aria-hidden className="shrink-0 text-muted-foreground/60">•</span>
|
||||
<span className="line-clamp-1">{line}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-1 text-sm text-muted-foreground">Open the synchronized plan to review it.</p>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-linear-to-b from-transparent to-card/95" />
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function ResolvedInteraction({ interaction }: { interaction: IssueThreadInteraction }) {
|
||||
const summary = buildIssueThreadInteractionSummary(interaction);
|
||||
let detail: ReactNode = null;
|
||||
@@ -453,12 +537,14 @@ function AskUserQuestionsCard({
|
||||
|
||||
function ConfirmationCard({
|
||||
interaction,
|
||||
planDocument,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
externalReferences,
|
||||
errorMessage,
|
||||
}: {
|
||||
interaction: RequestConfirmationInteraction;
|
||||
planDocument?: IssueDocument | null;
|
||||
onAcceptInteraction?: SharedInteractionProps["onAcceptInteraction"];
|
||||
onRejectInteraction?: SharedInteractionProps["onRejectInteraction"];
|
||||
externalReferences?: SharedInteractionProps["externalReferences"];
|
||||
@@ -473,6 +559,8 @@ function ConfirmationCard({
|
||||
|| interaction.payload.allowDeclineReason
|
||||
|| interaction.payload.declineReasonPlaceholder,
|
||||
);
|
||||
const isPlanConfirmation = interaction.payload.target?.type === "issue_document"
|
||||
&& interaction.payload.target.key === "plan";
|
||||
|
||||
async function resolve(action: "accept" | "reject") {
|
||||
if (action === "reject" && interaction.payload.rejectRequiresReason && !reason.trim()) return;
|
||||
@@ -490,11 +578,15 @@ function ConfirmationCard({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-sm leading-5 text-foreground">{interaction.payload.prompt}</p>
|
||||
<CompactTarget interaction={interaction} />
|
||||
</div>
|
||||
{interaction.payload.detailsMarkdown ? (
|
||||
{isPlanConfirmation ? (
|
||||
<PlanReviewPreview interaction={interaction} planDocument={planDocument} />
|
||||
) : (
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-sm leading-5 text-foreground">{interaction.payload.prompt}</p>
|
||||
<CompactTarget interaction={interaction} />
|
||||
</div>
|
||||
)}
|
||||
{!isPlanConfirmation && interaction.payload.detailsMarkdown ? (
|
||||
<Details><MarkdownBody externalReferences={externalReferences}>{interaction.payload.detailsMarkdown}</MarkdownBody></Details>
|
||||
) : null}
|
||||
{rejecting ? (
|
||||
@@ -887,6 +979,7 @@ function ItemVerdictsCard({
|
||||
|
||||
export function TaskChatCompactInteractionCard({
|
||||
interaction,
|
||||
planDocument,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
@@ -914,7 +1007,7 @@ export function TaskChatCompactInteractionCard({
|
||||
) : interaction.kind === "ask_user_questions" ? (
|
||||
<AskUserQuestionsCard interaction={interaction} onSubmitInteractionAnswers={onSubmitInteractionAnswers} onCancelInteraction={onCancelInteraction} errorMessage={errorMessage} />
|
||||
) : interaction.kind === "request_confirmation" ? (
|
||||
<ConfirmationCard interaction={interaction} onAcceptInteraction={onAcceptInteraction} onRejectInteraction={onRejectInteraction} externalReferences={externalReferences} errorMessage={errorMessage} />
|
||||
<ConfirmationCard interaction={interaction} planDocument={planDocument} onAcceptInteraction={onAcceptInteraction} onRejectInteraction={onRejectInteraction} externalReferences={externalReferences} errorMessage={errorMessage} />
|
||||
) : interaction.kind === "request_checkbox_confirmation" ? (
|
||||
<CheckboxConfirmationCard interaction={interaction} onAcceptInteraction={onAcceptInteraction} onRejectInteraction={onRejectInteraction} externalReferences={externalReferences} errorMessage={errorMessage} />
|
||||
) : interaction.kind === "suggest_tasks" ? (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { act } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { IssueDocument } from "@paperclipai/shared";
|
||||
import type { IssueThreadInteraction, RequestConfirmationInteraction } from "@/lib/issue-thread-interactions";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
@@ -87,6 +88,56 @@ describe("TaskChatInteractionCard", () => {
|
||||
expect(container.textContent).toContain("Approve the plan");
|
||||
});
|
||||
|
||||
it("renders a plan confirmation as a linked document preview", () => {
|
||||
const interaction = createRequestConfirmation({
|
||||
title: "Review plan revision 3",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve plan revision 3?",
|
||||
acceptLabel: "Approve plan",
|
||||
target: {
|
||||
type: "issue_document",
|
||||
issueId: "issue-1",
|
||||
documentId: "document-plan",
|
||||
key: "plan",
|
||||
revisionId: "revision-3",
|
||||
revisionNumber: 3,
|
||||
label: "Plan v3",
|
||||
},
|
||||
},
|
||||
});
|
||||
const planDocument = {
|
||||
id: "document-plan",
|
||||
issueId: "issue-1",
|
||||
key: "plan",
|
||||
title: "Plan",
|
||||
body: "# Health-check endpoint\n1. Define the health response contract.\n2. Test readiness before and after startup.\n3. Document the verification request.",
|
||||
latestRevisionId: "revision-3",
|
||||
latestRevisionNumber: 3,
|
||||
} as IssueDocument;
|
||||
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<ThemeProvider>
|
||||
<TaskChatInteractionCard
|
||||
item={interactionItem(interaction)}
|
||||
planDocument={planDocument}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const preview = container.querySelector('[data-testid="plan-review-preview"]');
|
||||
expect(preview?.getAttribute("href")).toBe("#document-plan");
|
||||
expect(preview?.getAttribute("aria-label")).toBe("Open Plan v3");
|
||||
expect(container.textContent).toContain("Health-check endpoint");
|
||||
expect(container.textContent).toContain("Test readiness before and after startup.");
|
||||
expect(container.textContent).not.toContain("Approve plan revision 3?");
|
||||
expect(container.textContent).toContain("Approve plan");
|
||||
});
|
||||
|
||||
it("puts the primary CTA at the right edge of the compact action row", () => {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import type { IssueDocument } from "@paperclipai/shared";
|
||||
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
|
||||
import { shouldHideInteractionCard } from "@/lib/issue-thread-interactions";
|
||||
import { TaskChatCompactInteractionCard } from "./TaskChatCompactInteractionCard";
|
||||
@@ -9,6 +10,7 @@ type InteractionCardProps = Omit<ComponentProps<typeof IssueThreadInteractionCar
|
||||
|
||||
export interface TaskChatInteractionCardProps extends InteractionCardProps {
|
||||
item: TaskChatInteractionItem;
|
||||
planDocument?: IssueDocument | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,7 +20,7 @@ export interface TaskChatInteractionCardProps extends InteractionCardProps {
|
||||
* secret proposals deliberately retain the specialized shared renderer because
|
||||
* its risk, expiry, and safe-binding receipts are security-relevant.
|
||||
*/
|
||||
export function TaskChatInteractionCard({ item, ...cardProps }: TaskChatInteractionCardProps) {
|
||||
export function TaskChatInteractionCard({ item, planDocument, ...cardProps }: TaskChatInteractionCardProps) {
|
||||
const interaction = item.interaction;
|
||||
if (shouldHideInteractionCard(interaction)) return null;
|
||||
if (
|
||||
@@ -49,7 +51,11 @@ export function TaskChatInteractionCard({ item, ...cardProps }: TaskChatInteract
|
||||
{useSpecializedRenderer ? (
|
||||
<IssueThreadInteractionCard interaction={interaction} primaryActionOnRight {...cardProps} />
|
||||
) : (
|
||||
<TaskChatCompactInteractionCard interaction={interaction} {...cardProps} />
|
||||
<TaskChatCompactInteractionCard
|
||||
interaction={interaction}
|
||||
planDocument={planDocument}
|
||||
{...cardProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -476,7 +476,20 @@ vi.mock("@/components/ui/dialog", () => ({
|
||||
|
||||
vi.mock("@/components/ui/sheet", () => ({
|
||||
Sheet: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ? <div>{children}</div> : null),
|
||||
SheetContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
SheetContent: ({
|
||||
children,
|
||||
className,
|
||||
"data-testid": testId,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
"data-testid"?: string;
|
||||
}) => (
|
||||
<div className={className} data-testid={testId}>
|
||||
{children}
|
||||
<button type="button" data-slot="sheet-close">Close</button>
|
||||
</div>
|
||||
),
|
||||
SheetHeader: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
SheetDescription: ({ children }: { children?: ReactNode }) => <p>{children}</p>,
|
||||
SheetTitle: ({ children }: { children?: ReactNode }) => <h2>{children}</h2>,
|
||||
@@ -1302,6 +1315,37 @@ describe("IssueDetail", () => {
|
||||
expect(mockSetPanelVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens a plan deep link in a dismissible full-screen mobile panel", async () => {
|
||||
mockSidebarState.isMobile = true;
|
||||
mockLocation.hash = "#document-plan";
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockIssuePropertiesRender).toHaveBeenCalledWith(expect.objectContaining({
|
||||
inline: true,
|
||||
documentDeepLink: expect.objectContaining({
|
||||
tab: "plans",
|
||||
documentKey: "plan",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
const panel = document.querySelector('[data-testid="mobile-plan-panel"]');
|
||||
expect(panel).not.toBeNull();
|
||||
expect(panel?.className).toContain("h-dvh");
|
||||
expect(panel?.className).toContain("w-screen");
|
||||
expect(panel?.textContent).toContain("Plan");
|
||||
expect(panel?.querySelector('[data-slot="sheet-close"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the full sub-task tree below the title in the chat center pane", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
mockIssuesApi.list.mockResolvedValue([
|
||||
|
||||
@@ -5605,9 +5605,19 @@ export function IssueDetail() {
|
||||
|
||||
{/* Mobile properties drawer */}
|
||||
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}>
|
||||
<SheetContent side="bottom" className="max-h-(--sz-85dvh) pb-(--sz-safe-bottom)">
|
||||
<SheetContent
|
||||
side={documentDeepLink?.documentKey === "plan" ? "right" : "bottom"}
|
||||
className={cn(
|
||||
documentDeepLink?.documentKey === "plan"
|
||||
? "inset-0 h-dvh w-screen max-w-none gap-0 border-0 p-0 sm:max-w-none"
|
||||
: "max-h-(--sz-85dvh) pb-(--sz-safe-bottom)",
|
||||
)}
|
||||
data-testid={documentDeepLink?.documentKey === "plan" ? "mobile-plan-panel" : undefined}
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-sm">Properties</SheetTitle>
|
||||
<SheetTitle className="text-sm">
|
||||
{documentDeepLink?.documentKey === "plan" ? "Plan" : "Properties"}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1 overflow-y-auto">
|
||||
<div className="px-4 pb-4">
|
||||
|
||||
Reference in new issue
Block a user