From 40bb466d787821e41d72f9d5159591c1346ed2cc Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:38:26 -0500 Subject: [PATCH 1/3] fix(ui): align chat connection and recovery cards (#15771) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task pages show external chat connections and recovery notices. > - These cards use different widths, colors, and spacing. > - The difference makes related task information harder to scan. > - This pull request gives these cards one shared surface. > - The benefit is a consistent task page for chat and email users. ## Linked Issues or Issue Description **What happened?** The external chat banner and recovery notice have different backgrounds, padding, and widths. The first banner needs more space above it. **Expected behavior** Connection, recovery, workspace repair, and task email cards use the same surface. The chat cards have the same width. **Steps to reproduce** Open a task with an external Slack conversation and a recovery notice. Compare the two cards. Open the Tasks / Context notices story to compare the updated cards with an AgentMail email card. ## What Changed - Add a shared token-based surface for task context cards. - Put chat recovery notices in the same width container as the connection banner. - Add space above the connection banner. - Apply the surface to task email cards and workspace export repair. - Add a Storybook view with per-card width, color, padding, and corner checks. - Give the workspace recovery card its own React key to prevent duplicate connection banners. ## Verification - Passed `pnpm check:token-gates`. - Passed 57 tests for ExecutionBlockerNotice, WorkspaceExportRecovery, and the external chat banner. - Passed UI typecheck and UI production build. - Captured desktop and mobile browser screenshots from the real components with fixture data. The three cards have equal widths, backgrounds, padding, and corners. - Full local typecheck needs Cargo, which is absent in the task environment. Full tests and build could not complete within the environment memory limit. Remote typecheck, build, all test shards, and security checks pass on commit e225a8e47. The Canary Dry Run also passes. Storybook visual/deploy jobs are skipped by workflow policy. ## Risks - Low risk. Recovery notices move outside the chat panel. Their actions and API calls stay the same. - Verify narrow screens and both task layout modes. ## Model Used - OpenAI GPT-6 through Codex. Tool use and code execution. The runtime does not expose an exact model variant or context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ui/src/components/EmailMessageCard.tsx | 4 +- ui/src/components/EmailTaskActivity.tsx | 3 +- .../ExecutionBlockerNotice.test.tsx | 3 +- ui/src/components/ExecutionBlockerNotice.tsx | 6 +- ui/src/components/WorkspaceExportRecovery.tsx | 6 +- .../chat/ExternallyConnectedTaskBanner.tsx | 5 +- ui/src/index.css | 12 ++-- ui/src/pages/IssueDetail.tsx | 15 ++-- .../stories/task-context-notices.stories.tsx | 68 +++++++++++++++++++ 9 files changed, 103 insertions(+), 19 deletions(-) create mode 100644 ui/storybook/stories/task-context-notices.stories.tsx diff --git a/ui/src/components/EmailMessageCard.tsx b/ui/src/components/EmailMessageCard.tsx index be4a083e15f..b1c374e1ac4 100644 --- a/ui/src/components/EmailMessageCard.tsx +++ b/ui/src/components/EmailMessageCard.tsx @@ -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"} >
diff --git a/ui/src/components/EmailTaskActivity.tsx b/ui/src/components/EmailTaskActivity.tsx index 6f442b1372c..6a384fb1d0e 100644 --- a/ui/src/components/EmailTaskActivity.tsx +++ b/ui/src/components/EmailTaskActivity.tsx @@ -36,6 +36,7 @@ export function EmailTaskActivity({ )} {messages.map((m) => (

{p.request.subject ?? "Email reply"}

{p.request.to &&

To: {p.request.to.join(", ")}

} diff --git a/ui/src/components/ExecutionBlockerNotice.test.tsx b/ui/src/components/ExecutionBlockerNotice.test.tsx index 3960f71070b..980f0a6fdf1 100644 --- a/ui/src/components/ExecutionBlockerNotice.test.tsx +++ b/ui/src/components/ExecutionBlockerNotice.test.tsx @@ -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 => { diff --git a/ui/src/components/ExecutionBlockerNotice.tsx b/ui/src/components/ExecutionBlockerNotice.tsx index c157a359b5d..0cb1eb0b775 100644 --- a/ui/src/components/ExecutionBlockerNotice.tsx +++ b/ui/src/components/ExecutionBlockerNotice.tsx @@ -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 ( -
+

{modelRejected ? "Model unavailable." : "Recovery needed."}{blocker.runError ? ` ${blocker.runError}` : ""}

{modelRejected &&

Choose a supported model or clear the task's model override, then retry.

} diff --git a/ui/src/components/WorkspaceExportRecovery.tsx b/ui/src/components/WorkspaceExportRecovery.tsx index 23ae74319b1..98376816817 100644 --- a/ui/src/components/WorkspaceExportRecovery.tsx +++ b/ui/src/components/WorkspaceExportRecovery.tsx @@ -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
+ return

Workspace export needs repair

{queued ?

Export is queued for the saved result. The agent will not repeat its work.

: <>

{"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."}

diff --git a/ui/src/components/chat/ExternallyConnectedTaskBanner.tsx b/ui/src/components/chat/ExternallyConnectedTaskBanner.tsx index 221f28c14aa..bb92647d243 100644 --- a/ui/src/components/chat/ExternallyConnectedTaskBanner.tsx +++ b/ui/src/components/chat/ExternallyConnectedTaskBanner.tsx @@ -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 (
diff --git a/ui/src/index.css b/ui/src/index.css index c45d030ffc3..ce91a3a3dba 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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); } diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index a7b7ff66094..b7ebb693a9a 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -7642,6 +7642,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS {taskChatShellEnabled ? null : createdFromNote} + {resolvedDetailTab === "chat" && ( + + )} + {resolvedDetailTab === "chat" && issue.executionBlocker && ( + + )} + {issue.status === "in_review" && issue.externalConversationState === "waiting" && (

Reply sent. Send a message to continue.

)} @@ -7952,11 +7962,6 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS : undefined } > - - {issue.executionBlocker && ( - - )} {resolvedDetailTab === "chat" ? ( { invalidateIssueDetail(); invalidateIssueCollections(); }} unavailableReason={!canManageBoardRuntime || !canResolveBoardRecoveryAction ? "You don’t have permission to repair this task’s workspace." diff --git a/ui/storybook/stories/task-context-notices.stories.tsx b/ui/storybook/stories/task-context-notices.stories.tsx new file mode 100644 index 00000000000..f92b8ffaaac --- /dev/null +++ b/ui/storybook/stories/task-context-notices.stories.tsx @@ -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 ( + +
+
+

Chat connection and recovery

+ + {}} + blocker={{ recoveryActionId: "demo-recovery", runId: null, agentId: null, cause: "action_outcome_unknown", + nextAction: "Inspect the run before continuing. Recorded work is preserved." }} /> +
+ +
+
+
+
+ ); +} + +const meta = { + title: "Tasks/Context notices", + component: TaskContextNotices, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +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); + } + }, +}; From 28e134873ef72d06c4bbff7ac0e214baab0dcfb9 Mon Sep 17 00:00:00 2001 From: Kilian Date: Sat, 10 Oct 2026 17:46:21 +0200 Subject: [PATCH 2/3] fix(opencode-local): run in the realized workspace (#11438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip adapters start each agent in its selected workspace > - Paperclip can realize a different workspace path for local and remote runs > - The OpenCode CLI prefers its inherited `PWD` when no `--dir` option is present > - The process `cwd` option does not update the inherited `PWD` value > - This pull request passes the realized workspace to OpenCode with `--dir` > - The benefit is that OpenCode sessions and tools use the workspace that Paperclip selected ## Linked Issues or Issue Description No matching issue exists. Refs #8287. That earlier pull request describes the realized remote workspace path. It does not make the OpenCode CLI use that path. I searched open and closed issues and pull requests for `opencode --dir`, `opencode cwd PWD`, and `opencode workspace directory`. I found no duplicate. **What happened?** Paperclip gives the OpenCode process the correct operating system `cwd`. However, the adapter does not pass OpenCode's `--dir` option. OpenCode uses `process.env.PWD` before `process.cwd()` when `--dir` is absent. A server `PWD` that differs from the realized workspace can therefore place the OpenCode session and its tools in the wrong directory. **Expected behavior** OpenCode must use the workspace that Paperclip realized for the run. An explicit `--dir` in `extraArgs` must remain valid and must not be duplicated. **Steps to reproduce** 1. Start the Paperclip server from directory A. 2. Configure an OpenCode agent with directory B, or realize directory B on a remote execution target. 3. Start a run without a manual `--dir` value in `extraArgs`. 4. Inspect the OpenCode session directory or run a directory-sensitive tool. 5. Before this change, OpenCode can use directory A from the inherited `PWD`. 6. After this change, OpenCode receives `--dir B`. **Paperclip version or commit** `b38d6ddb811b3ff3145ff5df60174eb9e7313fe6` **Deployment mode** Self-hosted server. The same adapter path is used in local development and remote execution. **Installation method** Built from source. **Agent adapter(s) involved** OpenCode. **Database mode** Not database-related. **Additional context** OpenCode v1.17.13 defines `--dir` and uses the inherited `PWD` as its fallback root. See the [OpenCode run command source](https://github.com/anomalyco/opencode/blob/10c894bdeef3618f5666fb506ef7f9491bb964d8/packages/opencode/src/cli/cmd/run.ts#L333-L339). ## What Changed - Add the realized execution workspace as `--dir` for OpenCode runs. - Preserve an explicit `--dir` or `--dir=...` value from `extraArgs`. - Stop option detection at the first `--` terminator. - Extend the remote execution tests for realized, explicit, and post-terminator directory arguments. ## Verification - `pnpm exec vitest run packages/adapters/opencode-local/src` passed: 7 files and 40 tests. - `pnpm --filter @paperclipai/adapter-opencode-local typecheck` passed. - `pnpm --filter @paperclipai/adapter-opencode-local build` passed. - `pnpm -r typecheck` passed for all 31 selected workspace projects. - `pnpm build` passed for all selected workspace projects. - `git diff --check` passed. I also ran `pnpm test:run`. It completed with 334 test files passing and 2 test files failing. It reported 3,790 passing tests and 4 skipped tests. The two failures are in `issue-monitor-scheduler.test.ts` and `workspace-runtime.test.ts`. Each failure reproduces on the unchanged base commit after this pull request is removed. Neither test imports or covers the OpenCode adapter. ## Risks - The behavior change is limited to OpenCode run arguments. - A custom old OpenCode binary that does not support `--dir` will reject the option. The current official OpenCode v1.17.13 CLI supports it. - An explicit operator `--dir` remains authoritative. The adapter does not add a duplicate option because OpenCode's parser converts repeated string options to an array. - This change does not modify schemas, migrations, APIs, telemetry, or user data. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex based on GPT-5. The runtime did not expose a more specific model ID or context-window size. The agent used high-depth reasoning, repository access, shell execution, and GitHub tools. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: commitperclip-bot <305974387+commitperclip-bot@users.noreply.github.com> --- .../opencode-local/src/server/execute.remote.test.ts | 7 ++++++- packages/adapters/opencode-local/src/server/execute.ts | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/adapters/opencode-local/src/server/execute.remote.test.ts b/packages/adapters/opencode-local/src/server/execute.remote.test.ts index 7472e11ffe5..2271749e335 100644 --- a/packages/adapters/opencode-local/src/server/execute.remote.test.ts +++ b/packages/adapters/opencode-local/src/server/execute.remote.test.ts @@ -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; 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); }); }); diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index a7458fd3d97..7171067d005 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -621,6 +621,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise arg === "--dir" || arg.startsWith("--dir="))) { + args.push("--dir", effectiveExecutionCwd); + } if (extraArgs.length > 0) args.push(...extraArgs); return args; }; From 0e37a385e678d1a568f483c31ae1c19024d2df98 Mon Sep 17 00:00:00 2001 From: lorenzozane Date: Sat, 10 Oct 2026 23:48:00 +0800 Subject: [PATCH 3/3] fix(pi-local): avoid embedding model catalogue in errors (#13628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The pi-local adapter discovers and validates models before it starts a local Pi process > - An unavailable-model error included model IDs from the discovered catalogue > - Repeating those IDs made persisted and rendered failures noisy and exposed substitute choices > - This pull request keeps the requested model, reports the available count, and points to the existing lookup command > - The benefit is a concise error without changing model discovery or matching ## Linked Issues or Issue Description Fixes #13612 ## What Changed - Replace model catalogue interpolation in unavailable-model errors with the requested model, normalized model count, and `pi --list-models`. - Add a regression test with 13 discovered models and exact error-message matching. ## Verification - `pnpm exec vitest run packages/adapters/pi-local/src/server/models.test.ts` - `pnpm exec vitest run packages/adapters/pi-local/src/server/*.test.ts` - `pnpm --filter @paperclipai/adapter-pi-local typecheck` - `git diff --check` All listed checks pass locally. The test suite uses a temporary Pi executable and verifies that model IDs are not appended to the error. ## Risks Low risk. Only unavailable-model error wording changes. Model discovery, matching, valid model execution, and failure handling remain unchanged. Full repository CI was not run locally because the host uses Node 22.22.1 and the repository declares Node >=24.11.0. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI GPT-5.6 Luna (`gpt-5.6-luna`); tool-assisted code execution. The context window size is not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [ ] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub #13612) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- .../pi-local/src/server/models.test.ts | 33 +++++++++++++++++++ .../adapters/pi-local/src/server/models.ts | 3 +- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/adapters/pi-local/src/server/models.test.ts b/packages/adapters/pi-local/src/server/models.test.ts index df777544a17..881c86defc1 100644 --- a/packages/adapters/pi-local/src/server/models.test.ts +++ b/packages/adapters/pi-local/src/server/models.test.ts @@ -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 }); + } + }); }); diff --git a/packages/adapters/pi-local/src/server/models.ts b/packages/adapters/pi-local/src/server/models.ts index a6081cad738..06b1a803474 100644 --- a/packages/adapters/pi-local/src/server/models.ts +++ b/packages/adapters/pi-local/src/server/models.ts @@ -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\`.`, ); }