Merge remote-tracking branch 'origin/master' into codex/boat-environment-core

* origin/master:
  fix(pi-local): avoid embedding model catalogue in errors (#13628)
  fix(opencode-local): run in the realized workspace (#11438)
  fix(ui): align chat connection and recovery cards (#15771)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-10 11:44:36 -05:00
commit 0a440b9333
13 files changed
+150 -22

No files matched your search

@@ -153,6 +153,7 @@ describe("opencode remote execution", () => {
config: {
command: "opencode",
model: "opencode/gpt-5-nano",
extraArgs: ["--", "--dir"],
...(managed ? {
managedAiConnection: { provider: "openrouter", method: "api_key" },
} : {}),
@@ -236,6 +237,7 @@ describe("opencode remote execution", () => {
`${managedRemoteWorkspace}/.paperclip-runtime/opencode/xdgConfig`,
);
expect(modelProbeCall?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
expect(runCall?.[2].slice(-4)).toEqual(["--dir", managedRemoteWorkspace, "--", "--dir"]);
const call = runCall as
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
| undefined;
@@ -335,7 +337,7 @@ describe("opencode remote execution", () => {
expect(startAdapterExecutionTargetPaperclipBridge).not.toHaveBeenCalled();
});
it("resumes saved OpenCode sessions for remote SSH execution only when the identity matches", async () => {
it("resumes matching remote sessions and preserves an explicit run directory", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-resume-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
@@ -370,6 +372,7 @@ describe("opencode remote execution", () => {
config: {
command: "opencode",
model: "opencode/gpt-5-nano",
extraArgs: ["--dir", "/operator-supplied"],
},
context: {
paperclipWorkspace: {
@@ -397,5 +400,7 @@ describe("opencode remote execution", () => {
| undefined;
expect(call?.[2]).toContain("--session");
expect(call?.[2]).toContain("session-123");
expect(call?.[2].slice(-2)).toEqual(["--dir", "/operator-supplied"]);
expect(call?.[2].filter((arg) => arg === "--dir" || arg.startsWith("--dir="))).toHaveLength(1);
});
});
@@ -621,6 +621,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (resumeSessionId) args.push("--session", resumeSessionId);
if (model) args.push("--model", model);
if (variant) args.push("--variant", variant);
// OpenCode prefers the inherited PWD over process.cwd(). Use the realized
// workspace unless the operator already selected a directory explicitly.
const optionTerminator = extraArgs.indexOf("--");
const extraOptionArgs = optionTerminator === -1 ? extraArgs : extraArgs.slice(0, optionTerminator);
if (!extraOptionArgs.some((arg) => arg === "--dir" || arg.startsWith("--dir="))) {
args.push("--dir", effectiveExecutionCwd);
}
if (extraArgs.length > 0) args.push(...extraArgs);
return args;
};
@@ -1,3 +1,6 @@
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
ensurePiModelConfiguredAndAvailable,
@@ -30,4 +33,34 @@ describe("pi models", () => {
}),
).rejects.toThrow();
});
it("does not embed the model catalogue when the configured model is unavailable", async () => {
const directory = await mkdtemp(join(tmpdir(), "paperclip-pi-models-"));
const command = join(directory, "pi");
const modelIds = Array.from({ length: 13 }, (_, index) => `provider/model-${index}`);
await writeFile(
command,
`#!/bin/sh\nprintf 'provider model\\n';\n${modelIds
.map((id) => {
const [provider, model] = id.split("/");
return `printf '${provider} ${model}\\n'`;
})
.join(";\n")}\n`,
);
await chmod(command, 0o755);
try {
await expect(
ensurePiModelConfiguredAndAvailable({
command,
model: "provider/missing",
}),
).rejects.toMatchObject({
message:
"Configured Pi model is unavailable: provider/missing. 13 models are available; list them with `pi --list-models`.",
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
@@ -188,9 +188,8 @@ export async function ensurePiModelConfiguredAndAvailable(input: {
}
if (!models.some((entry) => entry.id === model)) {
const sample = models.slice(0, 12).map((entry) => entry.id).join(", ");
throw new Error(
`Configured Pi model is unavailable: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`,
`Configured Pi model is unavailable: ${model}. ${models.length} models are available; list them with \`pi --list-models\`.`,
);
}
+3 -1
View File
@@ -42,7 +42,9 @@ export function EmailMessageCard({
message,
publication,
issueId,
contextNotice = false,
}: {
contextNotice?: boolean;
message: EmailMessage;
publication?: EmailPublicationSummary;
issueId: string;
@@ -57,7 +59,7 @@ export function EmailMessageCard({
aria-label={
message.direction === "inbound" ? "Email received" : "Email sent"
}
className="space-y-4 rounded-xl border border-border bg-card p-5"
className={contextNotice ? "task-context-notice space-y-3" : "space-y-4 rounded-xl border border-border bg-card p-5"}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="flex items-center gap-2 text-sm font-semibold">
+2 -1
View File
@@ -36,6 +36,7 @@ export function EmailTaskActivity({
)}
{messages.map((m) => (
<EmailMessageCard
contextNotice
key={m.id}
issueId={issueId}
message={m}
@@ -78,7 +79,7 @@ function EmailDelivery({
{p.request && !p.providerMessageId && (
<article
aria-label="Email send intent"
className="space-y-3 rounded-lg border border-border p-4"
className="task-context-notice space-y-3"
>
<p className="font-semibold">{p.request.subject ?? "Email reply"}</p>
{p.request.to && <p>To: {p.request.to.join(", ")}</p>}
@@ -36,8 +36,7 @@ describe("stopped task recovery notice", () => {
const notice = container.querySelector('[role="status"][aria-label="Task recovery"]')!;
expect(notice.textContent).toContain("Recorded work is preserved");
expect(notice.textContent).toContain("Retry");
expect(notice.classList.contains("border")).toBe(true);
expect(notice.classList.contains("bg-muted")).toBe(true);
expect(notice.classList.contains("task-context-notice")).toBe(true);
expect(notice.querySelector("a")?.getAttribute("href")).toBe("/agents/agent/runs/failed-run");
});
it.each([false, true])("explains cancelled runs and saved input; continue eligibility %s", async canContinue => {
+4 -2
View File
@@ -4,9 +4,11 @@ import { agentsApi } from "../api/agents";
import { activityApi } from "../api/activity";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
import { Link } from "../lib/router";
export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried }: {
export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried, className }: {
className?: string;
companyId: string;
issueId: string;
blocker: ExecutionBlocker;
@@ -33,7 +35,7 @@ export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried
},
});
return (
<div role="status" aria-label="Task recovery" className="mx-(--sz-execution-blocker-inline) my-(--sz-execution-blocker-block) flex flex-wrap items-center justify-between execution-blocker-notice border border-border bg-muted text-foreground">
<div role="status" aria-label="Task recovery" className={cn("task-context-notice flex flex-wrap items-center justify-between gap-2", className)}>
<div className="min-w-0 flex-1 break-words">
<p>{modelRejected ? "Model unavailable." : "Recovery needed."}{blocker.runError ? ` ${blocker.runError}` : ""}</p>
{modelRejected && <p>Choose a supported model or clear the task's model override, then retry.</p>}
@@ -3,12 +3,14 @@ import { useMutation } from "@tanstack/react-query";
import type { IssueRecoveryAction } from "@paperclipai/shared";
import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared";
import { issuesApi } from "../api/issues";
import { cn } from "../lib/utils";
import { Button } from "./ui/button";
import { Label } from "./ui/label";
import { Textarea } from "./ui/textarea";
/** Copyback repair preserves the accepted result and never wakes the provider. */
export function WorkspaceExportRecovery({ issueId, action, canManage, onQueued }: {
export function WorkspaceExportRecovery({ issueId, action, canManage, onQueued, className }: {
className?: string;
issueId: string; action: IssueRecoveryAction | null; canManage: boolean; onQueued: () => void;
}) {
const noteId = useId();
@@ -23,7 +25,7 @@ export function WorkspaceExportRecovery({ issueId, action, canManage, onQueued }
if (!action || !isNativeWorkspaceExportRepairCause(action.cause) || action.ownerType !== "board"
|| !["active", "escalated"].includes(action.status) || typeof action.evidence.runId !== "string") return null;
const queued = queuedActionVersion === String(action.updatedAt) || action.wakePolicy?.kind === "resume_native_run";
return <section aria-label="Workspace export repair" className="flex flex-col gap-2 p-4 text-sm">
return <section aria-label="Workspace export repair" className={cn("task-context-notice flex flex-col gap-2", className)}>
<p className="font-medium">Workspace export needs repair</p>
{queued ? <p role="status">Export is queued for the saved result. The agent will not repeat its work.</p> : <>
<p className="text-muted-foreground">{"Automatic workspace export retries stopped. Inspect the export failure, restore provider or destination availability, and preserve the saved files in the retained sandbox. Retry export here when the cause is resolved."}</p>
@@ -17,6 +17,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/context/ToastContext";
import { Link } from "@/lib/router";
import { cn } from "@/lib/utils";
import { queryKeys } from "@/lib/queryKeys";
import { useChatConnectorsEnabled, chatProviderVisible } from "@/hooks/useChatConnectorsEnabled";
import { issuesApi } from "@/api/issues";
@@ -124,6 +125,7 @@ export function useIssueChatBinding(companyId: string, issueId: string) {
}
type ConnectedTaskProps = {
className?: string;
attachments?: IssueAttachment[];
companyId: string;
issueId: string;
@@ -148,6 +150,7 @@ export function ExternallyConnectedTaskBanner(props: ConnectedTaskProps) {
}
function ConnectedTaskComposer({
className,
attachments = [],
companyId,
issueId,
@@ -486,7 +489,7 @@ function ConnectedTaskComposer({
return (
<section
aria-label="External conversation"
className="space-y-3 rounded-lg border border-border bg-muted/40 p-3 text-sm"
className={cn("task-context-notice space-y-3", className)}
>
<div className="flex flex-wrap items-center gap-3">
<div className="flex min-w-0 flex-1 basis-64 items-center gap-3">
+7 -5
View File
@@ -3120,11 +3120,13 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
inset-inline: 0;
}
/* Task recovery panel uses the shared spacing, radius, and type scales. */
.execution-blocker-notice {
gap: calc(var(--spacing) * 2);
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3);
border-radius: var(--radius-md);
/* Connection and recovery cards share one surface and spacing scale. */
.task-context-notice {
padding: calc(var(--spacing) * 3);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background-color: var(--muted);
color: var(--foreground);
font-size: var(--text-sm);
line-height: var(--text-sm--line-height);
}
+10 -5
View File
@@ -7642,6 +7642,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
{taskChatShellEnabled ? null : createdFromNote}
<ExternallyConnectedTaskBanner
className={cn(shellSectionClass, "mt-3")}
key={issue.id}
attachments={attachments ?? []}
companyId={issue.companyId}
@@ -7649,6 +7650,15 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
issueCacheRefs={issueCacheRefs}
/>
{resolvedDetailTab === "chat" && (
<WorkspaceExportRecovery key={`workspace-export:${issue.activeRecoveryAction?.id ?? issue.id}`} issueId={issue.id}
className={shellSectionClass}
action={issue.activeRecoveryAction ?? null} canManage={canManageBoardRuntime} onQueued={invalidateIssueDetail} />
)}
{resolvedDetailTab === "chat" && issue.executionBlocker && (
<ExecutionBlockerNotice className={shellSectionClass} companyId={issue.companyId} issueId={issue.id} blocker={issue.executionBlocker} onRetried={invalidateIssueDetail} />
)}
{issue.status === "in_review" && issue.externalConversationState === "waiting" && (
<p role="status" className="text-sm text-muted-foreground">Reply sent. Send a message to continue.</p>
)}
@@ -7952,11 +7962,6 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
: undefined
}
>
<WorkspaceExportRecovery key={issue.activeRecoveryAction?.id ?? issue.id} issueId={issue.id}
action={issue.activeRecoveryAction ?? null} canManage={canManageBoardRuntime} onQueued={invalidateIssueDetail} />
{issue.executionBlocker && (
<ExecutionBlockerNotice companyId={issue.companyId} issueId={issue.id} blocker={issue.executionBlocker} onRetried={invalidateIssueDetail} />
)}
{resolvedDetailTab === "chat" ? (
<WorkspaceBaseRefRecoveryProvider issue={issue} agentMap={agentMap} onRepaired={() => { invalidateIssueDetail(); invalidateIssueCollections(); }}
unavailableReason={!canManageBoardRuntime || !canResolveBoardRecoveryAction ? "You don’t have permission to repair this task’s workspace."
@@ -0,0 +1,68 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { expect, within } from "storybook/test";
import { ExternallyConnectedTaskBanner } from "@/components/chat/ExternallyConnectedTaskBanner";
import { ExecutionBlockerNotice } from "@/components/ExecutionBlockerNotice";
import { EmailMessageCard } from "@/components/EmailMessageCard";
import { queryKeys } from "@/lib/queryKeys";
const shellSectionClass = "mx-auto w-full max-w-(--tc-shell-max-w)";
function TaskContextNotices() {
const [client] = useState(() => {
const cache = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
cache.setQueryData(queryKeys.instance.experimentalSettings, { enableChatConnectors: true });
cache.setQueryData(["issue-chat-binding", "demo", "context-notices"], {
endpointId: "slack-demo", provider: "slack", externalLabel: "#design · Card styling",
externalUrl: "https://slack.com", conversationId: "demo-thread", assignedAgentLocked: true,
});
cache.setQueryData(queryKeys.issues.runs("context-notices"), []);
return cache;
});
return (
<QueryClientProvider client={client}>
<main className="min-h-screen bg-background p-6 text-foreground">
<div className="w-full space-y-3">
<h1 className={`${shellSectionClass} text-xl font-semibold`}>Chat connection and recovery</h1>
<ExternallyConnectedTaskBanner className={`${shellSectionClass} mt-3`} companyId="demo" issueId="context-notices" />
<ExecutionBlockerNotice className={shellSectionClass} companyId="demo" issueId="context-notices" onRetried={() => {}}
blocker={{ recoveryActionId: "demo-recovery", runId: null, agentId: null, cause: "action_outcome_unknown",
nextAction: "Inspect the run before continuing. Recorded work is preserved." }} />
<div className={shellSectionClass}>
<EmailMessageCard contextNotice issueId="context-notices" message={{
id: "email-demo", providerMessageId: "email-demo", direction: "inbound",
from: "operator@example.com", to: ["agent@example.com"], subject: "Card styling",
text: "Please keep the connection and recovery cards consistent.",
fullText: "Please keep the connection and recovery cards consistent.",
commentId: null, attachmentIds: [], timestamp: "2026-10-09T12:00:00Z", automatic: false,
}} />
</div>
</div>
</main>
</QueryClientProvider>
);
}
const meta = {
title: "Tasks/Context notices",
component: TaskContextNotices,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof TaskContextNotices>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ConnectionAndRecovery: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const connection = await canvas.findByRole("region", { name: "External conversation" });
const recovery = canvas.getByRole("status", { name: "Task recovery" });
const email = canvas.getByRole("article", { name: "Email received" });
for (const card of [recovery, email]) {
await expect(card.getBoundingClientRect().width).toBe(connection.getBoundingClientRect().width);
await expect(getComputedStyle(card).backgroundColor).toBe(getComputedStyle(connection).backgroundColor);
await expect(getComputedStyle(card).padding).toBe(getComputedStyle(connection).padding);
await expect(getComputedStyle(card).borderRadius).toBe(getComputedStyle(connection).borderRadius);
}
},
};