From a045958c40916f995bbc6e03ec29f6cf5119f505 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Dec 2025 14:30:15 +0100 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20feat:=20subagent=20tasks=20a?= =?UTF-8?q?nd=20reliable=20report=20delivery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I98401f98f52a9ba82adc854ef796fa7da0494553 Signed-off-by: Thomas Kosiewski --- .storybook/mocks/orpc.ts | 25 + src/browser/components/ModelSelector.tsx | 19 +- src/browser/components/ProjectSidebar.tsx | 3 + .../components/Settings/SettingsModal.tsx | 9 +- .../Settings/sections/TasksSection.tsx | 288 +++ src/browser/components/WorkspaceListItem.tsx | 7 +- .../contexts/WorkspaceContext.test.tsx | 69 + src/browser/contexts/WorkspaceContext.tsx | 58 +- src/browser/stories/App.settings.stories.tsx | 49 +- .../messages/modelMessageTransform.test.ts | 22 + .../utils/messages/modelMessageTransform.ts | 115 +- .../utils/ui/workspaceFiltering.test.ts | 44 +- src/browser/utils/ui/workspaceFiltering.ts | 103 + src/browser/utils/workspace.ts | 2 + src/cli/cli.test.ts | 1 + src/cli/server.test.ts | 1 + src/cli/server.ts | 1 + src/common/constants/agents.ts | 6 + src/common/orpc/schemas.ts | 2 + src/common/orpc/schemas/api.ts | 56 + src/common/orpc/schemas/project.ts | 23 + src/common/orpc/schemas/workspace.ts | 8 + src/common/types/project.ts | 5 + src/common/types/tasks.ts | 109 + src/common/types/tools.ts | 14 + src/common/utils/tools/toolDefinitions.ts | 270 ++- src/common/utils/tools/toolPolicy.test.ts | 23 + src/common/utils/tools/toolPolicy.ts | 72 +- src/common/utils/tools/tools.test.ts | 40 + src/common/utils/tools/tools.ts | 15 + src/common/utils/ui/modeUtils.ts | 8 + src/desktop/main.ts | 1 + src/node/config.ts | 26 + src/node/orpc/context.ts | 2 + src/node/orpc/router.ts | 59 + src/node/runtime/WorktreeRuntime.test.ts | 75 + src/node/runtime/WorktreeRuntime.ts | 28 + src/node/services/agentPresets.ts | 88 + .../agentSession.editMessageId.test.ts | 92 + src/node/services/agentSession.ts | 17 +- src/node/services/aiService.ts | 42 +- src/node/services/serviceContainer.ts | 12 + src/node/services/streamManager.ts | 27 +- src/node/services/systemMessage.ts | 46 +- src/node/services/taskService.test.ts | 1957 +++++++++++++++++ src/node/services/taskService.ts | 1614 ++++++++++++++ src/node/services/tools/agent_report.test.ts | 55 + src/node/services/tools/agent_report.ts | 28 + src/node/services/tools/task.test.ts | 109 + src/node/services/tools/task.ts | 78 + src/node/services/tools/task_await.test.ts | 131 ++ src/node/services/tools/task_await.ts | 87 + src/node/services/tools/task_list.test.ts | 91 + src/node/services/tools/task_list.ts | 31 + .../services/tools/task_terminate.test.ts | 86 + src/node/services/tools/task_terminate.ts | 55 + src/node/services/workspaceService.ts | 20 +- tests/ipc/setup.ts | 1 + 58 files changed, 6255 insertions(+), 70 deletions(-) create mode 100644 src/browser/components/Settings/sections/TasksSection.tsx create mode 100644 src/common/constants/agents.ts create mode 100644 src/common/types/tasks.ts create mode 100644 src/common/utils/tools/tools.test.ts create mode 100644 src/node/services/agentPresets.ts create mode 100644 src/node/services/agentSession.editMessageId.test.ts create mode 100644 src/node/services/taskService.test.ts create mode 100644 src/node/services/taskService.ts create mode 100644 src/node/services/tools/agent_report.test.ts create mode 100644 src/node/services/tools/agent_report.ts create mode 100644 src/node/services/tools/task.test.ts create mode 100644 src/node/services/tools/task.ts create mode 100644 src/node/services/tools/task_await.test.ts create mode 100644 src/node/services/tools/task_await.ts create mode 100644 src/node/services/tools/task_list.test.ts create mode 100644 src/node/services/tools/task_list.ts create mode 100644 src/node/services/tools/task_terminate.test.ts create mode 100644 src/node/services/tools/task_terminate.ts diff --git a/.storybook/mocks/orpc.ts b/.storybook/mocks/orpc.ts index e4dae7450e..10328b44ba 100644 --- a/.storybook/mocks/orpc.ts +++ b/.storybook/mocks/orpc.ts @@ -13,6 +13,13 @@ import type { } from "@/common/orpc/types"; import type { ChatStats } from "@/common/types/chatStats"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; +import { + DEFAULT_TASK_SETTINGS, + normalizeSubagentAiDefaults, + normalizeTaskSettings, + type SubagentAiDefaults, + type TaskSettings, +} from "@/common/types/tasks"; import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue"; /** Session usage data structure matching SessionUsageFileSchema */ @@ -46,6 +53,10 @@ export interface MockSessionUsage { export interface MockORPCClientOptions { projects?: Map; workspaces?: FrontendWorkspaceMetadata[]; + /** Initial task settings for config.getConfig (e.g., Settings → Tasks section) */ + taskSettings?: Partial; + /** Initial per-subagent AI defaults for config.getConfig (e.g., Settings → Tasks section) */ + subagentAiDefaults?: SubagentAiDefaults; /** Per-workspace chat callback. Return messages to emit, or use the callback for streaming. */ onChat?: (workspaceId: string, emit: (msg: WorkspaceChatMessage) => void) => (() => void) | void; /** Mock for executeBash per workspace */ @@ -123,6 +134,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl mcpServers = new Map(), mcpOverrides = new Map(), mcpTestResults = new Map(), + taskSettings: initialTaskSettings, + subagentAiDefaults: initialSubagentAiDefaults, } = options; // Feature flags @@ -140,6 +153,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }; const workspaceMap = new Map(workspaces.map((w) => [w.id, w])); + let taskSettings = normalizeTaskSettings(initialTaskSettings ?? DEFAULT_TASK_SETTINGS); + let subagentAiDefaults = normalizeSubagentAiDefaults(initialSubagentAiDefaults ?? {}); const mockStats: ChatStats = { consumers: [], @@ -172,6 +187,16 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl getSshHost: async () => null, setSshHost: async () => undefined, }, + config: { + getConfig: async () => ({ taskSettings, subagentAiDefaults }), + saveConfig: async (input: { taskSettings: unknown; subagentAiDefaults?: unknown }) => { + taskSettings = normalizeTaskSettings(input.taskSettings); + if (input.subagentAiDefaults !== undefined) { + subagentAiDefaults = normalizeSubagentAiDefaults(input.subagentAiDefaults); + } + return undefined; + }, + }, providers: { list: async () => providersList, getConfig: async () => providersConfig, diff --git a/src/browser/components/ModelSelector.tsx b/src/browser/components/ModelSelector.tsx index e7b0fc52a2..996accda0d 100644 --- a/src/browser/components/ModelSelector.tsx +++ b/src/browser/components/ModelSelector.tsx @@ -20,6 +20,8 @@ interface ModelSelectorProps { onChange: (value: string) => void; models: string[]; hiddenModels?: string[]; + emptyLabel?: string; + inputPlaceholder?: string; onComplete?: () => void; defaultModel?: string | null; onSetDefaultModel?: (model: string) => void; @@ -39,6 +41,8 @@ export const ModelSelector = forwardRef( onChange, models, hiddenModels = [], + emptyLabel, + inputPlaceholder, onComplete, defaultModel, onSetDefaultModel, @@ -229,6 +233,19 @@ export const ModelSelector = forwardRef( }, [highlightedIndex]); if (!isEditing) { + if (value.trim().length === 0) { + return ( +
+
+ {emptyLabel ?? ""} +
+
+ ); + } + const gatewayActive = gateway.isModelRoutingThroughGateway(value); // Parse provider and model name from value (format: "provider:model-name") @@ -276,7 +293,7 @@ export const ModelSelector = forwardRef( value={inputValue} onChange={handleInputChange} onKeyDown={handleKeyDown} - placeholder="provider:model-name" + placeholder={inputPlaceholder ?? "provider:model-name"} className="text-light bg-dark border-border-light font-monospace focus:border-exec-mode w-48 rounded-sm border px-1 py-0.5 text-[10px] leading-[11px] outline-none" /> {error && ( diff --git a/src/browser/components/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar.tsx index cd6cb8936b..539019922e 100644 --- a/src/browser/components/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar.tsx @@ -17,6 +17,7 @@ import { partitionWorkspacesByAge, formatDaysThreshold, AGE_THRESHOLDS_DAYS, + computeWorkspaceDepthMap, } from "@/browser/utils/ui/workspaceFiltering"; import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; import SecretsModal from "./SecretsModal"; @@ -608,6 +609,7 @@ const ProjectSidebarInner: React.FC = ({ {(() => { const allWorkspaces = sortedWorkspacesByProject.get(projectPath) ?? []; + const depthByWorkspaceId = computeWorkspaceDepthMap(allWorkspaces); const { recent, buckets } = partitionWorkspacesByAge( allWorkspaces, workspaceRecency @@ -625,6 +627,7 @@ const ProjectSidebarInner: React.FC = ({ onSelectWorkspace={handleSelectWorkspace} onRemoveWorkspace={handleRemoveWorkspace} onToggleUnread={_onToggleUnread} + depth={depthByWorkspaceId[metadata.id] ?? 0} /> ); diff --git a/src/browser/components/Settings/SettingsModal.tsx b/src/browser/components/Settings/SettingsModal.tsx index a7ead2eddb..757e7361f6 100644 --- a/src/browser/components/Settings/SettingsModal.tsx +++ b/src/browser/components/Settings/SettingsModal.tsx @@ -1,8 +1,9 @@ import React from "react"; -import { Settings, Key, Cpu, X, Briefcase, FlaskConical } from "lucide-react"; +import { Settings, Key, Cpu, X, Briefcase, FlaskConical, Bot } from "lucide-react"; import { useSettings } from "@/browser/contexts/SettingsContext"; import { Dialog, DialogContent, DialogTitle, VisuallyHidden } from "@/browser/components/ui/dialog"; import { GeneralSection } from "./sections/GeneralSection"; +import { TasksSection } from "./sections/TasksSection"; import { ProvidersSection } from "./sections/ProvidersSection"; import { ModelsSection } from "./sections/ModelsSection"; import { Button } from "@/browser/components/ui/button"; @@ -17,6 +18,12 @@ const SECTIONS: SettingsSection[] = [ icon: , component: GeneralSection, }, + { + id: "tasks", + label: "Agents", + icon: , + component: TasksSection, + }, { id: "providers", label: "Providers", diff --git a/src/browser/components/Settings/sections/TasksSection.tsx b/src/browser/components/Settings/sections/TasksSection.tsx new file mode 100644 index 0000000000..7fc2070755 --- /dev/null +++ b/src/browser/components/Settings/sections/TasksSection.tsx @@ -0,0 +1,288 @@ +import React, { useEffect, useRef, useState } from "react"; +import { useAPI } from "@/browser/contexts/API"; +import { Input } from "@/browser/components/ui/input"; +import { + DEFAULT_TASK_SETTINGS, + TASK_SETTINGS_LIMITS, + normalizeTaskSettings, + type TaskSettings, + type SubagentAiDefaults, +} from "@/common/types/tasks"; +import { BUILT_IN_SUBAGENTS } from "@/common/constants/agents"; +import type { ThinkingLevel } from "@/common/types/thinking"; +import { useModelsFromSettings } from "@/browser/hooks/useModelsFromSettings"; +import { ModelSelector } from "@/browser/components/ModelSelector"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/browser/components/ui/select"; +import { enforceThinkingPolicy, getThinkingPolicyForModel } from "@/browser/utils/thinking/policy"; + +export function TasksSection() { + const { api } = useAPI(); + const [taskSettings, setTaskSettings] = useState(DEFAULT_TASK_SETTINGS); + const [subagentAiDefaults, setSubagentAiDefaults] = useState({}); + const [loaded, setLoaded] = useState(false); + const [loadFailed, setLoadFailed] = useState(false); + const [saveError, setSaveError] = useState(null); + const saveTimerRef = useRef | null>(null); + const savingRef = useRef(false); + + const { models, hiddenModels } = useModelsFromSettings(); + + useEffect(() => { + if (!api) return; + + setLoaded(false); + setLoadFailed(false); + setSaveError(null); + + void api.config + .getConfig() + .then((cfg) => { + setTaskSettings(normalizeTaskSettings(cfg.taskSettings)); + setSubagentAiDefaults(() => { + const next: SubagentAiDefaults = {}; + const defaults = cfg.subagentAiDefaults ?? {}; + for (const [agentType, entry] of Object.entries(defaults)) { + if (!entry) continue; + if (!entry.modelString || !entry.thinkingLevel) { + next[agentType] = entry; + continue; + } + next[agentType] = { + ...entry, + thinkingLevel: enforceThinkingPolicy(entry.modelString, entry.thinkingLevel), + }; + } + return next; + }); + setLoadFailed(false); + setLoaded(true); + }) + .catch((error: unknown) => { + setSaveError(error instanceof Error ? error.message : String(error)); + setLoadFailed(true); + setLoaded(true); + }); + }, [api]); + + useEffect(() => { + if (!api) return; + if (!loaded) return; + if (loadFailed) return; + if (savingRef.current) return; + + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + + saveTimerRef.current = setTimeout(() => { + savingRef.current = true; + void api.config + .saveConfig({ taskSettings, subagentAiDefaults }) + .catch((error: unknown) => { + setSaveError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => { + savingRef.current = false; + }); + }, 400); + + return () => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + }; + }, [api, loaded, loadFailed, subagentAiDefaults, taskSettings]); + + const setMaxParallelAgentTasks = (rawValue: string) => { + const parsed = Number(rawValue); + setTaskSettings((prev) => normalizeTaskSettings({ ...prev, maxParallelAgentTasks: parsed })); + }; + + const setMaxTaskNestingDepth = (rawValue: string) => { + const parsed = Number(rawValue); + setTaskSettings((prev) => normalizeTaskSettings({ ...prev, maxTaskNestingDepth: parsed })); + }; + + const INHERIT = "__inherit__"; + + const setSubagentModel = (agentType: string, value: string) => { + setSubagentAiDefaults((prev) => { + const next = { ...prev }; + const existing = next[agentType] ?? {}; + const updated = { ...existing }; + + if (value === INHERIT) { + delete updated.modelString; + } else { + updated.modelString = value; + } + + if (updated.modelString && updated.thinkingLevel) { + updated.thinkingLevel = enforceThinkingPolicy(updated.modelString, updated.thinkingLevel); + } + + if (!updated.modelString && !updated.thinkingLevel) { + delete next[agentType]; + } else { + next[agentType] = updated; + } + + return next; + }); + }; + + const setSubagentThinking = (agentType: string, value: string) => { + setSubagentAiDefaults((prev) => { + const next = { ...prev }; + const existing = next[agentType] ?? {}; + const updated = { ...existing }; + + if (value === INHERIT) { + delete updated.thinkingLevel; + } else { + const requested = value as ThinkingLevel; + updated.thinkingLevel = updated.modelString + ? enforceThinkingPolicy(updated.modelString, requested) + : requested; + } + + if (!updated.modelString && !updated.thinkingLevel) { + delete next[agentType]; + } else { + next[agentType] = updated; + } + + return next; + }); + }; + + return ( +
+
+

Agents

+
+
+
+
Max Parallel Agent Tasks
+
+ Default {TASK_SETTINGS_LIMITS.maxParallelAgentTasks.default}, range{" "} + {TASK_SETTINGS_LIMITS.maxParallelAgentTasks.min}– + {TASK_SETTINGS_LIMITS.maxParallelAgentTasks.max} +
+
+ ) => + setMaxParallelAgentTasks(e.target.value) + } + className="border-border-medium bg-background-secondary h-9 w-28" + /> +
+ +
+
+
Max Task Nesting Depth
+
+ Default {TASK_SETTINGS_LIMITS.maxTaskNestingDepth.default}, range{" "} + {TASK_SETTINGS_LIMITS.maxTaskNestingDepth.min}– + {TASK_SETTINGS_LIMITS.maxTaskNestingDepth.max} +
+
+ ) => + setMaxTaskNestingDepth(e.target.value) + } + className="border-border-medium bg-background-secondary h-9 w-28" + /> +
+
+ + {saveError &&
{saveError}
} +
+ +
+

Sub-agents

+
+ {BUILT_IN_SUBAGENTS.map((preset) => { + const agentType = preset.agentType; + const entry = subagentAiDefaults[agentType]; + const modelValue = entry?.modelString ?? INHERIT; + const thinkingValue = entry?.thinkingLevel ?? INHERIT; + const allowedThinkingLevels = + modelValue !== INHERIT + ? getThinkingPolicyForModel(modelValue) + : (["off", "low", "medium", "high", "xhigh"] as const); + + return ( +
+
{preset.label}
+ +
+
+
Model
+
+ setSubagentModel(agentType, value)} + models={models} + hiddenModels={hiddenModels} + /> + {modelValue !== INHERIT ? ( + + ) : null} +
+
+ +
+
Reasoning
+ +
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/src/browser/components/WorkspaceListItem.tsx b/src/browser/components/WorkspaceListItem.tsx index 9c465a4484..7e98d16dbf 100644 --- a/src/browser/components/WorkspaceListItem.tsx +++ b/src/browser/components/WorkspaceListItem.tsx @@ -23,6 +23,7 @@ export interface WorkspaceListItemProps { projectName: string; isSelected: boolean; isDeleting?: boolean; + depth?: number; /** @deprecated No longer used since status dot was removed, kept for API compatibility */ lastReadTimestamp?: number; // Event handlers @@ -38,6 +39,7 @@ const WorkspaceListItemInner: React.FC = ({ projectName, isSelected, isDeleting, + depth, lastReadTimestamp: _lastReadTimestamp, onSelectWorkspace, onRemoveWorkspace, @@ -101,18 +103,21 @@ const WorkspaceListItemInner: React.FC = ({ const { canInterrupt, awaitingUserQuestion } = useWorkspaceSidebarState(workspaceId); const isWorking = canInterrupt && !awaitingUserQuestion; + const safeDepth = typeof depth === "number" && Number.isFinite(depth) ? Math.max(0, depth) : 0; + const paddingLeft = 9 + Math.min(32, safeDepth) * 12; return (
{ if (isDisabled) return; onSelectWorkspace({ diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index cdc07b2ae1..bcedfaf715 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -96,6 +96,75 @@ describe("WorkspaceContext", () => { expect(workspaceApi.onMetadata).toHaveBeenCalled(); }); + test("switches selection to parent when selected child workspace is deleted", async () => { + const parentId = "ws-parent"; + const childId = "ws-child"; + + const workspaces: FrontendWorkspaceMetadata[] = [ + createWorkspaceMetadata({ + id: parentId, + projectPath: "/alpha", + projectName: "alpha", + name: "main", + namedWorkspacePath: "/alpha-main", + }), + createWorkspaceMetadata({ + id: childId, + projectPath: "/alpha", + projectName: "alpha", + name: "agent_explore_ws-child", + namedWorkspacePath: "/alpha-agent", + parentWorkspaceId: parentId, + }), + ]; + + let emitDelete: + | ((event: { workspaceId: string; metadata: FrontendWorkspaceMetadata | null }) => void) + | null = null; + + const { workspace: workspaceApi } = createMockAPI({ + workspace: { + list: () => Promise.resolve(workspaces), + onMetadata: () => + Promise.resolve( + (async function* () { + const event = await new Promise<{ + workspaceId: string; + metadata: FrontendWorkspaceMetadata | null; + }>((resolve) => { + emitDelete = resolve; + }); + yield event; + })() as unknown as Awaited> + ), + }, + projects: { + list: () => Promise.resolve([]), + }, + localStorage: { + [SELECTED_WORKSPACE_KEY]: JSON.stringify({ + workspaceId: childId, + projectPath: "/alpha", + projectName: "alpha", + namedWorkspacePath: "/alpha-agent", + }), + }, + }); + + const ctx = await setup(); + + await waitFor(() => expect(ctx().workspaceMetadata.size).toBe(2)); + await waitFor(() => expect(ctx().selectedWorkspace?.workspaceId).toBe(childId)); + await waitFor(() => expect(workspaceApi.onMetadata).toHaveBeenCalled()); + await waitFor(() => expect(emitDelete).toBeTruthy()); + + act(() => { + emitDelete?.({ workspaceId: childId, metadata: null }); + }); + + await waitFor(() => expect(ctx().selectedWorkspace?.workspaceId).toBe(parentId)); + }); + test("seeds model + thinking localStorage from backend metadata", async () => { const initialWorkspaces: FrontendWorkspaceMetadata[] = [ createWorkspaceMetadata({ diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 5665b1cd61..30cbeee90c 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -4,6 +4,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, type ReactNode, type SetStateAction, @@ -155,6 +156,13 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { null ); + // Used by async subscription handlers to safely access the most recent metadata map + // without triggering render-phase state updates. + const workspaceMetadataRef = useRef(workspaceMetadata); + useEffect(() => { + workspaceMetadataRef.current = workspaceMetadata; + }, [workspaceMetadata]); + const loadWorkspaceMetadata = useCallback(async () => { if (!api) return false; // Return false to indicate metadata wasn't loaded try { @@ -296,6 +304,54 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { for await (const event of iterator) { if (signal.aborted) break; + if (event.metadata === null) { + // Workspace deleted - clean up workspace-scoped persisted state. + deleteWorkspaceStorage(event.workspaceId); + + // If the user is currently viewing a deleted child workspace, fall back to its parent. + // Otherwise, fall back to another workspace in the same project (best effort). + const deletedMeta = workspaceMetadataRef.current.get(event.workspaceId); + setSelectedWorkspace((current) => { + if (current?.workspaceId !== event.workspaceId) { + return current; + } + + const parentWorkspaceId = deletedMeta?.parentWorkspaceId; + if (parentWorkspaceId) { + const parentMeta = workspaceMetadataRef.current.get(parentWorkspaceId); + if (parentMeta) { + return { + workspaceId: parentMeta.id, + projectPath: parentMeta.projectPath, + projectName: parentMeta.projectName, + namedWorkspacePath: parentMeta.namedWorkspacePath, + }; + } + } + + const projectPath = deletedMeta?.projectPath; + const fallbackMeta = + (projectPath + ? Array.from(workspaceMetadataRef.current.values()).find( + (meta) => meta.projectPath === projectPath && meta.id !== event.workspaceId + ) + : null) ?? + Array.from(workspaceMetadataRef.current.values()).find( + (meta) => meta.id !== event.workspaceId + ); + if (!fallbackMeta) { + return null; + } + + return { + workspaceId: fallbackMeta.id, + projectPath: fallbackMeta.projectPath, + projectName: fallbackMeta.projectName, + namedWorkspacePath: fallbackMeta.namedWorkspacePath, + }; + }); + } + if (event.metadata !== null) { ensureCreatedAt(event.metadata); seedWorkspaceLocalStorageFromBackend(event.metadata); @@ -336,7 +392,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { return () => { controller.abort(); }; - }, [refreshProjects, setWorkspaceMetadata, api]); + }, [refreshProjects, setSelectedWorkspace, setWorkspaceMetadata, api]); const createWorkspace = useCallback( async ( diff --git a/src/browser/stories/App.settings.stories.tsx b/src/browser/stories/App.settings.stories.tsx index aa773478b1..1f65ec460a 100644 --- a/src/browser/stories/App.settings.stories.tsx +++ b/src/browser/stories/App.settings.stories.tsx @@ -14,8 +14,9 @@ import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; import { createWorkspace, groupWorkspacesByProject } from "./mockFactory"; import { selectWorkspace } from "./storyHelpers"; import { createMockORPCClient } from "../../../.storybook/mocks/orpc"; -import { within, userEvent } from "@storybook/test"; +import { within, userEvent, waitFor } from "@storybook/test"; import { getExperimentKey, EXPERIMENT_IDS } from "@/common/constants/experiments"; +import type { TaskSettings } from "@/common/types/tasks"; export default { ...appMeta, @@ -30,6 +31,7 @@ export default { function setupSettingsStory(options: { providersConfig?: Record; providersList?: string[]; + taskSettings?: Partial; /** Pre-set experiment states in localStorage before render */ experiments?: Partial>; }): APIClient { @@ -50,6 +52,7 @@ function setupSettingsStory(options: { workspaces, providersConfig: options.providersConfig ?? {}, providersList: options.providersList ?? ["anthropic", "openai", "xai"], + taskSettings: options.taskSettings, }); } @@ -89,6 +92,50 @@ export const General: AppStory = { }, }; +/** Agents settings section - task parallelism and nesting controls */ +export const Tasks: AppStory = { + render: () => ( + + setupSettingsStory({ + taskSettings: { maxParallelAgentTasks: 2, maxTaskNestingDepth: 4 }, + }) + } + /> + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await openSettingsToSection(canvasElement, "agents"); + + const body = within(canvasElement.ownerDocument.body); + + await body.findByText(/Max Parallel Agent Tasks/i); + await body.findByText(/Max Task Nesting Depth/i); + await body.findByText(/Sub-agents/i); + await body.findByText(/Research/i); + await body.findByText(/Explore/i); + + const inputs = await body.findAllByRole("spinbutton"); + if (inputs.length !== 2) { + throw new Error(`Expected 2 task settings inputs, got ${inputs.length}`); + } + + await waitFor(() => { + const maxParallelAgentTasks = (inputs[0] as HTMLInputElement).value; + const maxTaskNestingDepth = (inputs[1] as HTMLInputElement).value; + if (maxParallelAgentTasks !== "2") { + throw new Error( + `Expected maxParallelAgentTasks=2, got ${JSON.stringify(maxParallelAgentTasks)}` + ); + } + if (maxTaskNestingDepth !== "4") { + throw new Error( + `Expected maxTaskNestingDepth=4, got ${JSON.stringify(maxTaskNestingDepth)}` + ); + } + }); + }, +}; + /** Providers section - no providers configured */ export const ProvidersEmpty: AppStory = { render: () => setupSettingsStory({ providersConfig: {} })} />, diff --git a/src/browser/utils/messages/modelMessageTransform.test.ts b/src/browser/utils/messages/modelMessageTransform.test.ts index 450a92953a..ffb3c80540 100644 --- a/src/browser/utils/messages/modelMessageTransform.test.ts +++ b/src/browser/utils/messages/modelMessageTransform.test.ts @@ -133,6 +133,28 @@ describe("modelMessageTransform", () => { expect(result[5].role).toBe("tool"); expect((result[5] as ToolModelMessage).content[0]).toMatchObject({ toolCallId: "call2" }); }); + + it("should insert empty reasoning for final assistant message when Anthropic thinking is enabled", () => { + const messages: ModelMessage[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { role: "assistant", content: [{ type: "text", text: "Subagent report text" }] }, + { role: "user", content: [{ type: "text", text: "Continue" }] }, + ]; + + const result = transformModelMessages(messages, "anthropic", { + anthropicThinkingEnabled: true, + }); + + // Find last assistant message and ensure it starts with reasoning + const lastAssistant = [...result] + .reverse() + .find((m): m is AssistantModelMessage => m.role === "assistant"); + expect(lastAssistant).toBeTruthy(); + expect(Array.isArray(lastAssistant?.content)).toBe(true); + if (Array.isArray(lastAssistant?.content)) { + expect(lastAssistant.content[0]).toEqual({ type: "reasoning", text: "" }); + } + }); it("should keep text-only messages unchanged", () => { const assistantMsg1: AssistantModelMessage = { role: "assistant", diff --git a/src/browser/utils/messages/modelMessageTransform.ts b/src/browser/utils/messages/modelMessageTransform.ts index f22c0d26dc..b74ed67626 100644 --- a/src/browser/utils/messages/modelMessageTransform.ts +++ b/src/browser/utils/messages/modelMessageTransform.ts @@ -566,6 +566,106 @@ function mergeConsecutiveUserMessages(messages: ModelMessage[]): ModelMessage[] return merged; } +function ensureAnthropicThinkingBeforeToolCalls(messages: ModelMessage[]): ModelMessage[] { + const result: ModelMessage[] = []; + + for (const msg of messages) { + if (msg.role !== "assistant") { + result.push(msg); + continue; + } + + const assistantMsg = msg; + if (typeof assistantMsg.content === "string") { + result.push(msg); + continue; + } + + const content = assistantMsg.content; + const hasToolCall = content.some((part) => part.type === "tool-call"); + if (!hasToolCall) { + result.push(msg); + continue; + } + + let reasoningParts = content.filter((part) => part.type === "reasoning"); + const nonReasoningParts = content.filter((part) => part.type !== "reasoning"); + + // If no reasoning is present, try to merge it from the immediately preceding assistant message + // if that message consists of reasoning-only parts. This commonly happens after splitting. + if (reasoningParts.length === 0 && result.length > 0) { + const prev = result[result.length - 1]; + if (prev.role === "assistant") { + const prevAssistant = prev; + if (typeof prevAssistant.content !== "string") { + const prevIsReasoningOnly = + prevAssistant.content.length > 0 && + prevAssistant.content.every((part) => part.type === "reasoning"); + if (prevIsReasoningOnly) { + result.pop(); + reasoningParts = prevAssistant.content.filter((part) => part.type === "reasoning"); + } + } + } + } + + // Anthropic extended thinking requires tool-use assistant messages to start with a thinking block. + // If we still have no reasoning available, insert an empty reasoning part as a minimal placeholder. + if (reasoningParts.length === 0) { + reasoningParts = [{ type: "reasoning" as const, text: "" }]; + } + + result.push({ + ...assistantMsg, + content: [...reasoningParts, ...nonReasoningParts], + }); + } + + // Anthropic extended thinking also requires the *final* assistant message in the request + // to start with a thinking block. If the last assistant message is text-only (common for + // synthetic messages like sub-agent reports), insert an empty reasoning part as a minimal + // placeholder. This transformation affects only the provider request, not stored history/UI. + for (let i = result.length - 1; i >= 0; i--) { + const msg = result[i]; + if (msg.role !== "assistant") { + continue; + } + + const assistantMsg = msg; + + if (typeof assistantMsg.content === "string") { + // String-only assistant messages need converting to part arrays to insert reasoning. + const text = assistantMsg.content; + // If it's truly empty, leave it unchanged (it will be filtered elsewhere). + if (text.length === 0) break; + result[i] = { + ...assistantMsg, + content: [ + { type: "reasoning" as const, text: "" }, + { type: "text" as const, text }, + ], + }; + break; + } + + const content = assistantMsg.content; + if (content.length === 0) { + break; + } + if (content[0].type === "reasoning") { + break; + } + + result[i] = { + ...assistantMsg, + content: [{ type: "reasoning" as const, text: "" }, ...content], + }; + break; + } + + return result; +} + /** * Transform messages to ensure provider API compliance. * Applies multiple transformation passes based on provider requirements: @@ -582,7 +682,11 @@ function mergeConsecutiveUserMessages(messages: ModelMessage[]): ModelMessage[] * @param messages The messages to transform * @param provider The provider name (e.g., "anthropic", "openai") */ -export function transformModelMessages(messages: ModelMessage[], provider: string): ModelMessage[] { +export function transformModelMessages( + messages: ModelMessage[], + provider: string, + options?: { anthropicThinkingEnabled?: boolean } +): ModelMessage[] { // Pass 0: Coalesce consecutive parts to reduce JSON overhead from streaming (applies to all providers) const coalesced = coalesceConsecutiveParts(messages); @@ -596,8 +700,13 @@ export function transformModelMessages(messages: ModelMessage[], provider: strin // Only filter out reasoning-only messages (messages with no text/tool-call content) reasoningHandled = filterReasoningOnlyMessages(split); } else if (provider === "anthropic") { - // Anthropic: Filter out reasoning-only messages (API rejects messages with only reasoning) - reasoningHandled = filterReasoningOnlyMessages(split); + // Anthropic: When extended thinking is enabled, preserve reasoning-only messages and ensure + // tool-call messages start with reasoning. When it's disabled, filter reasoning-only messages. + if (options?.anthropicThinkingEnabled) { + reasoningHandled = ensureAnthropicThinkingBeforeToolCalls(split); + } else { + reasoningHandled = filterReasoningOnlyMessages(split); + } } else { // Unknown provider: no reasoning handling reasoningHandled = split; diff --git a/src/browser/utils/ui/workspaceFiltering.test.ts b/src/browser/utils/ui/workspaceFiltering.test.ts index dee631426f..ea68fb208d 100644 --- a/src/browser/utils/ui/workspaceFiltering.test.ts +++ b/src/browser/utils/ui/workspaceFiltering.test.ts @@ -180,7 +180,8 @@ describe("buildSortedWorkspacesByProject", () => { const createWorkspace = ( id: string, projectPath: string, - status?: "creating" + status?: "creating", + parentWorkspaceId?: string ): FrontendWorkspaceMetadata => ({ id, name: `workspace-${id}`, @@ -189,6 +190,7 @@ describe("buildSortedWorkspacesByProject", () => { namedWorkspacePath: `${projectPath}/workspace-${id}`, runtimeConfig: DEFAULT_RUNTIME_CONFIG, status, + parentWorkspaceId, }); it("should include workspaces from persisted config", () => { @@ -276,6 +278,46 @@ describe("buildSortedWorkspacesByProject", () => { expect(result.get("/project/a")?.map((w) => w.id)).toEqual(["ws2", "ws3", "ws1"]); }); + it("should flatten child workspaces directly under their parent", () => { + const now = Date.now(); + const projects = new Map([ + [ + "/project/a", + { + workspaces: [ + { path: "/a/root", id: "root" }, + { path: "/a/child1", id: "child1" }, + { path: "/a/child2", id: "child2" }, + { path: "/a/grand", id: "grand" }, + ], + }, + ], + ]); + + const metadata = new Map([ + ["root", createWorkspace("root", "/project/a")], + ["child1", createWorkspace("child1", "/project/a", undefined, "root")], + ["child2", createWorkspace("child2", "/project/a", undefined, "root")], + ["grand", createWorkspace("grand", "/project/a", undefined, "child1")], + ]); + + // Child workspaces are more recent than the parent, but should still render below it. + const recency = { + child1: now - 1000, + child2: now - 2000, + grand: now - 3000, + root: now - 4000, + }; + + const result = buildSortedWorkspacesByProject(projects, metadata, recency); + expect(result.get("/project/a")?.map((w) => w.id)).toEqual([ + "root", + "child1", + "grand", + "child2", + ]); + }); + it("should not duplicate workspaces that exist in both config and have creating status", () => { // Edge case: workspace was saved to config but still has status: "creating" // (this shouldn't happen in practice but tests defensive coding) diff --git a/src/browser/utils/ui/workspaceFiltering.ts b/src/browser/utils/ui/workspaceFiltering.ts index 75fad086f9..85c1103d21 100644 --- a/src/browser/utils/ui/workspaceFiltering.ts +++ b/src/browser/utils/ui/workspaceFiltering.ts @@ -1,6 +1,104 @@ import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { ProjectConfig } from "@/common/types/project"; +export function flattenWorkspaceTree( + workspaces: FrontendWorkspaceMetadata[] +): FrontendWorkspaceMetadata[] { + if (workspaces.length === 0) return []; + + const byId = new Map(); + for (const workspace of workspaces) { + byId.set(workspace.id, workspace); + } + + const childrenByParent = new Map(); + const roots: FrontendWorkspaceMetadata[] = []; + + // Preserve input order for both roots and siblings by iterating in-order. + for (const workspace of workspaces) { + const parentId = workspace.parentWorkspaceId; + if (parentId && byId.has(parentId)) { + const children = childrenByParent.get(parentId) ?? []; + children.push(workspace); + childrenByParent.set(parentId, children); + } else { + roots.push(workspace); + } + } + + const result: FrontendWorkspaceMetadata[] = []; + const visited = new Set(); + + const visit = (workspace: FrontendWorkspaceMetadata, depth: number) => { + if (visited.has(workspace.id)) return; + visited.add(workspace.id); + + // Cap depth defensively to avoid pathological cycles/graphs. + if (depth > 32) { + result.push(workspace); + return; + } + + result.push(workspace); + const children = childrenByParent.get(workspace.id); + if (children) { + for (const child of children) { + visit(child, depth + 1); + } + } + }; + + for (const root of roots) { + visit(root, 0); + } + + // Fallback: ensure we include any remaining nodes (cycles, missing parents, etc.). + for (const workspace of workspaces) { + if (!visited.has(workspace.id)) { + visit(workspace, 0); + } + } + + return result; +} + +export function computeWorkspaceDepthMap( + workspaces: FrontendWorkspaceMetadata[] +): Record { + const byId = new Map(); + for (const workspace of workspaces) { + byId.set(workspace.id, workspace); + } + + const depths = new Map(); + const visiting = new Set(); + + const computeDepth = (workspaceId: string): number => { + const existing = depths.get(workspaceId); + if (existing !== undefined) return existing; + + if (visiting.has(workspaceId)) { + // Cycle detected - treat as root. + return 0; + } + + visiting.add(workspaceId); + const workspace = byId.get(workspaceId); + const parentId = workspace?.parentWorkspaceId; + const depth = parentId && byId.has(parentId) ? Math.min(computeDepth(parentId) + 1, 32) : 0; + visiting.delete(workspaceId); + + depths.set(workspaceId, depth); + return depth; + }; + + for (const workspace of workspaces) { + computeDepth(workspace.id); + } + + return Object.fromEntries(depths); +} + /** * Age thresholds for workspace filtering, in ascending order. * Each tier hides workspaces older than the specified duration. @@ -57,6 +155,11 @@ export function buildSortedWorkspacesByProject( }); } + // Ensure child workspaces appear directly below their parents. + for (const [projectPath, metadataList] of result) { + result.set(projectPath, flattenWorkspaceTree(metadataList)); + } + return result; } diff --git a/src/browser/utils/workspace.ts b/src/browser/utils/workspace.ts index 74b93d9a4a..ba41dae464 100644 --- a/src/browser/utils/workspace.ts +++ b/src/browser/utils/workspace.ts @@ -13,5 +13,7 @@ export function getWorkspaceSidebarKey(meta: FrontendWorkspaceMetadata): string meta.name, meta.title ?? "", // Display title (falls back to name in UI) meta.status ?? "", // Working/idle status indicator + meta.parentWorkspaceId ?? "", // Nested sidebar indentation/order + meta.agentType ?? "", // Agent preset badge/label (future) ].join("|"); } diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 5eac460d0a..61d104be81 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -61,6 +61,7 @@ async function createTestServer(authToken?: string): Promise { aiService: services.aiService, projectService: services.projectService, workspaceService: services.workspaceService, + taskService: services.taskService, providerService: services.providerService, terminalService: services.terminalService, editorService: services.editorService, diff --git a/src/cli/server.test.ts b/src/cli/server.test.ts index 2b0fa4d36e..8945477bee 100644 --- a/src/cli/server.test.ts +++ b/src/cli/server.test.ts @@ -64,6 +64,7 @@ async function createTestServer(): Promise { aiService: services.aiService, projectService: services.projectService, workspaceService: services.workspaceService, + taskService: services.taskService, providerService: services.providerService, terminalService: services.terminalService, editorService: services.editorService, diff --git a/src/cli/server.ts b/src/cli/server.ts index f8f11051da..d236b2ea03 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -80,6 +80,7 @@ const mockWindow: BrowserWindow = { aiService: serviceContainer.aiService, projectService: serviceContainer.projectService, workspaceService: serviceContainer.workspaceService, + taskService: serviceContainer.taskService, providerService: serviceContainer.providerService, terminalService: serviceContainer.terminalService, editorService: serviceContainer.editorService, diff --git a/src/common/constants/agents.ts b/src/common/constants/agents.ts new file mode 100644 index 0000000000..2b97804adf --- /dev/null +++ b/src/common/constants/agents.ts @@ -0,0 +1,6 @@ +export const BUILT_IN_SUBAGENTS = [ + { agentType: "research", label: "Research" }, + { agentType: "explore", label: "Explore" }, +] as const; + +export type BuiltInSubagentType = (typeof BUILT_IN_SUBAGENTS)[number]["agentType"]; diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index d61eead7a2..75227459fb 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -114,6 +114,7 @@ export { // API router schemas export { AWSCredentialStatusSchema, + config, debug, features, general, @@ -125,6 +126,7 @@ export { ProvidersConfigMapSchema, server, splashScreens, + tasks, experiments, ExperimentValueSchema, telemetry, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 6e25b954ef..36072fee69 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -480,6 +480,29 @@ export const workspace = { export type WorkspaceSendMessageOutput = z.infer; +// Tasks (agent sub-workspaces) +export const tasks = { + create: { + input: z.object({ + parentWorkspaceId: z.string(), + kind: z.literal("agent"), + agentType: z.string(), + prompt: z.string(), + description: z.string().optional(), + modelString: z.string().optional(), + thinkingLevel: z.string().optional(), + }), + output: ResultSchema( + z.object({ + taskId: z.string(), + kind: z.literal("agent"), + status: z.enum(["queued", "running"]), + }), + z.string() + ), + }, +}; + // Name generation for new workspaces (decoupled from workspace creation) export const nameGeneration = { generate: { @@ -570,6 +593,39 @@ export const server = { }, }; +// Config (global settings) +const SubagentAiDefaultsEntrySchema = z + .object({ + modelString: z.string().min(1).optional(), + thinkingLevel: z.enum(["off", "low", "medium", "high", "xhigh"]).optional(), + }) + .strict(); + +const SubagentAiDefaultsSchema = z.record(z.string().min(1), SubagentAiDefaultsEntrySchema); + +export const config = { + getConfig: { + input: z.void(), + output: z.object({ + taskSettings: z.object({ + maxParallelAgentTasks: z.number().int(), + maxTaskNestingDepth: z.number().int(), + }), + subagentAiDefaults: SubagentAiDefaultsSchema, + }), + }, + saveConfig: { + input: z.object({ + taskSettings: z.object({ + maxParallelAgentTasks: z.number().int(), + maxTaskNestingDepth: z.number().int(), + }), + subagentAiDefaults: SubagentAiDefaultsSchema.optional(), + }), + output: z.void(), + }, +}; + // Splash screens export const splashScreens = { getViewedSplashScreens: { diff --git a/src/common/orpc/schemas/project.ts b/src/common/orpc/schemas/project.ts index 98019c44a9..3bc6e6606c 100644 --- a/src/common/orpc/schemas/project.ts +++ b/src/common/orpc/schemas/project.ts @@ -3,6 +3,8 @@ import { RuntimeConfigSchema } from "./runtime"; import { WorkspaceMCPOverridesSchema } from "./mcp"; import { WorkspaceAISettingsSchema } from "./workspaceAiSettings"; +const ThinkingLevelSchema = z.enum(["off", "low", "medium", "high", "xhigh"]); + export const WorkspaceConfigSchema = z.object({ path: z.string().meta({ description: "Absolute path to workspace directory - REQUIRED for backward compatibility", @@ -27,6 +29,27 @@ export const WorkspaceConfigSchema = z.object({ aiSettings: WorkspaceAISettingsSchema.optional().meta({ description: "Workspace-scoped AI settings (model + thinking level)", }), + parentWorkspaceId: z.string().optional().meta({ + description: + "If set, this workspace is a child workspace spawned from the parent workspaceId (enables nesting in UI and backend orchestration).", + }), + agentType: z.string().optional().meta({ + description: + 'If set, selects an agent preset for this workspace (e.g., "research" or "explore").', + }), + taskStatus: z.enum(["queued", "running", "awaiting_report", "reported"]).optional().meta({ + description: + "Agent task lifecycle status for child workspaces (queued|running|awaiting_report|reported).", + }), + reportedAt: z.string().optional().meta({ + description: "ISO 8601 timestamp for when an agent task reported completion (optional).", + }), + taskModelString: z.string().optional().meta({ + description: "Model string used to run this agent task (used for restart-safe resumptions).", + }), + taskThinkingLevel: ThinkingLevelSchema.optional().meta({ + description: "Thinking level used for this agent task (used for restart-safe resumptions).", + }), mcp: WorkspaceMCPOverridesSchema.optional().meta({ description: "Per-workspace MCP overrides (disabled servers, tool allowlists)", }), diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts index 459570677e..c1bc2a7ef0 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -30,6 +30,14 @@ export const WorkspaceMetadataSchema = z.object({ aiSettings: WorkspaceAISettingsSchema.optional().meta({ description: "Workspace-scoped AI settings (model + thinking level) persisted in config", }), + parentWorkspaceId: z.string().optional().meta({ + description: + "If set, this workspace is a child workspace spawned from the parent workspaceId (enables nesting in UI and backend orchestration).", + }), + agentType: z.string().optional().meta({ + description: + 'If set, selects an agent preset for this workspace (e.g., "research" or "explore").', + }), status: z.enum(["creating"]).optional().meta({ description: "Workspace creation status. 'creating' = pending setup (ephemeral, not persisted). Absent = ready.", diff --git a/src/common/types/project.ts b/src/common/types/project.ts index 800966b257..3365f5062c 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -5,6 +5,7 @@ import type { z } from "zod"; import type { ProjectConfigSchema, WorkspaceConfigSchema } from "../orpc/schemas"; +import type { TaskSettings, SubagentAiDefaults } from "./tasks"; export type Workspace = z.infer; @@ -20,4 +21,8 @@ export interface ProjectsConfig { viewedSplashScreens?: string[]; /** Cross-client feature flag overrides (shared via ~/.mux/config.json). */ featureFlagOverrides?: Record; + /** Global task settings (agent sub-workspaces, queue limits, nesting depth) */ + taskSettings?: TaskSettings; + /** Per-subagent default model + thinking overrides. Missing values inherit from the parent workspace. */ + subagentAiDefaults?: SubagentAiDefaults; } diff --git a/src/common/types/tasks.ts b/src/common/types/tasks.ts new file mode 100644 index 0000000000..f0f8710820 --- /dev/null +++ b/src/common/types/tasks.ts @@ -0,0 +1,109 @@ +import assert from "@/common/utils/assert"; +import type { ThinkingLevel } from "./thinking"; + +export interface TaskSettings { + maxParallelAgentTasks: number; + maxTaskNestingDepth: number; +} + +export const TASK_SETTINGS_LIMITS = { + maxParallelAgentTasks: { min: 1, max: 10, default: 3 }, + maxTaskNestingDepth: { min: 1, max: 5, default: 3 }, +} as const; + +export const DEFAULT_TASK_SETTINGS: TaskSettings = { + maxParallelAgentTasks: TASK_SETTINGS_LIMITS.maxParallelAgentTasks.default, + maxTaskNestingDepth: TASK_SETTINGS_LIMITS.maxTaskNestingDepth.default, +}; + +export interface SubagentAiDefaultsEntry { + modelString?: string; + thinkingLevel?: ThinkingLevel; +} + +export type SubagentAiDefaults = Record; + +export function normalizeSubagentAiDefaults(raw: unknown): SubagentAiDefaults { + const record = raw && typeof raw === "object" ? (raw as Record) : ({} as const); + + const result: SubagentAiDefaults = {}; + + for (const [agentTypeRaw, entryRaw] of Object.entries(record)) { + const agentType = agentTypeRaw.trim().toLowerCase(); + if (!agentType) continue; + if (!entryRaw || typeof entryRaw !== "object") continue; + + const entry = entryRaw as Record; + + const modelString = + typeof entry.modelString === "string" && entry.modelString.trim().length > 0 + ? entry.modelString.trim() + : undefined; + + const thinkingLevel = (() => { + const value = entry.thinkingLevel; + if ( + value === "off" || + value === "low" || + value === "medium" || + value === "high" || + value === "xhigh" + ) { + return value; + } + return undefined; + })(); + + if (!modelString && !thinkingLevel) { + continue; + } + + result[agentType] = { modelString, thinkingLevel }; + } + + return result; +} + +function clampInt(value: unknown, fallback: number, min: number, max: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fallback; + } + + const rounded = Math.floor(value); + if (rounded < min) return min; + if (rounded > max) return max; + return rounded; +} + +export function normalizeTaskSettings(raw: unknown): TaskSettings { + const record = raw && typeof raw === "object" ? (raw as Record) : ({} as const); + + const maxParallelAgentTasks = clampInt( + record.maxParallelAgentTasks, + DEFAULT_TASK_SETTINGS.maxParallelAgentTasks, + TASK_SETTINGS_LIMITS.maxParallelAgentTasks.min, + TASK_SETTINGS_LIMITS.maxParallelAgentTasks.max + ); + const maxTaskNestingDepth = clampInt( + record.maxTaskNestingDepth, + DEFAULT_TASK_SETTINGS.maxTaskNestingDepth, + TASK_SETTINGS_LIMITS.maxTaskNestingDepth.min, + TASK_SETTINGS_LIMITS.maxTaskNestingDepth.max + ); + + const result: TaskSettings = { + maxParallelAgentTasks, + maxTaskNestingDepth, + }; + + assert( + Number.isInteger(result.maxParallelAgentTasks), + "normalizeTaskSettings: maxParallelAgentTasks must be an integer" + ); + assert( + Number.isInteger(result.maxTaskNestingDepth), + "normalizeTaskSettings: maxTaskNestingDepth must be an integer" + ); + + return result; +} diff --git a/src/common/types/tools.ts b/src/common/types/tools.ts index 827622b8c5..333ce52204 100644 --- a/src/common/types/tools.ts +++ b/src/common/types/tools.ts @@ -5,6 +5,7 @@ import type { z } from "zod"; import type { + AgentReportToolResultSchema, AskUserQuestionOptionSchema, AskUserQuestionQuestionSchema, AskUserQuestionToolResultSchema, @@ -15,6 +16,7 @@ import type { FileEditInsertToolResultSchema, FileEditReplaceStringToolResultSchema, FileReadToolResultSchema, + TaskToolResultSchema, TOOL_DEFINITIONS, WebFetchToolResultSchema, } from "@/common/utils/tools/toolDefinitions"; @@ -150,6 +152,18 @@ export type AskUserQuestionToolSuccessResult = z.infer; + +export type TaskToolSuccessResult = z.infer; + +export type TaskToolResult = TaskToolSuccessResult | ToolErrorResult; + +// Agent Report Tool Types +export type AgentReportToolArgs = z.infer; + +export type AgentReportToolResult = z.infer | ToolErrorResult; + // Propose Plan Tool Types // Args derived from schema export type ProposePlanToolArgs = z.infer; diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index 7501d1eff3..f929a877f7 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -82,6 +82,229 @@ export const AskUserQuestionToolResultSchema = z answers: z.record(z.string(), z.string()), }) .strict(); + +// ----------------------------------------------------------------------------- +// task (sub-workspaces as subagents) +// ----------------------------------------------------------------------------- + +export const TaskToolArgsSchema = z + .object({ + subagent_type: z.string().min(1), + prompt: z.string().min(1), + description: z.string().optional(), + run_in_background: z.boolean().default(false), + }) + .strict(); + +export const TaskToolQueuedResultSchema = z + .object({ + status: z.enum(["queued", "running"]), + taskId: z.string(), + }) + .strict(); + +export const TaskToolCompletedResultSchema = z + .object({ + status: z.literal("completed"), + taskId: z.string().optional(), + reportMarkdown: z.string(), + title: z.string().optional(), + agentType: z.string().optional(), + }) + .strict(); + +export const TaskToolResultSchema = z.discriminatedUnion("status", [ + TaskToolQueuedResultSchema, + TaskToolCompletedResultSchema, +]); + +// ----------------------------------------------------------------------------- +// task_await (await one or more sub-agent tasks) +// ----------------------------------------------------------------------------- + +export const TaskAwaitToolArgsSchema = z + .object({ + task_ids: z + .array(z.string().min(1)) + .optional() + .describe( + "List of task IDs to await. When omitted, waits for all active descendant tasks of the current workspace." + ), + timeout_secs: z + .number() + .positive() + .optional() + .describe( + "Maximum time to wait in seconds for each task. " + + "If exceeded, the result returns status=queued|running|awaiting_report (task is still active). " + + "Optional, defaults to 10 minutes." + ), + }) + .strict(); + +export const TaskAwaitToolCompletedResultSchema = z + .object({ + status: z.literal("completed"), + taskId: z.string(), + reportMarkdown: z.string(), + title: z.string().optional(), + }) + .strict(); + +export const TaskAwaitToolActiveResultSchema = z + .object({ + status: z.enum(["queued", "running", "awaiting_report"]), + taskId: z.string(), + }) + .strict(); + +export const TaskAwaitToolNotFoundResultSchema = z + .object({ + status: z.literal("not_found"), + taskId: z.string(), + }) + .strict(); + +export const TaskAwaitToolInvalidScopeResultSchema = z + .object({ + status: z.literal("invalid_scope"), + taskId: z.string(), + }) + .strict(); + +export const TaskAwaitToolErrorResultSchema = z + .object({ + status: z.literal("error"), + taskId: z.string(), + error: z.string(), + }) + .strict(); + +export const TaskAwaitToolResultSchema = z + .object({ + results: z.array( + z.discriminatedUnion("status", [ + TaskAwaitToolCompletedResultSchema, + TaskAwaitToolActiveResultSchema, + TaskAwaitToolNotFoundResultSchema, + TaskAwaitToolInvalidScopeResultSchema, + TaskAwaitToolErrorResultSchema, + ]) + ), + }) + .strict(); + +// ----------------------------------------------------------------------------- +// task_terminate (terminate one or more sub-agent tasks) +// ----------------------------------------------------------------------------- + +export const TaskTerminateToolArgsSchema = z + .object({ + task_ids: z + .array(z.string().min(1)) + .min(1) + .describe( + "List of task IDs to terminate. Each must be a descendant sub-agent task of the current workspace." + ), + }) + .strict(); + +export const TaskTerminateToolTerminatedResultSchema = z + .object({ + status: z.literal("terminated"), + taskId: z.string(), + terminatedTaskIds: z + .array(z.string()) + .describe("All terminated task IDs (includes descendants)"), + }) + .strict(); + +export const TaskTerminateToolNotFoundResultSchema = z + .object({ + status: z.literal("not_found"), + taskId: z.string(), + }) + .strict(); + +export const TaskTerminateToolInvalidScopeResultSchema = z + .object({ + status: z.literal("invalid_scope"), + taskId: z.string(), + }) + .strict(); + +export const TaskTerminateToolErrorResultSchema = z + .object({ + status: z.literal("error"), + taskId: z.string(), + error: z.string(), + }) + .strict(); + +export const TaskTerminateToolResultSchema = z + .object({ + results: z.array( + z.discriminatedUnion("status", [ + TaskTerminateToolTerminatedResultSchema, + TaskTerminateToolNotFoundResultSchema, + TaskTerminateToolInvalidScopeResultSchema, + TaskTerminateToolErrorResultSchema, + ]) + ), + }) + .strict(); + +// ----------------------------------------------------------------------------- +// task_list (list descendant sub-agent tasks) +// ----------------------------------------------------------------------------- + +const TaskListStatusSchema = z.enum(["queued", "running", "awaiting_report", "reported"]); +const TaskListThinkingLevelSchema = z.enum(["off", "low", "medium", "high", "xhigh"]); + +export const TaskListToolArgsSchema = z + .object({ + statuses: z + .array(TaskListStatusSchema) + .optional() + .describe( + "Task statuses to include. Defaults to active tasks: queued, running, awaiting_report." + ), + }) + .strict(); + +export const TaskListToolTaskSchema = z + .object({ + taskId: z.string(), + status: TaskListStatusSchema, + parentWorkspaceId: z.string(), + agentType: z.string().optional(), + workspaceName: z.string().optional(), + title: z.string().optional(), + createdAt: z.string().optional(), + modelString: z.string().optional(), + thinkingLevel: TaskListThinkingLevelSchema.optional(), + depth: z.number().int().min(0), + }) + .strict(); + +export const TaskListToolResultSchema = z + .object({ + tasks: z.array(TaskListToolTaskSchema), + }) + .strict(); + +// ----------------------------------------------------------------------------- +// agent_report (explicit subagent -> parent report) +// ----------------------------------------------------------------------------- + +export const AgentReportToolArgsSchema = z + .object({ + reportMarkdown: z.string().min(1), + title: z.string().optional(), + }) + .strict(); + +export const AgentReportToolResultSchema = z.object({ success: z.literal(true) }).strict(); const FILE_EDIT_FILE_PATH = z .string() .describe("Path to the file to edit (absolute or relative to the current workspace)"); @@ -241,6 +464,41 @@ export const TOOL_DEFINITIONS = { "After calling this tool, do not paste the plan contents or mention the plan file path; the UI already shows the full plan.", schema: z.object({}), }, + task: { + description: + "Spawn a sub-agent task in a child workspace. " + + "Use this to delegate work to specialized presets like research or explore. " + + "If run_in_background is false, this tool blocks until the sub-agent calls agent_report, then returns the report. " + + "If run_in_background is true, you can await it later with task_await.", + schema: TaskToolArgsSchema, + }, + task_await: { + description: + "Wait for one or more sub-agent tasks to finish and return their reports. " + + "This is similar to Promise.allSettled(): you always get per-task results. " + + "Possible statuses: completed, queued, running, awaiting_report, not_found, invalid_scope, error.", + schema: TaskAwaitToolArgsSchema, + }, + task_terminate: { + description: + "Terminate one or more sub-agent tasks immediately. " + + "This stops their AI streams and deletes their workspaces (best-effort). " + + "No report will be delivered; any in-progress work is discarded. " + + "If the task has descendant sub-agent tasks, they are terminated too.", + schema: TaskTerminateToolArgsSchema, + }, + task_list: { + description: + "List descendant sub-agent tasks for the current workspace, including their status and metadata. " + + "Use this after compaction or interruptions to rediscover which tasks are still active.", + schema: TaskListToolArgsSchema, + }, + agent_report: { + description: + "Report the final result of a sub-agent task back to the parent workspace. " + + "Call this exactly once when you have a final answer (after any spawned sub-tasks complete).", + schema: AgentReportToolArgsSchema, + }, todo_write: { description: "Create or update the todo list for tracking multi-step tasks (limit: 7 items). " + @@ -620,8 +878,13 @@ export function getToolSchemas(): Record { * @param mode Optional mode ("plan" | "exec") - ask_user_question only available in plan mode * @returns Array of tool names available for the model */ -export function getAvailableTools(modelString: string, mode?: "plan" | "exec"): string[] { +export function getAvailableTools( + modelString: string, + mode?: "plan" | "exec", + options?: { enableAgentReport?: boolean } +): string[] { const [provider] = modelString.split(":"); + const enableAgentReport = options?.enableAgentReport ?? true; // Base tools available for all models const baseTools = [ @@ -636,6 +899,11 @@ export function getAvailableTools(modelString: string, mode?: "plan" | "exec"): // ask_user_question only available in plan mode ...(mode === "plan" ? ["ask_user_question"] : []), "propose_plan", + "task", + "task_await", + "task_terminate", + "task_list", + ...(enableAgentReport ? ["agent_report"] : []), "todo_write", "todo_read", "status_set", diff --git a/src/common/utils/tools/toolPolicy.test.ts b/src/common/utils/tools/toolPolicy.test.ts index 4782e9d4a4..6aa4e9c1e8 100644 --- a/src/common/utils/tools/toolPolicy.test.ts +++ b/src/common/utils/tools/toolPolicy.test.ts @@ -190,6 +190,29 @@ describe("applyToolPolicy", () => { expect(result.file_edit_insert).toBeUndefined(); expect(result.web_search).toBeUndefined(); }); + + test("preset policy cannot be overridden by caller", () => { + const callerPolicy: ToolPolicy = [{ regex_match: "file_edit_.*", action: "enable" }]; + const presetPolicy: ToolPolicy = [{ regex_match: "file_edit_.*", action: "disable" }]; + + const merged: ToolPolicy = [...callerPolicy, ...presetPolicy]; + const result = applyToolPolicy(mockTools, merged); + + expect(result.file_edit_replace_string).toBeUndefined(); + expect(result.file_edit_replace_lines).toBeUndefined(); + expect(result.file_edit_insert).toBeUndefined(); + }); + + test("preset policy cannot be overridden by caller require", () => { + const callerPolicy: ToolPolicy = [{ regex_match: "bash", action: "require" }]; + const presetPolicy: ToolPolicy = [{ regex_match: ".*", action: "disable" }]; + + const merged: ToolPolicy = [...callerPolicy, ...presetPolicy]; + const result = applyToolPolicy(mockTools, merged); + + expect(result.bash).toBeUndefined(); + expect(Object.keys(result)).toHaveLength(0); + }); }); describe("edge cases", () => { diff --git a/src/common/utils/tools/toolPolicy.ts b/src/common/utils/tools/toolPolicy.ts index 02b7d050ea..61c8f58d6c 100644 --- a/src/common/utils/tools/toolPolicy.ts +++ b/src/common/utils/tools/toolPolicy.ts @@ -22,10 +22,11 @@ export type ToolPolicy = z.infer; * @returns Filtered tools based on policy * * Algorithm: - * 1. Check if any tool is marked as "require" - * 2. If a tool is required, disable all other tools (at most one can be required) - * 3. Otherwise, start with default "allow" for all tools and apply filters in order - * 4. Last matching filter wins + * - Filters are applied in order, with default behavior "allow all". + * - If any tool is marked as "require" (at most one tool may match across the whole policy), + * only that tool remains eligible. Later filters may still disable it (resulting in no tools). + * - Without a required tool, enable/disable filters apply to all tools, and the last matching + * filter wins for each tool. */ export function applyToolPolicy( tools: Record, @@ -36,32 +37,49 @@ export function applyToolPolicy( return tools; } - // First pass: find any required tools - const requiredTools = new Set(); + const toolNames = Object.keys(tools); + + // First pass: find a single required tool (if any), validating that the policy + // never results in multiple required matches. + let requiredTool: string | null = null; for (const filter of policy) { - if (filter.action === "require") { - const regex = new RegExp(`^${filter.regex_match}$`); - for (const toolName of Object.keys(tools)) { - if (regex.test(toolName)) { - requiredTools.add(toolName); - } - } + if (filter.action !== "require") continue; + + const regex = new RegExp(`^${filter.regex_match}$`); + const matches = toolNames.filter((toolName) => regex.test(toolName)); + + if (matches.length > 1) { + throw new Error( + `Tool policy error: Multiple tools marked as required (${matches.join(", ")}). At most one tool can be required.` + ); } - } + if (matches.length === 0) continue; - // Validate: at most one tool can be required - if (requiredTools.size > 1) { - throw new Error( - `Tool policy error: Multiple tools marked as required (${Array.from(requiredTools).join(", ")}). At most one tool can be required.` - ); + if (requiredTool && requiredTool !== matches[0]) { + throw new Error( + `Tool policy error: Multiple tools marked as required (${requiredTool}, ${matches[0]}). At most one tool can be required.` + ); + } + requiredTool = matches[0]; } - // If a tool is required, return only that tool - if (requiredTools.size === 1) { - const requiredTool = Array.from(requiredTools)[0]; - return { - [requiredTool]: tools[requiredTool], - }; + // If a tool is required, only that tool remains eligible, but later filters may disable it. + if (requiredTool) { + let enabled = true; // Default allow + + for (const filter of policy) { + const regex = new RegExp(`^${filter.regex_match}$`); + if (!regex.test(requiredTool)) continue; + + if (filter.action === "disable") { + enabled = false; + continue; + } + // enable/require both imply enabled for the required tool at this point in the policy + enabled = true; + } + + return enabled ? { [requiredTool]: tools[requiredTool] } : {}; } // No required tools: apply standard enable/disable logic @@ -69,7 +87,7 @@ export function applyToolPolicy( const toolStatus = new Map(); // Initialize all tools as enabled (default allow) - for (const toolName of Object.keys(tools)) { + for (const toolName of toolNames) { toolStatus.set(toolName, true); } @@ -81,7 +99,7 @@ export function applyToolPolicy( const shouldEnable = filter.action === "enable"; // Apply filter to matching tools - for (const toolName of Object.keys(tools)) { + for (const toolName of toolNames) { if (regex.test(toolName)) { toolStatus.set(toolName, shouldEnable); } diff --git a/src/common/utils/tools/tools.test.ts b/src/common/utils/tools/tools.test.ts new file mode 100644 index 0000000000..6f705b6403 --- /dev/null +++ b/src/common/utils/tools/tools.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; + +import type { InitStateManager } from "@/node/services/initStateManager"; +import { LocalRuntime } from "@/node/runtime/LocalRuntime"; +import { getToolsForModel } from "./tools"; + +describe("getToolsForModel", () => { + test("only includes agent_report when enableAgentReport=true", async () => { + const runtime = new LocalRuntime(process.cwd()); + const initStateManager = { + waitForInit: () => Promise.resolve(), + } as unknown as InitStateManager; + + const toolsWithoutReport = await getToolsForModel( + "noop:model", + { + cwd: process.cwd(), + runtime, + runtimeTempDir: "/tmp", + enableAgentReport: false, + }, + "ws-1", + initStateManager + ); + expect(toolsWithoutReport.agent_report).toBeUndefined(); + + const toolsWithReport = await getToolsForModel( + "noop:model", + { + cwd: process.cwd(), + runtime, + runtimeTempDir: "/tmp", + enableAgentReport: true, + }, + "ws-1", + initStateManager + ); + expect(toolsWithReport.agent_report).toBeDefined(); + }); +}); diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index 2e0a9f533e..9f2ab48090 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -11,6 +11,11 @@ import { createAskUserQuestionTool } from "@/node/services/tools/ask_user_questi import { createProposePlanTool } from "@/node/services/tools/propose_plan"; import { createTodoWriteTool, createTodoReadTool } from "@/node/services/tools/todo"; import { createStatusSetTool } from "@/node/services/tools/status_set"; +import { createTaskTool } from "@/node/services/tools/task"; +import { createTaskAwaitTool } from "@/node/services/tools/task_await"; +import { createTaskTerminateTool } from "@/node/services/tools/task_terminate"; +import { createTaskListTool } from "@/node/services/tools/task_list"; +import { createAgentReportTool } from "@/node/services/tools/agent_report"; import { wrapWithInitWait } from "@/node/services/tools/wrapWithInitWait"; import { log } from "@/node/services/log"; import { sanitizeMCPToolsForOpenAI } from "@/common/utils/tools/schemaSanitizer"; @@ -18,6 +23,7 @@ import { sanitizeMCPToolsForOpenAI } from "@/common/utils/tools/schemaSanitizer" import type { Runtime } from "@/node/runtime/Runtime"; import type { InitStateManager } from "@/node/services/initStateManager"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; +import type { TaskService } from "@/node/services/taskService"; import type { UIMode } from "@/common/types/mode"; import type { WorkspaceChatMessage } from "@/common/orpc/types"; import type { FileState } from "@/node/services/agentSession"; @@ -55,6 +61,10 @@ export interface ToolConfiguration { workspaceId?: string; /** Callback to record file state for external edit detection (plan files) */ recordFileState?: (filePath: string, state: FileState) => void; + /** Task orchestration for sub-agent tasks */ + taskService?: TaskService; + /** Enable agent_report tool (only valid for child task workspaces) */ + enableAgentReport?: boolean; } /** @@ -136,6 +146,11 @@ export async function getToolsForModel( const nonRuntimeTools: Record = { ...(config.mode === "plan" ? { ask_user_question: createAskUserQuestionTool(config) } : {}), propose_plan: createProposePlanTool(config), + task: createTaskTool(config), + task_await: createTaskAwaitTool(config), + task_terminate: createTaskTerminateTool(config), + task_list: createTaskListTool(config), + ...(config.enableAgentReport ? { agent_report: createAgentReportTool(config) } : {}), todo_write: createTodoWriteTool(config), todo_read: createTodoReadTool(config), status_set: createStatusSetTool(config), diff --git a/src/common/utils/ui/modeUtils.ts b/src/common/utils/ui/modeUtils.ts index abcd87bda9..26fa714638 100644 --- a/src/common/utils/ui/modeUtils.ts +++ b/src/common/utils/ui/modeUtils.ts @@ -17,6 +17,14 @@ NOTE that this is the only file you are allowed to edit - other than this you ar Keep the plan crisp and focused on actionable recommendations. Put historical context, alternatives considered, or lengthy rationale into collapsible \`
/\` blocks so the core plan stays scannable. +If you need investigation (codebase exploration or deeper research) before you can produce a good plan, delegate it to sub-agents via the \`task\` tool: +- Use \`subagent_type: "explore"\` for quick, read-only repo/code exploration (identify relevant files/symbols, callsites, and facts). +- Use \`subagent_type: "research"\` for deeper investigation and feasibility analysis in this codebase (it may delegate to \`explore\`; web research is optional when relevant). +- In each task prompt, specify explicit deliverables (what questions to answer, what files/symbols to locate, and the exact output format you want back). +- Run tasks in parallel with \`run_in_background: true\`, then use \`task_await\` (optionally with \`task_ids\`) until all spawned tasks are \`completed\`. +- After spawning one or more tasks, do NOT continue with your own investigation/planning in parallel. Await the task reports first, then synthesize and proceed. +- Do NOT call \`propose_plan\` until you have awaited and incorporated sub-agent reports. + If you need clarification from the user before you can finalize the plan, you MUST use the ask_user_question tool. - Do not ask questions in a normal chat message. - Do not include an "Open Questions" section in the plan. diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 548310fa00..1e3e404bc7 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -326,6 +326,7 @@ async function loadServices(): Promise { aiService: services.aiService, projectService: services.projectService, workspaceService: services.workspaceService, + taskService: services.taskService, providerService: services.providerService, terminalService: services.terminalService, editorService: services.editorService, diff --git a/src/node/config.ts b/src/node/config.ts index 5243bdb71a..17d5d1294c 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -12,6 +12,11 @@ import type { ProjectsConfig, FeatureFlagOverride, } from "@/common/types/project"; +import { + DEFAULT_TASK_SETTINGS, + normalizeSubagentAiDefaults, + normalizeTaskSettings, +} from "@/common/types/tasks"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; import { getMuxHome } from "@/common/constants/paths"; @@ -63,6 +68,8 @@ export class Config { serverSshHost?: string; viewedSplashScreens?: string[]; featureFlagOverrides?: Record; + taskSettings?: unknown; + subagentAiDefaults?: unknown; }; // Config is stored as array of [path, config] pairs @@ -78,6 +85,8 @@ export class Config { projects: projectsMap, serverSshHost: parsed.serverSshHost, viewedSplashScreens: parsed.viewedSplashScreens, + taskSettings: normalizeTaskSettings(parsed.taskSettings), + subagentAiDefaults: normalizeSubagentAiDefaults(parsed.subagentAiDefaults), featureFlagOverrides: parsed.featureFlagOverrides, }; } @@ -89,6 +98,8 @@ export class Config { // Return default config return { projects: new Map(), + taskSettings: DEFAULT_TASK_SETTINGS, + subagentAiDefaults: {}, }; } @@ -103,8 +114,11 @@ export class Config { serverSshHost?: string; viewedSplashScreens?: string[]; featureFlagOverrides?: ProjectsConfig["featureFlagOverrides"]; + taskSettings?: ProjectsConfig["taskSettings"]; + subagentAiDefaults?: ProjectsConfig["subagentAiDefaults"]; } = { projects: Array.from(config.projects.entries()), + taskSettings: config.taskSettings ?? DEFAULT_TASK_SETTINGS, }; if (config.serverSshHost) { data.serverSshHost = config.serverSshHost; @@ -115,6 +129,9 @@ export class Config { if (config.viewedSplashScreens) { data.viewedSplashScreens = config.viewedSplashScreens; } + if (config.subagentAiDefaults && Object.keys(config.subagentAiDefaults).length > 0) { + data.subagentAiDefaults = config.subagentAiDefaults; + } await writeFileAtomic(this.configFile, JSON.stringify(data, null, 2), "utf-8"); } catch (error) { @@ -339,6 +356,8 @@ export class Config { // GUARANTEE: All workspaces must have runtimeConfig (apply default if missing) runtimeConfig: workspace.runtimeConfig ?? DEFAULT_RUNTIME_CONFIG, aiSettings: workspace.aiSettings, + parentWorkspaceId: workspace.parentWorkspaceId, + agentType: workspace.agentType, }; // Migrate missing createdAt to config for next load @@ -381,6 +400,9 @@ export class Config { // Preserve any config-only fields that may not exist in legacy metadata.json metadata.aiSettings ??= workspace.aiSettings; + // Preserve tree/task metadata when present in config (metadata.json won't have it) + metadata.parentWorkspaceId ??= workspace.parentWorkspaceId; + metadata.agentType ??= workspace.agentType; // Migrate to config for next load workspace.id = metadata.id; workspace.name = metadata.name; @@ -405,6 +427,8 @@ export class Config { // GUARANTEE: All workspaces must have runtimeConfig runtimeConfig: DEFAULT_RUNTIME_CONFIG, aiSettings: workspace.aiSettings, + parentWorkspaceId: workspace.parentWorkspaceId, + agentType: workspace.agentType, }; // Save to config for next load @@ -430,6 +454,8 @@ export class Config { // GUARANTEE: All workspaces must have runtimeConfig (even in error cases) runtimeConfig: DEFAULT_RUNTIME_CONFIG, aiSettings: workspace.aiSettings, + parentWorkspaceId: workspace.parentWorkspaceId, + agentType: workspace.agentType, }; workspaceMetadata.push(this.addPathsToMetadata(metadata, workspace.path, projectPath)); } diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index 6a7ee4935a..d04f9c86b5 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -19,12 +19,14 @@ import type { TelemetryService } from "@/node/services/telemetryService"; import type { FeatureFlagService } from "@/node/services/featureFlagService"; import type { SessionTimingService } from "@/node/services/sessionTimingService"; import type { SessionUsageService } from "@/node/services/sessionUsageService"; +import type { TaskService } from "@/node/services/taskService"; export interface ORPCContext { config: Config; aiService: AIService; projectService: ProjectService; workspaceService: WorkspaceService; + taskService: TaskService; providerService: ProviderService; terminalService: TerminalService; editorService: EditorService; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index d2ceabf328..5a1f4ce5f4 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -20,6 +20,11 @@ import { readPlanFile } from "@/node/utils/runtime/helpers"; import { secretsToRecord } from "@/common/types/secrets"; import { roundToBase2 } from "@/common/telemetry/utils"; import { createAsyncEventQueue } from "@/common/utils/asyncEventIterator"; +import { + DEFAULT_TASK_SETTINGS, + normalizeSubagentAiDefaults, + normalizeTaskSettings, +} from "@/common/types/tasks"; export const router = (authToken?: string) => { const t = os.$context().use(createAuthMiddleware(authToken)); @@ -113,6 +118,35 @@ export const router = (authToken?: string) => { return state; }), }, + config: { + getConfig: t + .input(schemas.config.getConfig.input) + .output(schemas.config.getConfig.output) + .handler(({ context }) => { + const config = context.config.loadConfigOrDefault(); + return { + taskSettings: config.taskSettings ?? DEFAULT_TASK_SETTINGS, + subagentAiDefaults: config.subagentAiDefaults ?? {}, + }; + }), + saveConfig: t + .input(schemas.config.saveConfig.input) + .output(schemas.config.saveConfig.output) + .handler(async ({ context, input }) => { + await context.config.editConfig((config) => { + const normalizedTaskSettings = normalizeTaskSettings(input.taskSettings); + const result = { ...config, taskSettings: normalizedTaskSettings }; + + if (input.subagentAiDefaults !== undefined) { + const normalizedDefaults = normalizeSubagentAiDefaults(input.subagentAiDefaults); + result.subagentAiDefaults = + Object.keys(normalizedDefaults).length > 0 ? normalizedDefaults : undefined; + } + + return result; + }); + }), + }, providers: { list: t .input(schemas.providers.list.input) @@ -1067,6 +1101,31 @@ export const router = (authToken?: string) => { }), }, }, + tasks: { + create: t + .input(schemas.tasks.create.input) + .output(schemas.tasks.create.output) + .handler(({ context, input }) => { + const thinkingLevel = + input.thinkingLevel === "off" || + input.thinkingLevel === "low" || + input.thinkingLevel === "medium" || + input.thinkingLevel === "high" || + input.thinkingLevel === "xhigh" + ? input.thinkingLevel + : undefined; + + return context.taskService.create({ + parentWorkspaceId: input.parentWorkspaceId, + kind: input.kind, + agentType: input.agentType, + prompt: input.prompt, + description: input.description, + modelString: input.modelString, + thinkingLevel, + }); + }), + }, window: { setTitle: t .input(schemas.window.setTitle.input) diff --git a/src/node/runtime/WorktreeRuntime.test.ts b/src/node/runtime/WorktreeRuntime.test.ts index 949b309d59..b6d1623756 100644 --- a/src/node/runtime/WorktreeRuntime.test.ts +++ b/src/node/runtime/WorktreeRuntime.test.ts @@ -1,7 +1,30 @@ import { describe, expect, it } from "bun:test"; import * as os from "os"; import * as path from "path"; +import * as fsPromises from "fs/promises"; +import { execSync } from "node:child_process"; import { WorktreeRuntime } from "./WorktreeRuntime"; +import type { InitLogger } from "./Runtime"; + +function initGitRepo(projectPath: string): void { + execSync("git init -b main", { cwd: projectPath, stdio: "ignore" }); + execSync('git config user.email "test@example.com"', { cwd: projectPath, stdio: "ignore" }); + execSync('git config user.name "test"', { cwd: projectPath, stdio: "ignore" }); + // Ensure tests don't hang when developers have global commit signing enabled. + execSync("git config commit.gpgsign false", { cwd: projectPath, stdio: "ignore" }); + execSync("bash -lc 'echo \"hello\" > README.md'", { cwd: projectPath, stdio: "ignore" }); + execSync("git add README.md", { cwd: projectPath, stdio: "ignore" }); + execSync('git commit -m "init"', { cwd: projectPath, stdio: "ignore" }); +} + +function createNullInitLogger(): InitLogger { + return { + logStep: (_message: string) => undefined, + logStdout: (_line: string) => undefined, + logStderr: (_line: string) => undefined, + logComplete: (_exitCode: number) => undefined, + }; +} describe("WorktreeRuntime constructor", () => { it("should expand tilde in srcBaseDir", () => { @@ -65,3 +88,55 @@ describe("WorktreeRuntime.resolvePath", () => { expect(path.isAbsolute(resolved)).toBe(true); }); }); + +describe("WorktreeRuntime.deleteWorkspace", () => { + it("deletes agent branches when removing worktrees", async () => { + const rootDir = await fsPromises.realpath( + await fsPromises.mkdtemp(path.join(os.tmpdir(), "worktree-runtime-delete-")) + ); + + try { + const projectPath = path.join(rootDir, "repo"); + await fsPromises.mkdir(projectPath, { recursive: true }); + initGitRepo(projectPath); + + const srcBaseDir = path.join(rootDir, "src"); + await fsPromises.mkdir(srcBaseDir, { recursive: true }); + + const runtime = new WorktreeRuntime(srcBaseDir); + const initLogger = createNullInitLogger(); + + const branchName = "agent_explore_aaaaaaaaaa"; + const createResult = await runtime.createWorkspace({ + projectPath, + branchName, + trunkBranch: "main", + directoryName: branchName, + initLogger, + }); + expect(createResult.success).toBe(true); + if (!createResult.success) return; + + const before = execSync(`git branch --list "${branchName}"`, { + cwd: projectPath, + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim(); + expect(before).toContain(branchName); + + const deleteResult = await runtime.deleteWorkspace(projectPath, branchName, true); + expect(deleteResult.success).toBe(true); + + const after = execSync(`git branch --list "${branchName}"`, { + cwd: projectPath, + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim(); + expect(after).toBe(""); + } finally { + await fsPromises.rm(rootDir, { recursive: true, force: true }); + } + }, 20_000); +}); diff --git a/src/node/runtime/WorktreeRuntime.ts b/src/node/runtime/WorktreeRuntime.ts index 60d69c1231..a3185388e0 100644 --- a/src/node/runtime/WorktreeRuntime.ts +++ b/src/node/runtime/WorktreeRuntime.ts @@ -18,6 +18,7 @@ import { getErrorMessage } from "@/common/utils/errors"; import { expandTilde } from "./tildeExpansion"; import { LocalBaseRuntime } from "./LocalBaseRuntime"; import { toPosixPath } from "@/node/utils/paths"; +import { log } from "@/node/services/log"; /** * Worktree runtime implementation that executes commands and file operations @@ -221,6 +222,25 @@ export class WorktreeRuntime extends LocalBaseRuntime { // In-place workspaces are identified by projectPath === workspaceName // These are direct workspace directories (e.g., CLI/benchmark sessions), not git worktrees const isInPlace = projectPath === workspaceName; + const shouldDeleteBranch = !isInPlace && workspaceName.startsWith("agent_"); + + const tryDeleteBranch = async () => { + if (!shouldDeleteBranch) return; + const deleteFlag = force ? "-D" : "-d"; + try { + using deleteProc = execAsync( + `git -C "${projectPath}" branch ${deleteFlag} "${workspaceName}"` + ); + await deleteProc.result; + } catch (error) { + // Best-effort: workspace deletion should not fail just because branch cleanup failed. + log.debug("Failed to delete git branch after removing worktree", { + projectPath, + workspaceName, + error: getErrorMessage(error), + }); + } + }; // Compute workspace path using the canonical method const deletedPath = this.getWorkspacePath(projectPath, workspaceName); @@ -239,6 +259,9 @@ export class WorktreeRuntime extends LocalBaseRuntime { // Ignore prune errors - directory is already deleted, which is the goal } } + + // Best-effort: if this looks like an agent workspace, also delete the branch. + await tryDeleteBranch(); return { success: true, deletedPath }; } @@ -259,6 +282,8 @@ export class WorktreeRuntime extends LocalBaseRuntime { ); await proc.result; + // Best-effort: if this looks like an agent workspace, also delete the branch. + await tryDeleteBranch(); return { success: true, deletedPath }; } catch (error) { const message = getErrorMessage(error); @@ -279,6 +304,7 @@ export class WorktreeRuntime extends LocalBaseRuntime { // Ignore prune errors } // Treat as success - workspace is gone (idempotent) + await tryDeleteBranch(); return { success: true, deletedPath }; } @@ -301,6 +327,8 @@ export class WorktreeRuntime extends LocalBaseRuntime { }); await rmProc.result; + // Best-effort: if this looks like an agent workspace, also delete the branch. + await tryDeleteBranch(); return { success: true, deletedPath }; } catch (rmError) { return { diff --git a/src/node/services/agentPresets.ts b/src/node/services/agentPresets.ts new file mode 100644 index 0000000000..664a380956 --- /dev/null +++ b/src/node/services/agentPresets.ts @@ -0,0 +1,88 @@ +import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; + +export interface AgentPreset { + /** Normalized agentType key (e.g., "research") */ + agentType: string; + toolPolicy: ToolPolicy; + systemPrompt: string; +} + +const RESEARCH_PRESET: AgentPreset = { + agentType: "research", + toolPolicy: [ + { regex_match: ".*", action: "disable" }, + { regex_match: "web_search", action: "enable" }, + { regex_match: "web_fetch", action: "enable" }, + { regex_match: "file_read", action: "enable" }, + { regex_match: "task", action: "enable" }, + { regex_match: "task_await", action: "enable" }, + { regex_match: "task_list", action: "enable" }, + { regex_match: "task_terminate", action: "enable" }, + { regex_match: "agent_report", action: "enable" }, + ], + systemPrompt: [ + "You are a Research sub-agent running inside a child workspace.", + "", + "Goals:", + "- Gather accurate, relevant information efficiently.", + "- Prefer primary sources and official docs when possible.", + "", + "Rules:", + "- Do not edit files.", + "- Do not run bash commands unless explicitly enabled (assume it is not).", + "- If you need repository exploration beyond file_read, delegate to an Explore sub-agent via the task tool.", + "", + "Delegation:", + '- Use: task({ subagent_type: "explore", prompt: "..." }) when you need repo exploration.', + "", + "Reporting:", + "- When you have a final answer, call agent_report exactly once.", + "- Do not call agent_report until any spawned sub-tasks have completed and you have integrated their results.", + ].join("\n"), +}; + +const EXPLORE_PRESET: AgentPreset = { + agentType: "explore", + toolPolicy: [ + { regex_match: ".*", action: "disable" }, + { regex_match: "file_read", action: "enable" }, + { regex_match: "bash", action: "enable" }, + { regex_match: "bash_output", action: "enable" }, + { regex_match: "bash_background_list", action: "enable" }, + { regex_match: "bash_background_terminate", action: "enable" }, + { regex_match: "task", action: "enable" }, + { regex_match: "task_await", action: "enable" }, + { regex_match: "task_list", action: "enable" }, + { regex_match: "task_terminate", action: "enable" }, + { regex_match: "agent_report", action: "enable" }, + ], + systemPrompt: [ + "You are an Explore sub-agent running inside a child workspace.", + "", + "Goals:", + "- Explore the repository to answer the prompt using read-only investigation.", + "- Keep output concise and actionable (paths, symbols, and findings).", + "", + "Rules:", + "- Do not edit files.", + "- Treat bash as read-only: prefer commands like rg, ls, cat, git show, git diff (read-only).", + "- If you need external information, delegate to a Research sub-agent via the task tool.", + "", + "Delegation:", + '- Use: task({ subagent_type: "research", prompt: "..." }) when you need web research.', + "", + "Reporting:", + "- When you have a final answer, call agent_report exactly once.", + "- Do not call agent_report until any spawned sub-tasks have completed and you have integrated their results.", + ].join("\n"), +}; + +const PRESETS_BY_AGENT_TYPE: Record = { + research: RESEARCH_PRESET, + explore: EXPLORE_PRESET, +}; + +export function getAgentPreset(agentType: string | undefined): AgentPreset | null { + const normalized = (agentType ?? "").trim().toLowerCase(); + return PRESETS_BY_AGENT_TYPE[normalized] ?? null; +} diff --git a/src/node/services/agentSession.editMessageId.test.ts b/src/node/services/agentSession.editMessageId.test.ts new file mode 100644 index 0000000000..2142aa4109 --- /dev/null +++ b/src/node/services/agentSession.editMessageId.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, mock } from "bun:test"; +import { EventEmitter } from "events"; +import type { AIService } from "@/node/services/aiService"; +import type { HistoryService } from "@/node/services/historyService"; +import type { PartialService } from "@/node/services/partialService"; +import type { InitStateManager } from "@/node/services/initStateManager"; +import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; +import type { Config } from "@/node/config"; +import type { MuxMessage } from "@/common/types/message"; +import type { SendMessageError } from "@/common/types/errors"; +import type { Result } from "@/common/types/result"; +import { Ok, Err } from "@/common/types/result"; +import { AgentSession } from "./agentSession"; + +describe("AgentSession.sendMessage (editMessageId)", () => { + it("treats missing edit target as no-op (allows recovery after compaction)", async () => { + const workspaceId = "ws-test"; + + const config = { + srcDir: "/tmp", + getSessionDir: (_workspaceId: string) => "/tmp", + } as unknown as Config; + + const messages: MuxMessage[] = []; + let nextSeq = 0; + + const truncateAfterMessage = mock((_workspaceId: string, messageId: string) => { + return Promise.resolve(Err(`Message with ID ${messageId} not found in history`)); + }); + + const appendToHistory = mock((_workspaceId: string, message: MuxMessage) => { + message.metadata = { ...(message.metadata ?? {}), historySequence: nextSeq++ }; + messages.push(message); + return Promise.resolve(Ok(undefined)); + }); + + const getHistory = mock((_workspaceId: string): Promise> => { + return Promise.resolve(Ok([...messages])); + }); + + const historyService = { + truncateAfterMessage, + appendToHistory, + getHistory, + } as unknown as HistoryService; + + const partialService = { + commitToHistory: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), + } as unknown as PartialService; + + const aiEmitter = new EventEmitter(); + const streamMessage = mock((_messages: MuxMessage[]) => { + return Promise.resolve(Ok(undefined)); + }); + const aiService = Object.assign(aiEmitter, { + isStreaming: mock((_workspaceId: string) => false), + stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), + streamMessage: streamMessage as unknown as ( + ...args: Parameters + ) => Promise>, + }) as unknown as AIService; + + const initStateManager = new EventEmitter() as unknown as InitStateManager; + + const backgroundProcessManager = { + cleanup: mock((_workspaceId: string) => Promise.resolve()), + setMessageQueued: mock((_workspaceId: string, _queued: boolean) => { + void _queued; + }), + } as unknown as BackgroundProcessManager; + + const session = new AgentSession({ + workspaceId, + config, + historyService, + partialService, + aiService, + initStateManager, + backgroundProcessManager, + }); + + const result = await session.sendMessage("hello", { + model: "anthropic:claude-3-5-sonnet-latest", + editMessageId: "missing-user-message-id", + }); + + expect(result.success).toBe(true); + expect(truncateAfterMessage.mock.calls).toHaveLength(1); + expect(appendToHistory.mock.calls).toHaveLength(1); + expect(streamMessage.mock.calls).toHaveLength(1); + }); +}); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 8314d34e35..df1b675091 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -38,6 +38,7 @@ import type { PostCompactionAttachment, PostCompactionExclusions } from "@/commo import { TURNS_BETWEEN_ATTACHMENTS } from "@/common/constants/attachments"; import { extractEditedFileDiffs } from "@/common/utils/messages/extractEditedFiles"; import { isValidModelFormat } from "@/common/utils/ai/models"; +import { log } from "@/node/services/log"; /** * Tracked file state for detecting external edits. @@ -405,7 +406,21 @@ export class AgentSession { options.editMessageId ); if (!truncateResult.success) { - return Err(createUnknownSendMessageError(truncateResult.error)); + const isMissingEditTarget = + truncateResult.error.includes("Message with ID") && + truncateResult.error.includes("not found in history"); + if (isMissingEditTarget) { + // This can happen if the frontend is briefly out-of-sync with persisted history + // (e.g., compaction/truncation completed and removed the message while the UI still + // shows it as editable). Treat as a no-op truncation so the user can recover. + log.warn("editMessageId not found in history; proceeding without truncation", { + workspaceId: this.workspaceId, + editMessageId: options.editMessageId, + error: truncateResult.error, + }); + } else { + return Err(createUnknownSendMessageError(truncateResult.error)); + } } } diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 2e86b2495c..712f2b6c8e 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -48,6 +48,7 @@ import { getTokenizerForModel } from "@/node/utils/main/tokenizer"; import type { TelemetryService } from "@/node/services/telemetryService"; import { getRuntimeTypeForTelemetry, roundToBase2 } from "@/common/telemetry/utils"; import type { MCPServerManager, MCPWorkspaceStats } from "@/node/services/mcpServerManager"; +import type { TaskService } from "@/node/services/taskService"; import { buildProviderOptions } from "@/common/utils/ai/providerOptions"; import type { ThinkingLevel } from "@/common/types/thinking"; import type { @@ -71,6 +72,7 @@ import { getPlanModeInstruction } from "@/common/utils/ui/modeUtils"; import type { UIMode } from "@/common/types/mode"; import { MUX_APP_ATTRIBUTION_TITLE, MUX_APP_ATTRIBUTION_URL } from "@/constants/appAttribution"; import { readPlanFile } from "@/node/utils/runtime/helpers"; +import { getAgentPreset } from "@/node/services/agentPresets"; // Export a standalone version of getToolsForModel for use in backend @@ -353,6 +355,7 @@ export class AIService extends EventEmitter { private readonly mockModeEnabled: boolean; private readonly mockScenarioPlayer?: MockScenarioPlayer; private readonly backgroundProcessManager?: BackgroundProcessManager; + private taskService?: TaskService; constructor( config: Config, @@ -391,6 +394,10 @@ export class AIService extends EventEmitter { this.mcpServerManager = manager; } + setTaskService(taskService: TaskService): void { + this.taskService = taskService; + } + /** * Forward all stream events from StreamManager to AIService consumers */ @@ -1045,7 +1052,8 @@ export class AIService extends EventEmitter { // Filter out assistant messages with only reasoning (no text/tools) // EXCEPTION: When extended thinking is enabled, preserve reasoning-only messages // to comply with Extended Thinking API requirements - const preserveReasoningOnly = Boolean(thinkingLevel); + const preserveReasoningOnly = + providerName === "anthropic" && thinkingLevel !== undefined && thinkingLevel !== "off"; const filteredMessages = filterEmptyAssistantMessages(messages, preserveReasoningOnly); log.debug(`Filtered ${messages.length - filteredMessages.length} empty assistant messages`); log.debug_obj(`${workspaceId}/1a_filtered_messages.json`, filteredMessages); @@ -1171,7 +1179,10 @@ export class AIService extends EventEmitter { log.debug_obj(`${workspaceId}/2_model_messages.json`, modelMessages); // Apply ModelMessage transforms based on provider requirements - const transformedMessages = transformModelMessages(modelMessages, providerName); + const transformedMessages = transformModelMessages(modelMessages, providerName, { + anthropicThinkingEnabled: + providerName === "anthropic" && thinkingLevel !== undefined && thinkingLevel !== "off", + }); // Apply cache control for Anthropic models AFTER transformation const finalMessages = applyCacheControl(transformedMessages, modelString); @@ -1188,6 +1199,8 @@ export class AIService extends EventEmitter { } } + const agentPreset = getAgentPreset(metadata.agentType); + // Build system message from workspace metadata const systemMessage = await buildSystemMessage( metadata, @@ -1196,7 +1209,8 @@ export class AIService extends EventEmitter { mode, effectiveAdditionalInstructions, modelString, - mcpServers + mcpServers, + agentPreset ? { variant: "agent", agentSystemPrompt: agentPreset.systemPrompt } : undefined ); // Count system message tokens for cost tracking @@ -1277,8 +1291,11 @@ export class AIService extends EventEmitter { }, planFilePath, workspaceId, + // Only child workspaces (tasks) can report to a parent. + enableAgentReport: Boolean(metadata.parentWorkspaceId), // External edit detection callback recordFileState, + taskService: this.taskService, }, workspaceId, this.initStateManager, @@ -1286,13 +1303,18 @@ export class AIService extends EventEmitter { mcpTools ); + // Preset tool policy must be applied last so callers cannot re-enable restricted tools. + const effectiveToolPolicy = agentPreset + ? [...(toolPolicy ?? []), ...agentPreset.toolPolicy] + : toolPolicy; + // Apply tool policy FIRST - this must happen before PTC to ensure sandbox // respects allow/deny filters. The policy-filtered tools are passed to // ToolBridge so the mux.* API only exposes policy-allowed tools. - const policyFilteredTools = applyToolPolicy(allTools, toolPolicy); + const policyFilteredTools = applyToolPolicy(allTools, effectiveToolPolicy); // Handle PTC experiments - add or replace tools with code_execution - let tools = policyFilteredTools; + let toolsForModel = policyFilteredTools; if (experiments?.programmaticToolCalling || experiments?.programmaticToolCallingExclusive) { try { // Lazy-load PTC modules only when experiments are enabled @@ -1324,10 +1346,10 @@ export class AIService extends EventEmitter { // Non-bridgeable tools can't be used from within code_execution, so they're still // available directly to the model (subject to policy) const nonBridgeable = toolBridge.getNonBridgeableTools(); - tools = { ...nonBridgeable, code_execution: codeExecutionTool }; + toolsForModel = { ...nonBridgeable, code_execution: codeExecutionTool }; } else { // Supplement mode: add code_execution alongside policy-filtered tools - tools = { ...policyFilteredTools, code_execution: codeExecutionTool }; + toolsForModel = { ...policyFilteredTools, code_execution: codeExecutionTool }; } } catch (error) { // Fall back to policy-filtered tools if PTC creation fails @@ -1335,6 +1357,10 @@ export class AIService extends EventEmitter { } } + // Apply tool policy to the final tool set so tools added by experiments + // (e.g., code_execution) are also gated by policy (important for agent presets). + const tools = applyToolPolicy(toolsForModel, effectiveToolPolicy); + const effectiveMcpStats: MCPWorkspaceStats = mcpStats ?? ({ @@ -1383,7 +1409,7 @@ export class AIService extends EventEmitter { workspaceId, model: modelString, toolNames: Object.keys(tools), - hasToolPolicy: Boolean(toolPolicy), + hasToolPolicy: Boolean(effectiveToolPolicy), }); // Create assistant message placeholder with historySequence from backend diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index cc8832ab7a..51f2d8c0d0 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -38,6 +38,7 @@ import { MCPConfigService } from "@/node/services/mcpConfigService"; import { MCPServerManager } from "@/node/services/mcpServerManager"; import { SessionUsageService } from "@/node/services/sessionUsageService"; import { IdleCompactionService } from "@/node/services/idleCompactionService"; +import { TaskService } from "@/node/services/taskService"; /** * ServiceContainer - Central dependency container for all backend services. @@ -52,6 +53,7 @@ export class ServiceContainer { public readonly aiService: AIService; public readonly projectService: ProjectService; public readonly workspaceService: WorkspaceService; + public readonly taskService: TaskService; public readonly providerService: ProviderService; public readonly terminalService: TerminalService; public readonly editorService: EditorService; @@ -108,6 +110,15 @@ export class ServiceContainer { this.backgroundProcessManager ); this.workspaceService.setMCPServerManager(this.mcpServerManager); + this.taskService = new TaskService( + config, + this.historyService, + this.partialService, + this.aiService, + this.workspaceService, + this.initStateManager + ); + this.aiService.setTaskService(this.taskService); // Idle compaction service - auto-compacts workspaces after configured idle period this.idleCompactionService = new IdleCompactionService( config, @@ -180,6 +191,7 @@ export class ServiceContainer { // Ignore feature flag failures. }); await this.experimentsService.initialize(); + await this.taskService.initialize(); // Start idle compaction checker this.idleCompactionService.start(); } diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 4e58ffdaa6..0ae67628fe 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -636,6 +636,31 @@ export class StreamManager extends EventEmitter { } } + // Anthropic Extended Thinking is incompatible with forced tool choice. + // If a tool is forced, disable thinking for this request to avoid API errors. + let finalProviderOptions = providerOptions; + const [provider] = normalizeGatewayModel(modelString).split(":", 2); + if ( + toolChoice && + provider === "anthropic" && + providerOptions && + typeof providerOptions === "object" && + "anthropic" in providerOptions + ) { + const anthropicOptions = (providerOptions as { anthropic?: unknown }).anthropic; + if ( + anthropicOptions && + typeof anthropicOptions === "object" && + "thinking" in anthropicOptions + ) { + const { thinking: _thinking, ...rest } = anthropicOptions as Record; + finalProviderOptions = { + ...providerOptions, + anthropic: rest, + }; + } + } + // Apply cache control for Anthropic models let finalMessages = messages; let finalTools = tools; @@ -672,7 +697,7 @@ export class StreamManager extends EventEmitter { // to complete complex tasks. The stopWhen condition allows the model to decide when it's done. ...(toolChoice ? { maxSteps: 1 } : { stopWhen: stepCountIs(100000) }), // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment - providerOptions: providerOptions as any, // Pass provider-specific options (thinking/reasoning config) + providerOptions: finalProviderOptions as any, // Pass provider-specific options (thinking/reasoning config) // Default to 32000 tokens if not specified (Anthropic defaults to 4096) maxOutputTokens: maxOutputTokens ?? 32000, }); diff --git a/src/node/services/systemMessage.ts b/src/node/services/systemMessage.ts index 9f05024b1c..f8c4db4c8e 100644 --- a/src/node/services/systemMessage.ts +++ b/src/node/services/systemMessage.ts @@ -155,9 +155,10 @@ export function extractToolInstructions( globalInstructions: string | null, contextInstructions: string | null, modelString: string, - mode?: "plan" | "exec" + mode?: "plan" | "exec", + options?: { enableAgentReport?: boolean } ): Record { - const availableTools = getAvailableTools(modelString, mode); + const availableTools = getAvailableTools(modelString, mode, options); const toolInstructions: Record = {}; for (const toolName of availableTools) { @@ -199,7 +200,9 @@ export async function readToolInstructions( workspacePath ); - return extractToolInstructions(globalInstructions, contextInstructions, modelString, mode); + return extractToolInstructions(globalInstructions, contextInstructions, modelString, mode, { + enableAgentReport: Boolean(metadata.parentWorkspaceId), + }); } /** @@ -251,11 +254,35 @@ export async function buildSystemMessage( mode?: string, additionalSystemInstructions?: string, modelString?: string, - mcpServers?: MCPServerMap + mcpServers?: MCPServerMap, + options?: { + variant?: "default" | "agent"; + agentSystemPrompt?: string; + } ): Promise { if (!metadata) throw new Error("Invalid workspace metadata: metadata is required"); if (!workspacePath) throw new Error("Invalid workspace path: workspacePath is required"); + // Read instruction sets + // Get runtime type from metadata (defaults to "local" for legacy workspaces without runtimeConfig) + const runtimeType = metadata.runtimeConfig?.type ?? "local"; + + // Build system message + let systemMessage = `${PRELUDE.trim()}\n\n${buildEnvironmentContext(workspacePath, runtimeType)}`; + + // Add MCP context if servers are configured + if (mcpServers && Object.keys(mcpServers).length > 0) { + systemMessage += buildMCPContext(mcpServers); + } + + if (options?.variant === "agent") { + const agentPrompt = options.agentSystemPrompt?.trim(); + if (agentPrompt) { + systemMessage += `\n\n${agentPrompt}\n`; + } + return systemMessage; + } + // Read instruction sets const [globalInstructions, contextInstructions] = await readInstructionSources( metadata, @@ -294,17 +321,6 @@ export async function buildSystemMessage( null; } - // Get runtime type from metadata (defaults to "local" for legacy workspaces without runtimeConfig) - const runtimeType = metadata.runtimeConfig?.type ?? "local"; - - // Build system message - let systemMessage = `${PRELUDE.trim()}\n\n${buildEnvironmentContext(workspacePath, runtimeType)}`; - - // Add MCP context if servers are configured - if (mcpServers && Object.keys(mcpServers).length > 0) { - systemMessage += buildMCPContext(mcpServers); - } - if (customInstructions) { systemMessage += `\n\n${customInstructions}\n`; } diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts new file mode 100644 index 0000000000..738ebdff6b --- /dev/null +++ b/src/node/services/taskService.test.ts @@ -0,0 +1,1957 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"; +import * as fsPromises from "fs/promises"; +import * as path from "path"; +import * as os from "os"; +import { execSync } from "node:child_process"; + +import { Config } from "@/node/config"; +import { HistoryService } from "@/node/services/historyService"; +import { PartialService } from "@/node/services/partialService"; +import { TaskService } from "@/node/services/taskService"; +import { createRuntime } from "@/node/runtime/runtimeFactory"; +import { Ok, Err, type Result } from "@/common/types/result"; +import { createMuxMessage } from "@/common/types/message"; +import type { WorkspaceMetadata } from "@/common/types/workspace"; +import type { AIService } from "@/node/services/aiService"; +import type { WorkspaceService } from "@/node/services/workspaceService"; +import type { InitStateManager } from "@/node/services/initStateManager"; + +function initGitRepo(projectPath: string): void { + execSync("git init -b main", { cwd: projectPath, stdio: "ignore" }); + execSync('git config user.email "test@example.com"', { cwd: projectPath, stdio: "ignore" }); + execSync('git config user.name "test"', { cwd: projectPath, stdio: "ignore" }); + // Ensure tests don't hang when developers have global commit signing enabled. + execSync("git config commit.gpgsign false", { cwd: projectPath, stdio: "ignore" }); + execSync("bash -lc 'echo \"hello\" > README.md'", { cwd: projectPath, stdio: "ignore" }); + execSync("git add README.md", { cwd: projectPath, stdio: "ignore" }); + execSync('git commit -m "init"', { cwd: projectPath, stdio: "ignore" }); +} + +function createNullInitLogger() { + return { + logStep: (_message: string) => undefined, + logStdout: (_line: string) => undefined, + logStderr: (_line: string) => undefined, + logComplete: (_exitCode: number) => undefined, + }; +} + +describe("TaskService", () => { + let rootDir: string; + + beforeEach(async () => { + rootDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-taskService-")); + }); + + afterEach(async () => { + await fsPromises.rm(rootDir, { recursive: true, force: true }); + }); + + test("enforces maxTaskNestingDepth", async () => { + const config = new Config(rootDir); + await fsPromises.mkdir(config.srcDir, { recursive: true }); + + // Deterministic IDs for workspace names. + const ids = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc"]; + let nextIdIndex = 0; + const configWithStableId = config as unknown as { generateStableId: () => string }; + configWithStableId.generateStableId = () => ids[nextIdIndex++] ?? "dddddddddd"; + + const projectPath = path.join(rootDir, "repo"); + await fsPromises.mkdir(projectPath, { recursive: true }); + initGitRepo(projectPath); + + const runtimeConfig = { type: "worktree" as const, srcBaseDir: config.srcDir }; + const runtime = createRuntime(runtimeConfig, { projectPath }); + + const initLogger = createNullInitLogger(); + + const parentName = "parent"; + const parentCreate = await runtime.createWorkspace({ + projectPath, + branchName: parentName, + trunkBranch: "main", + directoryName: parentName, + initLogger, + }); + expect(parentCreate.success).toBe(true); + + const parentId = "1111111111"; + const parentPath = runtime.getWorkspacePath(projectPath, parentName); + + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { + path: parentPath, + id: parentId, + name: parentName, + createdAt: new Date().toISOString(), + runtimeConfig, + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 1 }, + }); + + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + + const aiService: AIService = { + isStreaming: mock(() => false), + getWorkspaceMetadata: mock( + async (workspaceId: string): Promise> => { + const all = await config.getAllWorkspaceMetadata(); + const found = all.find((m) => m.id === workspaceId); + return found ? Ok(found) : Err("not found"); + } + ), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const workspaceService: WorkspaceService = { + sendMessage: mock(() => Promise.resolve(Ok(undefined))), + resumeStream: mock(() => Promise.resolve(Ok(undefined))), + remove: mock(() => Promise.resolve(Ok(undefined))), + emit: mock(() => true), + } as unknown as WorkspaceService; + + const initStateManager: InitStateManager = { + startInit: mock(() => undefined), + appendOutput: mock(() => undefined), + endInit: mock(() => Promise.resolve()), + } as unknown as InitStateManager; + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager + ); + + const first = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "explore", + prompt: "explore this repo", + }); + expect(first.success).toBe(true); + if (!first.success) return; + + const second = await taskService.create({ + parentWorkspaceId: first.data.taskId, + kind: "agent", + agentType: "explore", + prompt: "nested explore", + }); + expect(second.success).toBe(false); + if (!second.success) { + expect(second.error).toContain("maxTaskNestingDepth"); + } + }, 20_000); + + test("queues tasks when maxParallelAgentTasks is reached and starts them when a slot frees", async () => { + const config = new Config(rootDir); + await fsPromises.mkdir(config.srcDir, { recursive: true }); + + const ids = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", "dddddddddd"]; + let nextIdIndex = 0; + const configWithStableId = config as unknown as { generateStableId: () => string }; + configWithStableId.generateStableId = () => ids[nextIdIndex++] ?? "eeeeeeeeee"; + + const projectPath = path.join(rootDir, "repo"); + await fsPromises.mkdir(projectPath, { recursive: true }); + initGitRepo(projectPath); + + const runtimeConfig = { type: "worktree" as const, srcBaseDir: config.srcDir }; + const runtime = createRuntime(runtimeConfig, { projectPath }); + const initLogger = createNullInitLogger(); + + const parent1Name = "parent1"; + const parent2Name = "parent2"; + await runtime.createWorkspace({ + projectPath, + branchName: parent1Name, + trunkBranch: "main", + directoryName: parent1Name, + initLogger, + }); + await runtime.createWorkspace({ + projectPath, + branchName: parent2Name, + trunkBranch: "main", + directoryName: parent2Name, + initLogger, + }); + + const parent1Id = "1111111111"; + const parent2Id = "2222222222"; + + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { + path: runtime.getWorkspacePath(projectPath, parent1Name), + id: parent1Id, + name: parent1Name, + createdAt: new Date().toISOString(), + runtimeConfig, + }, + { + path: runtime.getWorkspacePath(projectPath, parent2Name), + id: parent2Id, + name: parent2Name, + createdAt: new Date().toISOString(), + runtimeConfig, + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 1, maxTaskNestingDepth: 3 }, + }); + + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + + const aiService: AIService = { + isStreaming: mock(() => false), + getWorkspaceMetadata: mock( + async (workspaceId: string): Promise> => { + const all = await config.getAllWorkspaceMetadata(); + const found = all.find((m) => m.id === workspaceId); + return found ? Ok(found) : Err("not found"); + } + ), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const resumeStream = mock(() => Promise.resolve(Ok(undefined))); + + const workspaceService: WorkspaceService = { + sendMessage: mock(() => Promise.resolve(Ok(undefined))), + resumeStream, + remove: mock(() => Promise.resolve(Ok(undefined))), + emit: mock(() => true), + } as unknown as WorkspaceService; + + const initStateManager: InitStateManager = { + startInit: mock(() => undefined), + appendOutput: mock(() => undefined), + endInit: mock(() => Promise.resolve()), + } as unknown as InitStateManager; + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager + ); + + const running = await taskService.create({ + parentWorkspaceId: parent1Id, + kind: "agent", + agentType: "explore", + prompt: "task 1", + }); + expect(running.success).toBe(true); + if (!running.success) return; + + const queued = await taskService.create({ + parentWorkspaceId: parent2Id, + kind: "agent", + agentType: "explore", + prompt: "task 2", + }); + expect(queued.success).toBe(true); + if (!queued.success) return; + expect(queued.data.status).toBe("queued"); + + // Free the slot by marking the first task as reported. + await config.editConfig((cfg) => { + for (const [_project, project] of cfg.projects) { + const ws = project.workspaces.find((w) => w.id === running.data.taskId); + if (ws) { + ws.taskStatus = "reported"; + } + } + return cfg; + }); + + await taskService.initialize(); + + expect(resumeStream).toHaveBeenCalled(); + + const cfg = config.loadConfigOrDefault(); + const started = Array.from(cfg.projects.values()) + .flatMap((p) => p.workspaces) + .find((w) => w.id === queued.data.taskId); + expect(started?.taskStatus).toBe("running"); + }, 20_000); + + test("does not start queued tasks while a reported task is still streaming", async () => { + const config = new Config(rootDir); + + const projectPath = path.join(rootDir, "repo"); + const rootWorkspaceId = "root-111"; + const reportedTaskId = "task-reported"; + const queuedTaskId = "task-queued"; + + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { path: path.join(projectPath, "root"), id: rootWorkspaceId, name: "root" }, + { + path: path.join(projectPath, "reported"), + id: reportedTaskId, + name: "agent_explore_reported", + parentWorkspaceId: rootWorkspaceId, + agentType: "explore", + taskStatus: "reported", + }, + { + path: path.join(projectPath, "queued"), + id: queuedTaskId, + name: "agent_explore_queued", + parentWorkspaceId: rootWorkspaceId, + agentType: "explore", + taskStatus: "queued", + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 1, maxTaskNestingDepth: 3 }, + }); + + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + + const aiService: AIService = { + isStreaming: mock((workspaceId: string) => workspaceId === reportedTaskId), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const resumeStream = mock(() => Promise.resolve(Ok(undefined))); + const workspaceService: WorkspaceService = { + sendMessage: mock(() => Promise.resolve(Ok(undefined))), + resumeStream, + remove: mock(() => Promise.resolve(Ok(undefined))), + emit: mock(() => true), + } as unknown as WorkspaceService; + + const initStateManager: InitStateManager = { + startInit: mock(() => undefined), + appendOutput: mock(() => undefined), + endInit: mock(() => Promise.resolve()), + } as unknown as InitStateManager; + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager + ); + + await taskService.initialize(); + + expect(resumeStream).not.toHaveBeenCalled(); + + const cfg = config.loadConfigOrDefault(); + const queued = Array.from(cfg.projects.values()) + .flatMap((p) => p.workspaces) + .find((w) => w.id === queuedTaskId); + expect(queued?.taskStatus).toBe("queued"); + }); + + test("allows multiple agent tasks under the same parent up to maxParallelAgentTasks", async () => { + const config = new Config(rootDir); + await fsPromises.mkdir(config.srcDir, { recursive: true }); + + const ids = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc"]; + let nextIdIndex = 0; + const configWithStableId = config as unknown as { generateStableId: () => string }; + configWithStableId.generateStableId = () => ids[nextIdIndex++] ?? "dddddddddd"; + + const projectPath = path.join(rootDir, "repo"); + await fsPromises.mkdir(projectPath, { recursive: true }); + initGitRepo(projectPath); + + const runtimeConfig = { type: "worktree" as const, srcBaseDir: config.srcDir }; + const runtime = createRuntime(runtimeConfig, { projectPath }); + + const initLogger = createNullInitLogger(); + + const parentName = "parent"; + const parentCreate = await runtime.createWorkspace({ + projectPath, + branchName: parentName, + trunkBranch: "main", + directoryName: parentName, + initLogger, + }); + expect(parentCreate.success).toBe(true); + + const parentId = "1111111111"; + const parentPath = runtime.getWorkspacePath(projectPath, parentName); + + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { + path: parentPath, + id: parentId, + name: parentName, + createdAt: new Date().toISOString(), + runtimeConfig, + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 2, maxTaskNestingDepth: 3 }, + }); + + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + + const aiService: AIService = { + isStreaming: mock(() => false), + getWorkspaceMetadata: mock( + async (workspaceId: string): Promise> => { + const all = await config.getAllWorkspaceMetadata(); + const found = all.find((m) => m.id === workspaceId); + return found ? Ok(found) : Err("not found"); + } + ), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const workspaceService: WorkspaceService = { + sendMessage: mock(() => Promise.resolve(Ok(undefined))), + resumeStream: mock(() => Promise.resolve(Ok(undefined))), + remove: mock(() => Promise.resolve(Ok(undefined))), + emit: mock(() => true), + } as unknown as WorkspaceService; + + const initStateManager: InitStateManager = { + startInit: mock(() => undefined), + appendOutput: mock(() => undefined), + endInit: mock(() => Promise.resolve()), + } as unknown as InitStateManager; + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager + ); + + const first = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "explore", + prompt: "task 1", + }); + expect(first.success).toBe(true); + if (!first.success) return; + expect(first.data.status).toBe("running"); + + const second = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "explore", + prompt: "task 2", + }); + expect(second.success).toBe(true); + if (!second.success) return; + expect(second.data.status).toBe("running"); + + const third = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "explore", + prompt: "task 3", + }); + expect(third.success).toBe(true); + if (!third.success) return; + expect(third.data.status).toBe("queued"); + }, 20_000); + + test("supports creating agent tasks from local (project-dir) workspaces without requiring git", async () => { + const config = new Config(rootDir); + await fsPromises.mkdir(config.srcDir, { recursive: true }); + + const ids = ["aaaaaaaaaa"]; + let nextIdIndex = 0; + const configWithStableId = config as unknown as { generateStableId: () => string }; + configWithStableId.generateStableId = () => ids[nextIdIndex++] ?? "bbbbbbbbbb"; + + const projectPath = path.join(rootDir, "repo"); + await fsPromises.mkdir(projectPath, { recursive: true }); + + const parentId = "1111111111"; + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { + path: projectPath, + id: parentId, + name: "parent", + createdAt: new Date().toISOString(), + runtimeConfig: { type: "local" }, + aiSettings: { model: "openai:gpt-5.2", thinkingLevel: "medium" }, + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, + }); + + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + + const aiService: AIService = { + isStreaming: mock(() => false), + getWorkspaceMetadata: mock( + async (workspaceId: string): Promise> => { + const all = await config.getAllWorkspaceMetadata(); + const found = all.find((m) => m.id === workspaceId); + return found ? Ok(found) : Err("not found"); + } + ), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const workspaceService: WorkspaceService = { + sendMessage: mock(() => Promise.resolve(Ok(undefined))), + resumeStream: mock(() => Promise.resolve(Ok(undefined))), + remove: mock(() => Promise.resolve(Ok(undefined))), + emit: mock(() => true), + } as unknown as WorkspaceService; + + const initStateManager: InitStateManager = { + startInit: mock(() => undefined), + appendOutput: mock(() => undefined), + endInit: mock(() => Promise.resolve()), + } as unknown as InitStateManager; + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager + ); + + const created = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "explore", + prompt: "run task from local workspace", + }); + expect(created.success).toBe(true); + if (!created.success) return; + + const postCfg = config.loadConfigOrDefault(); + const childEntry = Array.from(postCfg.projects.values()) + .flatMap((p) => p.workspaces) + .find((w) => w.id === created.data.taskId); + expect(childEntry).toBeTruthy(); + expect(childEntry?.path).toBe(projectPath); + expect(childEntry?.runtimeConfig?.type).toBe("local"); + expect(childEntry?.aiSettings).toEqual({ model: "openai:gpt-5.2", thinkingLevel: "medium" }); + expect(childEntry?.taskModelString).toBe("openai:gpt-5.2"); + expect(childEntry?.taskThinkingLevel).toBe("medium"); + }, 20_000); + + test("applies subagentAiDefaults model + thinking overrides on task create", async () => { + const config = new Config(rootDir); + await fsPromises.mkdir(config.srcDir, { recursive: true }); + + const ids = ["aaaaaaaaaa"]; + let nextIdIndex = 0; + const configWithStableId = config as unknown as { generateStableId: () => string }; + configWithStableId.generateStableId = () => ids[nextIdIndex++] ?? "bbbbbbbbbb"; + + const projectPath = path.join(rootDir, "repo"); + await fsPromises.mkdir(projectPath, { recursive: true }); + + const parentId = "1111111111"; + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { + path: projectPath, + id: parentId, + name: "parent", + createdAt: new Date().toISOString(), + runtimeConfig: { type: "local" }, + aiSettings: { model: "openai:gpt-5.2", thinkingLevel: "high" }, + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, + subagentAiDefaults: { + explore: { modelString: "anthropic:claude-haiku-4-5", thinkingLevel: "off" }, + }, + }); + + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + + const aiService: AIService = { + isStreaming: mock(() => false), + getWorkspaceMetadata: mock( + async (workspaceId: string): Promise> => { + const all = await config.getAllWorkspaceMetadata(); + const found = all.find((m) => m.id === workspaceId); + return found ? Ok(found) : Err("not found"); + } + ), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const sendMessage = mock(() => Promise.resolve(Ok(undefined))); + const workspaceService: WorkspaceService = { + sendMessage, + resumeStream: mock(() => Promise.resolve(Ok(undefined))), + remove: mock(() => Promise.resolve(Ok(undefined))), + emit: mock(() => true), + } as unknown as WorkspaceService; + + const initStateManager: InitStateManager = { + startInit: mock(() => undefined), + appendOutput: mock(() => undefined), + endInit: mock(() => Promise.resolve()), + } as unknown as InitStateManager; + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager + ); + + const created = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "explore", + prompt: "run task with overrides", + }); + expect(created.success).toBe(true); + if (!created.success) return; + + expect(sendMessage).toHaveBeenCalledWith(created.data.taskId, "run task with overrides", { + model: "anthropic:claude-haiku-4-5", + thinkingLevel: "off", + }); + + const postCfg = config.loadConfigOrDefault(); + const childEntry = Array.from(postCfg.projects.values()) + .flatMap((p) => p.workspaces) + .find((w) => w.id === created.data.taskId); + expect(childEntry).toBeTruthy(); + expect(childEntry?.aiSettings).toEqual({ + model: "anthropic:claude-haiku-4-5", + thinkingLevel: "off", + }); + expect(childEntry?.taskModelString).toBe("anthropic:claude-haiku-4-5"); + expect(childEntry?.taskThinkingLevel).toBe("off"); + }, 20_000); + + test("auto-resumes a parent workspace until background tasks finish", async () => { + const config = new Config(rootDir); + + const projectPath = path.join(rootDir, "repo"); + const rootWorkspaceId = "root-111"; + const childTaskId = "task-222"; + + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { + path: path.join(projectPath, "root"), + id: rootWorkspaceId, + name: "root", + aiSettings: { model: "openai:gpt-5.2", thinkingLevel: "medium" }, + }, + { + path: path.join(projectPath, "child-task"), + id: childTaskId, + name: "agent_explore_child", + parentWorkspaceId: rootWorkspaceId, + agentType: "explore", + taskStatus: "running", + taskModelString: "openai:gpt-5.2", + taskThinkingLevel: "medium", + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, + }); + + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + + const aiService: AIService = { + isStreaming: mock(() => false), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const resumeStream = mock(() => Promise.resolve(Ok(undefined))); + const workspaceService: WorkspaceService = { + sendMessage: mock(() => Promise.resolve(Ok(undefined))), + resumeStream, + remove: mock(() => Promise.resolve(Ok(undefined))), + emit: mock(() => true), + } as unknown as WorkspaceService; + + const initStateManager: InitStateManager = { + startInit: mock(() => undefined), + appendOutput: mock(() => undefined), + endInit: mock(() => Promise.resolve()), + } as unknown as InitStateManager; + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager + ); + + const internal = taskService as unknown as { + handleStreamEnd: (event: { type: "stream-end"; workspaceId: string }) => Promise; + }; + + await internal.handleStreamEnd({ type: "stream-end", workspaceId: rootWorkspaceId }); + + expect(resumeStream).toHaveBeenCalledTimes(1); + expect(resumeStream).toHaveBeenCalledWith( + rootWorkspaceId, + expect.objectContaining({ + model: "openai:gpt-5.2", + thinkingLevel: "medium", + }) + ); + + const resumeCalls = (resumeStream as unknown as { mock: { calls: unknown[][] } }).mock.calls; + const options = resumeCalls[0]?.[1]; + if (!options || typeof options !== "object") { + throw new Error("Expected resumeStream to be called with an options object"); + } + + const additionalSystemInstructions = (options as { additionalSystemInstructions?: unknown }) + .additionalSystemInstructions; + expect(typeof additionalSystemInstructions).toBe("string"); + expect(additionalSystemInstructions).toContain(childTaskId); + }); + + test("terminateDescendantAgentTask stops stream, removes workspace, and rejects waiters", async () => { + const config = new Config(rootDir); + + const projectPath = path.join(rootDir, "repo"); + const rootWorkspaceId = "root-111"; + const taskId = "task-222"; + + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { path: path.join(projectPath, "root"), id: rootWorkspaceId, name: "root" }, + { + path: path.join(projectPath, "task"), + id: taskId, + name: "agent_research_task", + parentWorkspaceId: rootWorkspaceId, + agentType: "research", + taskStatus: "running", + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, + }); + + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + + const stopStream = mock(() => Promise.resolve(Ok(undefined))); + const aiService: AIService = { + isStreaming: mock(() => false), + stopStream, + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const remove = mock(() => Promise.resolve(Ok(undefined))); + const workspaceService: WorkspaceService = { + sendMessage: mock(() => Promise.resolve(Ok(undefined))), + resumeStream: mock(() => Promise.resolve(Ok(undefined))), + remove, + emit: mock(() => true), + } as unknown as WorkspaceService; + + const initStateManager: InitStateManager = { + startInit: mock(() => undefined), + appendOutput: mock(() => undefined), + endInit: mock(() => Promise.resolve()), + } as unknown as InitStateManager; + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager + ); + + const waiter = taskService.waitForAgentReport(taskId, { timeoutMs: 10_000 }); + + const terminateResult = await taskService.terminateDescendantAgentTask(rootWorkspaceId, taskId); + expect(terminateResult.success).toBe(true); + + let caught: unknown = null; + try { + await waiter; + } catch (error: unknown) { + caught = error; + } + expect(caught).toBeInstanceOf(Error); + if (caught instanceof Error) { + expect(caught.message).toMatch(/terminated/i); + } + expect(stopStream).toHaveBeenCalledWith( + taskId, + expect.objectContaining({ abandonPartial: true }) + ); + expect(remove).toHaveBeenCalledWith(taskId, true); + }); + + test("terminateDescendantAgentTask terminates descendant tasks leaf-first", async () => { + const config = new Config(rootDir); + + const projectPath = path.join(rootDir, "repo"); + const rootWorkspaceId = "root-111"; + const parentTaskId = "task-parent"; + const childTaskId = "task-child"; + + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { path: path.join(projectPath, "root"), id: rootWorkspaceId, name: "root" }, + { + path: path.join(projectPath, "parent-task"), + id: parentTaskId, + name: "agent_research_parent", + parentWorkspaceId: rootWorkspaceId, + agentType: "research", + taskStatus: "running", + }, + { + path: path.join(projectPath, "child-task"), + id: childTaskId, + name: "agent_explore_child", + parentWorkspaceId: parentTaskId, + agentType: "explore", + taskStatus: "running", + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, + }); + + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + + const stopStream = mock(() => Promise.resolve(Ok(undefined))); + const aiService: AIService = { + isStreaming: mock(() => false), + stopStream, + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const remove = mock(() => Promise.resolve(Ok(undefined))); + const workspaceService: WorkspaceService = { + sendMessage: mock(() => Promise.resolve(Ok(undefined))), + resumeStream: mock(() => Promise.resolve(Ok(undefined))), + remove, + emit: mock(() => true), + } as unknown as WorkspaceService; + + const initStateManager: InitStateManager = { + startInit: mock(() => undefined), + appendOutput: mock(() => undefined), + endInit: mock(() => Promise.resolve()), + } as unknown as InitStateManager; + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager + ); + + const terminateResult = await taskService.terminateDescendantAgentTask( + rootWorkspaceId, + parentTaskId + ); + expect(terminateResult.success).toBe(true); + if (!terminateResult.success) return; + expect(terminateResult.data.terminatedTaskIds).toEqual([childTaskId, parentTaskId]); + + expect(remove).toHaveBeenNthCalledWith(1, childTaskId, true); + expect(remove).toHaveBeenNthCalledWith(2, parentTaskId, true); + }); + + test("initialize resumes awaiting_report tasks after restart", async () => { + const config = new Config(rootDir); + + const projectPath = path.join(rootDir, "repo"); + const parentId = "parent-111"; + const childId = "child-222"; + + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { path: path.join(projectPath, "parent"), id: parentId, name: "parent" }, + { + path: path.join(projectPath, "child"), + id: childId, + name: "agent_explore_child", + parentWorkspaceId: parentId, + agentType: "explore", + taskStatus: "awaiting_report", + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 1, maxTaskNestingDepth: 3 }, + }); + + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + + const aiService: AIService = { + isStreaming: mock(() => false), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const resumeStream = mock(() => Promise.resolve(Ok(undefined))); + + const workspaceService: WorkspaceService = { + sendMessage: mock(() => Promise.resolve(Ok(undefined))), + resumeStream, + remove: mock(() => Promise.resolve(Ok(undefined))), + emit: mock(() => true), + } as unknown as WorkspaceService; + + const initStateManager: InitStateManager = { + startInit: mock(() => undefined), + appendOutput: mock(() => undefined), + endInit: mock(() => Promise.resolve()), + } as unknown as InitStateManager; + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager + ); + + await taskService.initialize(); + + expect(resumeStream).toHaveBeenCalledWith( + childId, + expect.objectContaining({ + toolPolicy: [{ regex_match: "^agent_report$", action: "require" }], + }) + ); + }); + + test("waitForAgentReport does not time out while task is queued", async () => { + const config = new Config(rootDir); + + const projectPath = path.join(rootDir, "repo"); + const parentId = "parent-111"; + const childId = "child-222"; + + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { path: path.join(projectPath, "parent"), id: parentId, name: "parent" }, + { + path: path.join(projectPath, "child"), + id: childId, + name: "agent_explore_child", + parentWorkspaceId: parentId, + agentType: "explore", + taskStatus: "queued", + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 1, maxTaskNestingDepth: 3 }, + }); + + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + + const aiService: AIService = { + isStreaming: mock(() => false), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const workspaceService: WorkspaceService = { + sendMessage: mock(() => Promise.resolve(Ok(undefined))), + resumeStream: mock(() => Promise.resolve(Ok(undefined))), + remove: mock(() => Promise.resolve(Ok(undefined))), + emit: mock(() => true), + } as unknown as WorkspaceService; + + const initStateManager: InitStateManager = { + startInit: mock(() => undefined), + appendOutput: mock(() => undefined), + endInit: mock(() => Promise.resolve()), + } as unknown as InitStateManager; + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager + ); + + // Timeout is short so the test would fail if the timer started while queued. + const reportPromise = taskService.waitForAgentReport(childId, { timeoutMs: 50 }); + + // Wait longer than timeout while task is still queued. + await new Promise((r) => setTimeout(r, 100)); + + const internal = taskService as unknown as { + setTaskStatus: (workspaceId: string, status: "queued" | "running") => Promise; + resolveWaiters: (taskId: string, report: { reportMarkdown: string; title?: string }) => void; + }; + + await internal.setTaskStatus(childId, "running"); + internal.resolveWaiters(childId, { reportMarkdown: "ok" }); + + const report = await reportPromise; + expect(report.reportMarkdown).toBe("ok"); + }); + + test("waitForAgentReport returns cached report even after workspace is removed", async () => { + const config = new Config(rootDir); + + const projectPath = path.join(rootDir, "repo"); + const parentId = "parent-111"; + const childId = "child-222"; + + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { path: path.join(projectPath, "parent"), id: parentId, name: "parent" }, + { + path: path.join(projectPath, "child"), + id: childId, + name: "agent_explore_child", + parentWorkspaceId: parentId, + agentType: "explore", + taskStatus: "running", + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 1, maxTaskNestingDepth: 3 }, + }); + + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + + const aiService: AIService = { + isStreaming: mock(() => false), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const workspaceService: WorkspaceService = { + sendMessage: mock(() => Promise.resolve(Ok(undefined))), + resumeStream: mock(() => Promise.resolve(Ok(undefined))), + remove: mock(() => Promise.resolve(Ok(undefined))), + emit: mock(() => true), + } as unknown as WorkspaceService; + + const initStateManager: InitStateManager = { + startInit: mock(() => undefined), + appendOutput: mock(() => undefined), + endInit: mock(() => Promise.resolve()), + } as unknown as InitStateManager; + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager + ); + + const internal = taskService as unknown as { + resolveWaiters: (taskId: string, report: { reportMarkdown: string; title?: string }) => void; + }; + internal.resolveWaiters(childId, { reportMarkdown: "ok", title: "t" }); + + await config.removeWorkspace(childId); + + const report = await taskService.waitForAgentReport(childId, { timeoutMs: 10 }); + expect(report.reportMarkdown).toBe("ok"); + expect(report.title).toBe("t"); + }); + + test("does not request agent_report on stream end while task has active descendants", async () => { + const config = new Config(rootDir); + + const projectPath = path.join(rootDir, "repo"); + const rootWorkspaceId = "root-111"; + const parentTaskId = "task-222"; + const descendantTaskId = "task-333"; + + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { path: path.join(projectPath, "root"), id: rootWorkspaceId, name: "root" }, + { + path: path.join(projectPath, "parent-task"), + id: parentTaskId, + name: "agent_research_parent", + parentWorkspaceId: rootWorkspaceId, + agentType: "research", + taskStatus: "running", + }, + { + path: path.join(projectPath, "child-task"), + id: descendantTaskId, + name: "agent_explore_child", + parentWorkspaceId: parentTaskId, + agentType: "explore", + taskStatus: "running", + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, + }); + + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + + const aiService: AIService = { + isStreaming: mock(() => false), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const sendMessage = mock(() => Promise.resolve(Ok(undefined))); + const emit = mock(() => true); + const workspaceService: WorkspaceService = { + sendMessage, + resumeStream: mock(() => Promise.resolve(Ok(undefined))), + remove: mock(() => Promise.resolve(Ok(undefined))), + emit, + } as unknown as WorkspaceService; + + const initStateManager: InitStateManager = { + startInit: mock(() => undefined), + appendOutput: mock(() => undefined), + endInit: mock(() => Promise.resolve()), + } as unknown as InitStateManager; + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager + ); + + const internal = taskService as unknown as { + handleStreamEnd: (event: { type: "stream-end"; workspaceId: string }) => Promise; + }; + await internal.handleStreamEnd({ type: "stream-end", workspaceId: parentTaskId }); + + expect(sendMessage).not.toHaveBeenCalled(); + + const postCfg = config.loadConfigOrDefault(); + const ws = Array.from(postCfg.projects.values()) + .flatMap((p) => p.workspaces) + .find((w) => w.id === parentTaskId); + expect(ws?.taskStatus).toBe("running"); + }); + + test("reverts awaiting_report to running on stream end while task has active descendants", async () => { + const config = new Config(rootDir); + + const projectPath = path.join(rootDir, "repo"); + const rootWorkspaceId = "root-111"; + const parentTaskId = "task-222"; + const descendantTaskId = "task-333"; + + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { path: path.join(projectPath, "root"), id: rootWorkspaceId, name: "root" }, + { + path: path.join(projectPath, "parent-task"), + id: parentTaskId, + name: "agent_research_parent", + parentWorkspaceId: rootWorkspaceId, + agentType: "research", + taskStatus: "awaiting_report", + }, + { + path: path.join(projectPath, "child-task"), + id: descendantTaskId, + name: "agent_explore_child", + parentWorkspaceId: parentTaskId, + agentType: "explore", + taskStatus: "running", + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, + }); + + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + + const aiService: AIService = { + isStreaming: mock(() => false), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const sendMessage = mock(() => Promise.resolve(Ok(undefined))); + const emit = mock(() => true); + const workspaceService: WorkspaceService = { + sendMessage, + resumeStream: mock(() => Promise.resolve(Ok(undefined))), + remove: mock(() => Promise.resolve(Ok(undefined))), + emit, + } as unknown as WorkspaceService; + + const initStateManager: InitStateManager = { + startInit: mock(() => undefined), + appendOutput: mock(() => undefined), + endInit: mock(() => Promise.resolve()), + } as unknown as InitStateManager; + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager + ); + + const internal = taskService as unknown as { + handleStreamEnd: (event: { type: "stream-end"; workspaceId: string }) => Promise; + }; + await internal.handleStreamEnd({ type: "stream-end", workspaceId: parentTaskId }); + + expect(sendMessage).not.toHaveBeenCalled(); + + const postCfg = config.loadConfigOrDefault(); + const ws = Array.from(postCfg.projects.values()) + .flatMap((p) => p.workspaces) + .find((w) => w.id === parentTaskId); + expect(ws?.taskStatus).toBe("running"); + }); + + test("rolls back created workspace when initial sendMessage fails", async () => { + const config = new Config(rootDir); + await fsPromises.mkdir(config.srcDir, { recursive: true }); + + const configWithStableId = config as unknown as { generateStableId: () => string }; + configWithStableId.generateStableId = () => "aaaaaaaaaa"; + + const projectPath = path.join(rootDir, "repo"); + await fsPromises.mkdir(projectPath, { recursive: true }); + initGitRepo(projectPath); + + const runtimeConfig = { type: "worktree" as const, srcBaseDir: config.srcDir }; + const runtime = createRuntime(runtimeConfig, { projectPath }); + const initLogger = createNullInitLogger(); + + const parentName = "parent"; + const parentCreate = await runtime.createWorkspace({ + projectPath, + branchName: parentName, + trunkBranch: "main", + directoryName: parentName, + initLogger, + }); + expect(parentCreate.success).toBe(true); + + const parentId = "1111111111"; + const parentPath = runtime.getWorkspacePath(projectPath, parentName); + + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { + path: parentPath, + id: parentId, + name: parentName, + createdAt: new Date().toISOString(), + runtimeConfig, + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, + }); + + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + + const aiService: AIService = { + isStreaming: mock(() => false), + getWorkspaceMetadata: mock( + async (workspaceId: string): Promise> => { + const all = await config.getAllWorkspaceMetadata(); + const found = all.find((m) => m.id === workspaceId); + return found ? Ok(found) : Err("not found"); + } + ), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const sendMessage = mock(() => Promise.resolve(Err("send failed"))); + const resumeStream = mock(() => Promise.resolve(Ok(undefined))); + const remove = mock(() => Promise.resolve(Ok(undefined))); + const emit = mock(() => true); + + const workspaceService: WorkspaceService = { + sendMessage, + resumeStream, + remove, + emit, + } as unknown as WorkspaceService; + + const initStateManager: InitStateManager = { + startInit: mock(() => undefined), + appendOutput: mock(() => undefined), + endInit: mock(() => Promise.resolve()), + } as unknown as InitStateManager; + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager + ); + + const created = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "explore", + prompt: "do the thing", + }); + + expect(created.success).toBe(false); + + const postCfg = config.loadConfigOrDefault(); + const stillExists = Array.from(postCfg.projects.values()) + .flatMap((p) => p.workspaces) + .some((w) => w.id === "aaaaaaaaaa"); + expect(stillExists).toBe(false); + + const workspaceName = "agent_explore_aaaaaaaaaa"; + const workspacePath = runtime.getWorkspacePath(projectPath, workspaceName); + let workspacePathExists = true; + try { + await fsPromises.access(workspacePath); + } catch { + workspacePathExists = false; + } + expect(workspacePathExists).toBe(false); + }, 20_000); + + test("agent_report posts report to parent, finalizes pending task tool output, and triggers cleanup", async () => { + const config = new Config(rootDir); + + const projectPath = path.join(rootDir, "repo"); + const parentId = "parent-111"; + const childId = "child-222"; + + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { path: path.join(projectPath, "parent"), id: parentId, name: "parent" }, + { + path: path.join(projectPath, "child"), + id: childId, + name: "agent_explore_child", + parentWorkspaceId: parentId, + agentType: "explore", + taskStatus: "running", + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, + }); + + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + + const parentPartial = createMuxMessage( + "assistant-parent-partial", + "assistant", + "Waiting on subagent…", + { timestamp: Date.now() }, + [ + { + type: "dynamic-tool", + toolCallId: "task-call-1", + toolName: "task", + input: { subagent_type: "explore", prompt: "do the thing" }, + state: "input-available", + }, + ] + ); + const writeParentPartial = await partialService.writePartial(parentId, parentPartial); + expect(writeParentPartial.success).toBe(true); + + const childPartial = createMuxMessage( + "assistant-child-partial", + "assistant", + "", + { timestamp: Date.now() }, + [ + { + type: "dynamic-tool", + toolCallId: "agent-report-call-1", + toolName: "agent_report", + input: { reportMarkdown: "Hello from child", title: "Result" }, + state: "output-available", + output: { success: true }, + }, + ] + ); + const writeChildPartial = await partialService.writePartial(childId, childPartial); + expect(writeChildPartial.success).toBe(true); + + const aiService: AIService = { + isStreaming: mock(() => false), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const sendMessage = mock(() => Promise.resolve(Ok(undefined))); + const resumeStream = mock(() => Promise.resolve(Ok(undefined))); + const remove = mock(() => Promise.resolve(Ok(undefined))); + const emit = mock(() => true); + + const workspaceService: WorkspaceService = { + sendMessage, + resumeStream, + remove, + emit, + } as unknown as WorkspaceService; + + const initStateManager: InitStateManager = { + startInit: mock(() => undefined), + appendOutput: mock(() => undefined), + endInit: mock(() => Promise.resolve()), + } as unknown as InitStateManager; + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager + ); + + const internal = taskService as unknown as { + handleAgentReport: (event: { + type: "tool-call-end"; + workspaceId: string; + messageId: string; + toolCallId: string; + toolName: string; + result: unknown; + timestamp: number; + }) => Promise; + }; + await internal.handleAgentReport({ + type: "tool-call-end", + workspaceId: childId, + messageId: "assistant-child-partial", + toolCallId: "agent-report-call-1", + toolName: "agent_report", + result: { success: true }, + timestamp: Date.now(), + }); + + const parentHistory = await historyService.getHistory(parentId); + expect(parentHistory.success).toBe(true); + + const updatedParentPartial = await partialService.readPartial(parentId); + expect(updatedParentPartial).not.toBeNull(); + if (updatedParentPartial) { + const toolPart = updatedParentPartial.parts.find( + (p) => + p && + typeof p === "object" && + "type" in p && + (p as { type?: unknown }).type === "dynamic-tool" + ) as unknown as + | { + toolName: string; + state: string; + output?: unknown; + } + | undefined; + expect(toolPart?.toolName).toBe("task"); + expect(toolPart?.state).toBe("output-available"); + expect(toolPart?.output && typeof toolPart.output === "object").toBe(true); + expect(JSON.stringify(toolPart?.output)).toContain("Hello from child"); + } + + const postCfg = config.loadConfigOrDefault(); + const ws = Array.from(postCfg.projects.values()) + .flatMap((p) => p.workspaces) + .find((w) => w.id === childId); + expect(ws?.taskStatus).toBe("reported"); + expect(ws?.reportedAt).toBeTruthy(); + + expect(emit).toHaveBeenCalledWith( + "metadata", + expect.objectContaining({ workspaceId: childId }) + ); + + expect(remove).toHaveBeenCalled(); + expect(resumeStream).toHaveBeenCalled(); + expect(emit).toHaveBeenCalled(); + }); + + test("agent_report updates queued/running task tool output in parent history", async () => { + const config = new Config(rootDir); + + const projectPath = path.join(rootDir, "repo"); + const parentId = "parent-111"; + const childId = "child-222"; + + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { path: path.join(projectPath, "parent"), id: parentId, name: "parent" }, + { + path: path.join(projectPath, "child"), + id: childId, + name: "agent_explore_child", + parentWorkspaceId: parentId, + agentType: "explore", + taskStatus: "running", + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, + }); + + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + + const parentHistoryMessage = createMuxMessage( + "assistant-parent-history", + "assistant", + "Spawned subagent…", + { timestamp: Date.now() }, + [ + { + type: "dynamic-tool", + toolCallId: "task-call-1", + toolName: "task", + input: { subagent_type: "explore", prompt: "do the thing", run_in_background: true }, + state: "output-available", + output: { status: "running", taskId: childId }, + }, + ] + ); + const appendParentHistory = await historyService.appendToHistory( + parentId, + parentHistoryMessage + ); + expect(appendParentHistory.success).toBe(true); + + const childPartial = createMuxMessage( + "assistant-child-partial", + "assistant", + "", + { timestamp: Date.now() }, + [ + { + type: "dynamic-tool", + toolCallId: "agent-report-call-1", + toolName: "agent_report", + input: { reportMarkdown: "Hello from child", title: "Result" }, + state: "output-available", + output: { success: true }, + }, + ] + ); + const writeChildPartial = await partialService.writePartial(childId, childPartial); + expect(writeChildPartial.success).toBe(true); + + const aiService: AIService = { + isStreaming: mock(() => false), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const resumeStream = mock(() => Promise.resolve(Ok(undefined))); + const remove = mock(() => Promise.resolve(Ok(undefined))); + const emit = mock(() => true); + + const workspaceService: WorkspaceService = { + sendMessage: mock(() => Promise.resolve(Ok(undefined))), + resumeStream, + remove, + emit, + } as unknown as WorkspaceService; + + const initStateManager: InitStateManager = { + startInit: mock(() => undefined), + appendOutput: mock(() => undefined), + endInit: mock(() => Promise.resolve()), + } as unknown as InitStateManager; + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager + ); + + const internal = taskService as unknown as { + handleAgentReport: (event: { + type: "tool-call-end"; + workspaceId: string; + messageId: string; + toolCallId: string; + toolName: string; + result: unknown; + timestamp: number; + }) => Promise; + }; + await internal.handleAgentReport({ + type: "tool-call-end", + workspaceId: childId, + messageId: "assistant-child-partial", + toolCallId: "agent-report-call-1", + toolName: "agent_report", + result: { success: true }, + timestamp: Date.now(), + }); + + const parentHistory = await historyService.getHistory(parentId); + expect(parentHistory.success).toBe(true); + if (parentHistory.success) { + // Original task tool call remains immutable ("running"), and a synthetic report message is appended. + expect(parentHistory.data.length).toBeGreaterThanOrEqual(2); + + const taskCallMessage = + parentHistory.data.find((m) => m.id === "assistant-parent-history") ?? null; + expect(taskCallMessage).not.toBeNull(); + if (taskCallMessage) { + const toolPart = taskCallMessage.parts.find( + (p) => + p && + typeof p === "object" && + "type" in p && + (p as { type?: unknown }).type === "dynamic-tool" + ) as unknown as { output?: unknown } | undefined; + expect(JSON.stringify(toolPart?.output)).toContain('"status":"running"'); + expect(JSON.stringify(toolPart?.output)).toContain(childId); + } + + const syntheticReport = parentHistory.data.find((m) => m.metadata?.synthetic) ?? null; + expect(syntheticReport).not.toBeNull(); + if (syntheticReport) { + expect(syntheticReport.role).toBe("user"); + const text = syntheticReport.parts + .filter((p) => p.type === "text") + .map((p) => p.text) + .join(""); + expect(text).toContain("Hello from child"); + expect(text).toContain(childId); + } + } + + expect(remove).toHaveBeenCalled(); + expect(resumeStream).toHaveBeenCalled(); + }); + + test("missing agent_report triggers one reminder, then posts fallback output and cleans up", async () => { + const config = new Config(rootDir); + + const projectPath = path.join(rootDir, "repo"); + const parentId = "parent-111"; + const childId = "child-222"; + + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { path: path.join(projectPath, "parent"), id: parentId, name: "parent" }, + { + path: path.join(projectPath, "child"), + id: childId, + name: "agent_explore_child", + parentWorkspaceId: parentId, + agentType: "explore", + taskStatus: "running", + taskModelString: "openai:gpt-4o-mini", + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, + }); + + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + + const parentPartial = createMuxMessage( + "assistant-parent-partial", + "assistant", + "Waiting on subagent…", + { timestamp: Date.now() }, + [ + { + type: "dynamic-tool", + toolCallId: "task-call-1", + toolName: "task", + input: { subagent_type: "explore", prompt: "do the thing" }, + state: "input-available", + }, + ] + ); + const writeParentPartial = await partialService.writePartial(parentId, parentPartial); + expect(writeParentPartial.success).toBe(true); + + const assistantOutput = createMuxMessage( + "assistant-child-output", + "assistant", + "Final output without agent_report", + { timestamp: Date.now() } + ); + const appendChildHistory = await historyService.appendToHistory(childId, assistantOutput); + expect(appendChildHistory.success).toBe(true); + + const aiService: AIService = { + isStreaming: mock(() => false), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const sendMessage = mock(() => Promise.resolve(Ok(undefined))); + const resumeStream = mock(() => Promise.resolve(Ok(undefined))); + const remove = mock(() => Promise.resolve(Ok(undefined))); + const emit = mock(() => true); + + const workspaceService: WorkspaceService = { + sendMessage, + resumeStream, + remove, + emit, + } as unknown as WorkspaceService; + + const initStateManager: InitStateManager = { + startInit: mock(() => undefined), + appendOutput: mock(() => undefined), + endInit: mock(() => Promise.resolve()), + } as unknown as InitStateManager; + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager + ); + + const internal = taskService as unknown as { + handleStreamEnd: (event: { type: "stream-end"; workspaceId: string }) => Promise; + }; + + await internal.handleStreamEnd({ type: "stream-end", workspaceId: childId }); + expect(sendMessage).toHaveBeenCalled(); + + const midCfg = config.loadConfigOrDefault(); + const midWs = Array.from(midCfg.projects.values()) + .flatMap((p) => p.workspaces) + .find((w) => w.id === childId); + expect(midWs?.taskStatus).toBe("awaiting_report"); + + await internal.handleStreamEnd({ type: "stream-end", workspaceId: childId }); + + const emitCalls = (emit as unknown as { mock: { calls: Array<[string, unknown]> } }).mock.calls; + const metadataEmitsForChild = emitCalls.filter((call) => { + const [eventName, payload] = call; + if (eventName !== "metadata") return false; + if (!payload || typeof payload !== "object") return false; + const maybePayload = payload as { workspaceId?: unknown }; + return maybePayload.workspaceId === childId; + }); + expect(metadataEmitsForChild).toHaveLength(2); + + const parentHistory = await historyService.getHistory(parentId); + expect(parentHistory.success).toBe(true); + + const updatedParentPartial = await partialService.readPartial(parentId); + expect(updatedParentPartial).not.toBeNull(); + if (updatedParentPartial) { + const toolPart = updatedParentPartial.parts.find( + (p) => + p && + typeof p === "object" && + "type" in p && + (p as { type?: unknown }).type === "dynamic-tool" + ) as unknown as + | { + toolName: string; + state: string; + output?: unknown; + } + | undefined; + expect(toolPart?.toolName).toBe("task"); + expect(toolPart?.state).toBe("output-available"); + expect(JSON.stringify(toolPart?.output)).toContain("Final output without agent_report"); + expect(JSON.stringify(toolPart?.output)).toContain("fallback"); + } + + const postCfg = config.loadConfigOrDefault(); + const ws = Array.from(postCfg.projects.values()) + .flatMap((p) => p.workspaces) + .find((w) => w.id === childId); + expect(ws?.taskStatus).toBe("reported"); + + expect(remove).toHaveBeenCalled(); + expect(resumeStream).toHaveBeenCalled(); + }); +}); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts new file mode 100644 index 0000000000..32f7514996 --- /dev/null +++ b/src/node/services/taskService.ts @@ -0,0 +1,1614 @@ +import assert from "node:assert/strict"; +import * as fsPromises from "fs/promises"; + +import { AsyncMutex } from "@/node/utils/concurrency/asyncMutex"; +import type { Config, Workspace as WorkspaceConfigEntry } from "@/node/config"; +import type { AIService } from "@/node/services/aiService"; +import type { WorkspaceService } from "@/node/services/workspaceService"; +import type { HistoryService } from "@/node/services/historyService"; +import type { PartialService } from "@/node/services/partialService"; +import type { InitStateManager } from "@/node/services/initStateManager"; +import { log } from "@/node/services/log"; +import { createRuntime } from "@/node/runtime/runtimeFactory"; +import type { WorkspaceCreationResult } from "@/node/runtime/Runtime"; +import { validateWorkspaceName } from "@/common/utils/validation/workspaceValidation"; +import { Ok, Err, type Result } from "@/common/types/result"; +import type { TaskSettings } from "@/common/types/tasks"; +import { DEFAULT_TASK_SETTINGS } from "@/common/types/tasks"; +import { createMuxMessage, type MuxMessage } from "@/common/types/message"; +import { defaultModel, normalizeGatewayModel } from "@/common/utils/ai/models"; +import type { RuntimeConfig } from "@/common/types/runtime"; +import type { ThinkingLevel } from "@/common/types/thinking"; +import type { ToolCallEndEvent, StreamEndEvent } from "@/common/types/stream"; +import { isDynamicToolPart, type DynamicToolPart } from "@/common/types/toolParts"; +import { + AgentReportToolArgsSchema, + TaskToolResultSchema, + TaskToolArgsSchema, +} from "@/common/utils/tools/toolDefinitions"; +import { formatSendMessageError } from "@/node/services/utils/sendMessageError"; +import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy"; + +export type TaskKind = "agent"; + +export type AgentTaskStatus = NonNullable; + +export interface TaskCreateArgs { + parentWorkspaceId: string; + kind: TaskKind; + agentType: string; + prompt: string; + description?: string; + modelString?: string; + thinkingLevel?: ThinkingLevel; +} + +export interface TaskCreateResult { + taskId: string; + kind: TaskKind; + status: "queued" | "running"; +} + +export interface TerminateAgentTaskResult { + /** Task IDs terminated (includes descendants). */ + terminatedTaskIds: string[]; +} + +export interface DescendantAgentTaskInfo { + taskId: string; + status: AgentTaskStatus; + parentWorkspaceId: string; + agentType?: string; + workspaceName?: string; + title?: string; + createdAt?: string; + modelString?: string; + thinkingLevel?: ThinkingLevel; + depth: number; +} + +interface PendingTaskWaiter { + createdAt: number; + resolve: (report: { reportMarkdown: string; title?: string }) => void; + reject: (error: Error) => void; + cleanup: () => void; +} + +interface PendingTaskStartWaiter { + createdAt: number; + start: () => void; + cleanup: () => void; +} + +function isToolCallEndEvent(value: unknown): value is ToolCallEndEvent { + return ( + typeof value === "object" && + value !== null && + "type" in value && + (value as { type: unknown }).type === "tool-call-end" && + "workspaceId" in value && + typeof (value as { workspaceId: unknown }).workspaceId === "string" + ); +} + +function isStreamEndEvent(value: unknown): value is StreamEndEvent { + return ( + typeof value === "object" && + value !== null && + "type" in value && + (value as { type: unknown }).type === "stream-end" && + "workspaceId" in value && + typeof (value as { workspaceId: unknown }).workspaceId === "string" + ); +} + +function coerceNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function isSuccessfulToolResult(value: unknown): boolean { + return ( + typeof value === "object" && + value !== null && + "success" in value && + (value as { success?: unknown }).success === true + ); +} + +function sanitizeAgentTypeForName(agentType: string): string { + const normalized = agentType + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]/g, "_") + .replace(/_+/g, "_") + .replace(/-+/g, "-") + .replace(/^[_-]+|[_-]+$/g, ""); + + return normalized.length > 0 ? normalized : "agent"; +} + +function buildAgentWorkspaceName(agentType: string, workspaceId: string): string { + const safeType = sanitizeAgentTypeForName(agentType); + const base = `agent_${safeType}_${workspaceId}`; + // Hard cap to validation limit (64). Ensure stable suffix is preserved. + if (base.length <= 64) return base; + + const suffix = `_${workspaceId}`; + const maxPrefixLen = 64 - suffix.length; + const prefix = `agent_${safeType}`.slice(0, Math.max(0, maxPrefixLen)); + const name = `${prefix}${suffix}`; + return name.length <= 64 ? name : `agent_${workspaceId}`.slice(0, 64); +} + +function getIsoNow(): string { + return new Date().toISOString(); +} + +export class TaskService { + private readonly mutex = new AsyncMutex(); + private readonly pendingWaitersByTaskId = new Map(); + private readonly pendingStartWaitersByTaskId = new Map(); + private readonly completedReportsByTaskId = new Map< + string, + { reportMarkdown: string; title?: string } + >(); + private readonly remindedAwaitingReport = new Set(); + + constructor( + private readonly config: Config, + private readonly historyService: HistoryService, + private readonly partialService: PartialService, + private readonly aiService: AIService, + private readonly workspaceService: WorkspaceService, + private readonly initStateManager: InitStateManager + ) { + this.aiService.on("tool-call-end", (payload: unknown) => { + if (!isToolCallEndEvent(payload)) return; + if (payload.toolName !== "agent_report") return; + void this.handleAgentReport(payload).catch((error: unknown) => { + log.error("TaskService.handleAgentReport failed", { error }); + }); + }); + + this.aiService.on("stream-end", (payload: unknown) => { + if (!isStreamEndEvent(payload)) return; + void this.handleStreamEnd(payload).catch((error: unknown) => { + log.error("TaskService.handleStreamEnd failed", { error }); + }); + }); + } + + async initialize(): Promise { + await this.maybeStartQueuedTasks(); + + const config = this.config.loadConfigOrDefault(); + const awaitingReportTasks = this.listAgentTaskWorkspaces(config).filter( + (t) => t.taskStatus === "awaiting_report" + ); + const runningTasks = this.listAgentTaskWorkspaces(config).filter( + (t) => t.taskStatus === "running" + ); + + for (const task of awaitingReportTasks) { + if (!task.id) continue; + + // Avoid resuming a task while it still has active descendants (it shouldn't report yet). + const hasActiveDescendants = this.hasActiveDescendantAgentTasks(config, task.id); + if (hasActiveDescendants) { + continue; + } + + // Restart-safety: if this task stream ends again without agent_report, fall back immediately. + this.remindedAwaitingReport.add(task.id); + + const model = task.taskModelString ?? defaultModel; + const resumeResult = await this.workspaceService.resumeStream(task.id, { + model, + thinkingLevel: task.taskThinkingLevel, + toolPolicy: [{ regex_match: "^agent_report$", action: "require" }], + additionalSystemInstructions: + "This task is awaiting its final agent_report. Call agent_report exactly once now.", + }); + if (!resumeResult.success) { + log.error("Failed to resume awaiting_report task on startup", { + taskId: task.id, + error: resumeResult.error, + }); + + await this.fallbackReportMissingAgentReport({ + projectPath: task.projectPath, + workspace: task, + }); + } + } + + for (const task of runningTasks) { + if (!task.id) continue; + // Best-effort: if mux restarted mid-stream, nudge the agent to continue and report. + // Only do this when the task has no running descendants, to avoid duplicate spawns. + const hasActiveDescendants = this.hasActiveDescendantAgentTasks(config, task.id); + if (hasActiveDescendants) { + continue; + } + + const model = task.taskModelString ?? defaultModel; + await this.workspaceService.sendMessage( + task.id, + "Mux restarted while this task was running. Continue where you left off. " + + "When you have a final answer, call agent_report exactly once.", + { model, thinkingLevel: task.taskThinkingLevel } + ); + } + } + + async create(args: TaskCreateArgs): Promise> { + const parentWorkspaceId = coerceNonEmptyString(args.parentWorkspaceId); + if (!parentWorkspaceId) { + return Err("Task.create: parentWorkspaceId is required"); + } + if (args.kind !== "agent") { + return Err("Task.create: unsupported kind"); + } + + const prompt = coerceNonEmptyString(args.prompt); + if (!prompt) { + return Err("Task.create: prompt is required"); + } + + const agentType = coerceNonEmptyString(args.agentType); + if (!agentType) { + return Err("Task.create: agentType is required"); + } + + await using _lock = await this.mutex.acquire(); + + // Validate parent exists and fetch runtime context. + const parentMetaResult = await this.aiService.getWorkspaceMetadata(parentWorkspaceId); + if (!parentMetaResult.success) { + return Err(`Task.create: parent workspace not found (${parentMetaResult.error})`); + } + const parentMeta = parentMetaResult.data; + + // Enforce nesting depth. + const cfg = this.config.loadConfigOrDefault(); + const taskSettings = cfg.taskSettings ?? DEFAULT_TASK_SETTINGS; + + const parentEntry = this.findWorkspaceEntry(cfg, parentWorkspaceId); + if (parentEntry?.workspace.taskStatus === "reported") { + return Err("Task.create: cannot spawn new tasks after agent_report"); + } + + const requestedDepth = this.getTaskDepth(cfg, parentWorkspaceId) + 1; + if (requestedDepth > taskSettings.maxTaskNestingDepth) { + return Err( + `Task.create: maxTaskNestingDepth exceeded (requestedDepth=${requestedDepth}, max=${taskSettings.maxTaskNestingDepth})` + ); + } + + // Enforce parallelism (global). + const activeCount = this.countActiveAgentTasks(cfg); + const shouldQueue = activeCount >= taskSettings.maxParallelAgentTasks; + + const taskId = this.config.generateStableId(); + const workspaceName = buildAgentWorkspaceName(agentType, taskId); + + const nameValidation = validateWorkspaceName(workspaceName); + if (!nameValidation.valid) { + return Err( + `Task.create: generated workspace name invalid (${nameValidation.error ?? "unknown error"})` + ); + } + + const inheritedModelString = + typeof args.modelString === "string" && args.modelString.trim().length > 0 + ? args.modelString.trim() + : (parentMeta.aiSettings?.model ?? defaultModel); + const inheritedThinkingLevel: ThinkingLevel = + args.thinkingLevel ?? parentMeta.aiSettings?.thinkingLevel ?? "off"; + + const normalizedAgentType = agentType.trim().toLowerCase(); + const subagentDefaults = cfg.subagentAiDefaults?.[normalizedAgentType]; + + const taskModelString = subagentDefaults?.modelString ?? inheritedModelString; + const canonicalModel = normalizeGatewayModel(taskModelString).trim(); + + const requestedThinkingLevel = subagentDefaults?.thinkingLevel ?? inheritedThinkingLevel; + const effectiveThinkingLevel = enforceThinkingPolicy(canonicalModel, requestedThinkingLevel); + + const parentRuntimeConfig = parentMeta.runtimeConfig; + const taskRuntimeConfig: RuntimeConfig = parentRuntimeConfig; + + const runtime = createRuntime(taskRuntimeConfig, { + projectPath: parentMeta.projectPath, + }); + + // Init status streaming (mirrors WorkspaceService.create) + this.initStateManager.startInit(taskId, parentMeta.projectPath); + const initLogger = { + logStep: (message: string) => this.initStateManager.appendOutput(taskId, message, false), + logStdout: (line: string) => this.initStateManager.appendOutput(taskId, line, false), + logStderr: (line: string) => this.initStateManager.appendOutput(taskId, line, true), + logComplete: (exitCode: number) => void this.initStateManager.endInit(taskId, exitCode), + }; + + const createdAt = getIsoNow(); + + // Note: Local project-dir runtimes share the same directory (unsafe by design). + // For worktree/ssh runtimes we attempt a fork first; otherwise fall back to createWorkspace. + const forkResult = await runtime.forkWorkspace({ + projectPath: parentMeta.projectPath, + sourceWorkspaceName: parentMeta.name, + newWorkspaceName: workspaceName, + initLogger, + }); + + const trunkBranch = forkResult.success + ? (forkResult.sourceBranch ?? parentMeta.name) + : parentMeta.name; + const createResult: WorkspaceCreationResult = forkResult.success + ? { success: true as const, workspacePath: forkResult.workspacePath } + : await runtime.createWorkspace({ + projectPath: parentMeta.projectPath, + branchName: workspaceName, + trunkBranch, + directoryName: workspaceName, + initLogger, + }); + + if (!createResult.success || !createResult.workspacePath) { + return Err( + `Task.create: failed to create agent workspace (${createResult.error ?? "unknown error"})` + ); + } + + const workspacePath = createResult.workspacePath; + + // Persist workspace entry before starting work so it's durable across crashes. + await this.config.editConfig((config) => { + let projectConfig = config.projects.get(parentMeta.projectPath); + if (!projectConfig) { + projectConfig = { workspaces: [] }; + config.projects.set(parentMeta.projectPath, projectConfig); + } + + projectConfig.workspaces.push({ + path: workspacePath, + id: taskId, + name: workspaceName, + title: args.description, + createdAt, + runtimeConfig: taskRuntimeConfig, + aiSettings: { model: canonicalModel, thinkingLevel: effectiveThinkingLevel }, + parentWorkspaceId, + agentType, + taskStatus: shouldQueue ? "queued" : "running", + taskModelString, + taskThinkingLevel: effectiveThinkingLevel, + }); + return config; + }); + + // Emit metadata update so the UI sees the workspace immediately. + const allMetadata = await this.config.getAllWorkspaceMetadata(); + const childMeta = allMetadata.find((m) => m.id === taskId) ?? null; + this.workspaceService.emit("metadata", { workspaceId: taskId, metadata: childMeta }); + + // Kick init hook (best-effort, async). + void runtime + .initWorkspace({ + projectPath: parentMeta.projectPath, + branchName: workspaceName, + trunkBranch, + workspacePath, + initLogger, + }) + .catch((error: unknown) => { + const errorMessage = error instanceof Error ? error.message : String(error); + initLogger.logStderr(`Initialization failed: ${errorMessage}`); + initLogger.logComplete(-1); + }); + + if (shouldQueue) { + // Persist the prompt as the first user message so the task can be resumed later. + const messageId = `user-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + const userMessage = createMuxMessage(messageId, "user", prompt, { + timestamp: Date.now(), + }); + + const appendResult = await this.historyService.appendToHistory(taskId, userMessage); + if (!appendResult.success) { + await this.rollbackFailedTaskCreate(runtime, parentMeta.projectPath, workspaceName, taskId); + return Err(`Task.create: failed to persist queued prompt (${appendResult.error})`); + } + + // Schedule queue processing (best-effort). + void this.maybeStartQueuedTasks(); + return Ok({ taskId, kind: "agent", status: "queued" }); + } + + // Start immediately (counts towards parallel limit). + const sendResult = await this.workspaceService.sendMessage(taskId, prompt, { + model: taskModelString, + thinkingLevel: effectiveThinkingLevel, + }); + if (!sendResult.success) { + const message = + typeof sendResult.error === "string" + ? sendResult.error + : formatSendMessageError(sendResult.error).message; + await this.rollbackFailedTaskCreate(runtime, parentMeta.projectPath, workspaceName, taskId); + return Err(message); + } + + return Ok({ taskId, kind: "agent", status: "running" }); + } + + async terminateDescendantAgentTask( + ancestorWorkspaceId: string, + taskId: string + ): Promise> { + assert( + ancestorWorkspaceId.length > 0, + "terminateDescendantAgentTask: ancestorWorkspaceId must be non-empty" + ); + assert(taskId.length > 0, "terminateDescendantAgentTask: taskId must be non-empty"); + + const terminatedTaskIds: string[] = []; + + { + await using _lock = await this.mutex.acquire(); + + const cfg = this.config.loadConfigOrDefault(); + const entry = this.findWorkspaceEntry(cfg, taskId); + if (!entry?.workspace.parentWorkspaceId) { + return Err("Task not found"); + } + + if (!this.isDescendantAgentTask(ancestorWorkspaceId, taskId)) { + return Err("Task is not a descendant of this workspace"); + } + + // Terminate the entire subtree to avoid orphaned descendant tasks. + const descendants = this.listDescendantAgentTaskIds(cfg, taskId); + const toTerminate = Array.from(new Set([taskId, ...descendants])); + + // Delete leaves first to avoid leaving children with missing parents. + const depthById = new Map(); + for (const id of toTerminate) { + depthById.set(id, this.getTaskDepth(cfg, id)); + } + toTerminate.sort((a, b) => (depthById.get(b) ?? 0) - (depthById.get(a) ?? 0)); + + const terminationError = new Error("Task terminated"); + + for (const id of toTerminate) { + // Best-effort: stop any active stream immediately to avoid further token usage. + try { + const stopResult = await this.aiService.stopStream(id, { abandonPartial: true }); + if (!stopResult.success) { + log.debug("terminateDescendantAgentTask: stopStream failed", { taskId: id }); + } + } catch (error: unknown) { + log.debug("terminateDescendantAgentTask: stopStream threw", { taskId: id, error }); + } + + this.remindedAwaitingReport.delete(id); + this.completedReportsByTaskId.delete(id); + this.rejectWaiters(id, terminationError); + + const removeResult = await this.workspaceService.remove(id, true); + if (!removeResult.success) { + return Err(`Failed to remove task workspace (${id}): ${removeResult.error}`); + } + + terminatedTaskIds.push(id); + } + } + + // Free slots and start any queued tasks (best-effort). + await this.maybeStartQueuedTasks(); + + return Ok({ terminatedTaskIds }); + } + + private async rollbackFailedTaskCreate( + runtime: ReturnType, + projectPath: string, + workspaceName: string, + taskId: string + ): Promise { + try { + await this.config.removeWorkspace(taskId); + } catch (error: unknown) { + log.error("Task.create rollback: failed to remove workspace from config", { + taskId, + error: error instanceof Error ? error.message : String(error), + }); + } + + this.workspaceService.emit("metadata", { workspaceId: taskId, metadata: null }); + + try { + const deleteResult = await runtime.deleteWorkspace(projectPath, workspaceName, true); + if (!deleteResult.success) { + log.error("Task.create rollback: failed to delete workspace", { + taskId, + error: deleteResult.error, + }); + } + } catch (error: unknown) { + log.error("Task.create rollback: runtime.deleteWorkspace threw", { + taskId, + error: error instanceof Error ? error.message : String(error), + }); + } + + try { + const sessionDir = this.config.getSessionDir(taskId); + await fsPromises.rm(sessionDir, { recursive: true, force: true }); + } catch (error: unknown) { + log.error("Task.create rollback: failed to remove session directory", { + taskId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + waitForAgentReport( + taskId: string, + options?: { timeoutMs?: number; abortSignal?: AbortSignal } + ): Promise<{ reportMarkdown: string; title?: string }> { + assert(taskId.length > 0, "waitForAgentReport: taskId must be non-empty"); + + const cached = this.completedReportsByTaskId.get(taskId); + if (cached) { + return Promise.resolve(cached); + } + + const timeoutMs = options?.timeoutMs ?? 10 * 60 * 1000; // 10 minutes + assert(Number.isFinite(timeoutMs) && timeoutMs > 0, "waitForAgentReport: timeoutMs invalid"); + + return new Promise<{ reportMarkdown: string; title?: string }>((resolve, reject) => { + // Validate existence early to avoid waiting on never-resolving task IDs. + const cfg = this.config.loadConfigOrDefault(); + const taskWorkspaceEntry = this.findWorkspaceEntry(cfg, taskId); + if (!taskWorkspaceEntry) { + reject(new Error("Task not found")); + return; + } + + let timeout: ReturnType | null = null; + let startWaiter: PendingTaskStartWaiter | null = null; + let abortListener: (() => void) | null = null; + + const startReportTimeout = () => { + if (timeout) return; + timeout = setTimeout(() => { + entry.cleanup(); + reject(new Error("Timed out waiting for agent_report")); + }, timeoutMs); + }; + + const cleanupStartWaiter = () => { + if (!startWaiter) return; + startWaiter.cleanup(); + startWaiter = null; + }; + + const entry: PendingTaskWaiter = { + createdAt: Date.now(), + resolve: (report) => { + entry.cleanup(); + resolve(report); + }, + reject: (error) => { + entry.cleanup(); + reject(error); + }, + cleanup: () => { + const current = this.pendingWaitersByTaskId.get(taskId); + if (current) { + const next = current.filter((w) => w !== entry); + if (next.length === 0) { + this.pendingWaitersByTaskId.delete(taskId); + } else { + this.pendingWaitersByTaskId.set(taskId, next); + } + } + + cleanupStartWaiter(); + + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + + if (abortListener && options?.abortSignal) { + options.abortSignal.removeEventListener("abort", abortListener); + abortListener = null; + } + }, + }; + + const list = this.pendingWaitersByTaskId.get(taskId) ?? []; + list.push(entry); + this.pendingWaitersByTaskId.set(taskId, list); + + // Don't start the execution timeout while the task is still queued. + // The timer starts once the child actually begins running (queued -> running). + const initialStatus = taskWorkspaceEntry.workspace.taskStatus; + if (initialStatus === "queued") { + const startWaiterEntry: PendingTaskStartWaiter = { + createdAt: Date.now(), + start: startReportTimeout, + cleanup: () => { + const currentStartWaiters = this.pendingStartWaitersByTaskId.get(taskId); + if (currentStartWaiters) { + const next = currentStartWaiters.filter((w) => w !== startWaiterEntry); + if (next.length === 0) { + this.pendingStartWaitersByTaskId.delete(taskId); + } else { + this.pendingStartWaitersByTaskId.set(taskId, next); + } + } + }, + }; + startWaiter = startWaiterEntry; + + const currentStartWaiters = this.pendingStartWaitersByTaskId.get(taskId) ?? []; + currentStartWaiters.push(startWaiterEntry); + this.pendingStartWaitersByTaskId.set(taskId, currentStartWaiters); + + // Close the race where the task starts between the initial config read and registering the waiter. + const cfgAfterRegister = this.config.loadConfigOrDefault(); + const afterEntry = this.findWorkspaceEntry(cfgAfterRegister, taskId); + if (afterEntry?.workspace.taskStatus !== "queued") { + cleanupStartWaiter(); + startReportTimeout(); + } + } else { + startReportTimeout(); + } + + if (options?.abortSignal) { + if (options.abortSignal.aborted) { + entry.cleanup(); + reject(new Error("Interrupted")); + return; + } + + abortListener = () => { + entry.cleanup(); + reject(new Error("Interrupted")); + }; + options.abortSignal.addEventListener("abort", abortListener, { once: true }); + } + }); + } + + getAgentTaskStatus(taskId: string): AgentTaskStatus | null { + assert(taskId.length > 0, "getAgentTaskStatus: taskId must be non-empty"); + + const cfg = this.config.loadConfigOrDefault(); + const entry = this.findWorkspaceEntry(cfg, taskId); + const status = entry?.workspace.taskStatus; + return status ?? null; + } + + hasActiveDescendantAgentTasksForWorkspace(workspaceId: string): boolean { + assert( + workspaceId.length > 0, + "hasActiveDescendantAgentTasksForWorkspace: workspaceId must be non-empty" + ); + + const cfg = this.config.loadConfigOrDefault(); + return this.hasActiveDescendantAgentTasks(cfg, workspaceId); + } + + listActiveDescendantAgentTaskIds(workspaceId: string): string[] { + assert( + workspaceId.length > 0, + "listActiveDescendantAgentTaskIds: workspaceId must be non-empty" + ); + + const cfg = this.config.loadConfigOrDefault(); + const childrenByParent = new Map(); + const statusById = new Map(); + + for (const task of this.listAgentTaskWorkspaces(cfg)) { + statusById.set(task.id!, task.taskStatus); + const parent = task.parentWorkspaceId; + if (!parent) continue; + const list = childrenByParent.get(parent) ?? []; + list.push(task.id!); + childrenByParent.set(parent, list); + } + + const activeStatuses = new Set(["queued", "running", "awaiting_report"]); + const result: string[] = []; + const stack: string[] = [...(childrenByParent.get(workspaceId) ?? [])]; + while (stack.length > 0) { + const next = stack.pop()!; + const status = statusById.get(next); + if (status && activeStatuses.has(status)) { + result.push(next); + } + const children = childrenByParent.get(next); + if (children) { + for (const child of children) { + stack.push(child); + } + } + } + return result; + } + + listDescendantAgentTasks( + workspaceId: string, + options?: { statuses?: AgentTaskStatus[] } + ): DescendantAgentTaskInfo[] { + assert(workspaceId.length > 0, "listDescendantAgentTasks: workspaceId must be non-empty"); + + const statuses = options?.statuses; + const statusFilter = statuses && statuses.length > 0 ? new Set(statuses) : null; + + const cfg = this.config.loadConfigOrDefault(); + const tasks = this.listAgentTaskWorkspaces(cfg); + + const childrenByParent = new Map< + string, + Array + >(); + const byId = new Map(); + + for (const task of tasks) { + const taskId = task.id!; + byId.set(taskId, task); + + const parent = task.parentWorkspaceId; + if (!parent) continue; + const list = childrenByParent.get(parent) ?? []; + list.push(task); + childrenByParent.set(parent, list); + } + + const result: DescendantAgentTaskInfo[] = []; + + const stack: Array<{ taskId: string; depth: number }> = []; + for (const child of childrenByParent.get(workspaceId) ?? []) { + stack.push({ taskId: child.id!, depth: 1 }); + } + + while (stack.length > 0) { + const next = stack.pop()!; + const entry = byId.get(next.taskId); + if (!entry) continue; + + assert( + entry.parentWorkspaceId, + `listDescendantAgentTasks: task ${next.taskId} is missing parentWorkspaceId` + ); + + const status: AgentTaskStatus = entry.taskStatus ?? "running"; + if (!statusFilter || statusFilter.has(status)) { + result.push({ + taskId: next.taskId, + status, + parentWorkspaceId: entry.parentWorkspaceId, + agentType: entry.agentType, + workspaceName: entry.name, + title: entry.title, + createdAt: entry.createdAt, + modelString: entry.aiSettings?.model, + thinkingLevel: entry.aiSettings?.thinkingLevel, + depth: next.depth, + }); + } + + for (const child of childrenByParent.get(next.taskId) ?? []) { + stack.push({ taskId: child.id!, depth: next.depth + 1 }); + } + } + + // Stable ordering: oldest first, then depth (ties by taskId for determinism). + result.sort((a, b) => { + const aTime = a.createdAt ? Date.parse(a.createdAt) : 0; + const bTime = b.createdAt ? Date.parse(b.createdAt) : 0; + if (aTime !== bTime) return aTime - bTime; + if (a.depth !== b.depth) return a.depth - b.depth; + return a.taskId.localeCompare(b.taskId); + }); + + return result; + } + + private listDescendantAgentTaskIds( + config: ReturnType, + workspaceId: string + ): string[] { + assert(workspaceId.length > 0, "listDescendantAgentTaskIds: workspaceId must be non-empty"); + + const childrenByParent = new Map(); + + for (const task of this.listAgentTaskWorkspaces(config)) { + const parent = task.parentWorkspaceId; + if (!parent) continue; + const list = childrenByParent.get(parent) ?? []; + list.push(task.id!); + childrenByParent.set(parent, list); + } + + const result: string[] = []; + const stack: string[] = [...(childrenByParent.get(workspaceId) ?? [])]; + while (stack.length > 0) { + const next = stack.pop()!; + result.push(next); + const children = childrenByParent.get(next); + if (children) { + for (const child of children) { + stack.push(child); + } + } + } + return result; + } + + isDescendantAgentTask(ancestorWorkspaceId: string, taskId: string): boolean { + assert(ancestorWorkspaceId.length > 0, "isDescendantAgentTask: ancestorWorkspaceId required"); + assert(taskId.length > 0, "isDescendantAgentTask: taskId required"); + + const cfg = this.config.loadConfigOrDefault(); + const parentById = new Map(); + for (const task of this.listAgentTaskWorkspaces(cfg)) { + parentById.set(task.id!, task.parentWorkspaceId); + } + + let current = taskId; + for (let i = 0; i < 32; i++) { + const parent = parentById.get(current); + if (!parent) return false; + if (parent === ancestorWorkspaceId) return true; + current = parent; + } + + throw new Error( + `isDescendantAgentTask: possible parentWorkspaceId cycle starting at ${taskId}` + ); + } + + // --- Internal orchestration --- + + private listAgentTaskWorkspaces( + config: ReturnType + ): Array { + const tasks: Array = []; + for (const [projectPath, project] of config.projects) { + for (const workspace of project.workspaces) { + if (!workspace.id) continue; + if (!workspace.parentWorkspaceId) continue; + tasks.push({ ...workspace, projectPath }); + } + } + return tasks; + } + + private countActiveAgentTasks(config: ReturnType): number { + let activeCount = 0; + for (const task of this.listAgentTaskWorkspaces(config)) { + const status: AgentTaskStatus = task.taskStatus ?? "running"; + if (status === "running" || status === "awaiting_report") { + activeCount += 1; + continue; + } + + // Defensive: a task may still be streaming even after it transitioned to another status + // (e.g. tool-call-end happened but the stream hasn't ended yet). Count it as active so we + // never exceed the configured parallel limit. + if (task.id && this.aiService.isStreaming(task.id)) { + activeCount += 1; + } + } + + return activeCount; + } + + private hasActiveDescendantAgentTasks( + config: ReturnType, + workspaceId: string + ): boolean { + assert(workspaceId.length > 0, "hasActiveDescendantAgentTasks: workspaceId must be non-empty"); + + const childrenByParent = new Map(); + const statusById = new Map(); + + for (const task of this.listAgentTaskWorkspaces(config)) { + statusById.set(task.id!, task.taskStatus); + const parent = task.parentWorkspaceId; + if (!parent) continue; + const list = childrenByParent.get(parent) ?? []; + list.push(task.id!); + childrenByParent.set(parent, list); + } + + const activeStatuses = new Set(["queued", "running", "awaiting_report"]); + const stack: string[] = [...(childrenByParent.get(workspaceId) ?? [])]; + while (stack.length > 0) { + const next = stack.pop()!; + const status = statusById.get(next); + if (status && activeStatuses.has(status)) { + return true; + } + const children = childrenByParent.get(next); + if (children) { + for (const child of children) { + stack.push(child); + } + } + } + + return false; + } + + private getTaskDepth( + config: ReturnType, + workspaceId: string + ): number { + assert(workspaceId.length > 0, "getTaskDepth: workspaceId must be non-empty"); + + const parentById = new Map(); + for (const task of this.listAgentTaskWorkspaces(config)) { + parentById.set(task.id!, task.parentWorkspaceId); + } + + let depth = 0; + let current = workspaceId; + for (let i = 0; i < 32; i++) { + const parent = parentById.get(current); + if (!parent) break; + depth += 1; + current = parent; + } + + if (depth >= 32) { + throw new Error(`getTaskDepth: possible parentWorkspaceId cycle starting at ${workspaceId}`); + } + + return depth; + } + + private async maybeStartQueuedTasks(): Promise { + await using _lock = await this.mutex.acquire(); + + const config = this.config.loadConfigOrDefault(); + const taskSettings: TaskSettings = config.taskSettings ?? DEFAULT_TASK_SETTINGS; + + const activeCount = this.countActiveAgentTasks(config); + const availableSlots = Math.max(0, taskSettings.maxParallelAgentTasks - activeCount); + if (availableSlots === 0) return; + + const queued = this.listAgentTaskWorkspaces(config) + .filter((t) => t.taskStatus === "queued") + .sort((a, b) => { + const aTime = a.createdAt ? Date.parse(a.createdAt) : 0; + const bTime = b.createdAt ? Date.parse(b.createdAt) : 0; + return aTime - bTime; + }) + .slice(0, availableSlots); + + for (const task of queued) { + if (!task.id) continue; + + // Start by resuming from the queued prompt in history. + const model = task.taskModelString ?? defaultModel; + const resumeResult = await this.workspaceService.resumeStream(task.id, { + model, + thinkingLevel: task.taskThinkingLevel, + }); + + if (!resumeResult.success) { + log.error("Failed to start queued task", { taskId: task.id, error: resumeResult.error }); + continue; + } + + await this.setTaskStatus(task.id, "running"); + } + } + + private async setTaskStatus(workspaceId: string, status: AgentTaskStatus): Promise { + assert(workspaceId.length > 0, "setTaskStatus: workspaceId must be non-empty"); + + await this.config.editConfig((config) => { + for (const [_projectPath, project] of config.projects) { + const ws = project.workspaces.find((w) => w.id === workspaceId); + if (ws) { + ws.taskStatus = status; + return config; + } + } + throw new Error(`setTaskStatus: workspace ${workspaceId} not found`); + }); + + const allMetadata = await this.config.getAllWorkspaceMetadata(); + const metadata = allMetadata.find((m) => m.id === workspaceId) ?? null; + this.workspaceService.emit("metadata", { workspaceId, metadata }); + + if (status === "running") { + const waiters = this.pendingStartWaitersByTaskId.get(workspaceId); + if (!waiters || waiters.length === 0) return; + this.pendingStartWaitersByTaskId.delete(workspaceId); + for (const waiter of waiters) { + try { + waiter.start(); + } catch (error: unknown) { + log.error("Task start waiter callback failed", { workspaceId, error }); + } + } + } + } + + private async handleStreamEnd(event: StreamEndEvent): Promise { + const workspaceId = event.workspaceId; + + const cfg = this.config.loadConfigOrDefault(); + const entry = this.findWorkspaceEntry(cfg, workspaceId); + if (!entry) return; + + // Parent workspaces must not end while they have active background tasks. + // Enforce by auto-resuming the stream with a directive to await outstanding tasks. + if (!entry.workspace.parentWorkspaceId) { + const hasActiveDescendants = this.hasActiveDescendantAgentTasks(cfg, workspaceId); + if (!hasActiveDescendants) { + return; + } + + if (this.aiService.isStreaming(workspaceId)) { + return; + } + + const activeTaskIds = this.listActiveDescendantAgentTaskIds(workspaceId); + const model = entry.workspace.aiSettings?.model ?? defaultModel; + + const resumeResult = await this.workspaceService.resumeStream(workspaceId, { + model, + thinkingLevel: entry.workspace.aiSettings?.thinkingLevel, + additionalSystemInstructions: + `You have active background sub-agent task(s) (${activeTaskIds.join(", ")}). ` + + "You MUST NOT end your turn while any sub-agent tasks are queued/running/awaiting_report. " + + "Call task_await now to wait for them to finish (omit timeout_secs to wait up to 10 minutes). " + + "If any tasks are still queued/running/awaiting_report after that, call task_await again. " + + "Only once all tasks are completed should you write your final response, integrating their reports.", + }); + if (!resumeResult.success) { + log.error("Failed to resume parent with active background tasks", { + workspaceId, + error: resumeResult.error, + }); + } + return; + } + + const status = entry.workspace.taskStatus; + if (status === "reported") return; + + // Never allow a task to finish/report while it still has active descendant tasks. + // We'll auto-resume this task once the last descendant reports. + const hasActiveDescendants = this.hasActiveDescendantAgentTasks(cfg, workspaceId); + if (hasActiveDescendants) { + if (status === "awaiting_report") { + await this.setTaskStatus(workspaceId, "running"); + } + return; + } + + // If a task stream ends without agent_report, request it once. + if (status === "awaiting_report" && this.remindedAwaitingReport.has(workspaceId)) { + await this.fallbackReportMissingAgentReport(entry); + return; + } + + await this.setTaskStatus(workspaceId, "awaiting_report"); + + this.remindedAwaitingReport.add(workspaceId); + + const model = entry.workspace.taskModelString ?? defaultModel; + await this.workspaceService.sendMessage( + workspaceId, + "Your stream ended without calling agent_report. Call agent_report exactly once now with your final report.", + { + model, + thinkingLevel: entry.workspace.taskThinkingLevel, + toolPolicy: [{ regex_match: "^agent_report$", action: "require" }], + } + ); + } + + private async fallbackReportMissingAgentReport(entry: { + projectPath: string; + workspace: WorkspaceConfigEntry; + }): Promise { + const childWorkspaceId = entry.workspace.id; + const parentWorkspaceId = entry.workspace.parentWorkspaceId; + if (!childWorkspaceId || !parentWorkspaceId) { + return; + } + + const agentType = entry.workspace.agentType ?? "agent"; + const lastText = await this.readLatestAssistantText(childWorkspaceId); + + const reportMarkdown = + "*(Note: this agent task did not call `agent_report`; " + + "posting its last assistant output as a fallback.)*\n\n" + + (lastText?.trim().length ? lastText : "(No assistant output found.)"); + + await this.config.editConfig((config) => { + for (const [_projectPath, project] of config.projects) { + const ws = project.workspaces.find((w) => w.id === childWorkspaceId); + if (ws) { + ws.taskStatus = "reported"; + ws.reportedAt = getIsoNow(); + return config; + } + } + return config; + }); + + // Notify clients immediately even if we can't delete the workspace yet. + const updatedMetadata = (await this.config.getAllWorkspaceMetadata()).find( + (m) => m.id === childWorkspaceId + ); + this.workspaceService.emit("metadata", { + workspaceId: childWorkspaceId, + metadata: updatedMetadata ?? null, + }); + + await this.deliverReportToParent(parentWorkspaceId, entry, { + reportMarkdown, + title: `Subagent (${agentType}) report (fallback)`, + }); + + this.resolveWaiters(childWorkspaceId, { + reportMarkdown, + title: `Subagent (${agentType}) report (fallback)`, + }); + + await this.maybeStartQueuedTasks(); + await this.cleanupReportedLeafTask(childWorkspaceId); + + const postCfg = this.config.loadConfigOrDefault(); + const hasActiveDescendants = this.hasActiveDescendantAgentTasks(postCfg, parentWorkspaceId); + if (!hasActiveDescendants && !this.aiService.isStreaming(parentWorkspaceId)) { + const resumeResult = await this.workspaceService.resumeStream(parentWorkspaceId, { + model: entry.workspace.taskModelString ?? defaultModel, + }); + if (!resumeResult.success) { + log.error("Failed to auto-resume parent after fallback report", { + parentWorkspaceId, + error: resumeResult.error, + }); + } + } + } + + private async readLatestAssistantText(workspaceId: string): Promise { + const partial = await this.partialService.readPartial(workspaceId); + if (partial && partial.role === "assistant") { + const text = this.concatTextParts(partial).trim(); + if (text.length > 0) return text; + } + + const historyResult = await this.historyService.getHistory(workspaceId); + if (!historyResult.success) { + log.error("Failed to read history for fallback report", { + workspaceId, + error: historyResult.error, + }); + return null; + } + + const ordered = [...historyResult.data].sort((a, b) => { + const aSeq = a.metadata?.historySequence ?? -1; + const bSeq = b.metadata?.historySequence ?? -1; + return aSeq - bSeq; + }); + + for (let i = ordered.length - 1; i >= 0; i--) { + const msg = ordered[i]; + if (msg?.role !== "assistant") continue; + const text = this.concatTextParts(msg).trim(); + if (text.length > 0) return text; + } + + return null; + } + + private concatTextParts(msg: MuxMessage): string { + let combined = ""; + for (const part of msg.parts) { + if (!part || typeof part !== "object") continue; + const maybeText = part as { type?: unknown; text?: unknown }; + if (maybeText.type !== "text") continue; + if (typeof maybeText.text !== "string") continue; + combined += maybeText.text; + } + return combined; + } + + private async handleAgentReport(event: ToolCallEndEvent): Promise { + const childWorkspaceId = event.workspaceId; + + if (!isSuccessfulToolResult(event.result)) { + return; + } + + const cfgBeforeReport = this.config.loadConfigOrDefault(); + if (this.hasActiveDescendantAgentTasks(cfgBeforeReport, childWorkspaceId)) { + log.error("agent_report called while task has active descendants; ignoring", { + childWorkspaceId, + }); + return; + } + + // Read report payload from the tool-call input (persisted in partial/history). + const reportArgs = await this.readLatestAgentReportArgs(childWorkspaceId); + if (!reportArgs) { + log.error("agent_report tool-call args not found", { childWorkspaceId }); + return; + } + + await this.config.editConfig((config) => { + for (const [_projectPath, project] of config.projects) { + const ws = project.workspaces.find((w) => w.id === childWorkspaceId); + if (ws) { + ws.taskStatus = "reported"; + ws.reportedAt = getIsoNow(); + return config; + } + } + return config; + }); + + // Notify clients immediately even if we can't delete the workspace yet. + const updatedMetadata = (await this.config.getAllWorkspaceMetadata()).find( + (m) => m.id === childWorkspaceId + ); + this.workspaceService.emit("metadata", { + workspaceId: childWorkspaceId, + metadata: updatedMetadata ?? null, + }); + + // `agent_report` is terminal. Stop the child stream immediately to prevent any further token + // usage and to ensure parallelism accounting never "frees" a slot while the stream is still + // active (Claude/Anthropic can emit tool calls before the final assistant block completes). + try { + const stopResult = await this.aiService.stopStream(childWorkspaceId, { + abandonPartial: true, + }); + if (!stopResult.success) { + log.debug("Failed to stop task stream after agent_report", { + workspaceId: childWorkspaceId, + error: stopResult.error, + }); + } + } catch (error: unknown) { + log.debug("Failed to stop task stream after agent_report (threw)", { + workspaceId: childWorkspaceId, + error, + }); + } + + const cfgAfterReport = this.config.loadConfigOrDefault(); + const childEntry = this.findWorkspaceEntry(cfgAfterReport, childWorkspaceId); + const parentWorkspaceId = childEntry?.workspace.parentWorkspaceId; + if (!parentWorkspaceId) { + log.error("agent_report called from non-task workspace", { childWorkspaceId }); + return; + } + + await this.deliverReportToParent(parentWorkspaceId, childEntry, reportArgs); + + // Resolve foreground waiters. + this.resolveWaiters(childWorkspaceId, reportArgs); + + // Free slot and start queued tasks. + await this.maybeStartQueuedTasks(); + + // Attempt cleanup of reported tasks (leaf-first). + await this.cleanupReportedLeafTask(childWorkspaceId); + + // Auto-resume any parent stream that was waiting on a task tool call (restart-safe). + const postCfg = this.config.loadConfigOrDefault(); + const hasActiveDescendants = this.hasActiveDescendantAgentTasks(postCfg, parentWorkspaceId); + if (!hasActiveDescendants && !this.aiService.isStreaming(parentWorkspaceId)) { + const resumeResult = await this.workspaceService.resumeStream(parentWorkspaceId, { + model: childEntry?.workspace.taskModelString ?? defaultModel, + }); + if (!resumeResult.success) { + log.error("Failed to auto-resume parent after agent_report", { + parentWorkspaceId, + error: resumeResult.error, + }); + } + } + } + + private resolveWaiters(taskId: string, report: { reportMarkdown: string; title?: string }): void { + this.completedReportsByTaskId.set(taskId, report); + + const waiters = this.pendingWaitersByTaskId.get(taskId); + if (!waiters || waiters.length === 0) { + return; + } + + this.pendingWaitersByTaskId.delete(taskId); + for (const waiter of waiters) { + try { + waiter.cleanup(); + waiter.resolve(report); + } catch { + // ignore + } + } + } + + private rejectWaiters(taskId: string, error: Error): void { + const waiters = this.pendingWaitersByTaskId.get(taskId); + if (!waiters || waiters.length === 0) { + return; + } + + for (const waiter of [...waiters]) { + try { + waiter.reject(error); + } catch (rejectError: unknown) { + log.error("Task waiter reject callback failed", { taskId, error: rejectError }); + } + } + } + + private async readLatestAgentReportArgs( + workspaceId: string + ): Promise<{ reportMarkdown: string; title?: string } | null> { + const partial = await this.partialService.readPartial(workspaceId); + if (partial) { + const args = this.findAgentReportArgsInMessage(partial); + if (args) return args; + } + + const historyResult = await this.historyService.getHistory(workspaceId); + if (!historyResult.success) { + log.error("Failed to read history for agent_report args", { + workspaceId, + error: historyResult.error, + }); + return null; + } + + // Scan newest-first. + const ordered = [...historyResult.data].sort((a, b) => { + const aSeq = a.metadata?.historySequence ?? -1; + const bSeq = b.metadata?.historySequence ?? -1; + return bSeq - aSeq; + }); + + for (const msg of ordered) { + const args = this.findAgentReportArgsInMessage(msg); + if (args) return args; + } + + return null; + } + + private findAgentReportArgsInMessage( + msg: MuxMessage + ): { reportMarkdown: string; title?: string } | null { + for (let i = msg.parts.length - 1; i >= 0; i--) { + const part = msg.parts[i]; + if (!isDynamicToolPart(part)) continue; + if (part.toolName !== "agent_report") continue; + if (part.state !== "output-available") continue; + if (!isSuccessfulToolResult(part.output)) continue; + const parsed = AgentReportToolArgsSchema.safeParse(part.input); + if (!parsed.success) continue; + return parsed.data; + } + return null; + } + + private async deliverReportToParent( + parentWorkspaceId: string, + childEntry: { projectPath: string; workspace: WorkspaceConfigEntry } | null | undefined, + report: { reportMarkdown: string; title?: string } + ): Promise { + const agentType = childEntry?.workspace.agentType ?? "agent"; + const childWorkspaceId = childEntry?.workspace.id; + + const output = { + status: "completed" as const, + ...(childWorkspaceId ? { taskId: childWorkspaceId } : {}), + reportMarkdown: report.reportMarkdown, + title: report.title, + agentType, + }; + const parsedOutput = TaskToolResultSchema.safeParse(output); + if (!parsedOutput.success) { + log.error("Task tool output schema validation failed", { error: parsedOutput.error.message }); + return; + } + + // If someone is actively awaiting this report (foreground task tool call or task_await), + // skip injecting a synthetic history message to avoid duplicating the report in context. + if (childWorkspaceId) { + const waiters = this.pendingWaitersByTaskId.get(childWorkspaceId); + if (waiters && waiters.length > 0) { + return; + } + } + + // Restart-safe: if the parent has a pending task tool call in partial.json (interrupted stream), + // finalize it with the report. Avoid rewriting persisted history to keep earlier messages immutable. + if (!this.aiService.isStreaming(parentWorkspaceId)) { + const finalizedPending = await this.tryFinalizePendingTaskToolCallInPartial( + parentWorkspaceId, + parsedOutput.data + ); + if (finalizedPending) { + return; + } + } + + // Background tasks: append a synthetic user message containing the report so earlier history + // remains immutable (append-only) and prompt caches can still reuse the prefix. + const titlePrefix = report.title ?? `Subagent (${agentType}) report`; + const xml = [ + "", + `${childWorkspaceId ?? ""}`, + `${agentType}`, + `${titlePrefix}`, + "", + report.reportMarkdown, + "", + "", + ].join("\n"); + + const messageId = `task-report-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + const reportMessage = createMuxMessage(messageId, "user", xml, { + timestamp: Date.now(), + synthetic: true, + }); + + const appendResult = await this.historyService.appendToHistory( + parentWorkspaceId, + reportMessage + ); + if (!appendResult.success) { + log.error("Failed to append synthetic subagent report to parent history", { + parentWorkspaceId, + error: appendResult.error, + }); + } + } + + private async tryFinalizePendingTaskToolCallInPartial( + workspaceId: string, + output: unknown + ): Promise { + const parsedOutput = TaskToolResultSchema.safeParse(output); + if (!parsedOutput.success || parsedOutput.data.status !== "completed") { + log.error("tryFinalizePendingTaskToolCallInPartial: invalid output", { + error: parsedOutput.success ? "status is not 'completed'" : parsedOutput.error.message, + }); + return false; + } + + const partial = await this.partialService.readPartial(workspaceId); + if (!partial) { + return false; + } + + type PendingTaskToolPart = DynamicToolPart & { toolName: "task"; state: "input-available" }; + const pendingParts = partial.parts.filter( + (p): p is PendingTaskToolPart => + isDynamicToolPart(p) && p.toolName === "task" && p.state === "input-available" + ); + + if (pendingParts.length === 0) { + return false; + } + if (pendingParts.length > 1) { + log.error("tryFinalizePendingTaskToolCallInPartial: multiple pending task tool calls", { + workspaceId, + }); + return false; + } + + const toolCallId = pendingParts[0].toolCallId; + const parsedInput = TaskToolArgsSchema.safeParse(pendingParts[0].input); + if (!parsedInput.success) { + log.error("tryFinalizePendingTaskToolCallInPartial: task input validation failed", { + workspaceId, + error: parsedInput.error.message, + }); + return false; + } + + const updated: MuxMessage = { + ...partial, + parts: partial.parts.map((part) => { + if (!isDynamicToolPart(part)) return part; + if (part.toolCallId !== toolCallId) return part; + if (part.toolName !== "task") return part; + if (part.state === "output-available") return part; + return { ...part, state: "output-available" as const, output: parsedOutput.data }; + }), + }; + + const writeResult = await this.partialService.writePartial(workspaceId, updated); + if (!writeResult.success) { + log.error("Failed to write finalized task tool output to partial", { + workspaceId, + error: writeResult.error, + }); + return false; + } + + this.workspaceService.emit("chat", { + workspaceId, + message: { + type: "tool-call-end", + workspaceId, + messageId: updated.id, + toolCallId, + toolName: "task", + result: parsedOutput.data, + timestamp: Date.now(), + }, + }); + + return true; + } + + private async cleanupReportedLeafTask(workspaceId: string): Promise { + const config = this.config.loadConfigOrDefault(); + const entry = this.findWorkspaceEntry(config, workspaceId); + if (!entry) return; + + const ws = entry.workspace; + if (!ws.parentWorkspaceId) return; + if (ws.taskStatus !== "reported") return; + + const hasChildren = this.listAgentTaskWorkspaces(config).some( + (t) => t.parentWorkspaceId === workspaceId + ); + if (hasChildren) { + return; + } + + const removeResult = await this.workspaceService.remove(workspaceId, true); + if (!removeResult.success) { + log.error("Failed to auto-delete reported task workspace", { + workspaceId, + error: removeResult.error, + }); + return; + } + + // Recursively attempt cleanup on parent if it's also a reported agent task. + await this.cleanupReportedLeafTask(ws.parentWorkspaceId); + } + + private findWorkspaceEntry( + config: ReturnType, + workspaceId: string + ): { projectPath: string; workspace: WorkspaceConfigEntry } | null { + for (const [projectPath, project] of config.projects) { + for (const workspace of project.workspaces) { + if (workspace.id === workspaceId) { + return { projectPath, workspace }; + } + } + } + return null; + } +} diff --git a/src/node/services/tools/agent_report.test.ts b/src/node/services/tools/agent_report.test.ts new file mode 100644 index 0000000000..c861214e41 --- /dev/null +++ b/src/node/services/tools/agent_report.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, mock } from "bun:test"; +import type { ToolCallOptions } from "ai"; + +import { createAgentReportTool } from "./agent_report"; +import { TestTempDir, createTestToolConfig } from "./testHelpers"; +import type { TaskService } from "@/node/services/taskService"; + +const mockToolCallOptions: ToolCallOptions = { + toolCallId: "test-call-id", + messages: [], +}; + +describe("agent_report tool", () => { + it("throws when the task has active descendants", async () => { + using tempDir = new TestTempDir("test-agent-report-tool"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "task-workspace" }); + + const taskService = { + hasActiveDescendantAgentTasksForWorkspace: mock(() => true), + } as unknown as TaskService; + + const tool = createAgentReportTool({ ...baseConfig, taskService }); + + let caught: unknown = null; + try { + await Promise.resolve( + tool.execute!({ reportMarkdown: "done", title: "t" }, mockToolCallOptions) + ); + } catch (error: unknown) { + caught = error; + } + + expect(caught).toBeInstanceOf(Error); + if (caught instanceof Error) { + expect(caught.message).toMatch(/still has running\/queued/i); + } + }); + + it("returns success when the task has no active descendants", async () => { + using tempDir = new TestTempDir("test-agent-report-tool-ok"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "task-workspace" }); + + const taskService = { + hasActiveDescendantAgentTasksForWorkspace: mock(() => false), + } as unknown as TaskService; + + const tool = createAgentReportTool({ ...baseConfig, taskService }); + + const result: unknown = await Promise.resolve( + tool.execute!({ reportMarkdown: "done", title: "t" }, mockToolCallOptions) + ); + + expect(result).toEqual({ success: true }); + }); +}); diff --git a/src/node/services/tools/agent_report.ts b/src/node/services/tools/agent_report.ts new file mode 100644 index 0000000000..dd3f35158e --- /dev/null +++ b/src/node/services/tools/agent_report.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; + +import { tool } from "ai"; + +import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; +import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; + +export const createAgentReportTool: ToolFactory = (config: ToolConfiguration) => { + return tool({ + description: TOOL_DEFINITIONS.agent_report.description, + inputSchema: TOOL_DEFINITIONS.agent_report.schema, + execute: (): { success: true } => { + assert(config.workspaceId, "agent_report requires workspaceId"); + assert(config.taskService, "agent_report requires taskService"); + + if (config.taskService.hasActiveDescendantAgentTasksForWorkspace(config.workspaceId)) { + throw new Error( + "agent_report rejected: this task still has running/queued descendant tasks. " + + "Call task_await (or wait for tasks to finish) before reporting." + ); + } + + // Intentionally no side-effects. The backend orchestrator consumes the tool-call args + // via persisted history/partial state once the tool call completes successfully. + return { success: true }; + }, + }); +}; diff --git a/src/node/services/tools/task.test.ts b/src/node/services/tools/task.test.ts new file mode 100644 index 0000000000..d9d36cb1ec --- /dev/null +++ b/src/node/services/tools/task.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, mock } from "bun:test"; +import type { ToolCallOptions } from "ai"; + +import { createTaskTool } from "./task"; +import { TestTempDir, createTestToolConfig } from "./testHelpers"; +import { Ok, Err } from "@/common/types/result"; +import type { TaskService } from "@/node/services/taskService"; + +// Mock ToolCallOptions for testing +const mockToolCallOptions: ToolCallOptions = { + toolCallId: "test-call-id", + messages: [], +}; + +describe("task tool", () => { + it("should return immediately when run_in_background is true", async () => { + using tempDir = new TestTempDir("test-task-tool"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + + const create = mock(() => + Ok({ taskId: "child-task", kind: "agent" as const, status: "queued" as const }) + ); + const waitForAgentReport = mock(() => Promise.resolve({ reportMarkdown: "ignored" })); + const taskService = { create, waitForAgentReport } as unknown as TaskService; + + const tool = createTaskTool({ + ...baseConfig, + muxEnv: { MUX_MODEL_STRING: "openai:gpt-4o-mini", MUX_THINKING_LEVEL: "high" }, + taskService, + }); + + const result: unknown = await Promise.resolve( + tool.execute!( + { subagent_type: "explore", prompt: "do it", run_in_background: true }, + mockToolCallOptions + ) + ); + + expect(create).toHaveBeenCalled(); + expect(waitForAgentReport).not.toHaveBeenCalled(); + expect(result).toEqual({ status: "queued", taskId: "child-task" }); + }); + + it("should block and return report when run_in_background is false", async () => { + using tempDir = new TestTempDir("test-task-tool"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + + const create = mock(() => + Ok({ taskId: "child-task", kind: "agent" as const, status: "running" as const }) + ); + const waitForAgentReport = mock(() => + Promise.resolve({ + reportMarkdown: "Hello from child", + title: "Result", + }) + ); + const taskService = { create, waitForAgentReport } as unknown as TaskService; + + const tool = createTaskTool({ + ...baseConfig, + taskService, + }); + + const result: unknown = await Promise.resolve( + tool.execute!( + { subagent_type: "explore", prompt: "do it", run_in_background: false }, + mockToolCallOptions + ) + ); + + expect(create).toHaveBeenCalled(); + expect(waitForAgentReport).toHaveBeenCalledWith("child-task", expect.any(Object)); + expect(result).toEqual({ + status: "completed", + taskId: "child-task", + reportMarkdown: "Hello from child", + title: "Result", + agentType: "explore", + }); + }); + + it("should throw when TaskService.create fails (e.g., depth limit)", async () => { + using tempDir = new TestTempDir("test-task-tool"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + + const create = mock(() => Err("maxTaskNestingDepth exceeded")); + const waitForAgentReport = mock(() => Promise.resolve({ reportMarkdown: "ignored" })); + const taskService = { create, waitForAgentReport } as unknown as TaskService; + + const tool = createTaskTool({ + ...baseConfig, + taskService, + }); + + let caught: unknown = null; + try { + await Promise.resolve( + tool.execute!({ subagent_type: "explore", prompt: "do it" }, mockToolCallOptions) + ); + } catch (error: unknown) { + caught = error; + } + + expect(caught).toBeInstanceOf(Error); + if (caught instanceof Error) { + expect(caught.message).toMatch(/maxTaskNestingDepth/i); + } + }); +}); diff --git a/src/node/services/tools/task.ts b/src/node/services/tools/task.ts new file mode 100644 index 0000000000..7eb3f46801 --- /dev/null +++ b/src/node/services/tools/task.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; + +import { tool } from "ai"; + +import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; +import { TaskToolResultSchema, TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; +import type { ThinkingLevel } from "@/common/types/thinking"; + +function parseThinkingLevel(value: unknown): ThinkingLevel | undefined { + if ( + value === "off" || + value === "low" || + value === "medium" || + value === "high" || + value === "xhigh" + ) { + return value; + } + return undefined; +} + +export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { + return tool({ + description: TOOL_DEFINITIONS.task.description, + inputSchema: TOOL_DEFINITIONS.task.schema, + execute: async (args, { abortSignal }): Promise => { + assert(config.workspaceId, "task requires workspaceId"); + assert(config.taskService, "task requires taskService"); + + const modelString = + config.muxEnv && typeof config.muxEnv.MUX_MODEL_STRING === "string" + ? config.muxEnv.MUX_MODEL_STRING + : undefined; + const thinkingLevel = parseThinkingLevel(config.muxEnv?.MUX_THINKING_LEVEL); + + const created = await config.taskService.create({ + parentWorkspaceId: config.workspaceId, + kind: "agent", + agentType: args.subagent_type, + prompt: args.prompt, + description: args.description, + modelString, + thinkingLevel, + }); + + if (!created.success) { + throw new Error(created.error); + } + + if (args.run_in_background) { + const result = { status: created.data.status, taskId: created.data.taskId }; + const parsed = TaskToolResultSchema.safeParse(result); + if (!parsed.success) { + throw new Error(`task tool result validation failed: ${parsed.error.message}`); + } + return parsed.data; + } + + const report = await config.taskService.waitForAgentReport(created.data.taskId, { + abortSignal, + }); + + const result = { + status: "completed" as const, + taskId: created.data.taskId, + reportMarkdown: report.reportMarkdown, + title: report.title, + agentType: args.subagent_type, + }; + + const parsed = TaskToolResultSchema.safeParse(result); + if (!parsed.success) { + throw new Error(`task tool result validation failed: ${parsed.error.message}`); + } + return parsed.data; + }, + }); +}; diff --git a/src/node/services/tools/task_await.test.ts b/src/node/services/tools/task_await.test.ts new file mode 100644 index 0000000000..5e569302e4 --- /dev/null +++ b/src/node/services/tools/task_await.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, mock } from "bun:test"; +import type { ToolCallOptions } from "ai"; + +import { createTaskAwaitTool } from "./task_await"; +import { TestTempDir, createTestToolConfig } from "./testHelpers"; +import type { TaskService } from "@/node/services/taskService"; + +const mockToolCallOptions: ToolCallOptions = { + toolCallId: "test-call-id", + messages: [], +}; + +describe("task_await tool", () => { + it("returns completed results for all awaited tasks", async () => { + using tempDir = new TestTempDir("test-task-await-tool"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + + const taskService = { + listActiveDescendantAgentTaskIds: mock(() => ["t1", "t2"]), + isDescendantAgentTask: mock(() => true), + waitForAgentReport: mock((taskId: string) => + Promise.resolve({ reportMarkdown: `report:${taskId}`, title: `title:${taskId}` }) + ), + } as unknown as TaskService; + + const tool = createTaskAwaitTool({ ...baseConfig, taskService }); + + const result: unknown = await Promise.resolve( + tool.execute!({ task_ids: ["t1", "t2"] }, mockToolCallOptions) + ); + + expect(result).toEqual({ + results: [ + { status: "completed", taskId: "t1", reportMarkdown: "report:t1", title: "title:t1" }, + { status: "completed", taskId: "t2", reportMarkdown: "report:t2", title: "title:t2" }, + ], + }); + }); + + it("marks invalid_scope without calling waitForAgentReport", async () => { + using tempDir = new TestTempDir("test-task-await-tool-invalid-scope"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + + const isDescendantAgentTask = mock((ancestorId: string, taskId: string) => { + expect(ancestorId).toBe("parent-workspace"); + return taskId !== "other"; + }); + const waitForAgentReport = mock(() => Promise.resolve({ reportMarkdown: "ok" })); + + const taskService = { + listActiveDescendantAgentTaskIds: mock(() => []), + isDescendantAgentTask, + waitForAgentReport, + } as unknown as TaskService; + + const tool = createTaskAwaitTool({ ...baseConfig, taskService }); + + const result: unknown = await Promise.resolve( + tool.execute!({ task_ids: ["child", "other"] }, mockToolCallOptions) + ); + + expect(result).toEqual({ + results: [ + { status: "completed", taskId: "child", reportMarkdown: "ok", title: undefined }, + { status: "invalid_scope", taskId: "other" }, + ], + }); + expect(waitForAgentReport).toHaveBeenCalledTimes(1); + expect(waitForAgentReport).toHaveBeenCalledWith("child", expect.any(Object)); + }); + + it("defaults to waiting on all active descendant tasks when task_ids is omitted", async () => { + using tempDir = new TestTempDir("test-task-await-tool-descendants"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + + const listActiveDescendantAgentTaskIds = mock(() => ["t1"]); + const isDescendantAgentTask = mock(() => true); + const waitForAgentReport = mock(() => Promise.resolve({ reportMarkdown: "ok" })); + + const taskService = { + listActiveDescendantAgentTaskIds, + isDescendantAgentTask, + waitForAgentReport, + } as unknown as TaskService; + + const tool = createTaskAwaitTool({ ...baseConfig, taskService }); + + const result: unknown = await Promise.resolve(tool.execute!({}, mockToolCallOptions)); + + expect(listActiveDescendantAgentTaskIds).toHaveBeenCalledWith("parent-workspace"); + expect(result).toEqual({ + results: [{ status: "completed", taskId: "t1", reportMarkdown: "ok", title: undefined }], + }); + }); + + it("maps wait errors to running/not_found/error statuses", async () => { + using tempDir = new TestTempDir("test-task-await-tool-errors"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + + const waitForAgentReport = mock((taskId: string) => { + if (taskId === "timeout") { + return Promise.reject(new Error("Timed out waiting for agent_report")); + } + if (taskId === "missing") { + return Promise.reject(new Error("Task not found")); + } + return Promise.reject(new Error("Boom")); + }); + + const taskService = { + listActiveDescendantAgentTaskIds: mock(() => []), + isDescendantAgentTask: mock(() => true), + getAgentTaskStatus: mock((taskId: string) => (taskId === "timeout" ? "running" : null)), + waitForAgentReport, + } as unknown as TaskService; + + const tool = createTaskAwaitTool({ ...baseConfig, taskService }); + + const result: unknown = await Promise.resolve( + tool.execute!({ task_ids: ["timeout", "missing", "boom"] }, mockToolCallOptions) + ); + + expect(result).toEqual({ + results: [ + { status: "running", taskId: "timeout" }, + { status: "not_found", taskId: "missing" }, + { status: "error", taskId: "boom", error: "Boom" }, + ], + }); + }); +}); diff --git a/src/node/services/tools/task_await.ts b/src/node/services/tools/task_await.ts new file mode 100644 index 0000000000..347d39fde2 --- /dev/null +++ b/src/node/services/tools/task_await.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; + +import { tool } from "ai"; + +import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; +import { TaskAwaitToolResultSchema, TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; + +function coerceTimeoutMs(timeoutSecs: unknown): number | undefined { + if (typeof timeoutSecs !== "number" || !Number.isFinite(timeoutSecs)) return undefined; + const timeoutMs = Math.floor(timeoutSecs * 1000); + if (timeoutMs <= 0) return undefined; + return timeoutMs; +} + +export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { + return tool({ + description: TOOL_DEFINITIONS.task_await.description, + inputSchema: TOOL_DEFINITIONS.task_await.schema, + execute: async (args, { abortSignal }): Promise => { + assert(config.workspaceId, "task_await requires workspaceId"); + assert(config.taskService, "task_await requires taskService"); + + const timeoutMs = coerceTimeoutMs(args.timeout_secs); + + const requestedIds: string[] | null = + args.task_ids && args.task_ids.length > 0 ? args.task_ids : null; + + const candidateTaskIds = + requestedIds ?? config.taskService.listActiveDescendantAgentTaskIds(config.workspaceId); + + const uniqueTaskIds = Array.from(new Set(candidateTaskIds)); + + const results = await Promise.all( + uniqueTaskIds.map(async (taskId) => { + if (!config.taskService!.isDescendantAgentTask(config.workspaceId!, taskId)) { + return { status: "invalid_scope" as const, taskId }; + } + + try { + const report = await config.taskService!.waitForAgentReport(taskId, { + timeoutMs, + abortSignal, + }); + return { + status: "completed" as const, + taskId, + reportMarkdown: report.reportMarkdown, + title: report.title, + }; + } catch (error: unknown) { + if (abortSignal?.aborted) { + return { status: "error" as const, taskId, error: "Interrupted" }; + } + + const message = error instanceof Error ? error.message : String(error); + if (/not found/i.test(message)) { + return { status: "not_found" as const, taskId }; + } + if (/timed out/i.test(message)) { + const status = config.taskService!.getAgentTaskStatus(taskId); + if (status === "queued" || status === "running" || status === "awaiting_report") { + return { status, taskId }; + } + if (!status) { + return { status: "not_found" as const, taskId }; + } + return { + status: "error" as const, + taskId, + error: `Task status is '${status}' (not awaitable via task_await).`, + }; + } + return { status: "error" as const, taskId, error: message }; + } + }) + ); + + const output = { results }; + const parsed = TaskAwaitToolResultSchema.safeParse(output); + if (!parsed.success) { + throw new Error(`task_await tool result validation failed: ${parsed.error.message}`); + } + + return parsed.data; + }, + }); +}; diff --git a/src/node/services/tools/task_list.test.ts b/src/node/services/tools/task_list.test.ts new file mode 100644 index 0000000000..151f64a661 --- /dev/null +++ b/src/node/services/tools/task_list.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, mock } from "bun:test"; +import type { ToolCallOptions } from "ai"; + +import { createTaskListTool } from "./task_list"; +import { TestTempDir, createTestToolConfig } from "./testHelpers"; +import type { TaskService } from "@/node/services/taskService"; + +const mockToolCallOptions: ToolCallOptions = { + toolCallId: "test-call-id", + messages: [], +}; + +describe("task_list tool", () => { + it("uses default statuses when none are provided", async () => { + using tempDir = new TestTempDir("test-task-list-default-statuses"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "root-workspace" }); + + const listDescendantAgentTasks = mock(() => []); + const taskService = { listDescendantAgentTasks } as unknown as TaskService; + + const tool = createTaskListTool({ ...baseConfig, taskService }); + + const result: unknown = await Promise.resolve(tool.execute!({}, mockToolCallOptions)); + + expect(result).toEqual({ tasks: [] }); + expect(listDescendantAgentTasks).toHaveBeenCalledWith("root-workspace", { + statuses: ["queued", "running", "awaiting_report"], + }); + }); + + it("passes through provided statuses", async () => { + using tempDir = new TestTempDir("test-task-list-statuses"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "root-workspace" }); + + const listDescendantAgentTasks = mock(() => []); + const taskService = { listDescendantAgentTasks } as unknown as TaskService; + + const tool = createTaskListTool({ ...baseConfig, taskService }); + + const result: unknown = await Promise.resolve( + tool.execute!({ statuses: ["running"] }, mockToolCallOptions) + ); + + expect(result).toEqual({ tasks: [] }); + expect(listDescendantAgentTasks).toHaveBeenCalledWith("root-workspace", { + statuses: ["running"], + }); + }); + + it("returns tasks with metadata", async () => { + using tempDir = new TestTempDir("test-task-list-ok"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "root-workspace" }); + + const listDescendantAgentTasks = mock(() => [ + { + taskId: "task-1", + status: "running", + parentWorkspaceId: "root-workspace", + agentType: "research", + workspaceName: "agent_research_task-1", + title: "t", + createdAt: "2025-01-01T00:00:00.000Z", + modelString: "anthropic:claude-haiku-4-5", + thinkingLevel: "low", + depth: 1, + }, + ]); + const taskService = { listDescendantAgentTasks } as unknown as TaskService; + + const tool = createTaskListTool({ ...baseConfig, taskService }); + + const result: unknown = await Promise.resolve(tool.execute!({}, mockToolCallOptions)); + + expect(result).toEqual({ + tasks: [ + { + taskId: "task-1", + status: "running", + parentWorkspaceId: "root-workspace", + agentType: "research", + workspaceName: "agent_research_task-1", + title: "t", + createdAt: "2025-01-01T00:00:00.000Z", + modelString: "anthropic:claude-haiku-4-5", + thinkingLevel: "low", + depth: 1, + }, + ], + }); + }); +}); diff --git a/src/node/services/tools/task_list.ts b/src/node/services/tools/task_list.ts new file mode 100644 index 0000000000..814b9483a7 --- /dev/null +++ b/src/node/services/tools/task_list.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; + +import { tool } from "ai"; + +import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; +import { TaskListToolResultSchema, TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; + +const DEFAULT_STATUSES = ["queued", "running", "awaiting_report"] as const; + +export const createTaskListTool: ToolFactory = (config: ToolConfiguration) => { + return tool({ + description: TOOL_DEFINITIONS.task_list.description, + inputSchema: TOOL_DEFINITIONS.task_list.schema, + execute: (args): unknown => { + assert(config.workspaceId, "task_list requires workspaceId"); + assert(config.taskService, "task_list requires taskService"); + + const statuses = + args.statuses && args.statuses.length > 0 ? args.statuses : [...DEFAULT_STATUSES]; + const tasks = config.taskService.listDescendantAgentTasks(config.workspaceId, { statuses }); + + const output = { tasks }; + const parsed = TaskListToolResultSchema.safeParse(output); + if (!parsed.success) { + throw new Error(`task_list tool result validation failed: ${parsed.error.message}`); + } + + return parsed.data; + }, + }); +}; diff --git a/src/node/services/tools/task_terminate.test.ts b/src/node/services/tools/task_terminate.test.ts new file mode 100644 index 0000000000..29d9752960 --- /dev/null +++ b/src/node/services/tools/task_terminate.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, mock } from "bun:test"; +import type { ToolCallOptions } from "ai"; + +import { createTaskTerminateTool } from "./task_terminate"; +import { TestTempDir, createTestToolConfig } from "./testHelpers"; +import type { TaskService } from "@/node/services/taskService"; +import { Err, Ok, type Result } from "@/common/types/result"; + +const mockToolCallOptions: ToolCallOptions = { + toolCallId: "test-call-id", + messages: [], +}; + +describe("task_terminate tool", () => { + it("returns not_found when the task does not exist", async () => { + using tempDir = new TestTempDir("test-task-terminate-not-found"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "root-workspace" }); + + const taskService = { + terminateDescendantAgentTask: mock( + (): Promise> => + Promise.resolve(Err("Task not found")) + ), + } as unknown as TaskService; + + const tool = createTaskTerminateTool({ ...baseConfig, taskService }); + + const result: unknown = await Promise.resolve( + tool.execute!({ task_ids: ["missing-task"] }, mockToolCallOptions) + ); + + expect(result).toEqual({ + results: [{ status: "not_found", taskId: "missing-task" }], + }); + }); + + it("returns invalid_scope when the task is outside the workspace scope", async () => { + using tempDir = new TestTempDir("test-task-terminate-invalid-scope"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "root-workspace" }); + + const taskService = { + terminateDescendantAgentTask: mock( + (): Promise> => + Promise.resolve(Err("Task is not a descendant of this workspace")) + ), + } as unknown as TaskService; + + const tool = createTaskTerminateTool({ ...baseConfig, taskService }); + + const result: unknown = await Promise.resolve( + tool.execute!({ task_ids: ["other-task"] }, mockToolCallOptions) + ); + + expect(result).toEqual({ + results: [{ status: "invalid_scope", taskId: "other-task" }], + }); + }); + + it("returns terminated with terminatedTaskIds on success", async () => { + using tempDir = new TestTempDir("test-task-terminate-ok"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "root-workspace" }); + + const taskService = { + terminateDescendantAgentTask: mock( + (): Promise> => + Promise.resolve(Ok({ terminatedTaskIds: ["child-task", "parent-task"] })) + ), + } as unknown as TaskService; + + const tool = createTaskTerminateTool({ ...baseConfig, taskService }); + + const result: unknown = await Promise.resolve( + tool.execute!({ task_ids: ["parent-task"] }, mockToolCallOptions) + ); + + expect(result).toEqual({ + results: [ + { + status: "terminated", + taskId: "parent-task", + terminatedTaskIds: ["child-task", "parent-task"], + }, + ], + }); + }); +}); diff --git a/src/node/services/tools/task_terminate.ts b/src/node/services/tools/task_terminate.ts new file mode 100644 index 0000000000..7f98c60ae4 --- /dev/null +++ b/src/node/services/tools/task_terminate.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; + +import { tool } from "ai"; + +import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; +import { + TaskTerminateToolResultSchema, + TOOL_DEFINITIONS, +} from "@/common/utils/tools/toolDefinitions"; + +export const createTaskTerminateTool: ToolFactory = (config: ToolConfiguration) => { + return tool({ + description: TOOL_DEFINITIONS.task_terminate.description, + inputSchema: TOOL_DEFINITIONS.task_terminate.schema, + execute: async (args): Promise => { + assert(config.workspaceId, "task_terminate requires workspaceId"); + assert(config.taskService, "task_terminate requires taskService"); + + const uniqueTaskIds = Array.from(new Set(args.task_ids)); + + const results = await Promise.all( + uniqueTaskIds.map(async (taskId) => { + const terminateResult = await config.taskService!.terminateDescendantAgentTask( + config.workspaceId!, + taskId + ); + if (!terminateResult.success) { + const msg = terminateResult.error; + if (/not found/i.test(msg)) { + return { status: "not_found" as const, taskId }; + } + if (/descendant/i.test(msg) || /scope/i.test(msg)) { + return { status: "invalid_scope" as const, taskId }; + } + return { status: "error" as const, taskId, error: msg }; + } + + return { + status: "terminated" as const, + taskId, + terminatedTaskIds: terminateResult.data.terminatedTaskIds, + }; + }) + ); + + const output = { results }; + const parsed = TaskTerminateToolResultSchema.safeParse(output); + if (!parsed.success) { + throw new Error(`task_terminate tool result validation failed: ${parsed.error.message}`); + } + + return parsed.data; + }, + }); +}; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index c7de596a5f..d1e6876245 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -804,7 +804,9 @@ export class WorkspaceService extends EventEmitter { await this.config.editConfig((config) => { const projectConfig = config.projects.get(projectPath); if (projectConfig) { - const workspaceEntry = projectConfig.workspaces.find((w) => w.path === oldPath); + const workspaceEntry = + projectConfig.workspaces.find((w) => w.id === workspaceId) ?? + projectConfig.workspaces.find((w) => w.path === oldPath); if (workspaceEntry) { workspaceEntry.name = newName; workspaceEntry.path = newPath; @@ -854,7 +856,9 @@ export class WorkspaceService extends EventEmitter { await this.config.editConfig((config) => { const projectConfig = config.projects.get(projectPath); if (projectConfig) { - const workspaceEntry = projectConfig.workspaces.find((w) => w.path === workspacePath); + const workspaceEntry = + projectConfig.workspaces.find((w) => w.id === workspaceId) ?? + projectConfig.workspaces.find((w) => w.path === workspacePath); if (workspaceEntry) { workspaceEntry.title = title; } @@ -944,21 +948,21 @@ export class WorkspaceService extends EventEmitter { return Err(`Project not found: ${projectPath}`); } - const workspaceEntry = projectConfig.workspaces.find( - (w) => w.id === workspaceId || w.path === workspacePath - ); - if (!workspaceEntry) { + const workspaceEntry = projectConfig.workspaces.find((w) => w.id === workspaceId); + const workspaceEntryWithFallback = + workspaceEntry ?? projectConfig.workspaces.find((w) => w.path === workspacePath); + if (!workspaceEntryWithFallback) { return Err("Workspace not found"); } - const prev = workspaceEntry.aiSettings; + const prev = workspaceEntryWithFallback.aiSettings; const changed = prev?.model !== aiSettings.model || prev?.thinkingLevel !== aiSettings.thinkingLevel; if (!changed) { return Ok(false); } - workspaceEntry.aiSettings = aiSettings; + workspaceEntryWithFallback.aiSettings = aiSettings; await this.config.saveConfig(config); if (options?.emitMetadata !== false) { diff --git a/tests/ipc/setup.ts b/tests/ipc/setup.ts index 3eed6b912e..9237bfde27 100644 --- a/tests/ipc/setup.ts +++ b/tests/ipc/setup.ts @@ -73,6 +73,7 @@ export async function createTestEnvironment(): Promise { aiService: services.aiService, projectService: services.projectService, workspaceService: services.workspaceService, + taskService: services.taskService, providerService: services.providerService, terminalService: services.terminalService, editorService: services.editorService, From f89b8805d801dd7e69b3332bff821b1528b9d418 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Dec 2025 21:54:04 +0100 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A4=96=20fix:=20enforce=20agent=20tas?= =?UTF-8?q?k=20queue=20+=20depth=20tool=20gating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Queue agent tasks without creating worktrees until dequeued. - Persist queued prompts in metadata and render them in queued workspaces without auto-resume/backoff. - Disable task/task_* tools once maxTaskNestingDepth is reached. Signed-off-by: Thomas Kosiewski --- _Generated with `mux` • Model: unknown • Thinking: unknown_ Change-Id: Icf17d2634b2aff2061f75b44fdd8a6b63b887247 --- src/browser/components/AIView.tsx | 20 +- src/common/orpc/schemas/project.ts | 8 + src/common/orpc/schemas/workspace.ts | 23 ++ src/node/config.ts | 24 ++ src/node/services/agentPresets.ts | 8 +- src/node/services/aiService.ts | 65 ++++- src/node/services/taskService.test.ts | 282 ++++++++++++------ src/node/services/taskService.ts | 403 ++++++++++++++++++++++---- src/node/services/workspaceService.ts | 64 +++- 9 files changed, 741 insertions(+), 156 deletions(-) diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index ad2974d7e0..4c5d373269 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -73,6 +73,7 @@ import { ReviewsBanner } from "./ReviewsBanner"; import type { ReviewNoteData } from "@/common/types/review"; import { PopoverError } from "./PopoverError"; import { ConnectionStatusIndicator } from "./ConnectionStatusIndicator"; +import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; interface AIViewProps { workspaceId: string; @@ -99,6 +100,7 @@ const AIViewInner: React.FC = ({ status, }) => { const { api } = useAPI(); + const { workspaceMetadata } = useWorkspaceContext(); const chatAreaRef = useRef(null); // Track which right sidebar tab is selected (listener: true to sync with RightSidebar changes) @@ -134,6 +136,14 @@ const AIViewInner: React.FC = ({ const { statsTabState } = useFeatureFlags(); const statsEnabled = Boolean(statsTabState?.enabled); const workspaceState = useWorkspaceState(workspaceId); + const meta = workspaceMetadata.get(workspaceId); + const isQueuedAgentTask = Boolean(meta?.parentWorkspaceId) && meta?.taskStatus === "queued"; + const queuedAgentTaskPrompt = + isQueuedAgentTask && typeof meta?.taskPrompt === "string" && meta.taskPrompt.trim().length > 0 + ? meta.taskPrompt + : null; + const shouldShowQueuedAgentTaskPrompt = + Boolean(queuedAgentTaskPrompt) && (workspaceState?.messages.length ?? 0) === 0; const aggregator = useWorkspaceAggregator(workspaceId); const workspaceUsage = useWorkspaceUsage(workspaceId); @@ -727,6 +737,14 @@ const AIViewInner: React.FC = ({ } /> )} + {shouldShowQueuedAgentTaskPrompt && ( + + )} {workspaceState?.queuedMessage && ( = ({ onMessageSent={handleMessageSent} onTruncateHistory={handleClearHistory} onProviderConfig={handleProviderConfig} - disabled={!projectName || !workspaceName} + disabled={!projectName || !workspaceName || isQueuedAgentTask} isCompacting={isCompacting} editingMessage={editingMessage} onCancelEdit={handleCancelEdit} diff --git a/src/common/orpc/schemas/project.ts b/src/common/orpc/schemas/project.ts index 3bc6e6606c..e42cafb345 100644 --- a/src/common/orpc/schemas/project.ts +++ b/src/common/orpc/schemas/project.ts @@ -50,6 +50,14 @@ export const WorkspaceConfigSchema = z.object({ taskThinkingLevel: ThinkingLevelSchema.optional().meta({ description: "Thinking level used for this agent task (used for restart-safe resumptions).", }), + taskPrompt: z.string().optional().meta({ + description: + "Initial prompt for a queued agent task (persisted only until the task actually starts).", + }), + taskTrunkBranch: z.string().optional().meta({ + description: + "Trunk branch used to create/init this agent task workspace (used for restart-safe init on queued tasks).", + }), mcp: WorkspaceMCPOverridesSchema.optional().meta({ description: "Per-workspace MCP overrides (disabled servers, tool allowlists)", }), diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts index c1bc2a7ef0..e81d320565 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import { RuntimeConfigSchema } from "./runtime"; import { WorkspaceAISettingsSchema } from "./workspaceAiSettings"; +const ThinkingLevelSchema = z.enum(["off", "low", "medium", "high", "xhigh"]); + export const WorkspaceMetadataSchema = z.object({ id: z.string().meta({ description: @@ -38,6 +40,27 @@ export const WorkspaceMetadataSchema = z.object({ description: 'If set, selects an agent preset for this workspace (e.g., "research" or "explore").', }), + taskStatus: z.enum(["queued", "running", "awaiting_report", "reported"]).optional().meta({ + description: + "Agent task lifecycle status for child workspaces (queued|running|awaiting_report|reported).", + }), + reportedAt: z.string().optional().meta({ + description: "ISO 8601 timestamp for when an agent task reported completion (optional).", + }), + taskModelString: z.string().optional().meta({ + description: "Model string used to run this agent task (used for restart-safe resumptions).", + }), + taskThinkingLevel: ThinkingLevelSchema.optional().meta({ + description: "Thinking level used for this agent task (used for restart-safe resumptions).", + }), + taskPrompt: z.string().optional().meta({ + description: + "Initial prompt for a queued agent task (persisted only until the task actually starts).", + }), + taskTrunkBranch: z.string().optional().meta({ + description: + "Trunk branch used to create/init this agent task workspace (used for restart-safe init on queued tasks).", + }), status: z.enum(["creating"]).optional().meta({ description: "Workspace creation status. 'creating' = pending setup (ephemeral, not persisted). Absent = ready.", diff --git a/src/node/config.ts b/src/node/config.ts index 17d5d1294c..a7ccccff2a 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -358,6 +358,12 @@ export class Config { aiSettings: workspace.aiSettings, parentWorkspaceId: workspace.parentWorkspaceId, agentType: workspace.agentType, + taskStatus: workspace.taskStatus, + reportedAt: workspace.reportedAt, + taskModelString: workspace.taskModelString, + taskThinkingLevel: workspace.taskThinkingLevel, + taskPrompt: workspace.taskPrompt, + taskTrunkBranch: workspace.taskTrunkBranch, }; // Migrate missing createdAt to config for next load @@ -403,6 +409,12 @@ export class Config { // Preserve tree/task metadata when present in config (metadata.json won't have it) metadata.parentWorkspaceId ??= workspace.parentWorkspaceId; metadata.agentType ??= workspace.agentType; + metadata.taskStatus ??= workspace.taskStatus; + metadata.reportedAt ??= workspace.reportedAt; + metadata.taskModelString ??= workspace.taskModelString; + metadata.taskThinkingLevel ??= workspace.taskThinkingLevel; + metadata.taskPrompt ??= workspace.taskPrompt; + metadata.taskTrunkBranch ??= workspace.taskTrunkBranch; // Migrate to config for next load workspace.id = metadata.id; workspace.name = metadata.name; @@ -429,6 +441,12 @@ export class Config { aiSettings: workspace.aiSettings, parentWorkspaceId: workspace.parentWorkspaceId, agentType: workspace.agentType, + taskStatus: workspace.taskStatus, + reportedAt: workspace.reportedAt, + taskModelString: workspace.taskModelString, + taskThinkingLevel: workspace.taskThinkingLevel, + taskPrompt: workspace.taskPrompt, + taskTrunkBranch: workspace.taskTrunkBranch, }; // Save to config for next load @@ -456,6 +474,12 @@ export class Config { aiSettings: workspace.aiSettings, parentWorkspaceId: workspace.parentWorkspaceId, agentType: workspace.agentType, + taskStatus: workspace.taskStatus, + reportedAt: workspace.reportedAt, + taskModelString: workspace.taskModelString, + taskThinkingLevel: workspace.taskThinkingLevel, + taskPrompt: workspace.taskPrompt, + taskTrunkBranch: workspace.taskTrunkBranch, }; workspaceMetadata.push(this.addPathsToMetadata(metadata, workspace.path, projectPath)); } diff --git a/src/node/services/agentPresets.ts b/src/node/services/agentPresets.ts index 664a380956..3bd0986341 100644 --- a/src/node/services/agentPresets.ts +++ b/src/node/services/agentPresets.ts @@ -30,10 +30,10 @@ const RESEARCH_PRESET: AgentPreset = { "Rules:", "- Do not edit files.", "- Do not run bash commands unless explicitly enabled (assume it is not).", - "- If you need repository exploration beyond file_read, delegate to an Explore sub-agent via the task tool.", + "- If the task tool is available and you need repository exploration beyond file_read, delegate to an Explore sub-agent.", "", "Delegation:", - '- Use: task({ subagent_type: "explore", prompt: "..." }) when you need repo exploration.', + '- If available, use: task({ subagent_type: "explore", prompt: "..." }) when you need repo exploration.', "", "Reporting:", "- When you have a final answer, call agent_report exactly once.", @@ -66,10 +66,10 @@ const EXPLORE_PRESET: AgentPreset = { "Rules:", "- Do not edit files.", "- Treat bash as read-only: prefer commands like rg, ls, cat, git show, git diff (read-only).", - "- If you need external information, delegate to a Research sub-agent via the task tool.", + "- If the task tool is available and you need external information, delegate to a Research sub-agent.", "", "Delegation:", - '- Use: task({ subagent_type: "research", prompt: "..." }) when you need web research.', + '- If available, use: task({ subagent_type: "research", prompt: "..." }) when you need web research.', "", "Reporting:", "- When you have a final answer, call agent_report exactly once.", diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 712f2b6c8e..ce5dfe1871 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -51,6 +51,7 @@ import type { MCPServerManager, MCPWorkspaceStats } from "@/node/services/mcpSer import type { TaskService } from "@/node/services/taskService"; import { buildProviderOptions } from "@/common/utils/ai/providerOptions"; import type { ThinkingLevel } from "@/common/types/thinking"; +import { DEFAULT_TASK_SETTINGS } from "@/common/types/tasks"; import type { StreamAbortEvent, StreamDeltaEvent, @@ -344,6 +345,36 @@ function parseModelString(modelString: string): [string, string] { return [providerName, modelId]; } +function getTaskDepthFromConfig( + config: ReturnType, + workspaceId: string +): number { + const parentById = new Map(); + for (const project of config.projects.values()) { + for (const workspace of project.workspaces) { + if (!workspace.id) continue; + parentById.set(workspace.id, workspace.parentWorkspaceId); + } + } + + let depth = 0; + let current = workspaceId; + for (let i = 0; i < 32; i++) { + const parent = parentById.get(current); + if (!parent) break; + depth += 1; + current = parent; + } + + if (depth >= 32) { + throw new Error( + `getTaskDepthFromConfig: possible parentWorkspaceId cycle starting at ${workspaceId}` + ); + } + + return depth; +} + export class AIService extends EventEmitter { private readonly streamManager: StreamManager; private readonly historyService: HistoryService; @@ -1199,7 +1230,23 @@ export class AIService extends EventEmitter { } } + const cfg = this.config.loadConfigOrDefault(); + const taskSettings = cfg.taskSettings ?? DEFAULT_TASK_SETTINGS; + const taskDepth = getTaskDepthFromConfig(cfg, workspaceId); + const shouldDisableTaskToolsForDepth = taskDepth >= taskSettings.maxTaskNestingDepth; + const agentPreset = getAgentPreset(metadata.agentType); + const agentSystemPrompt = agentPreset + ? shouldDisableTaskToolsForDepth + ? [ + agentPreset.systemPrompt, + "", + "Nesting:", + `- Task delegation is disabled in this workspace (taskDepth=${taskDepth}, maxTaskNestingDepth=${taskSettings.maxTaskNestingDepth}).`, + "- Do not call task/task_await/task_list/task_terminate.", + ].join("\n") + : agentPreset.systemPrompt + : undefined; // Build system message from workspace metadata const systemMessage = await buildSystemMessage( @@ -1210,7 +1257,7 @@ export class AIService extends EventEmitter { effectiveAdditionalInstructions, modelString, mcpServers, - agentPreset ? { variant: "agent", agentSystemPrompt: agentPreset.systemPrompt } : undefined + agentSystemPrompt ? { variant: "agent", agentSystemPrompt } : undefined ); // Count system message tokens for cost tracking @@ -1303,10 +1350,18 @@ export class AIService extends EventEmitter { mcpTools ); - // Preset tool policy must be applied last so callers cannot re-enable restricted tools. - const effectiveToolPolicy = agentPreset - ? [...(toolPolicy ?? []), ...agentPreset.toolPolicy] - : toolPolicy; + const depthToolPolicy: ToolPolicy = shouldDisableTaskToolsForDepth + ? [ + { regex_match: "task", action: "disable" }, + { regex_match: "task_.*", action: "disable" }, + ] + : []; + + // Preset + depth tool policies must be applied last so callers cannot re-enable restricted tools. + const effectiveToolPolicy = + agentPreset || depthToolPolicy.length > 0 + ? [...(toolPolicy ?? []), ...(agentPreset?.toolPolicy ?? []), ...depthToolPolicy] + : toolPolicy; // Apply tool policy FIRST - this must happen before PTC to ensure sandbox // respects allow/deny filters. The policy-filtered tools are passed to diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 738ebdff6b..4b0df12319 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -15,6 +15,7 @@ import type { WorkspaceMetadata } from "@/common/types/workspace"; import type { AIService } from "@/node/services/aiService"; import type { WorkspaceService } from "@/node/services/workspaceService"; import type { InitStateManager } from "@/node/services/initStateManager"; +import { InitStateManager as RealInitStateManager } from "@/node/services/initStateManager"; function initGitRepo(projectPath: string): void { execSync("git init -b main", { cwd: projectPath, stdio: "ignore" }); @@ -36,6 +37,16 @@ function createNullInitLogger() { }; } +function createMockInitStateManager(): InitStateManager { + return { + startInit: mock(() => undefined), + appendOutput: mock(() => undefined), + endInit: mock(() => Promise.resolve()), + getInitState: mock(() => undefined), + readInitStatus: mock(() => Promise.resolve(null)), + } as unknown as InitStateManager; +} + describe("TaskService", () => { let rootDir: string; @@ -122,11 +133,7 @@ describe("TaskService", () => { emit: mock(() => true), } as unknown as WorkspaceService; - const initStateManager: InitStateManager = { - startInit: mock(() => undefined), - appendOutput: mock(() => undefined), - endInit: mock(() => Promise.resolve()), - } as unknown as InitStateManager; + const initStateManager = createMockInitStateManager(); const taskService = new TaskService( config, @@ -238,20 +245,16 @@ describe("TaskService", () => { off: mock(() => undefined), } as unknown as AIService; - const resumeStream = mock(() => Promise.resolve(Ok(undefined))); + const sendMessage = mock(() => Promise.resolve(Ok(undefined))); const workspaceService: WorkspaceService = { - sendMessage: mock(() => Promise.resolve(Ok(undefined))), - resumeStream, + sendMessage, + resumeStream: mock(() => Promise.resolve(Ok(undefined))), remove: mock(() => Promise.resolve(Ok(undefined))), emit: mock(() => true), } as unknown as WorkspaceService; - const initStateManager: InitStateManager = { - startInit: mock(() => undefined), - appendOutput: mock(() => undefined), - endInit: mock(() => Promise.resolve()), - } as unknown as InitStateManager; + const initStateManager = createMockInitStateManager(); const taskService = new TaskService( config, @@ -294,7 +297,12 @@ describe("TaskService", () => { await taskService.initialize(); - expect(resumeStream).toHaveBeenCalled(); + expect(sendMessage).toHaveBeenCalledWith( + queued.data.taskId, + "task 2", + expect.anything(), + expect.objectContaining({ allowQueuedAgentTask: true }) + ); const cfg = config.loadConfigOrDefault(); const started = Array.from(cfg.projects.values()) @@ -303,6 +311,156 @@ describe("TaskService", () => { expect(started?.taskStatus).toBe("running"); }, 20_000); + test("does not run init hooks for queued tasks until they start", async () => { + const config = new Config(rootDir); + await fsPromises.mkdir(config.srcDir, { recursive: true }); + + const ids = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc"]; + let nextIdIndex = 0; + const configWithStableId = config as unknown as { generateStableId: () => string }; + configWithStableId.generateStableId = () => ids[nextIdIndex++] ?? "dddddddddd"; + + const projectPath = path.join(rootDir, "repo"); + await fsPromises.mkdir(projectPath, { recursive: true }); + initGitRepo(projectPath); + + const runtimeConfig = { type: "worktree" as const, srcBaseDir: config.srcDir }; + const runtime = createRuntime(runtimeConfig, { projectPath }); + const initLogger = createNullInitLogger(); + + const parentName = "parent"; + await runtime.createWorkspace({ + projectPath, + branchName: parentName, + trunkBranch: "main", + directoryName: parentName, + initLogger, + }); + + const parentId = "1111111111"; + await config.saveConfig({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { + path: runtime.getWorkspacePath(projectPath, parentName), + id: parentId, + name: parentName, + createdAt: new Date().toISOString(), + runtimeConfig, + }, + ], + }, + ], + ]), + taskSettings: { maxParallelAgentTasks: 1, maxTaskNestingDepth: 3 }, + }); + + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + const initStateManager = new RealInitStateManager(config); + + const aiService: AIService = { + isStreaming: mock(() => false), + getWorkspaceMetadata: mock( + async (workspaceId: string): Promise> => { + const all = await config.getAllWorkspaceMetadata(); + const found = all.find((m) => m.id === workspaceId); + return found ? Ok(found) : Err("not found"); + } + ), + on: mock(() => undefined), + off: mock(() => undefined), + } as unknown as AIService; + + const sendMessage = mock(() => Promise.resolve(Ok(undefined))); + + const workspaceService: WorkspaceService = { + sendMessage, + resumeStream: mock(() => Promise.resolve(Ok(undefined))), + remove: mock(() => Promise.resolve(Ok(undefined))), + emit: mock(() => true), + } as unknown as WorkspaceService; + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager as unknown as InitStateManager + ); + + const running = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "explore", + prompt: "task 1", + }); + expect(running.success).toBe(true); + if (!running.success) return; + + // Wait for running task init (fire-and-forget) so the init-status file exists. + await initStateManager.waitForInit(running.data.taskId); + + const queued = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "explore", + prompt: "task 2", + }); + expect(queued.success).toBe(true); + if (!queued.success) return; + expect(queued.data.status).toBe("queued"); + + // Queued tasks should not create a worktree directory until they're dequeued. + const cfgBeforeStart = config.loadConfigOrDefault(); + const queuedEntryBeforeStart = Array.from(cfgBeforeStart.projects.values()) + .flatMap((p) => p.workspaces) + .find((w) => w.id === queued.data.taskId); + expect(queuedEntryBeforeStart).toBeTruthy(); + await expect(fsPromises.stat(queuedEntryBeforeStart!.path)).rejects.toThrow(); + + const queuedInitStatusPath = path.join( + config.getSessionDir(queued.data.taskId), + "init-status.json" + ); + await expect(fsPromises.stat(queuedInitStatusPath)).rejects.toThrow(); + + // Free slot and start queued tasks. + await config.editConfig((cfg) => { + for (const [_project, project] of cfg.projects) { + const ws = project.workspaces.find((w) => w.id === running.data.taskId); + if (ws) { + ws.taskStatus = "reported"; + } + } + return cfg; + }); + + await taskService.initialize(); + + expect(sendMessage).toHaveBeenCalledWith( + queued.data.taskId, + "task 2", + expect.anything(), + expect.objectContaining({ allowQueuedAgentTask: true }) + ); + + // Init should start only once the task is dequeued. + await initStateManager.waitForInit(queued.data.taskId); + await expect(fsPromises.stat(queuedInitStatusPath)).resolves.toBeTruthy(); + + const cfgAfterStart = config.loadConfigOrDefault(); + const queuedEntryAfterStart = Array.from(cfgAfterStart.projects.values()) + .flatMap((p) => p.workspaces) + .find((w) => w.id === queued.data.taskId); + expect(queuedEntryAfterStart).toBeTruthy(); + await expect(fsPromises.stat(queuedEntryAfterStart!.path)).resolves.toBeTruthy(); + }, 20_000); + test("does not start queued tasks while a reported task is still streaming", async () => { const config = new Config(rootDir); @@ -358,11 +516,7 @@ describe("TaskService", () => { emit: mock(() => true), } as unknown as WorkspaceService; - const initStateManager: InitStateManager = { - startInit: mock(() => undefined), - appendOutput: mock(() => undefined), - endInit: mock(() => Promise.resolve()), - } as unknown as InitStateManager; + const initStateManager = createMockInitStateManager(); const taskService = new TaskService( config, @@ -458,11 +612,7 @@ describe("TaskService", () => { emit: mock(() => true), } as unknown as WorkspaceService; - const initStateManager: InitStateManager = { - startInit: mock(() => undefined), - appendOutput: mock(() => undefined), - endInit: mock(() => Promise.resolve()), - } as unknown as InitStateManager; + const initStateManager = createMockInitStateManager(); const taskService = new TaskService( config, @@ -561,11 +711,7 @@ describe("TaskService", () => { emit: mock(() => true), } as unknown as WorkspaceService; - const initStateManager: InitStateManager = { - startInit: mock(() => undefined), - appendOutput: mock(() => undefined), - endInit: mock(() => Promise.resolve()), - } as unknown as InitStateManager; + const initStateManager = createMockInitStateManager(); const taskService = new TaskService( config, @@ -658,11 +804,7 @@ describe("TaskService", () => { emit: mock(() => true), } as unknown as WorkspaceService; - const initStateManager: InitStateManager = { - startInit: mock(() => undefined), - appendOutput: mock(() => undefined), - endInit: mock(() => Promise.resolve()), - } as unknown as InitStateManager; + const initStateManager = createMockInitStateManager(); const taskService = new TaskService( config, @@ -753,11 +895,7 @@ describe("TaskService", () => { emit: mock(() => true), } as unknown as WorkspaceService; - const initStateManager: InitStateManager = { - startInit: mock(() => undefined), - appendOutput: mock(() => undefined), - endInit: mock(() => Promise.resolve()), - } as unknown as InitStateManager; + const initStateManager = createMockInitStateManager(); const taskService = new TaskService( config, @@ -843,11 +981,7 @@ describe("TaskService", () => { emit: mock(() => true), } as unknown as WorkspaceService; - const initStateManager: InitStateManager = { - startInit: mock(() => undefined), - appendOutput: mock(() => undefined), - endInit: mock(() => Promise.resolve()), - } as unknown as InitStateManager; + const initStateManager = createMockInitStateManager(); const taskService = new TaskService( config, @@ -937,11 +1071,7 @@ describe("TaskService", () => { emit: mock(() => true), } as unknown as WorkspaceService; - const initStateManager: InitStateManager = { - startInit: mock(() => undefined), - appendOutput: mock(() => undefined), - endInit: mock(() => Promise.resolve()), - } as unknown as InitStateManager; + const initStateManager = createMockInitStateManager(); const taskService = new TaskService( config, @@ -1011,11 +1141,7 @@ describe("TaskService", () => { emit: mock(() => true), } as unknown as WorkspaceService; - const initStateManager: InitStateManager = { - startInit: mock(() => undefined), - appendOutput: mock(() => undefined), - endInit: mock(() => Promise.resolve()), - } as unknown as InitStateManager; + const initStateManager = createMockInitStateManager(); const taskService = new TaskService( config, @@ -1081,11 +1207,7 @@ describe("TaskService", () => { emit: mock(() => true), } as unknown as WorkspaceService; - const initStateManager: InitStateManager = { - startInit: mock(() => undefined), - appendOutput: mock(() => undefined), - endInit: mock(() => Promise.resolve()), - } as unknown as InitStateManager; + const initStateManager = createMockInitStateManager(); const taskService = new TaskService( config, @@ -1159,11 +1281,7 @@ describe("TaskService", () => { emit: mock(() => true), } as unknown as WorkspaceService; - const initStateManager: InitStateManager = { - startInit: mock(() => undefined), - appendOutput: mock(() => undefined), - endInit: mock(() => Promise.resolve()), - } as unknown as InitStateManager; + const initStateManager = createMockInitStateManager(); const taskService = new TaskService( config, @@ -1242,11 +1360,7 @@ describe("TaskService", () => { emit, } as unknown as WorkspaceService; - const initStateManager: InitStateManager = { - startInit: mock(() => undefined), - appendOutput: mock(() => undefined), - endInit: mock(() => Promise.resolve()), - } as unknown as InitStateManager; + const initStateManager = createMockInitStateManager(); const taskService = new TaskService( config, @@ -1327,11 +1441,7 @@ describe("TaskService", () => { emit, } as unknown as WorkspaceService; - const initStateManager: InitStateManager = { - startInit: mock(() => undefined), - appendOutput: mock(() => undefined), - endInit: mock(() => Promise.resolve()), - } as unknown as InitStateManager; + const initStateManager = createMockInitStateManager(); const taskService = new TaskService( config, @@ -1432,11 +1542,7 @@ describe("TaskService", () => { emit, } as unknown as WorkspaceService; - const initStateManager: InitStateManager = { - startInit: mock(() => undefined), - appendOutput: mock(() => undefined), - endInit: mock(() => Promise.resolve()), - } as unknown as InitStateManager; + const initStateManager = createMockInitStateManager(); const taskService = new TaskService( config, @@ -1560,11 +1666,7 @@ describe("TaskService", () => { emit, } as unknown as WorkspaceService; - const initStateManager: InitStateManager = { - startInit: mock(() => undefined), - appendOutput: mock(() => undefined), - endInit: mock(() => Promise.resolve()), - } as unknown as InitStateManager; + const initStateManager = createMockInitStateManager(); const taskService = new TaskService( config, @@ -1728,11 +1830,7 @@ describe("TaskService", () => { emit, } as unknown as WorkspaceService; - const initStateManager: InitStateManager = { - startInit: mock(() => undefined), - appendOutput: mock(() => undefined), - endInit: mock(() => Promise.resolve()), - } as unknown as InitStateManager; + const initStateManager = createMockInitStateManager(); const taskService = new TaskService( config, @@ -1880,11 +1978,7 @@ describe("TaskService", () => { emit, } as unknown as WorkspaceService; - const initStateManager: InitStateManager = { - startInit: mock(() => undefined), - appendOutput: mock(() => undefined), - endInit: mock(() => Promise.resolve()), - } as unknown as InitStateManager; + const initStateManager = createMockInitStateManager(); const taskService = new TaskService( config, diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 32f7514996..d50ac7d988 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -10,7 +10,7 @@ import type { PartialService } from "@/node/services/partialService"; import type { InitStateManager } from "@/node/services/initStateManager"; import { log } from "@/node/services/log"; import { createRuntime } from "@/node/runtime/runtimeFactory"; -import type { WorkspaceCreationResult } from "@/node/runtime/Runtime"; +import type { InitLogger, WorkspaceCreationResult } from "@/node/runtime/Runtime"; import { validateWorkspaceName } from "@/common/utils/validation/workspaceValidation"; import { Ok, Err, type Result } from "@/common/types/result"; import type { TaskSettings } from "@/common/types/tasks"; @@ -33,6 +33,13 @@ export type TaskKind = "agent"; export type AgentTaskStatus = NonNullable; +const NULL_INIT_LOGGER: InitLogger = { + logStep: () => undefined, + logStdout: () => undefined, + logStderr: () => undefined, + logComplete: () => undefined, +}; + export interface TaskCreateArgs { parentWorkspaceId: string; kind: TaskKind; @@ -146,6 +153,12 @@ function getIsoNow(): string { return new Date().toISOString(); } +function taskQueueDebug(message: string, details?: Record): void { + if (process.env.MUX_DEBUG_TASK_QUEUE !== "1") return; + // eslint-disable-next-line no-console + console.log(`[task-queue] ${message}`, details ?? {}); +} + export class TaskService { private readonly mutex = new AsyncMutex(); private readonly pendingWaitersByTaskId = new Map(); @@ -243,6 +256,19 @@ export class TaskService { } } + private startWorkspaceInit(workspaceId: string, projectPath: string): InitLogger { + assert(workspaceId.length > 0, "startWorkspaceInit: workspaceId must be non-empty"); + assert(projectPath.length > 0, "startWorkspaceInit: projectPath must be non-empty"); + + this.initStateManager.startInit(workspaceId, projectPath); + return { + logStep: (message: string) => this.initStateManager.appendOutput(workspaceId, message, false), + logStdout: (line: string) => this.initStateManager.appendOutput(workspaceId, line, false), + logStderr: (line: string) => this.initStateManager.appendOutput(workspaceId, line, true), + logComplete: (exitCode: number) => void this.initStateManager.endInit(workspaceId, exitCode), + }; + } + async create(args: TaskCreateArgs): Promise> { const parentWorkspaceId = coerceNonEmptyString(args.parentWorkspaceId); if (!parentWorkspaceId) { @@ -324,17 +350,89 @@ export class TaskService { projectPath: parentMeta.projectPath, }); - // Init status streaming (mirrors WorkspaceService.create) - this.initStateManager.startInit(taskId, parentMeta.projectPath); - const initLogger = { - logStep: (message: string) => this.initStateManager.appendOutput(taskId, message, false), - logStdout: (line: string) => this.initStateManager.appendOutput(taskId, line, false), - logStderr: (line: string) => this.initStateManager.appendOutput(taskId, line, true), - logComplete: (exitCode: number) => void this.initStateManager.endInit(taskId, exitCode), - }; - const createdAt = getIsoNow(); + taskQueueDebug("TaskService.create decision", { + parentWorkspaceId, + taskId, + agentType, + workspaceName, + createdAt, + activeCount, + maxParallelAgentTasks: taskSettings.maxParallelAgentTasks, + shouldQueue, + runtimeType: taskRuntimeConfig.type, + promptLength: prompt.length, + model: taskModelString, + thinkingLevel: effectiveThinkingLevel, + }); + + if (shouldQueue) { + const trunkBranch = coerceNonEmptyString(parentMeta.name); + if (!trunkBranch) { + return Err("Task.create: parent workspace name missing (cannot queue task)"); + } + + // NOTE: Queued tasks are persisted immediately, but their workspace is created later + // when a parallel slot is available. This ensures queued tasks don't create worktrees + // or run init hooks until they actually start. + const workspacePath = runtime.getWorkspacePath(parentMeta.projectPath, workspaceName); + + taskQueueDebug("TaskService.create queued (persist-only)", { + taskId, + workspaceName, + parentWorkspaceId, + trunkBranch, + workspacePath, + }); + + await this.config.editConfig((config) => { + let projectConfig = config.projects.get(parentMeta.projectPath); + if (!projectConfig) { + projectConfig = { workspaces: [] }; + config.projects.set(parentMeta.projectPath, projectConfig); + } + + projectConfig.workspaces.push({ + path: workspacePath, + id: taskId, + name: workspaceName, + title: args.description, + createdAt, + runtimeConfig: taskRuntimeConfig, + aiSettings: { model: canonicalModel, thinkingLevel: effectiveThinkingLevel }, + parentWorkspaceId, + agentType, + taskStatus: "queued", + taskPrompt: prompt, + taskTrunkBranch: trunkBranch, + taskModelString, + taskThinkingLevel: effectiveThinkingLevel, + }); + return config; + }); + + // Emit metadata update so the UI sees the workspace immediately. + const allMetadata = await this.config.getAllWorkspaceMetadata(); + const childMeta = allMetadata.find((m) => m.id === taskId) ?? null; + this.workspaceService.emit("metadata", { workspaceId: taskId, metadata: childMeta }); + + // NOTE: Do NOT persist the prompt into chat history until the task actually starts. + // Otherwise the frontend treats "last message is user" as an interrupted stream and + // will auto-retry / backoff-spam resume attempts while the task is queued. + taskQueueDebug("TaskService.create queued persisted (prompt stored in config)", { + taskId, + workspaceName, + }); + + // Schedule queue processing (best-effort). + void this.maybeStartQueuedTasks(); + taskQueueDebug("TaskService.create queued scheduled maybeStartQueuedTasks", { taskId }); + return Ok({ taskId, kind: "agent", status: "queued" }); + } + + const initLogger = this.startWorkspaceInit(taskId, parentMeta.projectPath); + // Note: Local project-dir runtimes share the same directory (unsafe by design). // For worktree/ssh runtimes we attempt a fork first; otherwise fall back to createWorkspace. const forkResult = await runtime.forkWorkspace({ @@ -358,6 +456,7 @@ export class TaskService { }); if (!createResult.success || !createResult.workspacePath) { + initLogger.logComplete(-1); return Err( `Task.create: failed to create agent workspace (${createResult.error ?? "unknown error"})` ); @@ -365,6 +464,14 @@ export class TaskService { const workspacePath = createResult.workspacePath; + taskQueueDebug("TaskService.create started (workspace created)", { + taskId, + workspaceName, + workspacePath, + trunkBranch, + forkSuccess: forkResult.success, + }); + // Persist workspace entry before starting work so it's durable across crashes. await this.config.editConfig((config) => { let projectConfig = config.projects.get(parentMeta.projectPath); @@ -373,20 +480,21 @@ export class TaskService { config.projects.set(parentMeta.projectPath, projectConfig); } - projectConfig.workspaces.push({ - path: workspacePath, - id: taskId, - name: workspaceName, + projectConfig.workspaces.push({ + path: workspacePath, + id: taskId, + name: workspaceName, title: args.description, createdAt, runtimeConfig: taskRuntimeConfig, - aiSettings: { model: canonicalModel, thinkingLevel: effectiveThinkingLevel }, - parentWorkspaceId, - agentType, - taskStatus: shouldQueue ? "queued" : "running", - taskModelString, - taskThinkingLevel: effectiveThinkingLevel, - }); + aiSettings: { model: canonicalModel, thinkingLevel: effectiveThinkingLevel }, + parentWorkspaceId, + agentType, + taskStatus: shouldQueue ? "queued" : "running", + taskTrunkBranch: trunkBranch, + taskModelString, + taskThinkingLevel: effectiveThinkingLevel, + }); return config; }); @@ -410,24 +518,6 @@ export class TaskService { initLogger.logComplete(-1); }); - if (shouldQueue) { - // Persist the prompt as the first user message so the task can be resumed later. - const messageId = `user-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; - const userMessage = createMuxMessage(messageId, "user", prompt, { - timestamp: Date.now(), - }); - - const appendResult = await this.historyService.appendToHistory(taskId, userMessage); - if (!appendResult.success) { - await this.rollbackFailedTaskCreate(runtime, parentMeta.projectPath, workspaceName, taskId); - return Err(`Task.create: failed to persist queued prompt (${appendResult.error})`); - } - - // Schedule queue processing (best-effort). - void this.maybeStartQueuedTasks(); - return Ok({ taskId, kind: "agent", status: "queued" }); - } - // Start immediately (counts towards parallel limit). const sendResult = await this.workspaceService.sendMessage(taskId, prompt, { model: taskModelString, @@ -986,6 +1076,11 @@ export class TaskService { const activeCount = this.countActiveAgentTasks(config); const availableSlots = Math.max(0, taskSettings.maxParallelAgentTasks - activeCount); + taskQueueDebug("TaskService.maybeStartQueuedTasks summary", { + activeCount, + maxParallelAgentTasks: taskSettings.maxParallelAgentTasks, + availableSlots, + }); if (availableSlots === 0) return; const queued = this.listAgentTaskWorkspaces(config) @@ -994,25 +1089,230 @@ export class TaskService { const aTime = a.createdAt ? Date.parse(a.createdAt) : 0; const bTime = b.createdAt ? Date.parse(b.createdAt) : 0; return aTime - bTime; - }) - .slice(0, availableSlots); + }); + + taskQueueDebug("TaskService.maybeStartQueuedTasks candidates", { + queuedCount: queued.length, + queuedIds: queued.map((t) => t.id).filter((id): id is string => typeof id === "string"), + }); + let startedCount = 0; for (const task of queued) { + if (startedCount >= availableSlots) { + break; + } if (!task.id) continue; + const taskId = task.id; - // Start by resuming from the queued prompt in history. - const model = task.taskModelString ?? defaultModel; - const resumeResult = await this.workspaceService.resumeStream(task.id, { - model, - thinkingLevel: task.taskThinkingLevel, - }); + assert(typeof task.name === "string" && task.name.trim().length > 0, "Task name missing"); - if (!resumeResult.success) { - log.error("Failed to start queued task", { taskId: task.id, error: resumeResult.error }); + const parentId = coerceNonEmptyString(task.parentWorkspaceId); + if (!parentId) { + log.error("Queued task missing parentWorkspaceId; cannot start", { taskId }); continue; } - await this.setTaskStatus(task.id, "running"); + const parentEntry = this.findWorkspaceEntry(config, parentId); + if (!parentEntry) { + log.error("Queued task parent not found; cannot start", { taskId, parentId }); + continue; + } + + const parentWorkspaceName = coerceNonEmptyString(parentEntry.workspace.name); + if (!parentWorkspaceName) { + log.error("Queued task parent missing workspace name; cannot start", { + taskId, + parentId, + }); + continue; + } + + const runtimeConfig = task.runtimeConfig ?? parentEntry.workspace.runtimeConfig; + if (!runtimeConfig) { + log.error("Queued task missing runtimeConfig; cannot start", { taskId }); + continue; + } + + const runtime = createRuntime(runtimeConfig, { + projectPath: task.projectPath, + }); + + const workspaceName = task.name.trim(); + let workspacePath = + coerceNonEmptyString(task.path) ?? runtime.getWorkspacePath(task.projectPath, workspaceName); + + let workspaceExists = false; + try { + await runtime.stat(workspacePath); + workspaceExists = true; + } catch { + workspaceExists = false; + } + + const inMemoryInit = this.initStateManager.getInitState(taskId); + const persistedInit = inMemoryInit ? null : await this.initStateManager.readInitStatus(taskId); + + // Ensure the workspace exists before starting. Queued tasks should not create worktrees/directories + // until they are actually dequeued. + let trunkBranch = + typeof task.taskTrunkBranch === "string" && task.taskTrunkBranch.trim().length > 0 + ? task.taskTrunkBranch.trim() + : parentWorkspaceName; + if (trunkBranch.length === 0) { + trunkBranch = "main"; + } + + let shouldRunInit = !inMemoryInit && !persistedInit; + let initLogger: InitLogger | null = null; + const getInitLogger = (): InitLogger => { + if (initLogger) return initLogger; + initLogger = this.startWorkspaceInit(taskId, task.projectPath); + return initLogger; + }; + + taskQueueDebug("TaskService.maybeStartQueuedTasks start attempt", { + taskId, + workspaceName, + parentId, + parentWorkspaceName, + runtimeType: runtimeConfig.type, + workspacePath, + workspaceExists, + trunkBranch, + shouldRunInit, + inMemoryInit: Boolean(inMemoryInit), + persistedInit: Boolean(persistedInit), + }); + + // If the workspace doesn't exist yet, create it now (fork preferred, else createWorkspace). + if (!workspaceExists) { + shouldRunInit = true; + const initLogger = getInitLogger(); + + const forkResult = await runtime.forkWorkspace({ + projectPath: task.projectPath, + sourceWorkspaceName: parentWorkspaceName, + newWorkspaceName: workspaceName, + initLogger, + }); + + trunkBranch = forkResult.success ? (forkResult.sourceBranch ?? trunkBranch) : trunkBranch; + const createResult: WorkspaceCreationResult = forkResult.success + ? { success: true as const, workspacePath: forkResult.workspacePath } + : await runtime.createWorkspace({ + projectPath: task.projectPath, + branchName: workspaceName, + trunkBranch, + directoryName: workspaceName, + initLogger, + }); + + if (!createResult.success || !createResult.workspacePath) { + initLogger.logComplete(-1); + const errorMessage = createResult.error ?? "unknown error"; + log.error("Failed to create queued task workspace", { taskId, error: errorMessage }); + taskQueueDebug("TaskService.maybeStartQueuedTasks createWorkspace failed", { + taskId, + error: errorMessage, + forkSuccess: forkResult.success, + }); + continue; + } + + workspacePath = createResult.workspacePath; + workspaceExists = true; + + taskQueueDebug("TaskService.maybeStartQueuedTasks workspace created", { + taskId, + workspacePath, + forkSuccess: forkResult.success, + trunkBranch, + }); + + // Persist any corrected path/trunkBranch for restart-safe init. + await this.config.editConfig((cfg) => { + for (const [_projectPath, project] of cfg.projects) { + const ws = project.workspaces.find((w) => w.id === taskId); + if (!ws) continue; + ws.path = workspacePath; + ws.taskTrunkBranch = trunkBranch; + return cfg; + } + return cfg; + }); + } + + // If init has not yet run for this workspace, start it now (best-effort, async). + // This is intentionally coupled to task start so queued tasks don't run init hooks + // (SSH sync, .mux/init scripts, etc.) until they actually begin execution. + if (shouldRunInit) { + const initLogger = getInitLogger(); + taskQueueDebug("TaskService.maybeStartQueuedTasks initWorkspace starting", { + taskId, + workspacePath, + trunkBranch, + }); + void runtime + .initWorkspace({ + projectPath: task.projectPath, + branchName: workspaceName, + trunkBranch, + workspacePath, + initLogger, + }) + .catch((error: unknown) => { + const errorMessage = error instanceof Error ? error.message : String(error); + initLogger.logStderr(`Initialization failed: ${errorMessage}`); + initLogger.logComplete(-1); + }); + } + + const model = task.taskModelString ?? defaultModel; + const queuedPrompt = coerceNonEmptyString(task.taskPrompt); + if (queuedPrompt) { + taskQueueDebug("TaskService.maybeStartQueuedTasks sendMessage starting (dequeue)", { + taskId, + model, + promptLength: queuedPrompt.length, + }); + const sendResult = await this.workspaceService.sendMessage( + taskId, + queuedPrompt, + { model, thinkingLevel: task.taskThinkingLevel }, + { allowQueuedAgentTask: true } + ); + if (!sendResult.success) { + log.error("Failed to start queued task via sendMessage", { + taskId, + error: sendResult.error, + }); + continue; + } + } else { + // Backward compatibility: older queued tasks persisted their prompt in chat history. + taskQueueDebug("TaskService.maybeStartQueuedTasks resumeStream starting (legacy dequeue)", { + taskId, + model, + }); + const resumeResult = await this.workspaceService.resumeStream( + taskId, + { model, thinkingLevel: task.taskThinkingLevel }, + { allowQueuedAgentTask: true } + ); + + if (!resumeResult.success) { + log.error("Failed to start queued task", { taskId, error: resumeResult.error }); + taskQueueDebug("TaskService.maybeStartQueuedTasks resumeStream failed", { + taskId, + error: String(resumeResult.error), + }); + continue; + } + } + + await this.setTaskStatus(taskId, "running"); + taskQueueDebug("TaskService.maybeStartQueuedTasks started", { taskId }); + startedCount += 1; } } @@ -1024,6 +1324,9 @@ export class TaskService { const ws = project.workspaces.find((w) => w.id === workspaceId); if (ws) { ws.taskStatus = status; + if (status === "running") { + ws.taskPrompt = undefined; + } return config; } } diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index d1e6876245..33b5ec8009 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -77,6 +77,12 @@ function isWorkspaceNameCollision(error: string | undefined): boolean { return error?.includes("Workspace already exists") ?? false; } +function taskQueueDebug(message: string, details?: Record): void { + if (process.env.MUX_DEBUG_TASK_QUEUE !== "1") return; + // eslint-disable-next-line no-console + console.log(`[task-queue] ${message}`, details ?? {}); +} + /** * Generates a unique workspace name by appending a random suffix */ @@ -1137,7 +1143,8 @@ export class WorkspaceService extends EventEmitter { | (SendMessageOptions & { imageParts?: ImagePart[]; }) - | undefined = { model: defaultModel } + | undefined = { model: defaultModel }, + internal?: { allowQueuedAgentTask?: boolean } ): Promise> { log.debug("sendMessage handler: Received", { workspaceId, @@ -1156,6 +1163,32 @@ export class WorkspaceService extends EventEmitter { }); } + // Guard: queued agent tasks must not start streaming via generic sendMessage calls. + // They should only be started by TaskService once a parallel slot is available. + if (!internal?.allowQueuedAgentTask) { + const config = this.config.loadConfigOrDefault(); + for (const [_projectPath, project] of config.projects) { + const ws = project.workspaces.find((w) => w.id === workspaceId); + if (!ws) continue; + if (ws.parentWorkspaceId && ws.taskStatus === "queued") { + taskQueueDebug("WorkspaceService.sendMessage blocked (queued task)", { + workspaceId, + stack: new Error("sendMessage blocked").stack, + }); + return Err({ + type: "unknown", + raw: "This agent task is queued and cannot start yet. Wait for a slot to free.", + }); + } + break; + } + } else { + taskQueueDebug("WorkspaceService.sendMessage allowed (internal dequeue)", { + workspaceId, + stack: new Error("sendMessage internal").stack, + }); + } + const session = this.getOrCreateSession(workspaceId); // Skip recency update for idle compaction - preserve original "last used" time @@ -1273,7 +1306,8 @@ export class WorkspaceService extends EventEmitter { async resumeStream( workspaceId: string, - options: SendMessageOptions | undefined = { model: "claude-3-5-sonnet-latest" } + options: SendMessageOptions | undefined = { model: "claude-3-5-sonnet-latest" }, + internal?: { allowQueuedAgentTask?: boolean } ): Promise> { try { // Block streaming while workspace is being renamed to prevent path conflicts @@ -1285,6 +1319,32 @@ export class WorkspaceService extends EventEmitter { }); } + // Guard: queued agent tasks must not be resumed by generic UI/API calls. + // TaskService is responsible for dequeuing and starting them. + if (!internal?.allowQueuedAgentTask) { + const config = this.config.loadConfigOrDefault(); + for (const [_projectPath, project] of config.projects) { + const ws = project.workspaces.find((w) => w.id === workspaceId); + if (!ws) continue; + if (ws.parentWorkspaceId && ws.taskStatus === "queued") { + taskQueueDebug("WorkspaceService.resumeStream blocked (queued task)", { + workspaceId, + stack: new Error("resumeStream blocked").stack, + }); + return Err({ + type: "unknown", + raw: "This agent task is queued and cannot start yet. Wait for a slot to free.", + }); + } + break; + } + } else { + taskQueueDebug("WorkspaceService.resumeStream allowed (internal dequeue)", { + workspaceId, + stack: new Error("resumeStream internal").stack, + }); + } + const session = this.getOrCreateSession(workspaceId); // Persist last-used model + thinking level for cross-device consistency. From cd17b4f7e7d534dd09b8836c6239eabdfa6cd969 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Dec 2025 23:09:05 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20DRY=20task=20orc?= =?UTF-8?q?hestration=20+=20thinking=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate shared thinking policy and reduce tool/task scaffolding. Signed-off-by: Thomas Kosiewski --- _Generated with `mux` • Model: gpt-5.2 • Thinking: unknown_ Change-Id: Id77858efe746d9b7551ab266f98886dc7712a5f3 --- src/browser/App.tsx | 2 +- src/browser/components/ChatInput/index.tsx | 2 +- .../Settings/sections/TasksSection.tsx | 134 +-- src/browser/components/ThinkingSlider.tsx | 2 +- src/browser/contexts/ThinkingContext.tsx | 2 +- src/browser/hooks/useAIViewKeybinds.ts | 2 +- src/browser/hooks/useSendMessageOptions.ts | 2 +- src/browser/utils/messages/sendOptions.ts | 2 +- src/common/types/tasks.ts | 16 +- src/common/types/thinking.ts | 14 + src/common/utils/ai/providerOptions.ts | 2 +- .../utils/thinking/policy.test.ts | 0 .../utils/thinking/policy.ts | 0 src/node/services/agentPresets.ts | 132 +-- src/node/services/agentSession.ts | 2 +- src/node/services/taskService.test.ts | 913 +++++------------- src/node/services/taskService.ts | 173 ++-- src/node/services/tools/agent_report.ts | 10 +- src/node/services/tools/task.ts | 64 +- src/node/services/tools/task_await.ts | 31 +- src/node/services/tools/task_list.ts | 18 +- src/node/services/tools/task_terminate.ts | 27 +- src/node/services/tools/toolUtils.ts | 32 + src/node/services/workspaceService.ts | 3 +- 24 files changed, 604 insertions(+), 981 deletions(-) rename src/{browser => common}/utils/thinking/policy.test.ts (100%) rename src/{browser => common}/utils/thinking/policy.ts (100%) create mode 100644 src/node/services/tools/toolUtils.ts diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 448dd5bcc2..41eecc811b 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -40,7 +40,7 @@ import { getModelKey, } from "@/common/constants/storage"; import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels"; -import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy"; +import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; import type { BranchListResult } from "@/common/orpc/types"; import { useTelemetry } from "./hooks/useTelemetry"; diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index ca89dc176c..a524fc7918 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -22,7 +22,7 @@ import { ModelSettings } from "../ModelSettings"; import { useAPI } from "@/browser/contexts/API"; import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel"; import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels"; -import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy"; +import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; import { getModelKey, diff --git a/src/browser/components/Settings/sections/TasksSection.tsx b/src/browser/components/Settings/sections/TasksSection.tsx index 7fc2070755..47eb227f77 100644 --- a/src/browser/components/Settings/sections/TasksSection.tsx +++ b/src/browser/components/Settings/sections/TasksSection.tsx @@ -7,6 +7,7 @@ import { normalizeTaskSettings, type TaskSettings, type SubagentAiDefaults, + type SubagentAiDefaultsEntry, } from "@/common/types/tasks"; import { BUILT_IN_SUBAGENTS } from "@/common/constants/agents"; import type { ThinkingLevel } from "@/common/types/thinking"; @@ -19,7 +20,33 @@ import { SelectTrigger, SelectValue, } from "@/browser/components/ui/select"; -import { enforceThinkingPolicy, getThinkingPolicyForModel } from "@/browser/utils/thinking/policy"; +import { enforceThinkingPolicy, getThinkingPolicyForModel } from "@/common/utils/thinking/policy"; + +const INHERIT = "__inherit__"; +const ALL_THINKING_LEVELS = ["off", "low", "medium", "high", "xhigh"] as const; + +function updateSubagentDefaultEntry( + previous: SubagentAiDefaults, + agentType: string, + update: (entry: SubagentAiDefaultsEntry) => void +): SubagentAiDefaults { + const next = { ...previous }; + const existing = next[agentType] ?? {}; + const updated: SubagentAiDefaultsEntry = { ...existing }; + update(updated); + + if (updated.modelString && updated.thinkingLevel) { + updated.thinkingLevel = enforceThinkingPolicy(updated.modelString, updated.thinkingLevel); + } + + if (!updated.modelString && !updated.thinkingLevel) { + delete next[agentType]; + } else { + next[agentType] = updated; + } + + return next; +} export function TasksSection() { const { api } = useAPI(); @@ -30,6 +57,10 @@ export function TasksSection() { const [saveError, setSaveError] = useState(null); const saveTimerRef = useRef | null>(null); const savingRef = useRef(false); + const pendingSaveRef = useRef<{ + taskSettings: TaskSettings; + subagentAiDefaults: SubagentAiDefaults; + } | null>(null); const { models, hiddenModels } = useModelsFromSettings(); @@ -74,7 +105,8 @@ export function TasksSection() { if (!api) return; if (!loaded) return; if (loadFailed) return; - if (savingRef.current) return; + + pendingSaveRef.current = { taskSettings, subagentAiDefaults }; if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); @@ -82,15 +114,27 @@ export function TasksSection() { } saveTimerRef.current = setTimeout(() => { - savingRef.current = true; - void api.config - .saveConfig({ taskSettings, subagentAiDefaults }) - .catch((error: unknown) => { - setSaveError(error instanceof Error ? error.message : String(error)); - }) - .finally(() => { - savingRef.current = false; - }); + const flush = () => { + if (savingRef.current) return; + if (!api) return; + + const payload = pendingSaveRef.current; + if (!payload) return; + + pendingSaveRef.current = null; + savingRef.current = true; + void api.config + .saveConfig(payload) + .catch((error: unknown) => { + setSaveError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => { + savingRef.current = false; + flush(); + }); + }; + + flush(); }, 400); return () => { @@ -111,57 +155,29 @@ export function TasksSection() { setTaskSettings((prev) => normalizeTaskSettings({ ...prev, maxTaskNestingDepth: parsed })); }; - const INHERIT = "__inherit__"; - const setSubagentModel = (agentType: string, value: string) => { - setSubagentAiDefaults((prev) => { - const next = { ...prev }; - const existing = next[agentType] ?? {}; - const updated = { ...existing }; - - if (value === INHERIT) { - delete updated.modelString; - } else { - updated.modelString = value; - } - - if (updated.modelString && updated.thinkingLevel) { - updated.thinkingLevel = enforceThinkingPolicy(updated.modelString, updated.thinkingLevel); - } - - if (!updated.modelString && !updated.thinkingLevel) { - delete next[agentType]; - } else { - next[agentType] = updated; - } - - return next; - }); + setSubagentAiDefaults((prev) => + updateSubagentDefaultEntry(prev, agentType, (updated) => { + if (value === INHERIT) { + delete updated.modelString; + } else { + updated.modelString = value; + } + }) + ); }; const setSubagentThinking = (agentType: string, value: string) => { - setSubagentAiDefaults((prev) => { - const next = { ...prev }; - const existing = next[agentType] ?? {}; - const updated = { ...existing }; - - if (value === INHERIT) { - delete updated.thinkingLevel; - } else { - const requested = value as ThinkingLevel; - updated.thinkingLevel = updated.modelString - ? enforceThinkingPolicy(updated.modelString, requested) - : requested; - } + setSubagentAiDefaults((prev) => + updateSubagentDefaultEntry(prev, agentType, (updated) => { + if (value === INHERIT) { + delete updated.thinkingLevel; + return; + } - if (!updated.modelString && !updated.thinkingLevel) { - delete next[agentType]; - } else { - next[agentType] = updated; - } - - return next; - }); + updated.thinkingLevel = value as ThinkingLevel; + }) + ); }; return ( @@ -224,9 +240,7 @@ export function TasksSection() { const modelValue = entry?.modelString ?? INHERIT; const thinkingValue = entry?.thinkingLevel ?? INHERIT; const allowedThinkingLevels = - modelValue !== INHERIT - ? getThinkingPolicyForModel(modelValue) - : (["off", "low", "medium", "high", "xhigh"] as const); + modelValue !== INHERIT ? getThinkingPolicyForModel(modelValue) : ALL_THINKING_LEVELS; return (
{ - const value = entry.thinkingLevel; - if ( - value === "off" || - value === "low" || - value === "medium" || - value === "high" || - value === "xhigh" - ) { - return value; - } - return undefined; - })(); + const thinkingLevel = coerceThinkingLevel(entry.thinkingLevel); if (!modelString && !thinkingLevel) { continue; diff --git a/src/common/types/thinking.ts b/src/common/types/thinking.ts index 0b820144e8..6f6969678b 100644 --- a/src/common/types/thinking.ts +++ b/src/common/types/thinking.ts @@ -13,6 +13,20 @@ export type ThinkingLevel = "off" | "low" | "medium" | "high" | "xhigh"; */ export type ThinkingLevelOn = Exclude; +export function isThinkingLevel(value: unknown): value is ThinkingLevel { + return ( + value === "off" || + value === "low" || + value === "medium" || + value === "high" || + value === "xhigh" + ); +} + +export function coerceThinkingLevel(value: unknown): ThinkingLevel | undefined { + return isThinkingLevel(value) ? value : undefined; +} + /** * Anthropic thinking token budget mapping * diff --git a/src/common/utils/ai/providerOptions.ts b/src/common/utils/ai/providerOptions.ts index f10e3feaab..132ac9c4a7 100644 --- a/src/common/utils/ai/providerOptions.ts +++ b/src/common/utils/ai/providerOptions.ts @@ -19,7 +19,7 @@ import { } from "@/common/types/thinking"; import { log } from "@/node/services/log"; import type { MuxMessage } from "@/common/types/message"; -import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy"; +import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; import { normalizeGatewayModel } from "./models"; /** diff --git a/src/browser/utils/thinking/policy.test.ts b/src/common/utils/thinking/policy.test.ts similarity index 100% rename from src/browser/utils/thinking/policy.test.ts rename to src/common/utils/thinking/policy.test.ts diff --git a/src/browser/utils/thinking/policy.ts b/src/common/utils/thinking/policy.ts similarity index 100% rename from src/browser/utils/thinking/policy.ts rename to src/common/utils/thinking/policy.ts diff --git a/src/node/services/agentPresets.ts b/src/node/services/agentPresets.ts index 3bd0986341..1ce1028095 100644 --- a/src/node/services/agentPresets.ts +++ b/src/node/services/agentPresets.ts @@ -7,74 +7,94 @@ export interface AgentPreset { systemPrompt: string; } -const RESEARCH_PRESET: AgentPreset = { - agentType: "research", - toolPolicy: [ +const TASK_TOOL_NAMES = [ + "task", + "task_await", + "task_list", + "task_terminate", + "agent_report", +] as const; + +function enableOnly(...toolNames: readonly string[]): ToolPolicy { + return [ { regex_match: ".*", action: "disable" }, - { regex_match: "web_search", action: "enable" }, - { regex_match: "web_fetch", action: "enable" }, - { regex_match: "file_read", action: "enable" }, - { regex_match: "task", action: "enable" }, - { regex_match: "task_await", action: "enable" }, - { regex_match: "task_list", action: "enable" }, - { regex_match: "task_terminate", action: "enable" }, - { regex_match: "agent_report", action: "enable" }, - ], - systemPrompt: [ - "You are a Research sub-agent running inside a child workspace.", + ...toolNames.map((toolName) => ({ regex_match: toolName, action: "enable" as const })), + ]; +} + +const REPORTING_PROMPT_LINES = [ + "Reporting:", + "- When you have a final answer, call agent_report exactly once.", + "- Do not call agent_report until any spawned sub-tasks have completed and you have integrated their results.", +] as const; + +function buildSystemPrompt(args: { + agentLabel: string; + goals: string[]; + rules: string[]; + delegation: string[]; +}): string { + return [ + `You are a ${args.agentLabel} sub-agent running inside a child workspace.`, "", "Goals:", - "- Gather accurate, relevant information efficiently.", - "- Prefer primary sources and official docs when possible.", + ...args.goals, "", "Rules:", - "- Do not edit files.", - "- Do not run bash commands unless explicitly enabled (assume it is not).", - "- If the task tool is available and you need repository exploration beyond file_read, delegate to an Explore sub-agent.", + ...args.rules, "", "Delegation:", - '- If available, use: task({ subagent_type: "explore", prompt: "..." }) when you need repo exploration.', + ...args.delegation, "", - "Reporting:", - "- When you have a final answer, call agent_report exactly once.", - "- Do not call agent_report until any spawned sub-tasks have completed and you have integrated their results.", - ].join("\n"), + ...REPORTING_PROMPT_LINES, + ].join("\n"); +} + +const RESEARCH_PRESET: AgentPreset = { + agentType: "research", + toolPolicy: enableOnly("web_search", "web_fetch", "file_read", ...TASK_TOOL_NAMES), + systemPrompt: buildSystemPrompt({ + agentLabel: "Research", + goals: [ + "- Gather accurate, relevant information efficiently.", + "- Prefer primary sources and official docs when possible.", + ], + rules: [ + "- Do not edit files.", + "- Do not run bash commands unless explicitly enabled (assume it is not).", + "- If the task tool is available and you need repository exploration beyond file_read, delegate to an Explore sub-agent.", + ], + delegation: [ + '- If available, use: task({ subagent_type: "explore", prompt: "..." }) when you need repo exploration.', + ], + }), }; const EXPLORE_PRESET: AgentPreset = { agentType: "explore", - toolPolicy: [ - { regex_match: ".*", action: "disable" }, - { regex_match: "file_read", action: "enable" }, - { regex_match: "bash", action: "enable" }, - { regex_match: "bash_output", action: "enable" }, - { regex_match: "bash_background_list", action: "enable" }, - { regex_match: "bash_background_terminate", action: "enable" }, - { regex_match: "task", action: "enable" }, - { regex_match: "task_await", action: "enable" }, - { regex_match: "task_list", action: "enable" }, - { regex_match: "task_terminate", action: "enable" }, - { regex_match: "agent_report", action: "enable" }, - ], - systemPrompt: [ - "You are an Explore sub-agent running inside a child workspace.", - "", - "Goals:", - "- Explore the repository to answer the prompt using read-only investigation.", - "- Keep output concise and actionable (paths, symbols, and findings).", - "", - "Rules:", - "- Do not edit files.", - "- Treat bash as read-only: prefer commands like rg, ls, cat, git show, git diff (read-only).", - "- If the task tool is available and you need external information, delegate to a Research sub-agent.", - "", - "Delegation:", - '- If available, use: task({ subagent_type: "research", prompt: "..." }) when you need web research.', - "", - "Reporting:", - "- When you have a final answer, call agent_report exactly once.", - "- Do not call agent_report until any spawned sub-tasks have completed and you have integrated their results.", - ].join("\n"), + toolPolicy: enableOnly( + "file_read", + "bash", + "bash_output", + "bash_background_list", + "bash_background_terminate", + ...TASK_TOOL_NAMES + ), + systemPrompt: buildSystemPrompt({ + agentLabel: "Explore", + goals: [ + "- Explore the repository to answer the prompt using read-only investigation.", + "- Keep output concise and actionable (paths, symbols, and findings).", + ], + rules: [ + "- Do not edit files.", + "- Treat bash as read-only: prefer commands like rg, ls, cat, git show, git diff (read-only).", + "- If the task tool is available and you need external information, delegate to a Research sub-agent.", + ], + delegation: [ + '- If available, use: task({ subagent_type: "research", prompt: "..." }) when you need web research.', + ], + }), }; const PRESETS_BY_AGENT_TYPE: Record = { diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index df1b675091..09ba8eb495 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -23,7 +23,7 @@ import type { SendMessageError } from "@/common/types/errors"; import { createUnknownSendMessageError } from "@/node/services/utils/sendMessageError"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; -import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy"; +import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; import type { MuxFrontendMetadata, ContinueMessage } from "@/common/types/message"; import { prepareUserMessageForSend } from "@/common/types/message"; import { createRuntime } from "@/node/runtime/runtimeFactory"; diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 4b0df12319..bcb4144fc8 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -47,6 +47,151 @@ function createMockInitStateManager(): InitStateManager { } as unknown as InitStateManager; } +async function createTestConfig(rootDir: string): Promise { + const config = new Config(rootDir); + await fsPromises.mkdir(config.srcDir, { recursive: true }); + return config; +} + +async function createTestProject( + rootDir: string, + name = "repo", + options?: { initGit?: boolean } +): Promise { + const projectPath = path.join(rootDir, name); + await fsPromises.mkdir(projectPath, { recursive: true }); + if (options?.initGit ?? true) { + initGitRepo(projectPath); + } + return projectPath; +} + +function stubStableIds(config: Config, ids: string[], fallbackId = "fffffffff0"): void { + let nextIdIndex = 0; + const configWithStableId = config as unknown as { generateStableId: () => string }; + configWithStableId.generateStableId = () => ids[nextIdIndex++] ?? fallbackId; +} + +function createAIServiceMocks( + config: Config, + overrides?: Partial<{ + isStreaming: ReturnType; + getWorkspaceMetadata: ReturnType; + stopStream: ReturnType; + on: ReturnType; + off: ReturnType; + }> +): { + aiService: AIService; + isStreaming: ReturnType; + getWorkspaceMetadata: ReturnType; + stopStream: ReturnType; + on: ReturnType; + off: ReturnType; +} { + const isStreaming = overrides?.isStreaming ?? mock(() => false); + const getWorkspaceMetadata = + overrides?.getWorkspaceMetadata ?? + mock(async (workspaceId: string): Promise> => { + const all = await config.getAllWorkspaceMetadata(); + const found = all.find((m) => m.id === workspaceId); + return found ? Ok(found) : Err("not found"); + }); + + const stopStream = + overrides?.stopStream ?? mock((): Promise> => Promise.resolve(Ok(undefined))); + + const on = overrides?.on ?? mock(() => undefined); + const off = overrides?.off ?? mock(() => undefined); + + return { + aiService: { isStreaming, getWorkspaceMetadata, stopStream, on, off } as unknown as AIService, + isStreaming, + getWorkspaceMetadata, + stopStream, + on, + off, + }; +} + +function createWorkspaceServiceMocks( + overrides?: Partial<{ + sendMessage: ReturnType; + resumeStream: ReturnType; + remove: ReturnType; + emit: ReturnType; + }> +): { + workspaceService: WorkspaceService; + sendMessage: ReturnType; + resumeStream: ReturnType; + remove: ReturnType; + emit: ReturnType; +} { + const sendMessage = + overrides?.sendMessage ?? mock((): Promise> => Promise.resolve(Ok(undefined))); + const resumeStream = + overrides?.resumeStream ?? mock((): Promise> => Promise.resolve(Ok(undefined))); + const remove = + overrides?.remove ?? mock((): Promise> => Promise.resolve(Ok(undefined))); + const emit = overrides?.emit ?? mock(() => true); + + return { + workspaceService: { + sendMessage, + resumeStream, + remove, + emit, + } as unknown as WorkspaceService, + sendMessage, + resumeStream, + remove, + emit, + }; +} + +function createTaskServiceHarness( + config: Config, + overrides?: { + aiService?: AIService; + workspaceService?: WorkspaceService; + initStateManager?: InitStateManager; + } +): { + historyService: HistoryService; + partialService: PartialService; + taskService: TaskService; + aiService: AIService; + workspaceService: WorkspaceService; + initStateManager: InitStateManager; +} { + const historyService = new HistoryService(config); + const partialService = new PartialService(config, historyService); + + const aiService = overrides?.aiService ?? createAIServiceMocks(config).aiService; + const workspaceService = + overrides?.workspaceService ?? createWorkspaceServiceMocks().workspaceService; + const initStateManager = overrides?.initStateManager ?? createMockInitStateManager(); + + const taskService = new TaskService( + config, + historyService, + partialService, + aiService, + workspaceService, + initStateManager + ); + + return { + historyService, + partialService, + taskService, + aiService, + workspaceService, + initStateManager, + }; +} + describe("TaskService", () => { let rootDir: string; @@ -59,18 +204,10 @@ describe("TaskService", () => { }); test("enforces maxTaskNestingDepth", async () => { - const config = new Config(rootDir); - await fsPromises.mkdir(config.srcDir, { recursive: true }); + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc"], "dddddddddd"); - // Deterministic IDs for workspace names. - const ids = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc"]; - let nextIdIndex = 0; - const configWithStableId = config as unknown as { generateStableId: () => string }; - configWithStableId.generateStableId = () => ids[nextIdIndex++] ?? "dddddddddd"; - - const projectPath = path.join(rootDir, "repo"); - await fsPromises.mkdir(projectPath, { recursive: true }); - initGitRepo(projectPath); + const projectPath = await createTestProject(rootDir); const runtimeConfig = { type: "worktree" as const, srcBaseDir: config.srcDir }; const runtime = createRuntime(runtimeConfig, { projectPath }); @@ -109,40 +246,7 @@ describe("TaskService", () => { ]), taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 1 }, }); - - const historyService = new HistoryService(config); - const partialService = new PartialService(config, historyService); - - const aiService: AIService = { - isStreaming: mock(() => false), - getWorkspaceMetadata: mock( - async (workspaceId: string): Promise> => { - const all = await config.getAllWorkspaceMetadata(); - const found = all.find((m) => m.id === workspaceId); - return found ? Ok(found) : Err("not found"); - } - ), - on: mock(() => undefined), - off: mock(() => undefined), - } as unknown as AIService; - - const workspaceService: WorkspaceService = { - sendMessage: mock(() => Promise.resolve(Ok(undefined))), - resumeStream: mock(() => Promise.resolve(Ok(undefined))), - remove: mock(() => Promise.resolve(Ok(undefined))), - emit: mock(() => true), - } as unknown as WorkspaceService; - - const initStateManager = createMockInitStateManager(); - - const taskService = new TaskService( - config, - historyService, - partialService, - aiService, - workspaceService, - initStateManager - ); + const { taskService } = createTaskServiceHarness(config); const first = await taskService.create({ parentWorkspaceId: parentId, @@ -166,17 +270,10 @@ describe("TaskService", () => { }, 20_000); test("queues tasks when maxParallelAgentTasks is reached and starts them when a slot frees", async () => { - const config = new Config(rootDir); - await fsPromises.mkdir(config.srcDir, { recursive: true }); + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", "dddddddddd"], "eeeeeeeeee"); - const ids = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", "dddddddddd"]; - let nextIdIndex = 0; - const configWithStableId = config as unknown as { generateStableId: () => string }; - configWithStableId.generateStableId = () => ids[nextIdIndex++] ?? "eeeeeeeeee"; - - const projectPath = path.join(rootDir, "repo"); - await fsPromises.mkdir(projectPath, { recursive: true }); - initGitRepo(projectPath); + const projectPath = await createTestProject(rootDir); const runtimeConfig = { type: "worktree" as const, srcBaseDir: config.srcDir }; const runtime = createRuntime(runtimeConfig, { projectPath }); @@ -229,41 +326,8 @@ describe("TaskService", () => { taskSettings: { maxParallelAgentTasks: 1, maxTaskNestingDepth: 3 }, }); - const historyService = new HistoryService(config); - const partialService = new PartialService(config, historyService); - - const aiService: AIService = { - isStreaming: mock(() => false), - getWorkspaceMetadata: mock( - async (workspaceId: string): Promise> => { - const all = await config.getAllWorkspaceMetadata(); - const found = all.find((m) => m.id === workspaceId); - return found ? Ok(found) : Err("not found"); - } - ), - on: mock(() => undefined), - off: mock(() => undefined), - } as unknown as AIService; - - const sendMessage = mock(() => Promise.resolve(Ok(undefined))); - - const workspaceService: WorkspaceService = { - sendMessage, - resumeStream: mock(() => Promise.resolve(Ok(undefined))), - remove: mock(() => Promise.resolve(Ok(undefined))), - emit: mock(() => true), - } as unknown as WorkspaceService; - - const initStateManager = createMockInitStateManager(); - - const taskService = new TaskService( - config, - historyService, - partialService, - aiService, - workspaceService, - initStateManager - ); + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); const running = await taskService.create({ parentWorkspaceId: parent1Id, @@ -312,17 +376,10 @@ describe("TaskService", () => { }, 20_000); test("does not run init hooks for queued tasks until they start", async () => { - const config = new Config(rootDir); - await fsPromises.mkdir(config.srcDir, { recursive: true }); - - const ids = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc"]; - let nextIdIndex = 0; - const configWithStableId = config as unknown as { generateStableId: () => string }; - configWithStableId.generateStableId = () => ids[nextIdIndex++] ?? "dddddddddd"; + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc"], "dddddddddd"); - const projectPath = path.join(rootDir, "repo"); - await fsPromises.mkdir(projectPath, { recursive: true }); - initGitRepo(projectPath); + const projectPath = await createTestProject(rootDir); const runtimeConfig = { type: "worktree" as const, srcBaseDir: config.srcDir }; const runtime = createRuntime(runtimeConfig, { projectPath }); @@ -358,40 +415,12 @@ describe("TaskService", () => { taskSettings: { maxParallelAgentTasks: 1, maxTaskNestingDepth: 3 }, }); - const historyService = new HistoryService(config); - const partialService = new PartialService(config, historyService); const initStateManager = new RealInitStateManager(config); - - const aiService: AIService = { - isStreaming: mock(() => false), - getWorkspaceMetadata: mock( - async (workspaceId: string): Promise> => { - const all = await config.getAllWorkspaceMetadata(); - const found = all.find((m) => m.id === workspaceId); - return found ? Ok(found) : Err("not found"); - } - ), - on: mock(() => undefined), - off: mock(() => undefined), - } as unknown as AIService; - - const sendMessage = mock(() => Promise.resolve(Ok(undefined))); - - const workspaceService: WorkspaceService = { - sendMessage, - resumeStream: mock(() => Promise.resolve(Ok(undefined))), - remove: mock(() => Promise.resolve(Ok(undefined))), - emit: mock(() => true), - } as unknown as WorkspaceService; - - const taskService = new TaskService( - config, - historyService, - partialService, - aiService, + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService, - initStateManager as unknown as InitStateManager - ); + initStateManager: initStateManager as unknown as InitStateManager, + }); const running = await taskService.create({ parentWorkspaceId: parentId, @@ -421,13 +450,23 @@ describe("TaskService", () => { .flatMap((p) => p.workspaces) .find((w) => w.id === queued.data.taskId); expect(queuedEntryBeforeStart).toBeTruthy(); - await expect(fsPromises.stat(queuedEntryBeforeStart!.path)).rejects.toThrow(); + await fsPromises.stat(queuedEntryBeforeStart!.path).then( + () => { + throw new Error("Expected queued task workspace path to not exist before start"); + }, + () => undefined + ); const queuedInitStatusPath = path.join( config.getSessionDir(queued.data.taskId), "init-status.json" ); - await expect(fsPromises.stat(queuedInitStatusPath)).rejects.toThrow(); + await fsPromises.stat(queuedInitStatusPath).then( + () => { + throw new Error("Expected queued task init-status to not exist before start"); + }, + () => undefined + ); // Free slot and start queued tasks. await config.editConfig((cfg) => { @@ -451,18 +490,18 @@ describe("TaskService", () => { // Init should start only once the task is dequeued. await initStateManager.waitForInit(queued.data.taskId); - await expect(fsPromises.stat(queuedInitStatusPath)).resolves.toBeTruthy(); + expect(await fsPromises.stat(queuedInitStatusPath)).toBeTruthy(); const cfgAfterStart = config.loadConfigOrDefault(); const queuedEntryAfterStart = Array.from(cfgAfterStart.projects.values()) .flatMap((p) => p.workspaces) .find((w) => w.id === queued.data.taskId); expect(queuedEntryAfterStart).toBeTruthy(); - await expect(fsPromises.stat(queuedEntryAfterStart!.path)).resolves.toBeTruthy(); + expect(await fsPromises.stat(queuedEntryAfterStart!.path)).toBeTruthy(); }, 20_000); test("does not start queued tasks while a reported task is still streaming", async () => { - const config = new Config(rootDir); + const config = await createTestConfig(rootDir); const projectPath = path.join(rootDir, "repo"); const rootWorkspaceId = "root-111"; @@ -499,33 +538,11 @@ describe("TaskService", () => { taskSettings: { maxParallelAgentTasks: 1, maxTaskNestingDepth: 3 }, }); - const historyService = new HistoryService(config); - const partialService = new PartialService(config, historyService); - - const aiService: AIService = { + const { aiService } = createAIServiceMocks(config, { isStreaming: mock((workspaceId: string) => workspaceId === reportedTaskId), - on: mock(() => undefined), - off: mock(() => undefined), - } as unknown as AIService; - - const resumeStream = mock(() => Promise.resolve(Ok(undefined))); - const workspaceService: WorkspaceService = { - sendMessage: mock(() => Promise.resolve(Ok(undefined))), - resumeStream, - remove: mock(() => Promise.resolve(Ok(undefined))), - emit: mock(() => true), - } as unknown as WorkspaceService; - - const initStateManager = createMockInitStateManager(); - - const taskService = new TaskService( - config, - historyService, - partialService, - aiService, - workspaceService, - initStateManager - ); + }); + const { workspaceService, resumeStream } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { aiService, workspaceService }); await taskService.initialize(); @@ -539,17 +556,10 @@ describe("TaskService", () => { }); test("allows multiple agent tasks under the same parent up to maxParallelAgentTasks", async () => { - const config = new Config(rootDir); - await fsPromises.mkdir(config.srcDir, { recursive: true }); + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc"], "dddddddddd"); - const ids = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc"]; - let nextIdIndex = 0; - const configWithStableId = config as unknown as { generateStableId: () => string }; - configWithStableId.generateStableId = () => ids[nextIdIndex++] ?? "dddddddddd"; - - const projectPath = path.join(rootDir, "repo"); - await fsPromises.mkdir(projectPath, { recursive: true }); - initGitRepo(projectPath); + const projectPath = await createTestProject(rootDir); const runtimeConfig = { type: "worktree" as const, srcBaseDir: config.srcDir }; const runtime = createRuntime(runtimeConfig, { projectPath }); @@ -588,40 +598,7 @@ describe("TaskService", () => { ]), taskSettings: { maxParallelAgentTasks: 2, maxTaskNestingDepth: 3 }, }); - - const historyService = new HistoryService(config); - const partialService = new PartialService(config, historyService); - - const aiService: AIService = { - isStreaming: mock(() => false), - getWorkspaceMetadata: mock( - async (workspaceId: string): Promise> => { - const all = await config.getAllWorkspaceMetadata(); - const found = all.find((m) => m.id === workspaceId); - return found ? Ok(found) : Err("not found"); - } - ), - on: mock(() => undefined), - off: mock(() => undefined), - } as unknown as AIService; - - const workspaceService: WorkspaceService = { - sendMessage: mock(() => Promise.resolve(Ok(undefined))), - resumeStream: mock(() => Promise.resolve(Ok(undefined))), - remove: mock(() => Promise.resolve(Ok(undefined))), - emit: mock(() => true), - } as unknown as WorkspaceService; - - const initStateManager = createMockInitStateManager(); - - const taskService = new TaskService( - config, - historyService, - partialService, - aiService, - workspaceService, - initStateManager - ); + const { taskService } = createTaskServiceHarness(config); const first = await taskService.create({ parentWorkspaceId: parentId, @@ -655,16 +632,10 @@ describe("TaskService", () => { }, 20_000); test("supports creating agent tasks from local (project-dir) workspaces without requiring git", async () => { - const config = new Config(rootDir); - await fsPromises.mkdir(config.srcDir, { recursive: true }); - - const ids = ["aaaaaaaaaa"]; - let nextIdIndex = 0; - const configWithStableId = config as unknown as { generateStableId: () => string }; - configWithStableId.generateStableId = () => ids[nextIdIndex++] ?? "bbbbbbbbbb"; + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); - const projectPath = path.join(rootDir, "repo"); - await fsPromises.mkdir(projectPath, { recursive: true }); + const projectPath = await createTestProject(rootDir, "repo", { initGit: false }); const parentId = "1111111111"; await config.saveConfig({ @@ -687,40 +658,7 @@ describe("TaskService", () => { ]), taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, }); - - const historyService = new HistoryService(config); - const partialService = new PartialService(config, historyService); - - const aiService: AIService = { - isStreaming: mock(() => false), - getWorkspaceMetadata: mock( - async (workspaceId: string): Promise> => { - const all = await config.getAllWorkspaceMetadata(); - const found = all.find((m) => m.id === workspaceId); - return found ? Ok(found) : Err("not found"); - } - ), - on: mock(() => undefined), - off: mock(() => undefined), - } as unknown as AIService; - - const workspaceService: WorkspaceService = { - sendMessage: mock(() => Promise.resolve(Ok(undefined))), - resumeStream: mock(() => Promise.resolve(Ok(undefined))), - remove: mock(() => Promise.resolve(Ok(undefined))), - emit: mock(() => true), - } as unknown as WorkspaceService; - - const initStateManager = createMockInitStateManager(); - - const taskService = new TaskService( - config, - historyService, - partialService, - aiService, - workspaceService, - initStateManager - ); + const { taskService } = createTaskServiceHarness(config); const created = await taskService.create({ parentWorkspaceId: parentId, @@ -744,16 +682,10 @@ describe("TaskService", () => { }, 20_000); test("applies subagentAiDefaults model + thinking overrides on task create", async () => { - const config = new Config(rootDir); - await fsPromises.mkdir(config.srcDir, { recursive: true }); + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); - const ids = ["aaaaaaaaaa"]; - let nextIdIndex = 0; - const configWithStableId = config as unknown as { generateStableId: () => string }; - configWithStableId.generateStableId = () => ids[nextIdIndex++] ?? "bbbbbbbbbb"; - - const projectPath = path.join(rootDir, "repo"); - await fsPromises.mkdir(projectPath, { recursive: true }); + const projectPath = await createTestProject(rootDir, "repo", { initGit: false }); const parentId = "1111111111"; await config.saveConfig({ @@ -779,41 +711,8 @@ describe("TaskService", () => { explore: { modelString: "anthropic:claude-haiku-4-5", thinkingLevel: "off" }, }, }); - - const historyService = new HistoryService(config); - const partialService = new PartialService(config, historyService); - - const aiService: AIService = { - isStreaming: mock(() => false), - getWorkspaceMetadata: mock( - async (workspaceId: string): Promise> => { - const all = await config.getAllWorkspaceMetadata(); - const found = all.find((m) => m.id === workspaceId); - return found ? Ok(found) : Err("not found"); - } - ), - on: mock(() => undefined), - off: mock(() => undefined), - } as unknown as AIService; - - const sendMessage = mock(() => Promise.resolve(Ok(undefined))); - const workspaceService: WorkspaceService = { - sendMessage, - resumeStream: mock(() => Promise.resolve(Ok(undefined))), - remove: mock(() => Promise.resolve(Ok(undefined))), - emit: mock(() => true), - } as unknown as WorkspaceService; - - const initStateManager = createMockInitStateManager(); - - const taskService = new TaskService( - config, - historyService, - partialService, - aiService, - workspaceService, - initStateManager - ); + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); const created = await taskService.create({ parentWorkspaceId: parentId, @@ -843,7 +742,7 @@ describe("TaskService", () => { }, 20_000); test("auto-resumes a parent workspace until background tasks finish", async () => { - const config = new Config(rootDir); + const config = await createTestConfig(rootDir); const projectPath = path.join(rootDir, "repo"); const rootWorkspaceId = "root-111"; @@ -878,33 +777,9 @@ describe("TaskService", () => { taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, }); - const historyService = new HistoryService(config); - const partialService = new PartialService(config, historyService); - - const aiService: AIService = { - isStreaming: mock(() => false), - on: mock(() => undefined), - off: mock(() => undefined), - } as unknown as AIService; - - const resumeStream = mock(() => Promise.resolve(Ok(undefined))); - const workspaceService: WorkspaceService = { - sendMessage: mock(() => Promise.resolve(Ok(undefined))), - resumeStream, - remove: mock(() => Promise.resolve(Ok(undefined))), - emit: mock(() => true), - } as unknown as WorkspaceService; - - const initStateManager = createMockInitStateManager(); - - const taskService = new TaskService( - config, - historyService, - partialService, - aiService, - workspaceService, - initStateManager - ); + const { aiService } = createAIServiceMocks(config); + const { workspaceService, resumeStream } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { aiService, workspaceService }); const internal = taskService as unknown as { handleStreamEnd: (event: { type: "stream-end"; workspaceId: string }) => Promise; @@ -934,7 +809,7 @@ describe("TaskService", () => { }); test("terminateDescendantAgentTask stops stream, removes workspace, and rejects waiters", async () => { - const config = new Config(rootDir); + const config = await createTestConfig(rootDir); const projectPath = path.join(rootDir, "repo"); const rootWorkspaceId = "root-111"; @@ -962,35 +837,9 @@ describe("TaskService", () => { taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, }); - const historyService = new HistoryService(config); - const partialService = new PartialService(config, historyService); - - const stopStream = mock(() => Promise.resolve(Ok(undefined))); - const aiService: AIService = { - isStreaming: mock(() => false), - stopStream, - on: mock(() => undefined), - off: mock(() => undefined), - } as unknown as AIService; - - const remove = mock(() => Promise.resolve(Ok(undefined))); - const workspaceService: WorkspaceService = { - sendMessage: mock(() => Promise.resolve(Ok(undefined))), - resumeStream: mock(() => Promise.resolve(Ok(undefined))), - remove, - emit: mock(() => true), - } as unknown as WorkspaceService; - - const initStateManager = createMockInitStateManager(); - - const taskService = new TaskService( - config, - historyService, - partialService, - aiService, - workspaceService, - initStateManager - ); + const { aiService, stopStream } = createAIServiceMocks(config); + const { workspaceService, remove } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { aiService, workspaceService }); const waiter = taskService.waitForAgentReport(taskId, { timeoutMs: 10_000 }); @@ -1015,7 +864,7 @@ describe("TaskService", () => { }); test("terminateDescendantAgentTask terminates descendant tasks leaf-first", async () => { - const config = new Config(rootDir); + const config = await createTestConfig(rootDir); const projectPath = path.join(rootDir, "repo"); const rootWorkspaceId = "root-111"; @@ -1052,35 +901,9 @@ describe("TaskService", () => { taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, }); - const historyService = new HistoryService(config); - const partialService = new PartialService(config, historyService); - - const stopStream = mock(() => Promise.resolve(Ok(undefined))); - const aiService: AIService = { - isStreaming: mock(() => false), - stopStream, - on: mock(() => undefined), - off: mock(() => undefined), - } as unknown as AIService; - - const remove = mock(() => Promise.resolve(Ok(undefined))); - const workspaceService: WorkspaceService = { - sendMessage: mock(() => Promise.resolve(Ok(undefined))), - resumeStream: mock(() => Promise.resolve(Ok(undefined))), - remove, - emit: mock(() => true), - } as unknown as WorkspaceService; - - const initStateManager = createMockInitStateManager(); - - const taskService = new TaskService( - config, - historyService, - partialService, - aiService, - workspaceService, - initStateManager - ); + const { aiService } = createAIServiceMocks(config); + const { workspaceService, remove } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { aiService, workspaceService }); const terminateResult = await taskService.terminateDescendantAgentTask( rootWorkspaceId, @@ -1095,7 +918,7 @@ describe("TaskService", () => { }); test("initialize resumes awaiting_report tasks after restart", async () => { - const config = new Config(rootDir); + const config = await createTestConfig(rootDir); const projectPath = path.join(rootDir, "repo"); const parentId = "parent-111"; @@ -1123,34 +946,9 @@ describe("TaskService", () => { taskSettings: { maxParallelAgentTasks: 1, maxTaskNestingDepth: 3 }, }); - const historyService = new HistoryService(config); - const partialService = new PartialService(config, historyService); - - const aiService: AIService = { - isStreaming: mock(() => false), - on: mock(() => undefined), - off: mock(() => undefined), - } as unknown as AIService; - - const resumeStream = mock(() => Promise.resolve(Ok(undefined))); - - const workspaceService: WorkspaceService = { - sendMessage: mock(() => Promise.resolve(Ok(undefined))), - resumeStream, - remove: mock(() => Promise.resolve(Ok(undefined))), - emit: mock(() => true), - } as unknown as WorkspaceService; - - const initStateManager = createMockInitStateManager(); - - const taskService = new TaskService( - config, - historyService, - partialService, - aiService, - workspaceService, - initStateManager - ); + const { aiService } = createAIServiceMocks(config); + const { workspaceService, resumeStream } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { aiService, workspaceService }); await taskService.initialize(); @@ -1163,7 +961,7 @@ describe("TaskService", () => { }); test("waitForAgentReport does not time out while task is queued", async () => { - const config = new Config(rootDir); + const config = await createTestConfig(rootDir); const projectPath = path.join(rootDir, "repo"); const parentId = "parent-111"; @@ -1191,32 +989,7 @@ describe("TaskService", () => { taskSettings: { maxParallelAgentTasks: 1, maxTaskNestingDepth: 3 }, }); - const historyService = new HistoryService(config); - const partialService = new PartialService(config, historyService); - - const aiService: AIService = { - isStreaming: mock(() => false), - on: mock(() => undefined), - off: mock(() => undefined), - } as unknown as AIService; - - const workspaceService: WorkspaceService = { - sendMessage: mock(() => Promise.resolve(Ok(undefined))), - resumeStream: mock(() => Promise.resolve(Ok(undefined))), - remove: mock(() => Promise.resolve(Ok(undefined))), - emit: mock(() => true), - } as unknown as WorkspaceService; - - const initStateManager = createMockInitStateManager(); - - const taskService = new TaskService( - config, - historyService, - partialService, - aiService, - workspaceService, - initStateManager - ); + const { taskService } = createTaskServiceHarness(config); // Timeout is short so the test would fail if the timer started while queued. const reportPromise = taskService.waitForAgentReport(childId, { timeoutMs: 50 }); @@ -1237,7 +1010,7 @@ describe("TaskService", () => { }); test("waitForAgentReport returns cached report even after workspace is removed", async () => { - const config = new Config(rootDir); + const config = await createTestConfig(rootDir); const projectPath = path.join(rootDir, "repo"); const parentId = "parent-111"; @@ -1265,32 +1038,7 @@ describe("TaskService", () => { taskSettings: { maxParallelAgentTasks: 1, maxTaskNestingDepth: 3 }, }); - const historyService = new HistoryService(config); - const partialService = new PartialService(config, historyService); - - const aiService: AIService = { - isStreaming: mock(() => false), - on: mock(() => undefined), - off: mock(() => undefined), - } as unknown as AIService; - - const workspaceService: WorkspaceService = { - sendMessage: mock(() => Promise.resolve(Ok(undefined))), - resumeStream: mock(() => Promise.resolve(Ok(undefined))), - remove: mock(() => Promise.resolve(Ok(undefined))), - emit: mock(() => true), - } as unknown as WorkspaceService; - - const initStateManager = createMockInitStateManager(); - - const taskService = new TaskService( - config, - historyService, - partialService, - aiService, - workspaceService, - initStateManager - ); + const { taskService } = createTaskServiceHarness(config); const internal = taskService as unknown as { resolveWaiters: (taskId: string, report: { reportMarkdown: string; title?: string }) => void; @@ -1305,7 +1053,7 @@ describe("TaskService", () => { }); test("does not request agent_report on stream end while task has active descendants", async () => { - const config = new Config(rootDir); + const config = await createTestConfig(rootDir); const projectPath = path.join(rootDir, "repo"); const rootWorkspaceId = "root-111"; @@ -1342,34 +1090,8 @@ describe("TaskService", () => { taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, }); - const historyService = new HistoryService(config); - const partialService = new PartialService(config, historyService); - - const aiService: AIService = { - isStreaming: mock(() => false), - on: mock(() => undefined), - off: mock(() => undefined), - } as unknown as AIService; - - const sendMessage = mock(() => Promise.resolve(Ok(undefined))); - const emit = mock(() => true); - const workspaceService: WorkspaceService = { - sendMessage, - resumeStream: mock(() => Promise.resolve(Ok(undefined))), - remove: mock(() => Promise.resolve(Ok(undefined))), - emit, - } as unknown as WorkspaceService; - - const initStateManager = createMockInitStateManager(); - - const taskService = new TaskService( - config, - historyService, - partialService, - aiService, - workspaceService, - initStateManager - ); + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); const internal = taskService as unknown as { handleStreamEnd: (event: { type: "stream-end"; workspaceId: string }) => Promise; @@ -1386,7 +1108,7 @@ describe("TaskService", () => { }); test("reverts awaiting_report to running on stream end while task has active descendants", async () => { - const config = new Config(rootDir); + const config = await createTestConfig(rootDir); const projectPath = path.join(rootDir, "repo"); const rootWorkspaceId = "root-111"; @@ -1423,34 +1145,8 @@ describe("TaskService", () => { taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, }); - const historyService = new HistoryService(config); - const partialService = new PartialService(config, historyService); - - const aiService: AIService = { - isStreaming: mock(() => false), - on: mock(() => undefined), - off: mock(() => undefined), - } as unknown as AIService; - - const sendMessage = mock(() => Promise.resolve(Ok(undefined))); - const emit = mock(() => true); - const workspaceService: WorkspaceService = { - sendMessage, - resumeStream: mock(() => Promise.resolve(Ok(undefined))), - remove: mock(() => Promise.resolve(Ok(undefined))), - emit, - } as unknown as WorkspaceService; - - const initStateManager = createMockInitStateManager(); - - const taskService = new TaskService( - config, - historyService, - partialService, - aiService, - workspaceService, - initStateManager - ); + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); const internal = taskService as unknown as { handleStreamEnd: (event: { type: "stream-end"; workspaceId: string }) => Promise; @@ -1467,15 +1163,10 @@ describe("TaskService", () => { }); test("rolls back created workspace when initial sendMessage fails", async () => { - const config = new Config(rootDir); - await fsPromises.mkdir(config.srcDir, { recursive: true }); + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa"], "aaaaaaaaaa"); - const configWithStableId = config as unknown as { generateStableId: () => string }; - configWithStableId.generateStableId = () => "aaaaaaaaaa"; - - const projectPath = path.join(rootDir, "repo"); - await fsPromises.mkdir(projectPath, { recursive: true }); - initGitRepo(projectPath); + const projectPath = await createTestProject(rootDir); const runtimeConfig = { type: "worktree" as const, srcBaseDir: config.srcDir }; const runtime = createRuntime(runtimeConfig, { projectPath }); @@ -1513,45 +1204,10 @@ describe("TaskService", () => { ]), taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, }); - - const historyService = new HistoryService(config); - const partialService = new PartialService(config, historyService); - - const aiService: AIService = { - isStreaming: mock(() => false), - getWorkspaceMetadata: mock( - async (workspaceId: string): Promise> => { - const all = await config.getAllWorkspaceMetadata(); - const found = all.find((m) => m.id === workspaceId); - return found ? Ok(found) : Err("not found"); - } - ), - on: mock(() => undefined), - off: mock(() => undefined), - } as unknown as AIService; - - const sendMessage = mock(() => Promise.resolve(Err("send failed"))); - const resumeStream = mock(() => Promise.resolve(Ok(undefined))); - const remove = mock(() => Promise.resolve(Ok(undefined))); - const emit = mock(() => true); - - const workspaceService: WorkspaceService = { - sendMessage, - resumeStream, - remove, - emit, - } as unknown as WorkspaceService; - - const initStateManager = createMockInitStateManager(); - - const taskService = new TaskService( - config, - historyService, - partialService, - aiService, - workspaceService, - initStateManager - ); + const { aiService } = createAIServiceMocks(config); + const failingSendMessage = mock(() => Promise.resolve(Err("send failed"))); + const { workspaceService } = createWorkspaceServiceMocks({ sendMessage: failingSendMessage }); + const { taskService } = createTaskServiceHarness(config, { aiService, workspaceService }); const created = await taskService.create({ parentWorkspaceId: parentId, @@ -1580,7 +1236,7 @@ describe("TaskService", () => { }, 20_000); test("agent_report posts report to parent, finalizes pending task tool output, and triggers cleanup", async () => { - const config = new Config(rootDir); + const config = await createTestConfig(rootDir); const projectPath = path.join(rootDir, "repo"); const parentId = "parent-111"; @@ -1608,8 +1264,12 @@ describe("TaskService", () => { taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, }); - const historyService = new HistoryService(config); - const partialService = new PartialService(config, historyService); + const { aiService } = createAIServiceMocks(config); + const { workspaceService, resumeStream, remove, emit } = createWorkspaceServiceMocks(); + const { historyService, partialService, taskService } = createTaskServiceHarness(config, { + aiService, + workspaceService, + }); const parentPartial = createMuxMessage( "assistant-parent-partial", @@ -1648,35 +1308,6 @@ describe("TaskService", () => { const writeChildPartial = await partialService.writePartial(childId, childPartial); expect(writeChildPartial.success).toBe(true); - const aiService: AIService = { - isStreaming: mock(() => false), - on: mock(() => undefined), - off: mock(() => undefined), - } as unknown as AIService; - - const sendMessage = mock(() => Promise.resolve(Ok(undefined))); - const resumeStream = mock(() => Promise.resolve(Ok(undefined))); - const remove = mock(() => Promise.resolve(Ok(undefined))); - const emit = mock(() => true); - - const workspaceService: WorkspaceService = { - sendMessage, - resumeStream, - remove, - emit, - } as unknown as WorkspaceService; - - const initStateManager = createMockInitStateManager(); - - const taskService = new TaskService( - config, - historyService, - partialService, - aiService, - workspaceService, - initStateManager - ); - const internal = taskService as unknown as { handleAgentReport: (event: { type: "tool-call-end"; @@ -1741,7 +1372,7 @@ describe("TaskService", () => { }); test("agent_report updates queued/running task tool output in parent history", async () => { - const config = new Config(rootDir); + const config = await createTestConfig(rootDir); const projectPath = path.join(rootDir, "repo"); const parentId = "parent-111"; @@ -1769,8 +1400,12 @@ describe("TaskService", () => { taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, }); - const historyService = new HistoryService(config); - const partialService = new PartialService(config, historyService); + const { aiService } = createAIServiceMocks(config); + const { workspaceService, resumeStream, remove } = createWorkspaceServiceMocks(); + const { historyService, partialService, taskService } = createTaskServiceHarness(config, { + aiService, + workspaceService, + }); const parentHistoryMessage = createMuxMessage( "assistant-parent-history", @@ -1813,34 +1448,6 @@ describe("TaskService", () => { const writeChildPartial = await partialService.writePartial(childId, childPartial); expect(writeChildPartial.success).toBe(true); - const aiService: AIService = { - isStreaming: mock(() => false), - on: mock(() => undefined), - off: mock(() => undefined), - } as unknown as AIService; - - const resumeStream = mock(() => Promise.resolve(Ok(undefined))); - const remove = mock(() => Promise.resolve(Ok(undefined))); - const emit = mock(() => true); - - const workspaceService: WorkspaceService = { - sendMessage: mock(() => Promise.resolve(Ok(undefined))), - resumeStream, - remove, - emit, - } as unknown as WorkspaceService; - - const initStateManager = createMockInitStateManager(); - - const taskService = new TaskService( - config, - historyService, - partialService, - aiService, - workspaceService, - initStateManager - ); - const internal = taskService as unknown as { handleAgentReport: (event: { type: "tool-call-end"; @@ -1901,7 +1508,7 @@ describe("TaskService", () => { }); test("missing agent_report triggers one reminder, then posts fallback output and cleans up", async () => { - const config = new Config(rootDir); + const config = await createTestConfig(rootDir); const projectPath = path.join(rootDir, "repo"); const parentId = "parent-111"; @@ -1930,8 +1537,13 @@ describe("TaskService", () => { taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, }); - const historyService = new HistoryService(config); - const partialService = new PartialService(config, historyService); + const { aiService } = createAIServiceMocks(config); + const { workspaceService, sendMessage, resumeStream, remove, emit } = + createWorkspaceServiceMocks(); + const { historyService, partialService, taskService } = createTaskServiceHarness(config, { + aiService, + workspaceService, + }); const parentPartial = createMuxMessage( "assistant-parent-partial", @@ -1960,35 +1572,6 @@ describe("TaskService", () => { const appendChildHistory = await historyService.appendToHistory(childId, assistantOutput); expect(appendChildHistory.success).toBe(true); - const aiService: AIService = { - isStreaming: mock(() => false), - on: mock(() => undefined), - off: mock(() => undefined), - } as unknown as AIService; - - const sendMessage = mock(() => Promise.resolve(Ok(undefined))); - const resumeStream = mock(() => Promise.resolve(Ok(undefined))); - const remove = mock(() => Promise.resolve(Ok(undefined))); - const emit = mock(() => true); - - const workspaceService: WorkspaceService = { - sendMessage, - resumeStream, - remove, - emit, - } as unknown as WorkspaceService; - - const initStateManager = createMockInitStateManager(); - - const taskService = new TaskService( - config, - historyService, - partialService, - aiService, - workspaceService, - initStateManager - ); - const internal = taskService as unknown as { handleStreamEnd: (event: { type: "stream-end"; workspaceId: string }) => Promise; }; diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index d50ac7d988..950f9ff0e6 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -27,19 +27,12 @@ import { TaskToolArgsSchema, } from "@/common/utils/tools/toolDefinitions"; import { formatSendMessageError } from "@/node/services/utils/sendMessageError"; -import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy"; +import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; export type TaskKind = "agent"; export type AgentTaskStatus = NonNullable; -const NULL_INIT_LOGGER: InitLogger = { - logStep: () => undefined, - logStdout: () => undefined, - logStderr: () => undefined, - logComplete: () => undefined, -}; - export interface TaskCreateArgs { parentWorkspaceId: string; kind: TaskKind; @@ -155,7 +148,6 @@ function getIsoNow(): string { function taskQueueDebug(message: string, details?: Record): void { if (process.env.MUX_DEBUG_TASK_QUEUE !== "1") return; - // eslint-disable-next-line no-console console.log(`[task-queue] ${message}`, details ?? {}); } @@ -193,6 +185,41 @@ export class TaskService { }); } + private async emitWorkspaceMetadata(workspaceId: string): Promise { + assert(workspaceId.length > 0, "emitWorkspaceMetadata: workspaceId must be non-empty"); + + const allMetadata = await this.config.getAllWorkspaceMetadata(); + const metadata = allMetadata.find((m) => m.id === workspaceId) ?? null; + this.workspaceService.emit("metadata", { workspaceId, metadata }); + } + + private async editWorkspaceEntry( + workspaceId: string, + updater: (workspace: WorkspaceConfigEntry) => void, + options?: { allowMissing?: boolean } + ): Promise { + assert(workspaceId.length > 0, "editWorkspaceEntry: workspaceId must be non-empty"); + + let found = false; + await this.config.editConfig((config) => { + for (const [_projectPath, project] of config.projects) { + const ws = project.workspaces.find((w) => w.id === workspaceId); + if (!ws) continue; + updater(ws); + found = true; + return config; + } + + if (options?.allowMissing) { + return config; + } + + throw new Error(`editWorkspaceEntry: workspace ${workspaceId} not found`); + }); + + return found; + } + async initialize(): Promise { await this.maybeStartQueuedTasks(); @@ -413,9 +440,7 @@ export class TaskService { }); // Emit metadata update so the UI sees the workspace immediately. - const allMetadata = await this.config.getAllWorkspaceMetadata(); - const childMeta = allMetadata.find((m) => m.id === taskId) ?? null; - this.workspaceService.emit("metadata", { workspaceId: taskId, metadata: childMeta }); + await this.emitWorkspaceMetadata(taskId); // NOTE: Do NOT persist the prompt into chat history until the task actually starts. // Otherwise the frontend treats "last message is user" as an interrupted stream and @@ -480,28 +505,26 @@ export class TaskService { config.projects.set(parentMeta.projectPath, projectConfig); } - projectConfig.workspaces.push({ - path: workspacePath, - id: taskId, - name: workspaceName, + projectConfig.workspaces.push({ + path: workspacePath, + id: taskId, + name: workspaceName, title: args.description, createdAt, runtimeConfig: taskRuntimeConfig, - aiSettings: { model: canonicalModel, thinkingLevel: effectiveThinkingLevel }, - parentWorkspaceId, - agentType, - taskStatus: shouldQueue ? "queued" : "running", - taskTrunkBranch: trunkBranch, - taskModelString, - taskThinkingLevel: effectiveThinkingLevel, - }); + aiSettings: { model: canonicalModel, thinkingLevel: effectiveThinkingLevel }, + parentWorkspaceId, + agentType, + taskStatus: "running", + taskTrunkBranch: trunkBranch, + taskModelString, + taskThinkingLevel: effectiveThinkingLevel, + }); return config; }); // Emit metadata update so the UI sees the workspace immediately. - const allMetadata = await this.config.getAllWorkspaceMetadata(); - const childMeta = allMetadata.find((m) => m.id === taskId) ?? null; - this.workspaceService.emit("metadata", { workspaceId: taskId, metadata: childMeta }); + await this.emitWorkspaceMetadata(taskId); // Kick init hook (best-effort, async). void runtime @@ -1139,7 +1162,8 @@ export class TaskService { const workspaceName = task.name.trim(); let workspacePath = - coerceNonEmptyString(task.path) ?? runtime.getWorkspacePath(task.projectPath, workspaceName); + coerceNonEmptyString(task.path) ?? + runtime.getWorkspacePath(task.projectPath, workspaceName); let workspaceExists = false; try { @@ -1150,7 +1174,9 @@ export class TaskService { } const inMemoryInit = this.initStateManager.getInitState(taskId); - const persistedInit = inMemoryInit ? null : await this.initStateManager.readInitStatus(taskId); + const persistedInit = inMemoryInit + ? null + : await this.initStateManager.readInitStatus(taskId); // Ensure the workspace exists before starting. Queued tasks should not create worktrees/directories // until they are actually dequeued. @@ -1230,16 +1256,14 @@ export class TaskService { }); // Persist any corrected path/trunkBranch for restart-safe init. - await this.config.editConfig((cfg) => { - for (const [_projectPath, project] of cfg.projects) { - const ws = project.workspaces.find((w) => w.id === taskId); - if (!ws) continue; + await this.editWorkspaceEntry( + taskId, + (ws) => { ws.path = workspacePath; ws.taskTrunkBranch = trunkBranch; - return cfg; - } - return cfg; - }); + }, + { allowMissing: true } + ); } // If init has not yet run for this workspace, start it now (best-effort, async). @@ -1304,7 +1328,7 @@ export class TaskService { log.error("Failed to start queued task", { taskId, error: resumeResult.error }); taskQueueDebug("TaskService.maybeStartQueuedTasks resumeStream failed", { taskId, - error: String(resumeResult.error), + error: resumeResult.error, }); continue; } @@ -1319,23 +1343,14 @@ export class TaskService { private async setTaskStatus(workspaceId: string, status: AgentTaskStatus): Promise { assert(workspaceId.length > 0, "setTaskStatus: workspaceId must be non-empty"); - await this.config.editConfig((config) => { - for (const [_projectPath, project] of config.projects) { - const ws = project.workspaces.find((w) => w.id === workspaceId); - if (ws) { - ws.taskStatus = status; - if (status === "running") { - ws.taskPrompt = undefined; - } - return config; - } + await this.editWorkspaceEntry(workspaceId, (ws) => { + ws.taskStatus = status; + if (status === "running") { + ws.taskPrompt = undefined; } - throw new Error(`setTaskStatus: workspace ${workspaceId} not found`); }); - const allMetadata = await this.config.getAllWorkspaceMetadata(); - const metadata = allMetadata.find((m) => m.id === workspaceId) ?? null; - this.workspaceService.emit("metadata", { workspaceId, metadata }); + await this.emitWorkspaceMetadata(workspaceId); if (status === "running") { const waiters = this.pendingStartWaitersByTaskId.get(workspaceId); @@ -1445,26 +1460,17 @@ export class TaskService { "posting its last assistant output as a fallback.)*\n\n" + (lastText?.trim().length ? lastText : "(No assistant output found.)"); - await this.config.editConfig((config) => { - for (const [_projectPath, project] of config.projects) { - const ws = project.workspaces.find((w) => w.id === childWorkspaceId); - if (ws) { - ws.taskStatus = "reported"; - ws.reportedAt = getIsoNow(); - return config; - } - } - return config; - }); - // Notify clients immediately even if we can't delete the workspace yet. - const updatedMetadata = (await this.config.getAllWorkspaceMetadata()).find( - (m) => m.id === childWorkspaceId + await this.editWorkspaceEntry( + childWorkspaceId, + (ws) => { + ws.taskStatus = "reported"; + ws.reportedAt = getIsoNow(); + }, + { allowMissing: true } ); - this.workspaceService.emit("metadata", { - workspaceId: childWorkspaceId, - metadata: updatedMetadata ?? null, - }); + + await this.emitWorkspaceMetadata(childWorkspaceId); await this.deliverReportToParent(parentWorkspaceId, entry, { reportMarkdown, @@ -1560,26 +1566,17 @@ export class TaskService { return; } - await this.config.editConfig((config) => { - for (const [_projectPath, project] of config.projects) { - const ws = project.workspaces.find((w) => w.id === childWorkspaceId); - if (ws) { - ws.taskStatus = "reported"; - ws.reportedAt = getIsoNow(); - return config; - } - } - return config; - }); - // Notify clients immediately even if we can't delete the workspace yet. - const updatedMetadata = (await this.config.getAllWorkspaceMetadata()).find( - (m) => m.id === childWorkspaceId + await this.editWorkspaceEntry( + childWorkspaceId, + (ws) => { + ws.taskStatus = "reported"; + ws.reportedAt = getIsoNow(); + }, + { allowMissing: true } ); - this.workspaceService.emit("metadata", { - workspaceId: childWorkspaceId, - metadata: updatedMetadata ?? null, - }); + + await this.emitWorkspaceMetadata(childWorkspaceId); // `agent_report` is terminal. Stop the child stream immediately to prevent any further token // usage and to ensure parallelism accounting never "frees" a slot while the stream is still diff --git a/src/node/services/tools/agent_report.ts b/src/node/services/tools/agent_report.ts index dd3f35158e..b6ce4124da 100644 --- a/src/node/services/tools/agent_report.ts +++ b/src/node/services/tools/agent_report.ts @@ -1,19 +1,19 @@ -import assert from "node:assert/strict"; - import { tool } from "ai"; import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; +import { requireTaskService, requireWorkspaceId } from "./toolUtils"; + export const createAgentReportTool: ToolFactory = (config: ToolConfiguration) => { return tool({ description: TOOL_DEFINITIONS.agent_report.description, inputSchema: TOOL_DEFINITIONS.agent_report.schema, execute: (): { success: true } => { - assert(config.workspaceId, "agent_report requires workspaceId"); - assert(config.taskService, "agent_report requires taskService"); + const workspaceId = requireWorkspaceId(config, "agent_report"); + const taskService = requireTaskService(config, "agent_report"); - if (config.taskService.hasActiveDescendantAgentTasksForWorkspace(config.workspaceId)) { + if (taskService.hasActiveDescendantAgentTasksForWorkspace(workspaceId)) { throw new Error( "agent_report rejected: this task still has running/queued descendant tasks. " + "Call task_await (or wait for tasks to finish) before reporting." diff --git a/src/node/services/tools/task.ts b/src/node/services/tools/task.ts index 7eb3f46801..1dde700451 100644 --- a/src/node/services/tools/task.ts +++ b/src/node/services/tools/task.ts @@ -1,40 +1,27 @@ -import assert from "node:assert/strict"; - import { tool } from "ai"; import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; import { TaskToolResultSchema, TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; -import type { ThinkingLevel } from "@/common/types/thinking"; +import { coerceThinkingLevel } from "@/common/types/thinking"; -function parseThinkingLevel(value: unknown): ThinkingLevel | undefined { - if ( - value === "off" || - value === "low" || - value === "medium" || - value === "high" || - value === "xhigh" - ) { - return value; - } - return undefined; -} +import { parseToolResult, requireTaskService, requireWorkspaceId } from "./toolUtils"; export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { return tool({ description: TOOL_DEFINITIONS.task.description, inputSchema: TOOL_DEFINITIONS.task.schema, execute: async (args, { abortSignal }): Promise => { - assert(config.workspaceId, "task requires workspaceId"); - assert(config.taskService, "task requires taskService"); + const workspaceId = requireWorkspaceId(config, "task"); + const taskService = requireTaskService(config, "task"); const modelString = config.muxEnv && typeof config.muxEnv.MUX_MODEL_STRING === "string" ? config.muxEnv.MUX_MODEL_STRING : undefined; - const thinkingLevel = parseThinkingLevel(config.muxEnv?.MUX_THINKING_LEVEL); + const thinkingLevel = coerceThinkingLevel(config.muxEnv?.MUX_THINKING_LEVEL); - const created = await config.taskService.create({ - parentWorkspaceId: config.workspaceId, + const created = await taskService.create({ + parentWorkspaceId: workspaceId, kind: "agent", agentType: args.subagent_type, prompt: args.prompt, @@ -48,31 +35,28 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { } if (args.run_in_background) { - const result = { status: created.data.status, taskId: created.data.taskId }; - const parsed = TaskToolResultSchema.safeParse(result); - if (!parsed.success) { - throw new Error(`task tool result validation failed: ${parsed.error.message}`); - } - return parsed.data; + return parseToolResult( + TaskToolResultSchema, + { status: created.data.status, taskId: created.data.taskId }, + "task" + ); } - const report = await config.taskService.waitForAgentReport(created.data.taskId, { + const report = await taskService.waitForAgentReport(created.data.taskId, { abortSignal, }); - const result = { - status: "completed" as const, - taskId: created.data.taskId, - reportMarkdown: report.reportMarkdown, - title: report.title, - agentType: args.subagent_type, - }; - - const parsed = TaskToolResultSchema.safeParse(result); - if (!parsed.success) { - throw new Error(`task tool result validation failed: ${parsed.error.message}`); - } - return parsed.data; + return parseToolResult( + TaskToolResultSchema, + { + status: "completed" as const, + taskId: created.data.taskId, + reportMarkdown: report.reportMarkdown, + title: report.title, + agentType: args.subagent_type, + }, + "task" + ); }, }); }; diff --git a/src/node/services/tools/task_await.ts b/src/node/services/tools/task_await.ts index 347d39fde2..5be738ba20 100644 --- a/src/node/services/tools/task_await.ts +++ b/src/node/services/tools/task_await.ts @@ -1,10 +1,15 @@ -import assert from "node:assert/strict"; - import { tool } from "ai"; import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; import { TaskAwaitToolResultSchema, TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; +import { + dedupeStrings, + parseToolResult, + requireTaskService, + requireWorkspaceId, +} from "./toolUtils"; + function coerceTimeoutMs(timeoutSecs: unknown): number | undefined { if (typeof timeoutSecs !== "number" || !Number.isFinite(timeoutSecs)) return undefined; const timeoutMs = Math.floor(timeoutSecs * 1000); @@ -17,8 +22,8 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { description: TOOL_DEFINITIONS.task_await.description, inputSchema: TOOL_DEFINITIONS.task_await.schema, execute: async (args, { abortSignal }): Promise => { - assert(config.workspaceId, "task_await requires workspaceId"); - assert(config.taskService, "task_await requires taskService"); + const workspaceId = requireWorkspaceId(config, "task_await"); + const taskService = requireTaskService(config, "task_await"); const timeoutMs = coerceTimeoutMs(args.timeout_secs); @@ -26,18 +31,18 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { args.task_ids && args.task_ids.length > 0 ? args.task_ids : null; const candidateTaskIds = - requestedIds ?? config.taskService.listActiveDescendantAgentTaskIds(config.workspaceId); + requestedIds ?? taskService.listActiveDescendantAgentTaskIds(workspaceId); - const uniqueTaskIds = Array.from(new Set(candidateTaskIds)); + const uniqueTaskIds = dedupeStrings(candidateTaskIds); const results = await Promise.all( uniqueTaskIds.map(async (taskId) => { - if (!config.taskService!.isDescendantAgentTask(config.workspaceId!, taskId)) { + if (!taskService.isDescendantAgentTask(workspaceId, taskId)) { return { status: "invalid_scope" as const, taskId }; } try { - const report = await config.taskService!.waitForAgentReport(taskId, { + const report = await taskService.waitForAgentReport(taskId, { timeoutMs, abortSignal, }); @@ -57,7 +62,7 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { return { status: "not_found" as const, taskId }; } if (/timed out/i.test(message)) { - const status = config.taskService!.getAgentTaskStatus(taskId); + const status = taskService.getAgentTaskStatus(taskId); if (status === "queued" || status === "running" || status === "awaiting_report") { return { status, taskId }; } @@ -75,13 +80,7 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { }) ); - const output = { results }; - const parsed = TaskAwaitToolResultSchema.safeParse(output); - if (!parsed.success) { - throw new Error(`task_await tool result validation failed: ${parsed.error.message}`); - } - - return parsed.data; + return parseToolResult(TaskAwaitToolResultSchema, { results }, "task_await"); }, }); }; diff --git a/src/node/services/tools/task_list.ts b/src/node/services/tools/task_list.ts index 814b9483a7..1027793a8a 100644 --- a/src/node/services/tools/task_list.ts +++ b/src/node/services/tools/task_list.ts @@ -1,10 +1,10 @@ -import assert from "node:assert/strict"; - import { tool } from "ai"; import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; import { TaskListToolResultSchema, TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; +import { parseToolResult, requireTaskService, requireWorkspaceId } from "./toolUtils"; + const DEFAULT_STATUSES = ["queued", "running", "awaiting_report"] as const; export const createTaskListTool: ToolFactory = (config: ToolConfiguration) => { @@ -12,20 +12,14 @@ export const createTaskListTool: ToolFactory = (config: ToolConfiguration) => { description: TOOL_DEFINITIONS.task_list.description, inputSchema: TOOL_DEFINITIONS.task_list.schema, execute: (args): unknown => { - assert(config.workspaceId, "task_list requires workspaceId"); - assert(config.taskService, "task_list requires taskService"); + const workspaceId = requireWorkspaceId(config, "task_list"); + const taskService = requireTaskService(config, "task_list"); const statuses = args.statuses && args.statuses.length > 0 ? args.statuses : [...DEFAULT_STATUSES]; - const tasks = config.taskService.listDescendantAgentTasks(config.workspaceId, { statuses }); - - const output = { tasks }; - const parsed = TaskListToolResultSchema.safeParse(output); - if (!parsed.success) { - throw new Error(`task_list tool result validation failed: ${parsed.error.message}`); - } + const tasks = taskService.listDescendantAgentTasks(workspaceId, { statuses }); - return parsed.data; + return parseToolResult(TaskListToolResultSchema, { tasks }, "task_list"); }, }); }; diff --git a/src/node/services/tools/task_terminate.ts b/src/node/services/tools/task_terminate.ts index 7f98c60ae4..20d9460c3a 100644 --- a/src/node/services/tools/task_terminate.ts +++ b/src/node/services/tools/task_terminate.ts @@ -1,5 +1,3 @@ -import assert from "node:assert/strict"; - import { tool } from "ai"; import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools"; @@ -8,20 +6,27 @@ import { TOOL_DEFINITIONS, } from "@/common/utils/tools/toolDefinitions"; +import { + dedupeStrings, + parseToolResult, + requireTaskService, + requireWorkspaceId, +} from "./toolUtils"; + export const createTaskTerminateTool: ToolFactory = (config: ToolConfiguration) => { return tool({ description: TOOL_DEFINITIONS.task_terminate.description, inputSchema: TOOL_DEFINITIONS.task_terminate.schema, execute: async (args): Promise => { - assert(config.workspaceId, "task_terminate requires workspaceId"); - assert(config.taskService, "task_terminate requires taskService"); + const workspaceId = requireWorkspaceId(config, "task_terminate"); + const taskService = requireTaskService(config, "task_terminate"); - const uniqueTaskIds = Array.from(new Set(args.task_ids)); + const uniqueTaskIds = dedupeStrings(args.task_ids); const results = await Promise.all( uniqueTaskIds.map(async (taskId) => { - const terminateResult = await config.taskService!.terminateDescendantAgentTask( - config.workspaceId!, + const terminateResult = await taskService.terminateDescendantAgentTask( + workspaceId, taskId ); if (!terminateResult.success) { @@ -43,13 +48,7 @@ export const createTaskTerminateTool: ToolFactory = (config: ToolConfiguration) }) ); - const output = { results }; - const parsed = TaskTerminateToolResultSchema.safeParse(output); - if (!parsed.success) { - throw new Error(`task_terminate tool result validation failed: ${parsed.error.message}`); - } - - return parsed.data; + return parseToolResult(TaskTerminateToolResultSchema, { results }, "task_terminate"); }, }); }; diff --git a/src/node/services/tools/toolUtils.ts b/src/node/services/tools/toolUtils.ts new file mode 100644 index 0000000000..d74790be87 --- /dev/null +++ b/src/node/services/tools/toolUtils.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; + +import type { z } from "zod"; + +import type { ToolConfiguration } from "@/common/utils/tools/tools"; +import type { TaskService } from "@/node/services/taskService"; + +export function requireWorkspaceId(config: ToolConfiguration, toolName: string): string { + assert(config.workspaceId, `${toolName} requires workspaceId`); + return config.workspaceId; +} + +export function requireTaskService(config: ToolConfiguration, toolName: string): TaskService { + assert(config.taskService, `${toolName} requires taskService`); + return config.taskService; +} + +export function parseToolResult( + schema: z.ZodType, + value: unknown, + toolName: string +): TSchema { + const parsed = schema.safeParse(value); + if (!parsed.success) { + throw new Error(`${toolName} tool result validation failed: ${parsed.error.message}`); + } + return parsed.data; +} + +export function dedupeStrings(values: readonly string[]): string[] { + return Array.from(new Set(values)); +} diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 33b5ec8009..ee2cf7a8b3 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -51,7 +51,7 @@ import { defaultModel, isValidModelFormat, normalizeGatewayModel } from "@/commo import type { StreamEndEvent, StreamAbortEvent } from "@/common/types/stream"; import type { TerminalService } from "@/node/services/terminalService"; import type { WorkspaceAISettingsSchema } from "@/common/orpc/schemas"; -import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy"; +import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import { DisposableTempDir } from "@/node/services/tempDir"; @@ -79,7 +79,6 @@ function isWorkspaceNameCollision(error: string | undefined): boolean { function taskQueueDebug(message: string, details?: Record): void { if (process.env.MUX_DEBUG_TASK_QUEUE !== "1") return; - // eslint-disable-next-line no-console console.log(`[task-queue] ${message}`, details ?? {}); }