mirror of
https://github.com/cline/cline.git
synced 2026-08-28 19:48:08 +08:00
Disable the agent todo tool and hide the Agenda UI in the desktop app (#13530)
* remove todo tool and Agenda UI, keep schedule-only tasks tool Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * chore: biome formatting fixes Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * restore agenda backend; disable todo kind behind a flag instead of deleting Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * keep agenda automation pump idle while the todo tool is disabled Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * remove todo tool and Agenda UI altogether (revert the disable-flag hybrid) Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * restore all agenda code to main state Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * disable agent todo tool and hide Agenda UI behind flags Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
// Covers the shipped state of the Agenda feature: with AGENDA_UI_ENABLED
|
||||
// false (the real flag value), the sidebar Agenda toggle and the welcome
|
||||
// quick actions stay hidden and no agenda commands are issued. The
|
||||
// feature-flag mock in agent-sidebar.test.tsx and welcome-chat.test.tsx
|
||||
// forces the flag on to keep exercising the dormant UI.
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AgentSidebar } from "@/components/agent-sidebar";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { WelcomeScreen } from "@/components/views/chat/welcome-chat";
|
||||
import { WorkspaceProvider } from "@/contexts/workspace-context";
|
||||
import type { UseSessionHistoryResult } from "@/hooks/use-session-history";
|
||||
|
||||
const desktopMocks = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
listAgendaTasks: vi.fn(),
|
||||
getAgendaAutomationPolicy: vi.fn(),
|
||||
subscribe: vi.fn(() => () => undefined),
|
||||
subscribeTransportState: vi.fn(() => () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/desktop-client", () => ({ desktopClient: desktopMocks }));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
desktopMocks.invoke.mockRejectedValue(new Error("not available in test"));
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})),
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function makeSessionHistory(): UseSessionHistoryResult {
|
||||
return {
|
||||
deleteThread: vi.fn(),
|
||||
forkThread: vi.fn(),
|
||||
hasLoadedHistory: true,
|
||||
isLoadingMore: false,
|
||||
loadOlderSessions: vi.fn(),
|
||||
loadMoreSessions: vi.fn(),
|
||||
mayHaveMoreSessions: false,
|
||||
openThread: vi.fn(),
|
||||
pendingAction: null,
|
||||
renameThread: vi.fn(),
|
||||
threads: [],
|
||||
unreadSessionIds: new Set<string>(),
|
||||
} as unknown as UseSessionHistoryResult;
|
||||
}
|
||||
|
||||
describe("Agenda UI hidden by default", () => {
|
||||
it("renders the sidebar without the Agenda toggle and issues no agenda commands", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarProvider>
|
||||
<AgentSidebar
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
sessionHistory={makeSessionHistory()}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
view="chat"
|
||||
workspaceRoot="/projects/current"
|
||||
/>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.querySelector('[aria-label="Show Agenda"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[aria-label="New Session"]'),
|
||||
).not.toBeNull();
|
||||
expect(container.querySelector('[aria-label="Agenda"]')).toBeNull();
|
||||
expect(desktopMocks.listAgendaTasks).not.toHaveBeenCalled();
|
||||
expect(desktopMocks.getAgendaAutomationPolicy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the welcome screen without agenda quick actions or agenda fetches", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceProvider
|
||||
value={{
|
||||
workspaceRoot: "/projects/project-1",
|
||||
workspaces: ["/projects/project-1"],
|
||||
listWorkspaces: vi.fn(async () => ["/projects/project-1"]),
|
||||
refreshWorkspaces: vi.fn(async () => undefined),
|
||||
switchWorkspace: vi.fn(async () => true),
|
||||
pickWorkspaceDirectory: vi.fn(async () => null),
|
||||
selectChat: vi.fn(async () => true),
|
||||
}}
|
||||
>
|
||||
<WelcomeScreen
|
||||
active
|
||||
body={null}
|
||||
composer={null}
|
||||
gitBranch="main"
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.querySelector("[data-welcome-hero]")).not.toBeNull();
|
||||
expect(desktopMocks.listAgendaTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,10 @@ const desktopMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
const { invoke } = desktopMocks;
|
||||
vi.mock("@/lib/desktop-client", () => ({ desktopClient: desktopMocks }));
|
||||
// The Agenda UI ships hidden for now; these tests force the flag on so they
|
||||
// keep guarding the dormant feature. agenda-ui-hidden.test.tsx covers the
|
||||
// shipped (hidden) state.
|
||||
vi.mock("@/lib/feature-flags", () => ({ AGENDA_UI_ENABLED: true }));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
@@ -114,6 +114,7 @@ import {
|
||||
productNameForVersion,
|
||||
} from "@/lib/app-channel";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { AGENDA_UI_ENABLED } from "@/lib/feature-flags";
|
||||
import { readModelSelectionStorageFromWindow } from "@/lib/model-selection";
|
||||
import {
|
||||
ALL_SESSION_SOURCES,
|
||||
@@ -326,9 +327,11 @@ export function AgentSidebar({
|
||||
workspaceRoot: agendaWorkspaceRoot,
|
||||
limit: 200,
|
||||
},
|
||||
view !== "settings",
|
||||
AGENDA_UI_ENABLED && view !== "settings",
|
||||
);
|
||||
const agendaAutomation = useAgendaAutomation(
|
||||
AGENDA_UI_ENABLED && view !== "settings",
|
||||
);
|
||||
const agendaAutomation = useAgendaAutomation(view !== "settings");
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "settings") {
|
||||
@@ -795,31 +798,37 @@ export function AgentSidebar({
|
||||
</div>
|
||||
{!isCollapsed ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
aria-label={
|
||||
sidebarContent === "agenda" ? "Show Sessions" : "Show Agenda"
|
||||
}
|
||||
aria-pressed={sidebarContent === "agenda"}
|
||||
className={cn(
|
||||
"relative size-8 shrink-0 justify-center px-0",
|
||||
sidebarContent === "agenda" &&
|
||||
"bg-surface-hover text-sidebar-foreground",
|
||||
)}
|
||||
onClick={toggleSidebarContent}
|
||||
title={
|
||||
sidebarContent === "agenda" ? "Show Sessions" : "Show Agenda"
|
||||
}
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<ClipboardList className="size-4" />
|
||||
{hasNewTodoTasks ? (
|
||||
<span
|
||||
className="absolute right-1 top-1 size-1.5 rounded-full bg-primary"
|
||||
data-testid="new-todo-indicator"
|
||||
/>
|
||||
) : null}
|
||||
</Button>
|
||||
{AGENDA_UI_ENABLED ? (
|
||||
<Button
|
||||
aria-label={
|
||||
sidebarContent === "agenda"
|
||||
? "Show Sessions"
|
||||
: "Show Agenda"
|
||||
}
|
||||
aria-pressed={sidebarContent === "agenda"}
|
||||
className={cn(
|
||||
"relative size-8 shrink-0 justify-center px-0",
|
||||
sidebarContent === "agenda" &&
|
||||
"bg-surface-hover text-sidebar-foreground",
|
||||
)}
|
||||
onClick={toggleSidebarContent}
|
||||
title={
|
||||
sidebarContent === "agenda"
|
||||
? "Show Sessions"
|
||||
: "Show Agenda"
|
||||
}
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<ClipboardList className="size-4" />
|
||||
{hasNewTodoTasks ? (
|
||||
<span
|
||||
className="absolute right-1 top-1 size-1.5 rounded-full bg-primary"
|
||||
data-testid="new-todo-indicator"
|
||||
/>
|
||||
) : null}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
aria-label="New Session"
|
||||
className="size-8 shrink-0 justify-center px-0"
|
||||
@@ -863,7 +872,7 @@ export function AgentSidebar({
|
||||
onSelect={openSettingsSection}
|
||||
/>
|
||||
</div>
|
||||
) : sidebarContent === "agenda" ? (
|
||||
) : AGENDA_UI_ENABLED && sidebarContent === "agenda" ? (
|
||||
<AgendaSection
|
||||
automatic={
|
||||
agendaAutomation.policy !== null &&
|
||||
|
||||
@@ -21,6 +21,10 @@ vi.mock("@/lib/desktop-client", () => ({
|
||||
subscribeTransportState: vi.fn(() => () => undefined),
|
||||
},
|
||||
}));
|
||||
// The Agenda UI ships hidden for now; these tests force the flag on so they
|
||||
// keep guarding the dormant feature. agenda-ui-hidden.test.tsx covers the
|
||||
// shipped (hidden) state.
|
||||
vi.mock("@/lib/feature-flags", () => ({ AGENDA_UI_ENABLED: true }));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AgendaTaskReviewDialog } from "@/components/agenda-task-review-dialog";
|
||||
import { useWorkspace } from "@/contexts/workspace-context";
|
||||
import { isAgendaTaskExpired, useAgendaTasks } from "@/hooks/use-agenda-tasks";
|
||||
import { AGENDA_UI_ENABLED } from "@/lib/feature-flags";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SessionContent } from "./session-content";
|
||||
import { WelcomeHero } from "./welcome-hero";
|
||||
@@ -49,7 +50,7 @@ export function WelcomeScreen({
|
||||
statuses: ["pending_approval", "approved", "in_progress", "failed"],
|
||||
limit: 8,
|
||||
},
|
||||
active && workspaceRoot.trim().length > 0,
|
||||
AGENDA_UI_ENABLED && active && workspaceRoot.trim().length > 0,
|
||||
);
|
||||
const [runningTaskId, setRunningTaskId] = useState<string | null>(null);
|
||||
const [reviewTask, setReviewTask] = useState<AgendaTaskRecord | null>(null);
|
||||
@@ -168,7 +169,7 @@ export function WelcomeScreen({
|
||||
{active ? composer : <SessionContent>{composer}</SessionContent>}
|
||||
</div>
|
||||
|
||||
{active ? (
|
||||
{active && AGENDA_UI_ENABLED ? (
|
||||
<>
|
||||
<AgentQuickActions
|
||||
actions={actions}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* The Agenda (Todo) UI is temporarily hidden while its UX is reworked, in
|
||||
* lockstep with `AGENDA_TODO_TOOL_ENABLED` in the hub server transport, which
|
||||
* disables the agent-facing todo kind of the `tasks` tool. All Agenda
|
||||
* components, hooks, and sidecar plumbing stay in the codebase; flip this back
|
||||
* to true (together with the hub flag) to restore the feature.
|
||||
*/
|
||||
export const AGENDA_UI_ENABLED = false;
|
||||
@@ -558,6 +558,13 @@ agent-team task board. Shared, browser-safe contracts use `AgendaTaskRecord`
|
||||
and `AgendaTaskRunRecord`; orchestration and persistence remain in
|
||||
`@cline/core`.
|
||||
|
||||
> **Status:** the agent-facing `kind: "todo"` half of the `tasks` tool and the
|
||||
> desktop Agenda UI are temporarily disabled while the Agenda UX is reworked
|
||||
> (`AGENDA_TODO_TOOL_ENABLED` in `hub-server-transport.ts` and
|
||||
> `AGENDA_UI_ENABLED` in the desktop webview). The backend described below —
|
||||
> the manager, storage, `task.*` Hub commands, and desktop plumbing — stays
|
||||
> fully wired, and the schedule kind remains active.
|
||||
|
||||
### Authority and persistence
|
||||
|
||||
- A Hub process owns one Agenda task manager. Its
|
||||
|
||||
@@ -92,7 +92,7 @@ const BASE_TOOL_CATALOG: readonly RuntimeToolCatalogEntry[] = [
|
||||
{
|
||||
id: "tasks",
|
||||
description:
|
||||
"Create and manage durable Todo items or explicitly requested one-time and recurring agent schedules.",
|
||||
"Create and manage explicitly requested one-time and recurring agent schedules.",
|
||||
headlessToolNames: ["tasks"],
|
||||
unavailableClientTypes: ["cli", "vscode"],
|
||||
},
|
||||
|
||||
@@ -327,6 +327,10 @@ describe("Hub agenda task vertical slice", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// While the Agenda todo tool and UI are disabled, the hub keeps the
|
||||
// automation pump idle: a persisted auto_start policy must not
|
||||
// approve or start eligible work even once an approval-capable
|
||||
// client registers, because no surface remains to supervise it.
|
||||
const automated = await transport.handleCommand({
|
||||
version: "v1",
|
||||
command: "task.create",
|
||||
@@ -358,17 +362,6 @@ describe("Hub agenda task vertical slice", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await vi.waitFor(async () => {
|
||||
const waiting = await transport.handleCommand({
|
||||
version: "v1",
|
||||
command: "task.get",
|
||||
clientId: "desktop",
|
||||
payload: { taskId: automatedTask.taskId },
|
||||
});
|
||||
expect(waiting.payload?.task).toMatchObject({ status: "approved" });
|
||||
});
|
||||
expect(startSession).toHaveBeenCalledTimes(1);
|
||||
|
||||
await transport.handleCommand({
|
||||
version: "v1",
|
||||
command: "client.register",
|
||||
@@ -380,16 +373,19 @@ describe("Hub agenda task vertical slice", () => {
|
||||
capabilities: [{ name: "approval.respond" }],
|
||||
},
|
||||
});
|
||||
await vi.waitFor(async () => {
|
||||
const completed = await transport.handleCommand({
|
||||
version: "v1",
|
||||
command: "task.get",
|
||||
clientId: "desktop",
|
||||
payload: { taskId: automatedTask.taskId },
|
||||
});
|
||||
expect(completed.payload?.task).toMatchObject({ status: "completed" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const idle = await transport.handleCommand({
|
||||
version: "v1",
|
||||
command: "task.get",
|
||||
clientId: "desktop",
|
||||
payload: { taskId: automatedTask.taskId },
|
||||
});
|
||||
expect(startSession).toHaveBeenCalledTimes(2);
|
||||
expect(idle.payload?.task).toMatchObject({
|
||||
// User-created todos are approved on creation; automation must
|
||||
// still never start them while it is disabled.
|
||||
status: "approved",
|
||||
});
|
||||
expect(startSession).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await transport.stop();
|
||||
}
|
||||
|
||||
@@ -114,6 +114,18 @@ import {
|
||||
isAgendaTaskCommand,
|
||||
} from "./task-command-service";
|
||||
|
||||
/**
|
||||
* The agent-facing `kind: "todo"` half of the `tasks` tool and the Agenda
|
||||
* automation pump are temporarily disabled while the Agenda UX is reworked;
|
||||
* the desktop Agenda UI is hidden for the same reason. Automation must stay
|
||||
* off with the UI hidden: a previously persisted `auto_start`/`unattended`
|
||||
* policy would otherwise keep starting eligible tasks with no surface left to
|
||||
* inspect, pause, or cancel them. The Agenda backend (manager, `task.*` Hub
|
||||
* commands, storage, persisted policies) stays fully wired so flipping this
|
||||
* back on restores the feature.
|
||||
*/
|
||||
const AGENDA_TODO_TOOL_ENABLED = false;
|
||||
|
||||
const SETTINGS_TYPES = new Set<CoreSettingsType>([
|
||||
"skills",
|
||||
"workflows",
|
||||
@@ -274,6 +286,7 @@ export class HubServerTransport implements NativeHubTransport {
|
||||
};
|
||||
this.tasks = new AgendaTaskManager({
|
||||
...options.taskOptions,
|
||||
automationEnabled: AGENDA_TODO_TOOL_ENABLED,
|
||||
runtime: {
|
||||
isInteractiveClientAvailable: () =>
|
||||
[...this.clients.values()].some((client) =>
|
||||
@@ -327,29 +340,33 @@ export class HubServerTransport implements NativeHubTransport {
|
||||
this.scheduleCommands = new HubScheduleCommandService(this.schedules);
|
||||
this.sessionTools.push(
|
||||
createTasksTool({
|
||||
todo: {
|
||||
manager: this.tasks,
|
||||
telemetry: options.telemetry,
|
||||
resolveSessionDefaults: async (sessionId) => {
|
||||
const session = await this.sessionHost.getSession(sessionId);
|
||||
if (!session) return undefined;
|
||||
const projectWorkspace = !isChatWorkspacePath(session.workspaceRoot)
|
||||
? session.workspaceRoot
|
||||
: undefined;
|
||||
return {
|
||||
workspaceRoot: projectWorkspace,
|
||||
cwd: projectWorkspace ? session.cwd : undefined,
|
||||
modelSelection: {
|
||||
providerId: session.provider,
|
||||
modelId: session.model,
|
||||
todo: AGENDA_TODO_TOOL_ENABLED
|
||||
? {
|
||||
manager: this.tasks,
|
||||
telemetry: options.telemetry,
|
||||
resolveSessionDefaults: async (sessionId) => {
|
||||
const session = await this.sessionHost.getSession(sessionId);
|
||||
if (!session) return undefined;
|
||||
const projectWorkspace = !isChatWorkspacePath(
|
||||
session.workspaceRoot,
|
||||
)
|
||||
? session.workspaceRoot
|
||||
: undefined;
|
||||
return {
|
||||
workspaceRoot: projectWorkspace,
|
||||
cwd: projectWorkspace ? session.cwd : undefined,
|
||||
modelSelection: {
|
||||
providerId: session.provider,
|
||||
modelId: session.model,
|
||||
},
|
||||
originTaskId:
|
||||
typeof session.metadata?.agendaTaskId === "string"
|
||||
? session.metadata.agendaTaskId
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
originTaskId:
|
||||
typeof session.metadata?.agendaTaskId === "string"
|
||||
? session.metadata.agendaTaskId
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
scheduled: {
|
||||
schedules: this.schedules,
|
||||
telemetry: options.telemetry,
|
||||
@@ -372,7 +389,9 @@ export class HubServerTransport implements NativeHubTransport {
|
||||
},
|
||||
}) as AgentTool,
|
||||
);
|
||||
this.sessionExtensions.push(createTasksPromptExtension());
|
||||
this.sessionExtensions.push(
|
||||
createTasksPromptExtension({ todoEnabled: AGENDA_TODO_TOOL_ENABLED }),
|
||||
);
|
||||
this.settings = options.settingsService ?? new CoreSettingsService();
|
||||
if (options.cronOptions) {
|
||||
this.cronService = new CronService({
|
||||
|
||||
@@ -29,7 +29,10 @@ function future(days = 7): string {
|
||||
|
||||
function createHarness(
|
||||
result: "completed" | "failed" = "completed",
|
||||
options: { interactiveClientAvailable?: boolean } = {},
|
||||
options: {
|
||||
interactiveClientAvailable?: boolean;
|
||||
automationEnabled?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-agenda-manager-"));
|
||||
const events: string[] = [];
|
||||
@@ -51,6 +54,7 @@ function createHarness(
|
||||
dbPath: join(root, "tasks.db"),
|
||||
globalSpecsDir: join(root, "specs"),
|
||||
watchFiles: false,
|
||||
automationEnabled: options.automationEnabled,
|
||||
publish: (event) => events.push(event),
|
||||
});
|
||||
managers.push(manager);
|
||||
@@ -628,6 +632,51 @@ describe("AgendaTaskManager", () => {
|
||||
expect(runtime.startSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps a persisted automation policy idle when automation is disabled", async () => {
|
||||
const { root, manager, runtime } = createHarness("completed", {
|
||||
automationEnabled: false,
|
||||
});
|
||||
await manager.start();
|
||||
await manager.setAutomationPolicy(
|
||||
{
|
||||
scopeKey: "global",
|
||||
mode: "unattended",
|
||||
applyToAgentCreated: true,
|
||||
maxConcurrentRuns: 1,
|
||||
maxChainDepth: 3,
|
||||
maxStartsPerHour: 20,
|
||||
},
|
||||
{ kind: "user", clientId: "desktop" },
|
||||
);
|
||||
const task = await createPending(manager);
|
||||
manager.notifyAutomationReadinessChanged();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect((await manager.getTask(task.taskId))?.status).toBe(
|
||||
"pending_approval",
|
||||
);
|
||||
expect(runtime.startSession).not.toHaveBeenCalled();
|
||||
await manager.dispose();
|
||||
|
||||
// A restarted Hub with the same store and the flag still off must not
|
||||
// pick the persisted unattended policy back up either.
|
||||
const restarted = new AgendaTaskManager({
|
||||
runtime,
|
||||
dbPath: join(root, "tasks.db"),
|
||||
globalSpecsDir: join(root, "specs"),
|
||||
watchFiles: false,
|
||||
automationEnabled: false,
|
||||
});
|
||||
managers.push(restarted);
|
||||
await restarted.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect((await restarted.getTask(task.taskId))?.status).toBe(
|
||||
"pending_approval",
|
||||
);
|
||||
expect(runtime.startSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only automates tasks in the policy workspace", async () => {
|
||||
const { root, manager, runtime } = createHarness();
|
||||
await manager.start();
|
||||
|
||||
@@ -80,6 +80,12 @@ export interface AgendaTaskManagerOptions {
|
||||
globalSpecsDir?: string;
|
||||
watcherDebounceMs?: number;
|
||||
watchFiles?: boolean;
|
||||
/**
|
||||
* Set to false to keep the automation pump idle regardless of persisted
|
||||
* automation policies. Policies stay stored untouched and manual approve/run
|
||||
* commands keep working; nothing is auto-approved or auto-started.
|
||||
*/
|
||||
automationEnabled?: boolean;
|
||||
logger?: BasicLogger;
|
||||
publish?: (
|
||||
event: AgendaTaskManagerEventName,
|
||||
@@ -272,6 +278,7 @@ export class AgendaTaskManager implements AgendaTaskManagerApi {
|
||||
private readonly backgroundRuns = new Set<Promise<void>>();
|
||||
private maintenanceTimer?: ReturnType<typeof setInterval>;
|
||||
private readonly queuedAutomationScopes = new Set<string>();
|
||||
private readonly automationEnabled: boolean;
|
||||
private automationPumping = false;
|
||||
private automationPolicyGeneration = 0;
|
||||
private started = false;
|
||||
@@ -288,6 +295,7 @@ export class AgendaTaskManager implements AgendaTaskManagerApi {
|
||||
options.watcherDebounceMs ?? DEFAULT_WATCH_DEBOUNCE_MS,
|
||||
);
|
||||
this.watchFiles = options.watchFiles !== false;
|
||||
this.automationEnabled = options.automationEnabled !== false;
|
||||
this.logger = options.logger ?? noopBasicLogger;
|
||||
this.publishEvent = options.publish ?? (() => {});
|
||||
}
|
||||
@@ -1222,7 +1230,7 @@ export class AgendaTaskManager implements AgendaTaskManagerApi {
|
||||
}
|
||||
|
||||
private queueAutomation(scopeKey?: string): void {
|
||||
if (this.disposed) return;
|
||||
if (this.disposed || !this.automationEnabled) return;
|
||||
if (scopeKey) {
|
||||
this.queuedAutomationScopes.add(scopeKey);
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createTasksPromptExtension,
|
||||
createTasksTool,
|
||||
SCHEDULED_TASKS_SYSTEM_PROMPT_RULE,
|
||||
TASKS_SYSTEM_PROMPT_RULE,
|
||||
} from "./task-tool";
|
||||
|
||||
@@ -121,4 +122,70 @@ describe("tasks agent tool", () => {
|
||||
whenToolAvailable: "tasks",
|
||||
});
|
||||
});
|
||||
|
||||
describe("with the Todo kind disabled", () => {
|
||||
function scheduledOnlyOptions() {
|
||||
const manager = {
|
||||
listTasks: vi.fn(async () => []),
|
||||
};
|
||||
const schedules = {
|
||||
listSchedules: vi.fn(() => []),
|
||||
};
|
||||
return {
|
||||
manager,
|
||||
schedules,
|
||||
tool: createTasksTool({
|
||||
scheduled: {
|
||||
schedules: schedules as never,
|
||||
resolveSessionDefaults: async () => ({
|
||||
workspaceRoot: process.cwd(),
|
||||
interactive: true,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
it("advertises only the scheduled kind", () => {
|
||||
const { tool } = scheduledOnlyOptions();
|
||||
const schema = tool.inputSchema as Record<string, unknown>;
|
||||
|
||||
expect(schema.type).toBe("object");
|
||||
expect(schema.properties).toMatchObject({
|
||||
kind: { enum: ["scheduled"] },
|
||||
});
|
||||
expect(schema.properties).not.toHaveProperty("title");
|
||||
expect(tool.description).not.toContain("Todo");
|
||||
});
|
||||
|
||||
it("routes scheduled operations and rejects todo requests", async () => {
|
||||
const { manager, schedules, tool } = scheduledOnlyOptions();
|
||||
|
||||
await expect(
|
||||
tool.execute({ kind: "scheduled", operation: "list" }, context),
|
||||
).resolves.toMatchObject({ ok: true, kind: "scheduled", schedules: [] });
|
||||
expect(schedules.listSchedules).toHaveBeenCalledOnce();
|
||||
|
||||
await expect(
|
||||
tool.execute({ kind: "todo", operation: "list" } as never, context),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
kind: "todo",
|
||||
error: { code: "invalid_tasks_input" },
|
||||
});
|
||||
expect(manager.listTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers the schedule-only prompt rule", async () => {
|
||||
const extension = createTasksPromptExtension({ todoEnabled: false });
|
||||
const registerRule = vi.fn();
|
||||
await extension.setup?.({ registerRule } as never, {});
|
||||
|
||||
expect(registerRule).toHaveBeenCalledWith({
|
||||
id: "hub:task-guidance",
|
||||
content: SCHEDULED_TASKS_SYSTEM_PROMPT_RULE,
|
||||
whenToolAvailable: "tasks",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,16 @@ Use the \`tasks\` tool to manage durable Todo items and explicitly requested sch
|
||||
- Todo instructions and scheduled prompts must be self-contained. Include the goal, constraints, relevant project context, and expected output.
|
||||
- Never approve or start a Todo yourself. Only mutate schedules from an interactive user session, and only update, pause, resume, delete, or run one immediately when the user asks.`;
|
||||
|
||||
/** Guidance used while the Todo kind is disabled and only schedules remain. */
|
||||
export const SCHEDULED_TASKS_SYSTEM_PROMPT_RULE = `# Tasks
|
||||
|
||||
Use the \`tasks\` tool only when the user explicitly asks Cline to execute work once at a future time or on a recurrence. Always pass \`kind: "scheduled"\`.
|
||||
|
||||
- One-time schedules require an exact future ISO 8601 \`run_at\` with an offset or Z. Recurring schedules require a five-field \`cron_pattern\` and may include an IANA \`timezone\`.
|
||||
- Never create a schedule proactively. Check existing schedules before creating a likely duplicate.
|
||||
- Make the scheduled \`prompt\` self-contained because it runs in a new unattended session. Include the goal, constraints, relevant project context, and expected output.
|
||||
- Only update, pause, resume, delete, or run a schedule immediately when the user asks for that action.`;
|
||||
|
||||
const TodoRequestSchema = TodoTaskInputSchema.extend({
|
||||
kind: z.literal("todo"),
|
||||
}).strict();
|
||||
@@ -40,25 +50,39 @@ export const TasksToolInputSchema = z.discriminatedUnion("kind", [
|
||||
ScheduledRequestSchema,
|
||||
]);
|
||||
|
||||
const ScheduledOnlyTasksToolInputSchema = z.discriminatedUnion("kind", [
|
||||
ScheduledRequestSchema,
|
||||
]);
|
||||
|
||||
const TASKS_OPERATIONS = [
|
||||
"create",
|
||||
"update",
|
||||
"list",
|
||||
"get",
|
||||
"pause",
|
||||
"resume",
|
||||
"delete",
|
||||
"run_now",
|
||||
] as const;
|
||||
|
||||
// Anthropic requires a plain object at the top level of a tool input schema and
|
||||
// rejects the oneOf emitted by a discriminated union. Advertise the union of
|
||||
// both domains' fields as one object, then retain strict domain validation with
|
||||
// TasksToolInputSchema inside execute().
|
||||
// the enabled domains' fields as one object, then retain strict domain
|
||||
// validation inside execute().
|
||||
const TasksToolProviderInputSchema = z
|
||||
.object({
|
||||
...ScheduledTaskInputSchema.shape,
|
||||
...TodoTaskInputSchema.shape,
|
||||
kind: z.enum(["todo", "scheduled"]),
|
||||
operation: z.enum([
|
||||
"create",
|
||||
"update",
|
||||
"list",
|
||||
"get",
|
||||
"pause",
|
||||
"resume",
|
||||
"delete",
|
||||
"run_now",
|
||||
]),
|
||||
operation: z.enum(TASKS_OPERATIONS),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ScheduledOnlyTasksToolProviderInputSchema = z
|
||||
.object({
|
||||
...ScheduledTaskInputSchema.shape,
|
||||
kind: z.enum(["scheduled"]),
|
||||
operation: z.enum(TASKS_OPERATIONS),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -74,7 +98,12 @@ export type TasksToolResult =
|
||||
};
|
||||
|
||||
export interface CreateTasksToolOptions {
|
||||
todo: TodoTaskOperationOptions;
|
||||
/**
|
||||
* Omit to disable the Todo kind: the tool then advertises and accepts only
|
||||
* `kind: "scheduled"` while the Agenda backend stays intact for hosts that
|
||||
* re-enable it.
|
||||
*/
|
||||
todo?: TodoTaskOperationOptions;
|
||||
scheduled: ScheduleTaskOperationOptions;
|
||||
}
|
||||
|
||||
@@ -86,18 +115,27 @@ export interface CreateTasksToolOptions {
|
||||
export function createTasksTool(
|
||||
options: CreateTasksToolOptions,
|
||||
): AgentTool<TasksToolInput, TasksToolResult> {
|
||||
const todoOptions = options.todo;
|
||||
return createTool<TasksToolInput, TasksToolResult>({
|
||||
name: TASKS_TOOL_NAME,
|
||||
description:
|
||||
"Create and manage reviewed Todo items or explicitly requested scheduled agent work. " +
|
||||
'Use kind "todo" for durable Agenda items that require user or automation approval. ' +
|
||||
'Use kind "scheduled" for one-time or recurring autonomous execution. ' +
|
||||
"Todo available_at is not an execution timer; schedules use run_at or cron_pattern.",
|
||||
inputSchema: zodToJsonSchema(TasksToolProviderInputSchema),
|
||||
description: todoOptions
|
||||
? "Create and manage reviewed Todo items or explicitly requested scheduled agent work. " +
|
||||
'Use kind "todo" for durable Agenda items that require user or automation approval. ' +
|
||||
'Use kind "scheduled" for one-time or recurring autonomous execution. ' +
|
||||
"Todo available_at is not an execution timer; schedules use run_at or cron_pattern."
|
||||
: 'Create and manage explicitly requested scheduled agent work with kind "scheduled". ' +
|
||||
"One-time schedules use a future ISO 8601 run_at; recurring schedules use a five-field cron_pattern.",
|
||||
inputSchema: zodToJsonSchema(
|
||||
todoOptions
|
||||
? TasksToolProviderInputSchema
|
||||
: ScheduledOnlyTasksToolProviderInputSchema,
|
||||
),
|
||||
retryable: false,
|
||||
maxRetries: 0,
|
||||
execute: async (rawInput, context) => {
|
||||
const parsed = TasksToolInputSchema.safeParse(rawInput);
|
||||
const parsed = (
|
||||
todoOptions ? TasksToolInputSchema : ScheduledOnlyTasksToolInputSchema
|
||||
).safeParse(rawInput);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -116,8 +154,18 @@ export function createTasksTool(
|
||||
}
|
||||
|
||||
if (parsed.data.kind === "todo") {
|
||||
if (!todoOptions) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: "todo",
|
||||
error: {
|
||||
code: "invalid_tasks_input",
|
||||
message: 'kind "todo" is not available in this session',
|
||||
},
|
||||
};
|
||||
}
|
||||
const result = await executeTodoTaskOperation(
|
||||
options.todo,
|
||||
todoOptions,
|
||||
parsed.data,
|
||||
context,
|
||||
);
|
||||
@@ -135,14 +183,19 @@ export function createTasksTool(
|
||||
}
|
||||
|
||||
/** Adds selection and safety guidance only when the unified tool is enabled. */
|
||||
export function createTasksPromptExtension(): AgentExtension {
|
||||
export function createTasksPromptExtension(options?: {
|
||||
todoEnabled?: boolean;
|
||||
}): AgentExtension {
|
||||
return {
|
||||
name: "hub-task-guidance",
|
||||
manifest: { capabilities: ["rules"] },
|
||||
setup: (api) => {
|
||||
api.registerRule({
|
||||
id: "hub:task-guidance",
|
||||
content: TASKS_SYSTEM_PROMPT_RULE,
|
||||
content:
|
||||
options?.todoEnabled === false
|
||||
? SCHEDULED_TASKS_SYSTEM_PROMPT_RULE
|
||||
: TASKS_SYSTEM_PROMPT_RULE,
|
||||
whenToolAvailable: TASKS_TOOL_NAME,
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user