Add Reflection Coach routine controls

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-09 20:50:34 +00:00
1 parent f2b71590cc
commit 67db22407a
12 files changed
+862 -32

No files matched your search

+14
View File
@@ -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", () => {
+117 -9
View File
@@ -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>
);
+36
View File
@@ -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}
/>
)}