feat(KB-025): add per-task model overrides for executor and validator
- Add model override fields to Task type and TaskStore (modelProvider, modelId, validatorModelProvider, validatorModelId) - Extend PATCH /api/tasks/:id endpoint with validation for model fields - Create ModelSelectorTab component with provider/model dropdowns and tests - Integrate Model tab into TaskDetailModal for per-task model configuration - Update executor to use per-task model overrides when both provider and modelId are set - Update reviewer to use per-task validator model overrides in reviewStep - Document per-task model selection feature in AGENTS.md settings section
This commit is contained in:
@@ -615,6 +615,64 @@ describe("TaskStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTask — model overrides", () => {
|
||||
it("sets executor model provider and id via updateTask", async () => {
|
||||
const task = await createTestTask();
|
||||
const updated = await store.updateTask(task.id, { modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
|
||||
expect(updated.modelProvider).toBe("anthropic");
|
||||
expect(updated.modelId).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("sets validator model provider and id via updateTask", async () => {
|
||||
const task = await createTestTask();
|
||||
const updated = await store.updateTask(task.id, { validatorModelProvider: "openai", validatorModelId: "gpt-4o" });
|
||||
expect(updated.validatorModelProvider).toBe("openai");
|
||||
expect(updated.validatorModelId).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("clears executor model fields via null", async () => {
|
||||
const task = await createTestTask();
|
||||
await store.updateTask(task.id, { modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
|
||||
const updated = await store.updateTask(task.id, { modelProvider: null, modelId: null });
|
||||
expect(updated.modelProvider).toBeUndefined();
|
||||
expect(updated.modelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears validator model fields via null", async () => {
|
||||
const task = await createTestTask();
|
||||
await store.updateTask(task.id, { validatorModelProvider: "openai", validatorModelId: "gpt-4o" });
|
||||
const updated = await store.updateTask(task.id, { validatorModelProvider: null, validatorModelId: null });
|
||||
expect(updated.validatorModelProvider).toBeUndefined();
|
||||
expect(updated.validatorModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets only executor model without affecting validator model", async () => {
|
||||
const task = await createTestTask();
|
||||
await store.updateTask(task.id, { validatorModelProvider: "openai", validatorModelId: "gpt-4o" });
|
||||
const updated = await store.updateTask(task.id, { modelProvider: "anthropic", modelId: "claude-sonnet-4-5" });
|
||||
expect(updated.modelProvider).toBe("anthropic");
|
||||
expect(updated.modelId).toBe("claude-sonnet-4-5");
|
||||
expect(updated.validatorModelProvider).toBe("openai");
|
||||
expect(updated.validatorModelId).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("preserves model fields when updating unrelated fields", async () => {
|
||||
const task = await createTestTask();
|
||||
await store.updateTask(task.id, {
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
validatorModelProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
});
|
||||
const updated = await store.updateTask(task.id, { title: "Updated title" });
|
||||
expect(updated.modelProvider).toBe("anthropic");
|
||||
expect(updated.modelId).toBe("claude-sonnet-4-5");
|
||||
expect(updated.validatorModelProvider).toBe("openai");
|
||||
expect(updated.validatorModelId).toBe("gpt-4o");
|
||||
expect(updated.title).toBe("Updated title");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent log persistence", () => {
|
||||
it("appendAgentLog creates agent.log and getAgentLogs reads it back", async () => {
|
||||
const task = await createTestTask();
|
||||
|
||||
@@ -340,7 +340,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
async updateTask(
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string; size?: "S" | "M" | "L"; reviewLevel?: number },
|
||||
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string; size?: "S" | "M" | "L"; reviewLevel?: number; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null },
|
||||
): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
@@ -382,6 +382,26 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
if (updates.baseBranch !== undefined) task.baseBranch = updates.baseBranch;
|
||||
if (updates.size !== undefined) task.size = updates.size;
|
||||
if (updates.reviewLevel !== undefined) task.reviewLevel = updates.reviewLevel;
|
||||
if (updates.modelProvider === null) {
|
||||
task.modelProvider = undefined;
|
||||
} else if (updates.modelProvider !== undefined) {
|
||||
task.modelProvider = updates.modelProvider;
|
||||
}
|
||||
if (updates.modelId === null) {
|
||||
task.modelId = undefined;
|
||||
} else if (updates.modelId !== undefined) {
|
||||
task.modelId = updates.modelId;
|
||||
}
|
||||
if (updates.validatorModelProvider === null) {
|
||||
task.validatorModelProvider = undefined;
|
||||
} else if (updates.validatorModelProvider !== undefined) {
|
||||
task.validatorModelProvider = updates.validatorModelProvider;
|
||||
}
|
||||
if (updates.validatorModelId === null) {
|
||||
task.validatorModelId = undefined;
|
||||
} else if (updates.validatorModelId !== undefined) {
|
||||
task.validatorModelId = updates.validatorModelId;
|
||||
}
|
||||
task.updatedAt = new Date().toISOString();
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
|
||||
@@ -100,6 +100,22 @@ export interface Task {
|
||||
log: TaskLogEntry[];
|
||||
size?: "S" | "M" | "L";
|
||||
reviewLevel?: number;
|
||||
/** AI model provider override for the executor agent (e.g., "anthropic").
|
||||
* Must be set together with `modelId`. When both model fields are undefined,
|
||||
* the executor uses global settings defaults. */
|
||||
modelProvider?: string;
|
||||
/** AI model ID override for the executor agent (e.g., "claude-sonnet-4-5").
|
||||
* Must be set together with `modelProvider`. When both model fields are undefined,
|
||||
* the executor uses global settings defaults. */
|
||||
modelId?: string;
|
||||
/** AI model provider override for the validator/reviewer agent.
|
||||
* Must be set together with `validatorModelId`. When both validator model fields
|
||||
* are undefined, the reviewer uses global settings defaults. */
|
||||
validatorModelProvider?: string;
|
||||
/** AI model ID override for the validator/reviewer agent.
|
||||
* Must be set together with `validatorModelProvider`. When both validator model
|
||||
* fields are undefined, the reviewer uses global settings defaults. */
|
||||
validatorModelId?: string;
|
||||
/** Number of merge retry attempts made for this task (auto-merge conflict recovery) */
|
||||
mergeRetries?: number;
|
||||
/** ISO-8601 timestamp of when the task last entered its current column.
|
||||
|
||||
Reference in New Issue
Block a user