mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
Add Reflection Coach routine controls
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f2b71590cc
commit
67db22407a
12 files changed
+862
-32
No files matched your search
@@ -39,6 +39,7 @@ export interface BuiltInAgentBundleMeta {
|
||||
title: string;
|
||||
status: "active" | "paused";
|
||||
triggerCount: number;
|
||||
scheduleLabel?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,6 +84,13 @@ export interface BuiltInManagedResourceState {
|
||||
/** True when the resource has drifted and can be reset to the default. */
|
||||
resetAvailable: boolean;
|
||||
changedFiles?: string[];
|
||||
/** True when the managed weekly schedule is active and can create background work. */
|
||||
scheduleEnabled?: boolean;
|
||||
/** Pending request_confirmation for a Reflection Coach update proposal, when one exists. */
|
||||
pendingUpdateInteractionId?: string | null;
|
||||
/** Issue containing the pending proposal interaction. */
|
||||
pendingUpdateIssueId?: string | null;
|
||||
pendingUpdateIssueIdentifier?: string | null;
|
||||
}
|
||||
|
||||
export interface BuiltInAgentState {
|
||||
@@ -148,4 +156,10 @@ export const builtInAgentsApi = {
|
||||
*/
|
||||
reconcile: (companyId: string, key: string) =>
|
||||
api.post<BuiltInAgentState>(`/companies/${companyId}/built-in-agents/${key}/reconcile`, {}),
|
||||
runRoutine: (companyId: string, key: string, routineKey: string) =>
|
||||
api.post<unknown>(`/companies/${companyId}/built-in-agents/${key}/routines/${routineKey}/run`, {}),
|
||||
enableRoutineSchedule: (companyId: string, key: string, routineKey: string) =>
|
||||
api.post<BuiltInAgentState>(`/companies/${companyId}/built-in-agents/${key}/routines/${routineKey}/enable`, {}),
|
||||
disableRoutineSchedule: (companyId: string, key: string, routineKey: string) =>
|
||||
api.post<BuiltInAgentState>(`/companies/${companyId}/built-in-agents/${key}/routines/${routineKey}/disable`, {}),
|
||||
};
|
||||
@@ -20,6 +20,7 @@ vi.mock("@/lib/router", () => ({
|
||||
function resource(
|
||||
resourceKind: BuiltInManagedResourceState["resourceKind"],
|
||||
stockStatus: BuiltInManagedResourceStockStatus,
|
||||
overrides: Partial<BuiltInManagedResourceState> = {},
|
||||
): BuiltInManagedResourceState {
|
||||
return {
|
||||
resourceKind,
|
||||
@@ -31,6 +32,7 @@ function resource(
|
||||
stockStatus,
|
||||
updateAvailable: stockStatus === "stock_update_available" || stockStatus === "operator_modified",
|
||||
resetAvailable: stockStatus !== "stock_current",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,6 +65,7 @@ function makeState(
|
||||
title: "Recent agent reflection",
|
||||
status: "paused",
|
||||
triggerCount: 1,
|
||||
scheduleLabel: "Weekly · Mon 09:00 UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -96,6 +99,9 @@ describe("BuiltInBundlePanel (PAP-13099)", () => {
|
||||
function render(state: BuiltInAgentState, handlers: Partial<{
|
||||
onConfigure: () => void;
|
||||
onResetResource: (kind: BuiltInManagedResourceState["resourceKind"]) => void;
|
||||
onRunRoutine: (routineKey: string) => void;
|
||||
onEnableSchedule: (routineKey: string) => void;
|
||||
onDisableSchedule: (routineKey: string) => void;
|
||||
}> = {}) {
|
||||
root = createRoot(container);
|
||||
flushSync(() => {
|
||||
@@ -105,6 +111,9 @@ describe("BuiltInBundlePanel (PAP-13099)", () => {
|
||||
agentRef="reflectioncoach"
|
||||
onConfigure={handlers.onConfigure ?? (() => {})}
|
||||
onResetResource={handlers.onResetResource ?? (() => {})}
|
||||
onRunRoutine={handlers.onRunRoutine ?? (() => {})}
|
||||
onEnableSchedule={handlers.onEnableSchedule ?? (() => {})}
|
||||
onDisableSchedule={handlers.onDisableSchedule ?? (() => {})}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
@@ -135,6 +144,36 @@ describe("BuiltInBundlePanel (PAP-13099)", () => {
|
||||
expect(text).toContain("costs zero tokens by default");
|
||||
expect(text).toContain("Schedule off");
|
||||
expect(text).toContain("Ready");
|
||||
expect(text).toContain("Run once");
|
||||
expect(text).toContain("Enable weekly");
|
||||
});
|
||||
|
||||
it("shows the active weekly schedule and disable action when enabled", () => {
|
||||
render(makeState("ready", [
|
||||
resource("skill", "stock_current"),
|
||||
resource("instructions", "stock_current"),
|
||||
resource("routine", "stock_current", { scheduleEnabled: true }),
|
||||
]));
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Weekly · Mon 09:00 UTC");
|
||||
expect(text).toContain("can create background work");
|
||||
expect(text).toContain("Disable schedule");
|
||||
expect(text).not.toContain("Enable weekly");
|
||||
});
|
||||
|
||||
it("links to a pending proposal interaction when the routine resource reports one", () => {
|
||||
render(makeState("ready", [
|
||||
resource("skill", "stock_current"),
|
||||
resource("instructions", "stock_current"),
|
||||
resource("routine", "stock_current", {
|
||||
pendingUpdateInteractionId: "interaction-1",
|
||||
pendingUpdateIssueId: "issue-1",
|
||||
pendingUpdateIssueIdentifier: "PAP-42",
|
||||
}),
|
||||
]));
|
||||
const link = Array.from(container.querySelectorAll("a")).find((anchor) => anchor.textContent === "Review proposal");
|
||||
expect(container.textContent).toContain("Proposal pending");
|
||||
expect(link?.getAttribute("href")).toBe("/issues/PAP-42#interaction-interaction-1");
|
||||
});
|
||||
|
||||
it("shows Needs setup for the adapter when the agent is not configured yet", () => {
|
||||
|
||||
@@ -134,6 +134,42 @@ function ResourceActionButton({
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmActionButton({
|
||||
title,
|
||||
body,
|
||||
triggerLabel,
|
||||
confirmLabel,
|
||||
pending,
|
||||
onConfirm,
|
||||
}: {
|
||||
title: string;
|
||||
body: string;
|
||||
triggerLabel: string;
|
||||
confirmLabel: string;
|
||||
pending: boolean;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={pending}>
|
||||
{pending ? "Working…" : triggerLabel}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{body}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>{confirmLabel}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface BundleRowProps {
|
||||
label: string;
|
||||
secondary?: string;
|
||||
@@ -191,8 +227,15 @@ export interface BuiltInBundlePanelProps {
|
||||
onConfigure: () => void;
|
||||
/** Scoped reset for one resource (apply update / reset drift / recreate). */
|
||||
onResetResource: (kind: BuiltInManagedResourceKind) => void;
|
||||
/** Trigger the managed routine once without enabling its weekly schedule. */
|
||||
onRunRoutine?: (routineKey: string) => void;
|
||||
/** Enable the managed routine's weekly schedule. */
|
||||
onEnableSchedule?: (routineKey: string) => void;
|
||||
/** Disable the managed routine's weekly schedule. */
|
||||
onDisableSchedule?: (routineKey: string) => void;
|
||||
/** The resource kind whose reset is currently in flight, if any. */
|
||||
resettingResource?: BuiltInManagedResourceKind | null;
|
||||
routineActionPending?: "run" | "enable" | "disable" | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -201,7 +244,11 @@ export function BuiltInBundlePanel({
|
||||
agentRef,
|
||||
onConfigure,
|
||||
onResetResource,
|
||||
onRunRoutine,
|
||||
onEnableSchedule,
|
||||
onDisableSchedule,
|
||||
resettingResource = null,
|
||||
routineActionPending = null,
|
||||
className,
|
||||
}: BuiltInBundlePanelProps) {
|
||||
const { status, definition, resources } = state;
|
||||
@@ -224,6 +271,13 @@ export function BuiltInBundlePanel({
|
||||
const skill = findResource(resources, "skill");
|
||||
const instructions = findResource(resources, "instructions");
|
||||
const routine = findResource(resources, "routine");
|
||||
const scheduleEnabled = routine?.scheduleEnabled === true;
|
||||
const routineKey = bundle.routine.routineKey;
|
||||
const scheduleLabel = bundle.routine.scheduleLabel ?? "Weekly schedule";
|
||||
const proposalIssueRef = routine?.pendingUpdateIssueIdentifier ?? routine?.pendingUpdateIssueId ?? null;
|
||||
const proposalHref = proposalIssueRef && routine?.pendingUpdateInteractionId
|
||||
? `/issues/${proposalIssueRef}#interaction-${routine.pendingUpdateInteractionId}`
|
||||
: null;
|
||||
|
||||
const renderResourceRow = (
|
||||
kind: BuiltInManagedResourceKind,
|
||||
@@ -303,24 +357,78 @@ export function BuiltInBundlePanel({
|
||||
secondary={bundle.routine.title}
|
||||
chips={
|
||||
<>
|
||||
<ResourceStatusChip variant="schedule_off" />
|
||||
<ResourceStatusChip
|
||||
variant={scheduleEnabled ? "schedule_on" : "schedule_off"}
|
||||
label={scheduleEnabled ? scheduleLabel : undefined}
|
||||
/>
|
||||
{routine && driftVariant(routine) && (
|
||||
<ResourceStatusChip variant={driftVariant(routine)!} />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
detail="Nothing runs until you enable the weekly schedule — it costs zero tokens by default."
|
||||
detail={
|
||||
scheduleEnabled
|
||||
? "The weekly schedule is enabled and can create background work."
|
||||
: "Nothing runs until you enable the weekly schedule — it costs zero tokens by default."
|
||||
}
|
||||
actions={
|
||||
routine && driftVariant(routine) ? (
|
||||
<ResourceActionButton
|
||||
resource={routine}
|
||||
label="routine"
|
||||
onConfirm={() => onResetResource("routine")}
|
||||
pending={resettingResource === "routine"}
|
||||
/>
|
||||
routine ? (
|
||||
<>
|
||||
{onRunRoutine && (
|
||||
<ConfirmActionButton
|
||||
title="Run Reflection Coach once?"
|
||||
body="Paperclip will create one routine task now. This does not enable the weekly schedule or turn on background work."
|
||||
triggerLabel="Run once"
|
||||
confirmLabel="Run once"
|
||||
pending={routineActionPending === "run"}
|
||||
onConfirm={() => onRunRoutine(routineKey)}
|
||||
/>
|
||||
)}
|
||||
{scheduleEnabled
|
||||
? onDisableSchedule && (
|
||||
<ConfirmActionButton
|
||||
title="Disable the weekly schedule?"
|
||||
body="Paperclip will stop future scheduled Reflection Coach runs. Manual Run once remains available."
|
||||
triggerLabel="Disable schedule"
|
||||
confirmLabel="Disable schedule"
|
||||
pending={routineActionPending === "disable"}
|
||||
onConfirm={() => onDisableSchedule(routineKey)}
|
||||
/>
|
||||
)
|
||||
: onEnableSchedule && (
|
||||
<ConfirmActionButton
|
||||
title="Enable the weekly schedule?"
|
||||
body="Paperclip will allow Reflection Coach to create routine tasks on the weekly schedule. It can spend tokens when those tasks run."
|
||||
triggerLabel="Enable weekly"
|
||||
confirmLabel="Enable weekly"
|
||||
pending={routineActionPending === "enable"}
|
||||
onConfirm={() => onEnableSchedule(routineKey)}
|
||||
/>
|
||||
)}
|
||||
{driftVariant(routine) && (
|
||||
<ResourceActionButton
|
||||
resource={routine}
|
||||
label="routine"
|
||||
onConfirm={() => onResetResource("routine")}
|
||||
pending={resettingResource === "routine"}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{proposalHref && (
|
||||
<BundleRow
|
||||
label="Proposal"
|
||||
chips={<ResourceStatusChip variant="proposal_pending" />}
|
||||
detail="A proposed Reflection Coach update is waiting for review."
|
||||
actions={
|
||||
<Button asChild variant="link" size="sm">
|
||||
<Link to={proposalHref}>Review proposal</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -754,6 +754,38 @@ export function AgentDetail() {
|
||||
setActionError(error instanceof Error ? error.message : "Failed to update bundle resource");
|
||||
},
|
||||
});
|
||||
const runBuiltInRoutine = useMutation({
|
||||
mutationFn: (routineKey: string) =>
|
||||
builtInAgentsApi.runRoutine(resolvedCompanyId!, builtInState!.definition.key, routineKey),
|
||||
onSuccess: invalidateBuiltIn,
|
||||
onError: (error) => {
|
||||
setActionError(error instanceof Error ? error.message : "Failed to run built-in routine");
|
||||
},
|
||||
});
|
||||
const enableBuiltInSchedule = useMutation({
|
||||
mutationFn: (routineKey: string) =>
|
||||
builtInAgentsApi.enableRoutineSchedule(resolvedCompanyId!, builtInState!.definition.key, routineKey),
|
||||
onSuccess: invalidateBuiltIn,
|
||||
onError: (error) => {
|
||||
setActionError(error instanceof Error ? error.message : "Failed to enable routine schedule");
|
||||
},
|
||||
});
|
||||
const disableBuiltInSchedule = useMutation({
|
||||
mutationFn: (routineKey: string) =>
|
||||
builtInAgentsApi.disableRoutineSchedule(resolvedCompanyId!, builtInState!.definition.key, routineKey),
|
||||
onSuccess: invalidateBuiltIn,
|
||||
onError: (error) => {
|
||||
setActionError(error instanceof Error ? error.message : "Failed to disable routine schedule");
|
||||
},
|
||||
});
|
||||
const builtInRoutineActionPending =
|
||||
runBuiltInRoutine.isPending
|
||||
? "run"
|
||||
: enableBuiltInSchedule.isPending
|
||||
? "enable"
|
||||
: disableBuiltInSchedule.isPending
|
||||
? "disable"
|
||||
: null;
|
||||
|
||||
const { data: runtimeState } = useQuery({
|
||||
queryKey: queryKeys.agents.runtimeState(resolvedAgentId ?? routeAgentRef),
|
||||
@@ -1161,7 +1193,11 @@ export function AgentDetail() {
|
||||
agentRef={canonicalAgentRef}
|
||||
onConfigure={() => setShowBuiltInConfigure(true)}
|
||||
onResetResource={(kind) => resetBuiltInResource.mutate(kind)}
|
||||
onRunRoutine={(routineKey) => runBuiltInRoutine.mutate(routineKey)}
|
||||
onEnableSchedule={(routineKey) => enableBuiltInSchedule.mutate(routineKey)}
|
||||
onDisableSchedule={(routineKey) => disableBuiltInSchedule.mutate(routineKey)}
|
||||
resettingResource={resetBuiltInResource.isPending ? resetBuiltInResource.variables ?? null : null}
|
||||
routineActionPending={builtInRoutineActionPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in new issue
Block a user