diff --git a/src/commands/task.ts b/src/commands/task.ts index c095ca7..d8b1396 100644 --- a/src/commands/task.ts +++ b/src/commands/task.ts @@ -140,6 +140,7 @@ function formatTaskRecord(state: RuntimeWorkspaceStateResponse, task: RuntimeBoa id: task.id, prompt: task.prompt, column: columnId, + agentId: task.agentId ?? null, baseRef: task.baseRef, startInPlanMode: task.startInPlanMode, autoReviewEnabled: task.autoReviewEnabled === true, @@ -427,6 +428,7 @@ async function startTask(input: { cwd: string; taskId: string; projectPath?: str taskId: task.id, prompt: task.prompt, startInPlanMode: task.startInPlanMode, + agentId: task.agentId, baseRef: task.baseRef, }); if (!started.ok || !started.summary) { diff --git a/src/core/api-contract.ts b/src/core/api-contract.ts index bc06fa6..fb832ad 100644 --- a/src/core/api-contract.ts +++ b/src/core/api-contract.ts @@ -73,6 +73,7 @@ export const runtimeBoardCardSchema = z.object({ startInPlanMode: z.boolean(), autoReviewEnabled: z.boolean().optional(), autoReviewMode: runtimeTaskAutoReviewModeSchema.optional(), + agentId: runtimeAgentIdSchema.optional(), baseRef: z.string(), createdAt: z.number(), updatedAt: z.number(), @@ -492,6 +493,7 @@ export const runtimeTaskSessionStartRequestSchema = z.object({ prompt: z.string(), startInPlanMode: z.boolean().optional(), resumeFromTrash: z.boolean().optional(), + agentId: runtimeAgentIdSchema.optional(), baseRef: z.string(), cols: z.number().int().positive().optional(), rows: z.number().int().positive().optional(), diff --git a/src/core/task-board-mutations.ts b/src/core/task-board-mutations.ts index 683c074..2e87f34 100644 --- a/src/core/task-board-mutations.ts +++ b/src/core/task-board-mutations.ts @@ -1,4 +1,5 @@ import type { + RuntimeAgentId, RuntimeBoardCard, RuntimeBoardColumnId, RuntimeBoardData, @@ -12,6 +13,7 @@ export interface RuntimeCreateTaskInput { startInPlanMode?: boolean; autoReviewEnabled?: boolean; autoReviewMode?: RuntimeTaskAutoReviewMode; + agentId?: RuntimeAgentId; baseRef: string; } @@ -20,6 +22,7 @@ export interface RuntimeUpdateTaskInput { startInPlanMode?: boolean; autoReviewEnabled?: boolean; autoReviewMode?: RuntimeTaskAutoReviewMode; + agentId?: RuntimeAgentId; baseRef: string; } @@ -266,6 +269,7 @@ export function addTaskToColumn( startInPlanMode: Boolean(input.startInPlanMode), autoReviewEnabled: Boolean(input.autoReviewEnabled), autoReviewMode: normalizeTaskAutoReviewMode(input.autoReviewMode), + ...(input.agentId ? { agentId: input.agentId } : {}), baseRef, createdAt: now, updatedAt: now, @@ -530,6 +534,7 @@ export function updateTask( startInPlanMode: Boolean(input.startInPlanMode), autoReviewEnabled: Boolean(input.autoReviewEnabled), autoReviewMode: normalizeTaskAutoReviewMode(input.autoReviewMode), + agentId: input.agentId ?? card.agentId, baseRef, updatedAt: now, }; diff --git a/src/terminal/agent-registry.ts b/src/terminal/agent-registry.ts index 045d769..4061c99 100644 --- a/src/terminal/agent-registry.ts +++ b/src/terminal/agent-registry.ts @@ -64,8 +64,12 @@ function getCuratedDefinitions(runtimeConfig: RuntimeConfigState, detected: stri }); } -export function resolveAgentCommand(runtimeConfig: RuntimeConfigState): ResolvedAgentCommand | null { - const selected = RUNTIME_AGENT_CATALOG.find((entry) => entry.id === runtimeConfig.selectedAgentId); +export function resolveAgentCommand( + runtimeConfig: RuntimeConfigState, + agentIdOverride?: RuntimeAgentId | null, +): ResolvedAgentCommand | null { + const selectedAgentId = agentIdOverride ?? runtimeConfig.selectedAgentId; + const selected = RUNTIME_AGENT_CATALOG.find((entry) => entry.id === selectedAgentId); if (!selected) { return null; } diff --git a/src/trpc/runtime-api.ts b/src/trpc/runtime-api.ts index b9eb6a8..94531cd 100644 --- a/src/trpc/runtime-api.ts +++ b/src/trpc/runtime-api.ts @@ -65,7 +65,7 @@ export function createRuntimeApi(deps: CreateRuntimeApiDependencies): RuntimeTrp try { const body = parseTaskSessionStartRequest(input); const scopedRuntimeConfig = await deps.loadScopedRuntimeConfig(workspaceScope); - const resolved = resolveAgentCommand(scopedRuntimeConfig); + const resolved = resolveAgentCommand(scopedRuntimeConfig, body.agentId); if (!resolved) { return { ok: false, diff --git a/test/integration/workspace-state.integration.test.ts b/test/integration/workspace-state.integration.test.ts index ad4a492..5e2adb3 100644 --- a/test/integration/workspace-state.integration.test.ts +++ b/test/integration/workspace-state.integration.test.ts @@ -18,7 +18,7 @@ import { import { createGitTestEnv } from "../utilities/git-env.js"; import { createTempDir } from "../utilities/temp-dir.js"; -function createBoard(title: string): RuntimeBoardData { +function createBoard(title: string, cardOverrides: Partial = {}): RuntimeBoardData { return { columns: [ { @@ -32,6 +32,7 @@ function createBoard(title: string): RuntimeBoardData { baseRef: "main", createdAt: Date.now(), updatedAt: Date.now(), + ...cardOverrides, }, ], }, @@ -176,6 +177,27 @@ describe.sequential("workspace-state integration", () => { }); }); + it("round-trips per-task agent assignments through persisted workspace state", async () => { + await withTemporaryHome(async () => { + const { path: sandboxRoot, cleanup } = createTempDir("kanban-agent-persistence-"); + try { + const workspacePath = join(sandboxRoot, "project-agent"); + mkdirSync(workspacePath, { recursive: true }); + initGitRepository(workspacePath); + + await saveWorkspaceState(workspacePath, { + board: createBoard("Assigned task", { agentId: "codex" }), + sessions: {}, + }); + + const loaded = await loadWorkspaceState(workspacePath); + expect(loaded.board.columns[0]?.cards[0]?.agentId).toBe("codex"); + } finally { + cleanup(); + } + }); + }); + it("creates readable workspace ids from folder names with random suffix on collisions", async () => { await withTemporaryHome(async () => { const { path: sandboxRoot, cleanup } = createTempDir("kanban-workspace-id-format-"); diff --git a/test/runtime/terminal/agent-registry.test.ts b/test/runtime/terminal/agent-registry.test.ts index 2402c96..7f3885b 100644 --- a/test/runtime/terminal/agent-registry.test.ts +++ b/test/runtime/terminal/agent-registry.test.ts @@ -54,6 +54,15 @@ describe("agent-registry", () => { expect(resolved).toBeNull(); }); + + it("prefers an explicit task agent override when it is runnable", () => { + commandDiscoveryMocks.isBinaryAvailableOnPath.mockImplementation((binary: string) => binary === "codex"); + + const resolved = resolveAgentCommand(createRuntimeConfigState({ selectedAgentId: "claude" }), "codex"); + + expect(resolved?.agentId).toBe("codex"); + expect(resolved?.binary).toBe("codex"); + }); }); describe("buildRuntimeConfigResponse", () => { diff --git a/test/runtime/trpc/runtime-api.test.ts b/test/runtime/trpc/runtime-api.test.ts index ea4f6ad..c365805 100644 --- a/test/runtime/trpc/runtime-api.test.ts +++ b/test/runtime/trpc/runtime-api.test.ts @@ -176,4 +176,50 @@ describe("createRuntimeApi startTaskSession", () => { ensure: true, }); }); + + it("uses the task agent override when starting a session", async () => { + taskWorktreeMocks.resolveTaskCwd.mockResolvedValue("/tmp/existing-worktree"); + agentRegistryMocks.resolveAgentCommand.mockReturnValue({ + agentId: "codex", + label: "OpenAI Codex", + command: "codex", + binary: "codex", + args: [], + }); + + const terminalManager = { + startTaskSession: vi.fn(async () => createSummary({ agentId: "codex" })), + applyTurnCheckpoint: vi.fn(), + }; + const runtimeConfig = createRuntimeConfigState(); + const api = createRuntimeApi({ + getActiveWorkspaceId: vi.fn(() => "workspace-1"), + loadScopedRuntimeConfig: vi.fn(async () => runtimeConfig), + setActiveRuntimeConfig: vi.fn(), + getScopedTerminalManager: vi.fn(async () => terminalManager as never), + resolveInteractiveShellCommand: vi.fn(), + runCommand: vi.fn(), + }); + + const response = await api.startTaskSession( + { + workspaceId: "workspace-1", + workspacePath: "/tmp/repo", + }, + { + taskId: "task-1", + baseRef: "main", + prompt: "Use codex for this task", + agentId: "codex", + }, + ); + + expect(response.ok).toBe(true); + expect(agentRegistryMocks.resolveAgentCommand).toHaveBeenCalledWith(runtimeConfig, "codex"); + expect(terminalManager.startTaskSession).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "codex", + }), + ); + }); }); diff --git a/web-ui/src/App.tsx b/web-ui/src/App.tsx index 99031a8..88a74b0 100644 --- a/web-ui/src/App.tsx +++ b/web-ui/src/App.tsx @@ -111,6 +111,15 @@ export default function App(): ReactElement { } = useTerminalConnectionReady(); const readyForReviewNotificationsEnabled = runtimeProjectConfig?.readyForReviewNotificationsEnabled ?? true; const shortcuts = runtimeProjectConfig?.shortcuts ?? []; + const taskAgentOptions = useMemo( + () => + (runtimeProjectConfig?.agents ?? []).map((agent) => ({ + value: agent.id, + label: agent.label, + installed: agent.installed, + })), + [runtimeProjectConfig?.agents], + ); const selectedShortcutLabel = useMemo(() => { if (shortcuts.length === 0) { return null; @@ -219,6 +228,8 @@ export default function App(): ReactElement { setNewTaskAutoReviewEnabled, newTaskAutoReviewMode, setNewTaskAutoReviewMode, + newTaskAgentId, + setNewTaskAgentId, isNewTaskStartInPlanModeDisabled, newTaskBranchRef, setNewTaskBranchRef, @@ -231,6 +242,8 @@ export default function App(): ReactElement { setEditTaskAutoReviewEnabled, editTaskAutoReviewMode, setEditTaskAutoReviewMode, + editTaskAgentId, + setEditTaskAgentId, isEditTaskStartInPlanModeDisabled, editTaskBranchRef, setEditTaskBranchRef, @@ -635,6 +648,9 @@ export default function App(): ReactElement { onAutoReviewEnabledChange={setEditTaskAutoReviewEnabled} autoReviewMode={editTaskAutoReviewMode} onAutoReviewModeChange={setEditTaskAutoReviewMode} + agentId={editTaskAgentId} + agentOptions={taskAgentOptions} + onAgentIdChange={setEditTaskAgentId} workspaceId={currentProjectId} branchRef={editTaskBranchRef} branchOptions={createTaskBranchOptions} @@ -920,6 +936,9 @@ export default function App(): ReactElement { onAutoReviewEnabledChange={setNewTaskAutoReviewEnabled} autoReviewMode={newTaskAutoReviewMode} onAutoReviewModeChange={setNewTaskAutoReviewMode} + agentId={newTaskAgentId} + agentOptions={taskAgentOptions} + onAgentIdChange={setNewTaskAgentId} workspaceId={currentProjectId} branchRef={newTaskBranchRef} branchOptions={createTaskBranchOptions} diff --git a/web-ui/src/components/task-agent-select.tsx b/web-ui/src/components/task-agent-select.tsx new file mode 100644 index 0000000..02db112 --- /dev/null +++ b/web-ui/src/components/task-agent-select.tsx @@ -0,0 +1,51 @@ +import { ChevronDown } from "lucide-react"; +import type { ReactElement } from "react"; + +import type { RuntimeAgentId } from "@/runtime/types"; + +export interface TaskAgentOption { + value: RuntimeAgentId; + label: string; + installed: boolean; +} + +export function TaskAgentSelect({ + id, + value, + options, + onChange, +}: { + id?: string; + value: RuntimeAgentId | null; + options: TaskAgentOption[]; + onChange: (value: RuntimeAgentId) => void; +}): ReactElement { + return ( +
+ Agent runtime +
+ + +
+
+ ); +} diff --git a/web-ui/src/components/task-create-dialog.tsx b/web-ui/src/components/task-create-dialog.tsx index 045a1a7..8a0c9d1 100644 --- a/web-ui/src/components/task-create-dialog.tsx +++ b/web-ui/src/components/task-create-dialog.tsx @@ -15,11 +15,13 @@ import type { ReactElement } from "react"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; +import { TaskAgentSelect, type TaskAgentOption } from "@/components/task-agent-select"; import type { BranchSelectOption } from "@/components/branch-select-dropdown"; import { BranchSelectDropdown } from "@/components/branch-select-dropdown"; import { TaskPromptComposer } from "@/components/task-prompt-composer"; import { Button } from "@/components/ui/button"; import { Dialog, DialogBody, DialogFooter, DialogHeader } from "@/components/ui/dialog"; +import type { RuntimeAgentId } from "@/runtime/types"; import type { TaskAutoReviewMode } from "@/types"; const AUTO_REVIEW_MODE_OPTIONS: Array<{ value: TaskAutoReviewMode; label: string }> = [ @@ -76,6 +78,9 @@ export function TaskCreateDialog({ onAutoReviewEnabledChange, autoReviewMode, onAutoReviewModeChange, + agentId, + agentOptions, + onAgentIdChange, startInPlanModeDisabled = false, workspaceId, branchRef, @@ -96,6 +101,9 @@ export function TaskCreateDialog({ onAutoReviewEnabledChange: (value: boolean) => void; autoReviewMode: TaskAutoReviewMode; onAutoReviewModeChange: (value: TaskAutoReviewMode) => void; + agentId: RuntimeAgentId | null; + agentOptions: TaskAgentOption[]; + onAgentIdChange: (value: RuntimeAgentId) => void; startInPlanModeDisabled?: boolean; workspaceId: string | null; branchRef: string; @@ -108,6 +116,7 @@ export function TaskCreateDialog({ const nextFocusIndexRef = useRef(null); const startInPlanModeId = useId(); const autoReviewEnabledId = useId(); + const agentSelectId = useId(); const detectedItems = useMemo(() => parseListItems(prompt), [prompt]); const validTaskCount = useMemo( @@ -380,6 +389,13 @@ export function TaskCreateDialog({ /> + +
+ +