mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Add ACPX local adapter runtime (#4893)
## Thinking Path > - Paperclip orchestrates AI-agent companies through a control plane that can start, supervise, and recover agent runs. > - Local adapters are the bridge between Paperclip issues and concrete agent runtimes such as Claude, Codex, and other ACP-compatible tools. > - The roadmap calls out broader “bring your own agent” and claw-style agent support, and ACPX gives Paperclip one path to normalize multiple ACP agents behind a single adapter. > - The branch needed to become one reviewable PR against current `paperclipai/paperclip:master`, without carrying stale base conflicts or generated lockfile churn. > - This pull request adds an experimental built-in `acpx_local` adapter, integrates it through the server/CLI/UI adapter surfaces, and adds regression coverage for runtime execution, skill sync, stream parsing, diagnostics, and log redaction. > - The benefit is that Paperclip can run Claude/Codex/custom ACP agents through ACPX while keeping operator configuration, skills, logging, and transcript rendering inside the existing adapter model. ## What Changed - Added `@paperclipai/adapter-acpx-local` with server execution, config schema, ACPX session handling, CLI formatting, UI config helpers, and stdout parsing. - Registered `acpx_local` across CLI, server, shared constants, UI adapter metadata, adapter capabilities, and agent creation/editing surfaces. - Added ACPX runtime execution support with persistent sessions, local-agent JWT environment handling, skill snapshots, runtime skill materialization, and isolation/security regressions. - Added ACPX adapter diagnostics and marked the adapter experimental in the UI. - Added command/env secret redaction for resolved command metadata in adapter-utils, server event storage, and the Agent Detail invocation UI. - Added Storybook coverage for ACPX config, transcript rendering, and skill states, plus PR screenshots under `docs/pr-screenshots/pap-2944/`. - Rebased the branch onto current `public-gh/master`; `pnpm-lock.yaml` is intentionally not included and there are no migration/schema changes. ## Verification - `pnpm exec vitest run packages/adapters/acpx-local/src/server/execute.test.ts packages/adapters/acpx-local/src/server/test.test.ts packages/adapters/acpx-local/src/cli/format-event.test.ts packages/adapters/acpx-local/src/ui/parse-stdout.test.ts packages/adapter-utils/src/server-utils.test.ts server/src/__tests__/redaction.test.ts server/src/__tests__/acpx-local-execute.test.ts server/src/__tests__/acpx-local-skill-sync.test.ts server/src/__tests__/acpx-local-adapter-environment.test.ts server/src/__tests__/adapter-routes.test.ts server/src/__tests__/agent-skills-routes.test.ts ui/src/adapters/metadata.test.ts` — 12 files, 87 tests passed. - `pnpm --filter @paperclipai/adapter-acpx-local typecheck` — passed. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - Confirmed PR diff does not include `pnpm-lock.yaml`, database schema files, or migrations. Screenshots:    ## Risks - Medium risk: this introduces a new built-in adapter package and touches runtime execution, adapter registration, agent config, skills, and transcript rendering. - ACPX and ACP agent behavior can vary by installed tool versions; the adapter is marked experimental to set operator expectations. - `pnpm-lock.yaml` is excluded per repository PR policy, so dependency lock refresh must be handled by the repo’s automation or maintainers. - No database migration risk: no schema or migration files changed. > 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 coding agent based on GPT-5, with repository tool use, shell execution, git operations, and local verification. Exact hosted context window was not exposed in this environment. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
ad5432fece
commit
4272c1604d
70 changed files with 5521 additions and 31 deletions
|
|
@ -34,6 +34,7 @@
|
|||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@lexical/link": "0.35.0",
|
||||
"@mdxeditor/editor": "^3.52.4",
|
||||
"@paperclipai/adapter-acpx-local": "workspace:*",
|
||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
|
|
|
|||
11
ui/src/adapters/acpx-local/index.ts
Normal file
11
ui/src/adapters/acpx-local/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { UIAdapterModule } from "../types";
|
||||
import { parseAcpxStdoutLine, buildAcpxLocalConfig } from "@paperclipai/adapter-acpx-local/ui";
|
||||
import { SchemaConfigFields } from "../schema-config-fields";
|
||||
|
||||
export const acpxLocalUIAdapter: UIAdapterModule = {
|
||||
type: "acpx_local",
|
||||
label: "ACPX (local)",
|
||||
parseStdoutLine: parseAcpxStdoutLine,
|
||||
ConfigFields: SchemaConfigFields,
|
||||
buildAdapterConfig: buildAcpxLocalConfig,
|
||||
};
|
||||
|
|
@ -49,9 +49,18 @@ export interface AdapterDisplayInfo {
|
|||
recommended?: boolean;
|
||||
comingSoon?: boolean;
|
||||
disabledLabel?: string;
|
||||
experimental?: boolean;
|
||||
hideFromVisualSelection?: boolean;
|
||||
}
|
||||
|
||||
const adapterDisplayMap: Record<string, AdapterDisplayInfo> = {
|
||||
acpx_local: {
|
||||
label: "ACPX",
|
||||
description: "Experimental local ACPX multi-agent adapter",
|
||||
icon: Bot,
|
||||
experimental: true,
|
||||
hideFromVisualSelection: true,
|
||||
},
|
||||
claude_local: {
|
||||
label: "Claude Code",
|
||||
description: "Local Claude agent",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { isEnabledAdapterType, listAdapterOptions } from "./metadata";
|
||||
import {
|
||||
isEnabledAdapterType,
|
||||
isValidAdapterType,
|
||||
isVisualAdapterChoice,
|
||||
listAdapterOptions,
|
||||
} from "./metadata";
|
||||
import type { UIAdapterModule } from "./types";
|
||||
|
||||
const externalAdapter: UIAdapterModule = {
|
||||
|
|
@ -22,6 +27,7 @@ describe("adapter metadata", () => {
|
|||
label: "external_test",
|
||||
comingSoon: false,
|
||||
hidden: false,
|
||||
experimental: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -30,4 +36,27 @@ describe("adapter metadata", () => {
|
|||
expect(isEnabledAdapterType("process")).toBe(false);
|
||||
expect(isEnabledAdapterType("http")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps ACPX selectable from explicit configuration but out of visual pickers", () => {
|
||||
expect(isEnabledAdapterType("acpx_local")).toBe(true);
|
||||
expect(isValidAdapterType("acpx_local")).toBe(true);
|
||||
expect(isVisualAdapterChoice("acpx_local")).toBe(false);
|
||||
|
||||
expect(
|
||||
listAdapterOptions((type) => type, [
|
||||
{
|
||||
...externalAdapter,
|
||||
type: "acpx_local",
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
value: "acpx_local",
|
||||
label: "acpx_local",
|
||||
comingSoon: false,
|
||||
hidden: false,
|
||||
experimental: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export interface AdapterOptionMetadata {
|
|||
label: string;
|
||||
comingSoon: boolean;
|
||||
hidden: boolean;
|
||||
experimental: boolean;
|
||||
}
|
||||
|
||||
export function listKnownAdapterTypes(): string[] {
|
||||
|
|
@ -43,6 +44,15 @@ export function isValidAdapterType(type: string): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an adapter should appear in card-style visual pickers.
|
||||
* Experimental adapters can remain selectable from explicit configuration
|
||||
* dropdowns without being recommended during onboarding or setup flows.
|
||||
*/
|
||||
export function isVisualAdapterChoice(type: string): boolean {
|
||||
return !getAdapterDisplay(type).hideFromVisualSelection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build option metadata for a list of adapters (for dropdowns).
|
||||
* `labelFor` callback allows callers to override labels; defaults to display registry.
|
||||
|
|
@ -57,6 +67,7 @@ export function listAdapterOptions(
|
|||
label: getLabel(adapter.type),
|
||||
comingSoon: !!getAdapterDisplay(adapter.type).comingSoon,
|
||||
hidden: isAdapterTypeHidden(adapter.type),
|
||||
experimental: !!getAdapterDisplay(adapter.type).experimental,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { UIAdapterModule } from "./types";
|
||||
import { acpxLocalUIAdapter } from "./acpx-local";
|
||||
import { claudeLocalUIAdapter } from "./claude-local";
|
||||
import { codexLocalUIAdapter } from "./codex-local";
|
||||
import { cursorLocalUIAdapter } from "./cursor";
|
||||
|
|
@ -49,6 +50,7 @@ setDynamicParserResultNotifier(notifyAdapterChange);
|
|||
|
||||
function registerBuiltInUIAdapters() {
|
||||
for (const adapter of [
|
||||
acpxLocalUIAdapter,
|
||||
claudeLocalUIAdapter,
|
||||
codexLocalUIAdapter,
|
||||
geminiLocalUIAdapter,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const ALL_FALSE: AdapterCapabilities = {
|
|||
* return correct values on first render before the /api/adapters call resolves.
|
||||
*/
|
||||
const KNOWN_DEFAULTS: Record<string, AdapterCapabilities> = {
|
||||
acpx_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false },
|
||||
claude_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true },
|
||||
codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true },
|
||||
cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true },
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ import { ReportsToPicker } from "./ReportsToPicker";
|
|||
import { EnvVarEditor } from "./EnvVarEditor";
|
||||
import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
|
||||
import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata";
|
||||
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
import { getAdapterDisplay, getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
|
||||
import { buildAgentUpdatePatch, type AgentConfigOverlay } from "../lib/agent-config-patch";
|
||||
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
|
||||
|
|
@ -1239,6 +1239,7 @@ function AdapterTypeDropdown({
|
|||
disabledTypes: Set<string>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const selectedDisplay = getAdapterDisplay(value);
|
||||
const adapterList = useMemo(
|
||||
() =>
|
||||
listAdapterOptions((type) => adapterLabels[type] ?? getAdapterLabel(type)).filter(
|
||||
|
|
@ -1251,9 +1252,10 @@ function AdapterTypeDropdown({
|
|||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
{value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
|
||||
<span>{adapterLabels[value] ?? getAdapterLabel(value)}</span>
|
||||
<span className="truncate">{adapterLabels[value] ?? getAdapterLabel(value)}</span>
|
||||
{selectedDisplay.experimental && <ExperimentalBadge />}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
|
|
@ -1280,6 +1282,7 @@ function AdapterTypeDropdown({
|
|||
<span className="inline-flex items-center gap-1.5">
|
||||
{item.value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
|
||||
<span>{item.label}</span>
|
||||
{item.experimental && <ExperimentalBadge />}
|
||||
</span>
|
||||
{item.comingSoon && (
|
||||
<span className="text-[10px] text-muted-foreground">Coming soon</span>
|
||||
|
|
@ -1291,6 +1294,14 @@ function AdapterTypeDropdown({
|
|||
);
|
||||
}
|
||||
|
||||
function ExperimentalBadge() {
|
||||
return (
|
||||
<span className="shrink-0 rounded border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-700 dark:text-amber-200">
|
||||
Experimental
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelDropdown({
|
||||
models,
|
||||
value,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { listUIAdapters } from "../adapters";
|
||||
import { isVisualAdapterChoice } from "../adapters/metadata";
|
||||
import { getAdapterDisplay } from "../adapters/adapter-display-registry";
|
||||
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
|
||||
|
||||
|
|
@ -57,7 +58,11 @@ export function NewAgentDialog() {
|
|||
// This automatically includes external/plugin adapters.
|
||||
const adapterGrid = useMemo(() => {
|
||||
const registered = listUIAdapters()
|
||||
.filter((a) => isAgentAdapterType(a.type) && !disabledTypes.has(a.type));
|
||||
.filter((a) =>
|
||||
isAgentAdapterType(a.type) &&
|
||||
!disabledTypes.has(a.type) &&
|
||||
isVisualAdapterChoice(a.type)
|
||||
);
|
||||
|
||||
// Sort: recommended first, then alphabetical
|
||||
return registered
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
} from "../lib/model-utils";
|
||||
import { getUIAdapter } from "../adapters";
|
||||
import { listUIAdapters } from "../adapters";
|
||||
import { isVisualAdapterChoice } from "../adapters/metadata";
|
||||
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
|
||||
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
|
||||
import { getAdapterDisplay } from "../adapters/adapter-display-registry";
|
||||
|
|
@ -209,7 +210,11 @@ export function OnboardingWizard() {
|
|||
const { recommendedAdapters, moreAdapters } = useMemo(() => {
|
||||
const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]);
|
||||
const all = listUIAdapters()
|
||||
.filter((a) => !SYSTEM_ADAPTER_TYPES.has(a.type) && !disabledTypes.has(a.type))
|
||||
.filter((a) =>
|
||||
!SYSTEM_ADAPTER_TYPES.has(a.type) &&
|
||||
!disabledTypes.has(a.type) &&
|
||||
isVisualAdapterChoice(a.type)
|
||||
)
|
||||
.map((a) => ({ ...getAdapterDisplay(a.type), type: a.type }));
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { PageTabBar } from "../components/PageTabBar";
|
|||
import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { useAdapterCapabilities } from "@/adapters/use-adapter-capabilities";
|
||||
import { redactCommandText as redactCommandSecretText } from "@paperclipai/adapter-utils";
|
||||
import { MarkdownEditor } from "../components/MarkdownEditor";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters";
|
||||
|
|
@ -115,6 +116,7 @@ const RUN_LOG_PAGE_BYTES = 256_000;
|
|||
const REDACTED_ENV_VALUE = "***REDACTED***";
|
||||
const SECRET_ENV_KEY_RE =
|
||||
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
||||
const COMMAND_ENV_KEY_RE = /(^command$|^cmd$|command[-_]?line|resolved[-_]?command|PAPERCLIP_RESOLVED_COMMAND)/i;
|
||||
const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/;
|
||||
|
||||
function redactPathText(value: string, censorUsernameInLogs: boolean) {
|
||||
|
|
@ -125,6 +127,10 @@ function redactPathValue<T>(value: T, censorUsernameInLogs: boolean): T {
|
|||
return redactHomePathUserSegmentsInValue(value, { enabled: censorUsernameInLogs });
|
||||
}
|
||||
|
||||
function redactCommandText(value: string, censorUsernameInLogs: boolean): string {
|
||||
return redactPathText(redactCommandSecretText(value, REDACTED_ENV_VALUE), censorUsernameInLogs);
|
||||
}
|
||||
|
||||
function shouldRedactSecretValue(key: string, value: unknown): boolean {
|
||||
if (SECRET_ENV_KEY_RE.test(key)) return true;
|
||||
if (typeof value !== "string") return false;
|
||||
|
|
@ -142,6 +148,7 @@ function redactEnvValue(key: string, value: unknown, censorUsernameInLogs: boole
|
|||
}
|
||||
if (shouldRedactSecretValue(key, value)) return REDACTED_ENV_VALUE;
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string" && COMMAND_ENV_KEY_RE.test(key)) return redactCommandText(value, censorUsernameInLogs);
|
||||
if (typeof value === "string") return redactPathText(value, censorUsernameInLogs);
|
||||
try {
|
||||
return JSON.stringify(redactPathValue(value, censorUsernameInLogs));
|
||||
|
|
@ -302,7 +309,7 @@ export function RunInvocationCard({
|
|||
payload: Record<string, unknown>;
|
||||
censorUsernameInLogs: boolean;
|
||||
}) {
|
||||
const commandLine = [
|
||||
const rawCommandLine = [
|
||||
typeof payload.command === "string" ? payload.command : null,
|
||||
...(Array.isArray(payload.commandArgs)
|
||||
? payload.commandArgs.filter((value): value is string => typeof value === "string")
|
||||
|
|
@ -310,6 +317,7 @@ export function RunInvocationCard({
|
|||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(" ");
|
||||
const commandLine = rawCommandLine ? redactCommandText(rawCommandLine, censorUsernameInLogs) : "";
|
||||
|
||||
const hasAdvancedDetails =
|
||||
commandLine.length > 0
|
||||
|
|
@ -2466,7 +2474,7 @@ function PromptEditorSkeleton() {
|
|||
);
|
||||
}
|
||||
|
||||
function AgentSkillsTab({
|
||||
export function AgentSkillsTab({
|
||||
agent,
|
||||
companyId,
|
||||
}: {
|
||||
|
|
@ -2649,11 +2657,18 @@ function AgentSkillsTab({
|
|||
}, [skillSnapshot?.mode]);
|
||||
const unsupportedSkillMessage = useMemo(() => {
|
||||
if (skillSnapshot?.mode !== "unsupported") return null;
|
||||
if (
|
||||
agent.adapterType === "acpx_local" &&
|
||||
typeof agent.adapterConfig.agent === "string" &&
|
||||
agent.adapterConfig.agent === "custom"
|
||||
) {
|
||||
return "Paperclip cannot manage skills for custom ACP commands yet.";
|
||||
}
|
||||
if (agent.adapterType === "openclaw_gateway") {
|
||||
return "Paperclip cannot manage OpenClaw skills here. Visit your OpenClaw instance to manage this agent's skills.";
|
||||
}
|
||||
return "Paperclip cannot manage skills for this adapter yet. Manage them in the adapter directly.";
|
||||
}, [agent.adapterType, skillSnapshot?.mode]);
|
||||
}, [agent.adapterConfig.agent, agent.adapterType, skillSnapshot?.mode]);
|
||||
const hasUnsavedChanges = !arraysEqual(skillDraft, lastSavedSkills);
|
||||
const saveStatusLabel = syncSkills.isPending
|
||||
? "Saving changes..."
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ import "@mdxeditor/editor/style.css";
|
|||
import "./tailwind-entry.css";
|
||||
import "./styles.css";
|
||||
|
||||
// Install fetch monkeypatch eagerly so any module-load-time fetches (e.g. schema
|
||||
// caches in adapter config renderers) hit our fixtures before they reach the
|
||||
// network. Some renderers issue a fetch from useEffect on first paint, which
|
||||
// can otherwise race the StorybookProviders mount.
|
||||
installStorybookApiFixtures();
|
||||
|
||||
function installStorybookApiFixtures() {
|
||||
if (typeof window === "undefined") return;
|
||||
const currentWindow = window as typeof window & {
|
||||
|
|
@ -148,6 +154,16 @@ function installStorybookApiFixtures() {
|
|||
return Response.json([]);
|
||||
}
|
||||
|
||||
const adapterSchemaMatch = url.pathname.match(/^\/api\/adapters\/([^/]+)\/config-schema$/);
|
||||
if (adapterSchemaMatch) {
|
||||
const [, adapterType] = adapterSchemaMatch;
|
||||
const schemas = (window as typeof window & {
|
||||
__paperclipStorybookAdapterSchemas?: Record<string, unknown>;
|
||||
}).__paperclipStorybookAdapterSchemas;
|
||||
const schema = schemas?.[adapterType];
|
||||
if (schema) return Response.json(schema);
|
||||
}
|
||||
|
||||
const companyResourceMatch = url.pathname.match(/^\/api\/companies\/([^/]+)\/([^/]+)$/);
|
||||
if (companyResourceMatch) {
|
||||
const [, companyId, resource] = companyResourceMatch;
|
||||
|
|
@ -233,7 +249,6 @@ function StorybookProviders({
|
|||
|
||||
useEffect(() => {
|
||||
applyStorybookTheme(theme);
|
||||
installStorybookApiFixtures();
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
896
ui/storybook/stories/acpx-local.stories.tsx
Normal file
896
ui/storybook/stories/acpx-local.stories.tsx
Normal file
|
|
@ -0,0 +1,896 @@
|
|||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { AdapterConfigSchema, CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
import { parseAcpxStdoutLine } from "@paperclipai/adapter-acpx-local/ui";
|
||||
import type {
|
||||
Agent,
|
||||
AgentSkillSnapshot,
|
||||
CompanySkillListItem,
|
||||
} from "@paperclipai/shared";
|
||||
import { SchemaConfigFields } from "@/adapters/schema-config-fields";
|
||||
import type { TranscriptEntry } from "@/adapters";
|
||||
import { RunTranscriptView } from "@/components/transcript/RunTranscriptView";
|
||||
import { AgentSkillsTab } from "@/pages/AgentDetail";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
type SchemaWindow = typeof window & {
|
||||
__paperclipStorybookAdapterSchemas?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// Mirrors packages/adapters/acpx-local/src/server/config-schema.ts. Inlined so the
|
||||
// storybook bundle does not pull node-only imports from the adapter server entry.
|
||||
const acpxLocalConfigSchema: AdapterConfigSchema = {
|
||||
fields: [
|
||||
{
|
||||
key: "agent",
|
||||
label: "ACP agent",
|
||||
type: "select",
|
||||
default: "claude",
|
||||
required: true,
|
||||
options: [
|
||||
{ value: "claude", label: "Claude via ACPX" },
|
||||
{ value: "codex", label: "Codex via ACPX" },
|
||||
{ value: "custom", label: "Custom ACP command" },
|
||||
],
|
||||
hint: "Choose the ACP agent launched through ACPX.",
|
||||
},
|
||||
{
|
||||
key: "agentCommand",
|
||||
label: "Agent command",
|
||||
type: "text",
|
||||
hint: "Required for custom agents; optional override for built-in Claude or Codex ACP commands.",
|
||||
},
|
||||
{
|
||||
key: "mode",
|
||||
label: "Session mode",
|
||||
type: "select",
|
||||
default: "persistent",
|
||||
options: [
|
||||
{ value: "persistent", label: "Persistent" },
|
||||
{ value: "oneshot", label: "One shot" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "permissionMode",
|
||||
label: "Permission mode",
|
||||
type: "select",
|
||||
default: "approve-all",
|
||||
options: [
|
||||
{ value: "approve-all", label: "Approve all" },
|
||||
{ value: "default", label: "ACP default" },
|
||||
],
|
||||
hint: "Defaults to maximum permissions: ACPX permission requests are auto-approved.",
|
||||
},
|
||||
{
|
||||
key: "nonInteractivePermissions",
|
||||
label: "Non-interactive permissions",
|
||||
type: "select",
|
||||
default: "deny",
|
||||
options: [
|
||||
{ value: "deny", label: "Deny" },
|
||||
{ value: "fail", label: "Fail" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "cwd",
|
||||
label: "Working directory",
|
||||
type: "text",
|
||||
hint: "Absolute fallback directory. Paperclip execution workspaces can override this at runtime.",
|
||||
},
|
||||
{
|
||||
key: "stateDir",
|
||||
label: "State directory",
|
||||
type: "text",
|
||||
hint: "Optional ACPX session state directory. Defaults to Paperclip-managed company/agent scoped storage.",
|
||||
},
|
||||
{
|
||||
key: "instructionsFilePath",
|
||||
label: "Instructions file",
|
||||
type: "text",
|
||||
hint: "Optional absolute path to markdown instructions injected into the run prompt.",
|
||||
},
|
||||
{ key: "promptTemplate", label: "Prompt template", type: "textarea" },
|
||||
{ key: "bootstrapPromptTemplate", label: "Bootstrap prompt template", type: "textarea" },
|
||||
{ key: "timeoutSec", label: "Timeout seconds", type: "number", default: 0 },
|
||||
{
|
||||
key: "env",
|
||||
label: "Environment JSON",
|
||||
type: "textarea",
|
||||
hint: "Optional JSON object of environment values or secret bindings.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function installAcpxSchemaMock(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const win = window as SchemaWindow;
|
||||
win.__paperclipStorybookAdapterSchemas = {
|
||||
...(win.__paperclipStorybookAdapterSchemas ?? {}),
|
||||
acpx_local: acpxLocalConfigSchema,
|
||||
};
|
||||
}
|
||||
|
||||
function ConfigSection({ title, description, children }: { title: string; description?: string; children: ReactNode }) {
|
||||
return (
|
||||
<Card className="shadow-none border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">{title}</CardTitle>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">{children}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AcpxLocalConfigStory() {
|
||||
installAcpxSchemaMock();
|
||||
|
||||
const [values, setValues] = useState<CreateConfigValues>(() => ({
|
||||
name: "",
|
||||
role: "",
|
||||
title: "",
|
||||
capabilities: "",
|
||||
icon: "code",
|
||||
adapterType: "acpx_local",
|
||||
command: "",
|
||||
promptTemplate: "",
|
||||
bootstrapPromptTemplate: "",
|
||||
instructionsFilePath: "",
|
||||
extraArgs: "",
|
||||
envVars: "",
|
||||
envBindings: {},
|
||||
runtimeServicesJson: "",
|
||||
runtimeDesiredState: "manual",
|
||||
runtimeServiceStates: {},
|
||||
heartbeatEnabled: false,
|
||||
intervalSec: 900,
|
||||
wakeOnDemand: true,
|
||||
cooldownSec: 60,
|
||||
maxConcurrentRuns: 1,
|
||||
pauseOnIdle: false,
|
||||
idleTimeoutSec: 0,
|
||||
runtimeMaxStuckHeartbeats: 0,
|
||||
adapterSchemaValues: {},
|
||||
} as unknown as CreateConfigValues));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-5 p-6">
|
||||
<header className="space-y-2">
|
||||
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
|
||||
UX preview
|
||||
</Badge>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Agent config — acpx_local</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Renders the schema-driven adapter config block exactly as the operator sees it inside the agent edit form.
|
||||
Defaults reflect Phase 3 of PAP-2944: maximum-permission auto-approve, persistent session mode, Claude as the
|
||||
default ACP agent.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ConfigSection
|
||||
title="Adapter configuration"
|
||||
description="Schema fields rendered through the generic SchemaConfigFields component."
|
||||
>
|
||||
<SchemaConfigFields
|
||||
mode="create"
|
||||
isCreate
|
||||
adapterType="acpx_local"
|
||||
values={values}
|
||||
set={(patch) => setValues((current) => ({ ...current, ...patch }))}
|
||||
config={{}}
|
||||
eff={(_group, _field, original) => original}
|
||||
mark={() => {}}
|
||||
models={[]}
|
||||
/>
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection title="Resolved values (debug)">
|
||||
<pre className="whitespace-pre-wrap text-xs font-mono text-muted-foreground">
|
||||
{JSON.stringify(values.adapterSchemaValues ?? {}, null, 2)}
|
||||
</pre>
|
||||
</ConfigSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ACPX_TS_BASE = new Date("2026-04-30T15:30:00.000Z").getTime();
|
||||
|
||||
function ts(offsetMs: number): string {
|
||||
return new Date(ACPX_TS_BASE + offsetMs).toISOString();
|
||||
}
|
||||
|
||||
function flattenLines(lines: Array<{ payload: Record<string, unknown>; offsetMs: number }>): TranscriptEntry[] {
|
||||
const entries: TranscriptEntry[] = [];
|
||||
for (const { payload, offsetMs } of lines) {
|
||||
const parsed = parseAcpxStdoutLine(JSON.stringify(payload), ts(offsetMs));
|
||||
entries.push(...parsed);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function useAcpxTranscript(): TranscriptEntry[] {
|
||||
return useMemo(
|
||||
() =>
|
||||
flattenLines([
|
||||
{
|
||||
offsetMs: 0,
|
||||
payload: {
|
||||
type: "acpx.session",
|
||||
agent: "claude",
|
||||
mode: "persistent",
|
||||
permissionMode: "approve-all",
|
||||
acpSessionId: "acp_session_42a8c1",
|
||||
runtimeSessionName: "acpx-claude-PAP-1812",
|
||||
},
|
||||
},
|
||||
{
|
||||
offsetMs: 800,
|
||||
payload: {
|
||||
type: "acpx.status",
|
||||
tag: "context_window",
|
||||
used: 12000,
|
||||
size: 200000,
|
||||
},
|
||||
},
|
||||
{
|
||||
offsetMs: 1200,
|
||||
payload: {
|
||||
type: "acpx.text_delta",
|
||||
text: "Looking at the failing test in `runtime-state.test.ts` — ",
|
||||
channel: "thought",
|
||||
},
|
||||
},
|
||||
{
|
||||
offsetMs: 1500,
|
||||
payload: {
|
||||
type: "acpx.text_delta",
|
||||
text: "the assertion expects `pendingRestart` but the new state machine uses `restartScheduled`.\n",
|
||||
channel: "thought",
|
||||
},
|
||||
},
|
||||
{
|
||||
offsetMs: 1900,
|
||||
payload: {
|
||||
type: "acpx.text_delta",
|
||||
text: "I'll inspect the test file to confirm the change.\n\n",
|
||||
channel: "output",
|
||||
tag: "agent_message_chunk",
|
||||
},
|
||||
},
|
||||
{
|
||||
offsetMs: 2200,
|
||||
payload: {
|
||||
type: "acpx.tool_call",
|
||||
name: "read",
|
||||
toolCallId: "tool_read_01",
|
||||
status: "running",
|
||||
text: "server/src/runtime-state.test.ts",
|
||||
input: { path: "server/src/runtime-state.test.ts" },
|
||||
},
|
||||
},
|
||||
{
|
||||
offsetMs: 3500,
|
||||
payload: {
|
||||
type: "acpx.tool_call",
|
||||
name: "read",
|
||||
toolCallId: "tool_read_01",
|
||||
status: "completed",
|
||||
text: "Read 142 lines",
|
||||
},
|
||||
},
|
||||
{
|
||||
offsetMs: 3700,
|
||||
payload: {
|
||||
type: "acpx.text_delta",
|
||||
text:
|
||||
"The test still references the old `pendingRestart` field. I'll update the assertion to use the renamed `restartScheduled` flag.\n\n",
|
||||
channel: "output",
|
||||
},
|
||||
},
|
||||
{
|
||||
offsetMs: 4200,
|
||||
payload: {
|
||||
type: "acpx.tool_call",
|
||||
name: "edit",
|
||||
toolCallId: "tool_edit_02",
|
||||
status: "running",
|
||||
input: {
|
||||
path: "server/src/runtime-state.test.ts",
|
||||
find: "expect(state.pendingRestart).toBe(true)",
|
||||
replace: "expect(state.restartScheduled).toBe(true)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
offsetMs: 5400,
|
||||
payload: {
|
||||
type: "acpx.tool_call",
|
||||
name: "edit",
|
||||
toolCallId: "tool_edit_02",
|
||||
status: "completed",
|
||||
text: "1 replacement",
|
||||
},
|
||||
},
|
||||
{
|
||||
offsetMs: 5800,
|
||||
payload: {
|
||||
type: "acpx.status",
|
||||
text: "Running vitest for runtime-state.test.ts",
|
||||
},
|
||||
},
|
||||
{
|
||||
offsetMs: 6100,
|
||||
payload: {
|
||||
type: "acpx.tool_call",
|
||||
name: "command",
|
||||
toolCallId: "tool_run_03",
|
||||
status: "running",
|
||||
input: { command: "pnpm exec vitest run server/src/runtime-state.test.ts" },
|
||||
},
|
||||
},
|
||||
{
|
||||
offsetMs: 9100,
|
||||
payload: {
|
||||
type: "acpx.tool_call",
|
||||
name: "command",
|
||||
toolCallId: "tool_run_03",
|
||||
status: "completed",
|
||||
text:
|
||||
"Test Files 1 passed (1)\nTests 6 passed (6)\nDuration 2.31s",
|
||||
},
|
||||
},
|
||||
{
|
||||
offsetMs: 9400,
|
||||
payload: {
|
||||
type: "acpx.text_delta",
|
||||
text:
|
||||
"**Test passes.** Updated `runtime-state.test.ts` to assert against `restartScheduled` instead of the renamed `pendingRestart` field.\n\n",
|
||||
channel: "output",
|
||||
},
|
||||
},
|
||||
{
|
||||
offsetMs: 9600,
|
||||
payload: {
|
||||
type: "acpx.text_delta",
|
||||
text:
|
||||
"Next I'll update the issue with a summary and hand it back to QA for verification.",
|
||||
channel: "output",
|
||||
},
|
||||
},
|
||||
{
|
||||
offsetMs: 9800,
|
||||
payload: {
|
||||
type: "acpx.status",
|
||||
tag: "context_window",
|
||||
used: 18450,
|
||||
size: 200000,
|
||||
},
|
||||
},
|
||||
{
|
||||
offsetMs: 10000,
|
||||
payload: {
|
||||
type: "acpx.result",
|
||||
summary: "completed",
|
||||
stopReason: "end_turn",
|
||||
inputTokens: 18450,
|
||||
outputTokens: 412,
|
||||
cachedTokens: 12000,
|
||||
costUsd: 0.024,
|
||||
subtype: "end_turn",
|
||||
},
|
||||
},
|
||||
]),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
function AcpxLocalTranscriptStory() {
|
||||
const entries = useAcpxTranscript();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-5 p-6">
|
||||
<header className="space-y-2">
|
||||
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
|
||||
UX preview
|
||||
</Badge>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Run transcript — acpx_local streamed events</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Demonstrates how a streamed acpx_local run renders through the existing transcript pipeline. Events flow
|
||||
through <code>parseAcpxStdoutLine</code> (session init, thought delta, assistant delta, tool call/result
|
||||
pairs, context window status, final result) and into <code>RunTranscriptView</code> in nice mode.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Card className="shadow-none border-border overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">Run Transcript (nice mode)</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Streaming, comfortable density. Mirrors the agent detail page transcript surface.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RunTranscriptView entries={entries} mode="nice" density="comfortable" streaming />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-none border-border overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">Run Transcript (compact density)</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Same parsed events, compact density — matches the live-run widget on the issue thread.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RunTranscriptView entries={entries} mode="nice" density="compact" streaming={false} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SKILLS_COMPANY_ID = "company-storybook";
|
||||
|
||||
const acpxSkillsCompanyLibrary: CompanySkillListItem[] = [
|
||||
{
|
||||
id: "skill-paperclip",
|
||||
companyId: SKILLS_COMPANY_ID,
|
||||
key: "paperclip",
|
||||
slug: "paperclip",
|
||||
name: "Paperclip",
|
||||
description:
|
||||
"Coordination skill: heartbeats, checkout, comments, and routine API patterns for Paperclip agents.",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: "skills/paperclip",
|
||||
sourceRef: null,
|
||||
trustLevel: "scripts_executables",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||
createdAt: new Date("2026-04-12T09:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-22T15:30:00.000Z"),
|
||||
attachedAgentCount: 4,
|
||||
editable: false,
|
||||
editableReason: "Required by Paperclip",
|
||||
sourceLabel: "Paperclip",
|
||||
sourceBadge: "paperclip",
|
||||
sourcePath: "skills/paperclip",
|
||||
},
|
||||
{
|
||||
id: "skill-design-guide",
|
||||
companyId: SKILLS_COMPANY_ID,
|
||||
key: "design-guide",
|
||||
slug: "design-guide",
|
||||
name: "Design guide",
|
||||
description:
|
||||
"Paperclip UI design system reference: tokens, typography, status colors, and reusable component patterns.",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: "skills/design-guide",
|
||||
sourceRef: null,
|
||||
trustLevel: "markdown_only",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||
createdAt: new Date("2026-04-15T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-25T12:00:00.000Z"),
|
||||
attachedAgentCount: 2,
|
||||
editable: true,
|
||||
editableReason: null,
|
||||
sourceLabel: "Local",
|
||||
sourceBadge: "local",
|
||||
sourcePath: "skills/design-guide",
|
||||
},
|
||||
{
|
||||
id: "skill-mobile-qa",
|
||||
companyId: SKILLS_COMPANY_ID,
|
||||
key: "mobile-app-qa",
|
||||
slug: "mobile-app-qa",
|
||||
name: "Mobile app QA",
|
||||
description:
|
||||
"Exploratory QA flows for mobile/web apps using Chrome automation. Captures bugs and writes a final report.",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: "skills/mobile-app-qa",
|
||||
sourceRef: null,
|
||||
trustLevel: "assets",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
||||
createdAt: new Date("2026-04-18T11:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-26T09:30:00.000Z"),
|
||||
attachedAgentCount: 1,
|
||||
editable: true,
|
||||
editableReason: null,
|
||||
sourceLabel: "Local",
|
||||
sourceBadge: "local",
|
||||
sourcePath: "skills/mobile-app-qa",
|
||||
},
|
||||
];
|
||||
|
||||
function buildAcpxAgent({
|
||||
agentId,
|
||||
acpAgent,
|
||||
desiredSkills,
|
||||
}: {
|
||||
agentId: string;
|
||||
acpAgent: "claude" | "codex" | "custom";
|
||||
desiredSkills: string[];
|
||||
}): Agent {
|
||||
return {
|
||||
id: agentId,
|
||||
companyId: SKILLS_COMPANY_ID,
|
||||
name: `ACPX ${acpAgent === "custom" ? "Custom" : acpAgent === "codex" ? "Codex" : "Claude"}`,
|
||||
urlKey: `acpx-${acpAgent}`,
|
||||
role: "engineer",
|
||||
title: `ACPX ${acpAgent} agent`,
|
||||
icon: "code",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
capabilities: "Routes work through the ACPX adapter for skill-tagged agent flows.",
|
||||
adapterType: "acpx_local",
|
||||
adapterConfig: {
|
||||
agent: acpAgent,
|
||||
mode: "persistent",
|
||||
permissionMode: "approve-all",
|
||||
paperclipSkillSync: {
|
||||
desiredSkills,
|
||||
},
|
||||
},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 100_000,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-04-30T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-30T12:00:00.000Z"),
|
||||
} as Agent;
|
||||
}
|
||||
|
||||
function buildAcpxClaudeSnapshot(): AgentSkillSnapshot {
|
||||
return {
|
||||
adapterType: "acpx_local",
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills: ["paperclip", "design-guide"],
|
||||
warnings: [],
|
||||
entries: [
|
||||
{
|
||||
key: "paperclip",
|
||||
runtimeName: "paperclip",
|
||||
desired: true,
|
||||
managed: true,
|
||||
required: true,
|
||||
requiredReason: "Paperclip coordination skill is mandatory for control-plane agents.",
|
||||
state: "configured",
|
||||
origin: "paperclip_required",
|
||||
originLabel: "Required by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: "skills/paperclip",
|
||||
targetPath: null,
|
||||
detail: "Will be mounted into the next ACPX Claude session.",
|
||||
},
|
||||
{
|
||||
key: "design-guide",
|
||||
runtimeName: "design-guide",
|
||||
desired: true,
|
||||
managed: true,
|
||||
required: false,
|
||||
state: "configured",
|
||||
origin: "company_managed",
|
||||
originLabel: "Managed by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: "skills/design-guide",
|
||||
targetPath: null,
|
||||
detail: "Will be mounted into the next ACPX Claude session.",
|
||||
},
|
||||
{
|
||||
key: "mobile-app-qa",
|
||||
runtimeName: "mobile-app-qa",
|
||||
desired: false,
|
||||
managed: true,
|
||||
required: false,
|
||||
state: "available",
|
||||
origin: "company_managed",
|
||||
originLabel: "Managed by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: "skills/mobile-app-qa",
|
||||
targetPath: null,
|
||||
detail: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildAcpxCodexSnapshot(): AgentSkillSnapshot {
|
||||
return {
|
||||
adapterType: "acpx_local",
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills: ["paperclip"],
|
||||
warnings: [],
|
||||
entries: [
|
||||
{
|
||||
key: "paperclip",
|
||||
runtimeName: "paperclip",
|
||||
desired: true,
|
||||
managed: true,
|
||||
required: true,
|
||||
requiredReason: "Paperclip coordination skill is mandatory for control-plane agents.",
|
||||
state: "configured",
|
||||
origin: "paperclip_required",
|
||||
originLabel: "Required by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: "skills/paperclip",
|
||||
targetPath: null,
|
||||
detail: "Will be linked into the effective CODEX_HOME/skills/ directory for the next ACPX Codex session.",
|
||||
},
|
||||
{
|
||||
key: "design-guide",
|
||||
runtimeName: "design-guide",
|
||||
desired: false,
|
||||
managed: true,
|
||||
required: false,
|
||||
state: "available",
|
||||
origin: "company_managed",
|
||||
originLabel: "Managed by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: "skills/design-guide",
|
||||
targetPath: null,
|
||||
detail: null,
|
||||
},
|
||||
{
|
||||
key: "mobile-app-qa",
|
||||
runtimeName: "mobile-app-qa",
|
||||
desired: false,
|
||||
managed: true,
|
||||
required: false,
|
||||
state: "available",
|
||||
origin: "company_managed",
|
||||
originLabel: "Managed by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: "skills/mobile-app-qa",
|
||||
targetPath: null,
|
||||
detail: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildAcpxCustomSnapshot(): AgentSkillSnapshot {
|
||||
return {
|
||||
adapterType: "acpx_local",
|
||||
supported: false,
|
||||
mode: "unsupported",
|
||||
desiredSkills: ["design-guide"],
|
||||
warnings: [
|
||||
"Custom ACP commands do not expose a Paperclip skill integration contract yet; selected skills are tracked only.",
|
||||
],
|
||||
entries: [
|
||||
{
|
||||
key: "paperclip",
|
||||
runtimeName: "paperclip",
|
||||
desired: false,
|
||||
managed: true,
|
||||
required: true,
|
||||
requiredReason: "Paperclip coordination skill is mandatory for control-plane agents.",
|
||||
state: "available",
|
||||
origin: "paperclip_required",
|
||||
originLabel: "Required by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: "skills/paperclip",
|
||||
targetPath: null,
|
||||
detail: null,
|
||||
},
|
||||
{
|
||||
key: "design-guide",
|
||||
runtimeName: "design-guide",
|
||||
desired: true,
|
||||
managed: true,
|
||||
required: false,
|
||||
state: "configured",
|
||||
origin: "company_managed",
|
||||
originLabel: "Managed by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: "skills/design-guide",
|
||||
targetPath: null,
|
||||
detail:
|
||||
"Desired state is stored in Paperclip only; custom ACP commands need an explicit skill integration contract before runtime sync is available.",
|
||||
},
|
||||
{
|
||||
key: "mobile-app-qa",
|
||||
runtimeName: "mobile-app-qa",
|
||||
desired: false,
|
||||
managed: true,
|
||||
required: false,
|
||||
state: "available",
|
||||
origin: "company_managed",
|
||||
originLabel: "Managed by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: "skills/mobile-app-qa",
|
||||
targetPath: null,
|
||||
detail: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function StoryFrame({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-5 p-6">
|
||||
<header className="space-y-2">
|
||||
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
|
||||
UX preview
|
||||
</Badge>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
||||
</header>
|
||||
|
||||
<Card className="shadow-none border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">Agent detail — Skills tab</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AcpxSkillsState({
|
||||
agent,
|
||||
snapshot,
|
||||
library,
|
||||
}: {
|
||||
agent: Agent;
|
||||
snapshot: AgentSkillSnapshot;
|
||||
library: CompanySkillListItem[];
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
queryClient.setQueryData(queryKeys.companySkills.list(SKILLS_COMPANY_ID), library);
|
||||
queryClient.setQueryData(queryKeys.agents.skills(agent.id), snapshot);
|
||||
return <AgentSkillsTab agent={agent} companyId={SKILLS_COMPANY_ID} />;
|
||||
}
|
||||
|
||||
function AcpxClaudeSkillsStory() {
|
||||
const agent = buildAcpxAgent({
|
||||
agentId: "agent-acpx-claude",
|
||||
acpAgent: "claude",
|
||||
desiredSkills: ["paperclip", "design-guide"],
|
||||
});
|
||||
return (
|
||||
<StoryFrame
|
||||
title="ACPX Claude — Skills tab"
|
||||
subtitle="Runtime-synced state. Selected skills are mounted into the next ACPX Claude session via the Paperclip skills directory."
|
||||
>
|
||||
<AcpxSkillsState agent={agent} snapshot={buildAcpxClaudeSnapshot()} library={acpxSkillsCompanyLibrary} />
|
||||
</StoryFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function AcpxCodexSkillsStory() {
|
||||
const agent = buildAcpxAgent({
|
||||
agentId: "agent-acpx-codex",
|
||||
acpAgent: "codex",
|
||||
desiredSkills: ["paperclip"],
|
||||
});
|
||||
return (
|
||||
<StoryFrame
|
||||
title="ACPX Codex — Skills tab"
|
||||
subtitle="Runtime-synced state. Selected skills are linked into the effective CODEX_HOME/skills/ directory for the next ACPX Codex session."
|
||||
>
|
||||
<AcpxSkillsState agent={agent} snapshot={buildAcpxCodexSnapshot()} library={acpxSkillsCompanyLibrary} />
|
||||
</StoryFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function AcpxCustomSkillsStory() {
|
||||
const agent = buildAcpxAgent({
|
||||
agentId: "agent-acpx-custom",
|
||||
acpAgent: "custom",
|
||||
desiredSkills: ["design-guide"],
|
||||
});
|
||||
return (
|
||||
<StoryFrame
|
||||
title="ACPX custom — Skills tab"
|
||||
subtitle="Unsupported runtime sync. Desired skills are tracked in Paperclip only until a custom ACP command declares a skill integration contract."
|
||||
>
|
||||
<AcpxSkillsState agent={agent} snapshot={buildAcpxCustomSnapshot()} library={acpxSkillsCompanyLibrary} />
|
||||
</StoryFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function AcpxClaudeSkillsLoadingStory() {
|
||||
const agent = buildAcpxAgent({
|
||||
agentId: "agent-acpx-claude-loading",
|
||||
acpAgent: "claude",
|
||||
desiredSkills: [],
|
||||
});
|
||||
return (
|
||||
<StoryFrame
|
||||
title="ACPX Claude — Skills tab (loading)"
|
||||
subtitle="Initial render before /api/agents/{id}/skills resolves. Uses the shared list skeleton."
|
||||
>
|
||||
<AgentSkillsTab agent={agent} companyId={SKILLS_COMPANY_ID} />
|
||||
</StoryFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function AcpxClaudeSkillsEmptyLibraryStory() {
|
||||
const agent = buildAcpxAgent({
|
||||
agentId: "agent-acpx-claude-empty",
|
||||
acpAgent: "claude",
|
||||
desiredSkills: [],
|
||||
});
|
||||
const emptySnapshot: AgentSkillSnapshot = {
|
||||
adapterType: "acpx_local",
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills: [],
|
||||
warnings: [],
|
||||
entries: [],
|
||||
};
|
||||
return (
|
||||
<StoryFrame
|
||||
title="ACPX Claude — Skills tab (empty company library)"
|
||||
subtitle="Runtime supports skills, but the company library has no skills imported yet. Operator is prompted to import skills first."
|
||||
>
|
||||
<AcpxSkillsState agent={agent} snapshot={emptySnapshot} library={[]} />
|
||||
</StoryFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Adapters / acpx_local",
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const ConfigForm: StoryObj = {
|
||||
name: "Agent config form",
|
||||
render: () => <AcpxLocalConfigStory />,
|
||||
};
|
||||
|
||||
export const Transcript: StoryObj = {
|
||||
name: "Streamed run transcript",
|
||||
render: () => <AcpxLocalTranscriptStory />,
|
||||
};
|
||||
|
||||
export const SkillsTabClaude: StoryObj = {
|
||||
name: "Skills tab — ACPX Claude",
|
||||
render: () => <AcpxClaudeSkillsStory />,
|
||||
};
|
||||
|
||||
export const SkillsTabCodex: StoryObj = {
|
||||
name: "Skills tab — ACPX Codex",
|
||||
render: () => <AcpxCodexSkillsStory />,
|
||||
};
|
||||
|
||||
export const SkillsTabCustom: StoryObj = {
|
||||
name: "Skills tab — ACPX custom (unsupported)",
|
||||
render: () => <AcpxCustomSkillsStory />,
|
||||
};
|
||||
|
||||
export const SkillsTabLoading: StoryObj = {
|
||||
name: "Skills tab — loading",
|
||||
render: () => <AcpxClaudeSkillsLoadingStory />,
|
||||
};
|
||||
|
||||
export const SkillsTabEmptyLibrary: StoryObj = {
|
||||
name: "Skills tab — empty company library",
|
||||
render: () => <AcpxClaudeSkillsEmptyLibraryStory />,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue