feat(FN-1063): add per-task thinkingLevel override in executor

- Extract per-task thinkingLevel from task detail, falling back to global defaultThinkingLevel
- Apply override in initial execution, retry session, and spawned agent paths
- Add 3 tests: per-task override, global fallback, explicit 'off' overrides global
This commit is contained in:
gsxdsm
2026-04-07 14:12:04 -07:00
parent 6b269f85c6
commit e16c75f975
2 changed files with 163 additions and 3 deletions

View File

@@ -5120,6 +5120,163 @@ describe("Per-task model overrides", () => {
});
});
// ── Per-task thinkingLevel override tests ───────────────────────────
describe("Per-task thinkingLevel override", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
});
it("uses per-task thinkingLevel when set on the task", async () => {
const store = createMockStore();
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
// Override getTask to return task with thinkingLevel override
store.getTask.mockResolvedValue({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prompt: "# test",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
thinkingLevel: "high",
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
thinkingLevel: "high",
});
// Should use per-task thinkingLevel override
const callArgs = mockedCreateHaiAgent.mock.calls[0];
expect(callArgs).toBeDefined();
expect(callArgs[0].defaultThinkingLevel).toBe("high");
});
it("falls back to global defaultThinkingLevel when task has no thinkingLevel", async () => {
const store = createMockStore();
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
worktreeInitCommand: undefined,
defaultThinkingLevel: "medium",
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
// No thinkingLevel set
});
// Should fall back to global defaultThinkingLevel
const callArgs = mockedCreateHaiAgent.mock.calls[0];
expect(callArgs).toBeDefined();
expect(callArgs[0].defaultThinkingLevel).toBe("medium");
});
it("uses explicit 'off' thinkingLevel from task over global setting", async () => {
const store = createMockStore();
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
worktreeInitCommand: undefined,
defaultThinkingLevel: "high",
});
// Override getTask to return task with thinkingLevel: "off"
store.getTask.mockResolvedValue({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prompt: "# test",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
thinkingLevel: "off",
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
thinkingLevel: "off",
});
// Should use task's explicit "off" instead of global "high"
const callArgs = mockedCreateHaiAgent.mock.calls[0];
expect(callArgs).toBeDefined();
expect(callArgs[0].defaultThinkingLevel).toBe("off");
});
});
// ── Invalid transition error handling tests ─────────────────────────
describe("Invalid transition error handling", () => {

View File

@@ -978,6 +978,9 @@ export class TaskExecutor {
: settings.defaultModelId;
const executorFallbackProvider = settings.fallbackProvider;
const executorFallbackModelId = settings.fallbackModelId;
const executorThinkingLevel = detail.thinkingLevel
? detail.thinkingLevel
: settings.defaultThinkingLevel;
// Determine whether we're resuming a previous session (pause/resume)
// or starting fresh. Use file-based sessions so conversation state
@@ -1002,7 +1005,7 @@ export class TaskExecutor {
defaultModelId: executorModelId,
fallbackProvider: executorFallbackProvider,
fallbackModelId: executorFallbackModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
defaultThinkingLevel: executorThinkingLevel,
sessionManager,
});
@@ -1189,7 +1192,7 @@ export class TaskExecutor {
defaultModelId: executorModelId,
fallbackProvider: executorFallbackProvider,
fallbackModelId: executorFallbackModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
defaultThinkingLevel: executorThinkingLevel,
sessionManager: SessionManager.create(worktreePath),
});
// Update session file for the retry session (so pause/resume works)
@@ -1708,7 +1711,7 @@ export class TaskExecutor {
defaultModelId: settings.defaultModelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
defaultThinkingLevel: detail.thinkingLevel ?? settings.defaultThinkingLevel,
// Per-task validator overrides take precedence over global validator settings
validatorModelProvider: detail.validatorModelProvider ?? settings.validatorProvider,
validatorModelId: detail.validatorModelId ?? settings.validatorModelId,