Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/commands/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/core/api-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
5 changes: 5 additions & 0 deletions src/core/task-board-mutations.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
RuntimeAgentId,
RuntimeBoardCard,
RuntimeBoardColumnId,
RuntimeBoardData,
Expand All @@ -12,6 +13,7 @@ export interface RuntimeCreateTaskInput {
startInPlanMode?: boolean;
autoReviewEnabled?: boolean;
autoReviewMode?: RuntimeTaskAutoReviewMode;
agentId?: RuntimeAgentId;
baseRef: string;
}

Expand All @@ -20,6 +22,7 @@ export interface RuntimeUpdateTaskInput {
startInPlanMode?: boolean;
autoReviewEnabled?: boolean;
autoReviewMode?: RuntimeTaskAutoReviewMode;
agentId?: RuntimeAgentId;
baseRef: string;
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
Expand Down
8 changes: 6 additions & 2 deletions src/terminal/agent-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/trpc/runtime-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 23 additions & 1 deletion test/integration/workspace-state.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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["columns"][number]["cards"][number]> = {}): RuntimeBoardData {
return {
columns: [
{
Expand All @@ -32,6 +32,7 @@ function createBoard(title: string): RuntimeBoardData {
baseRef: "main",
createdAt: Date.now(),
updatedAt: Date.now(),
...cardOverrides,
},
],
},
Expand Down Expand Up @@ -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-");
Expand Down
9 changes: 9 additions & 0 deletions test/runtime/terminal/agent-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
46 changes: 46 additions & 0 deletions test/runtime/trpc/runtime-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}),
);
});
});
19 changes: 19 additions & 0 deletions web-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -219,6 +228,8 @@ export default function App(): ReactElement {
setNewTaskAutoReviewEnabled,
newTaskAutoReviewMode,
setNewTaskAutoReviewMode,
newTaskAgentId,
setNewTaskAgentId,
isNewTaskStartInPlanModeDisabled,
newTaskBranchRef,
setNewTaskBranchRef,
Expand All @@ -231,6 +242,8 @@ export default function App(): ReactElement {
setEditTaskAutoReviewEnabled,
editTaskAutoReviewMode,
setEditTaskAutoReviewMode,
editTaskAgentId,
setEditTaskAgentId,
isEditTaskStartInPlanModeDisabled,
editTaskBranchRef,
setEditTaskBranchRef,
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand Down
51 changes: 51 additions & 0 deletions web-ui/src/components/task-agent-select.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<span className="text-[11px] text-text-secondary block mb-1">Agent runtime</span>
<div className="relative inline-flex w-full">
<select
id={id}
value={value ?? ""}
onChange={(event) => onChange(event.currentTarget.value as RuntimeAgentId)}
className="h-8 w-full appearance-none rounded-md border border-border-bright bg-surface-2 pl-2 pr-7 text-[12px] text-text-primary cursor-pointer focus:border-border-focus focus:outline-none disabled:cursor-default disabled:text-text-secondary"
disabled={options.length === 0}
>
<option value="" disabled>
{options.length === 0 ? "Loading runtimes…" : "Select runtime"}
</option>
{options.map((option) => (
<option key={option.value} value={option.value} disabled={!option.installed}>
{option.label}
{option.installed ? "" : " (not installed)"}
</option>
))}
</select>
<ChevronDown
size={14}
className="pointer-events-none absolute right-1.5 top-1/2 -translate-y-1/2 text-text-secondary"
/>
</div>
</div>
);
}
16 changes: 16 additions & 0 deletions web-ui/src/components/task-create-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> = [
Expand Down Expand Up @@ -76,6 +78,9 @@ export function TaskCreateDialog({
onAutoReviewEnabledChange,
autoReviewMode,
onAutoReviewModeChange,
agentId,
agentOptions,
onAgentIdChange,
startInPlanModeDisabled = false,
workspaceId,
branchRef,
Expand All @@ -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;
Expand All @@ -108,6 +116,7 @@ export function TaskCreateDialog({
const nextFocusIndexRef = useRef<number | null>(null);
const startInPlanModeId = useId();
const autoReviewEnabledId = useId();
const agentSelectId = useId();

const detectedItems = useMemo(() => parseListItems(prompt), [prompt]);
const validTaskCount = useMemo(
Expand Down Expand Up @@ -380,6 +389,13 @@ export function TaskCreateDialog({
/>
</div>

<TaskAgentSelect
id={agentSelectId}
value={agentId}
options={agentOptions}
onChange={onAgentIdChange}
/>

<div className="flex items-center gap-2 flex-wrap">
<label
htmlFor={autoReviewEnabledId}
Expand Down
Loading