feat(config): support object-style fallback_models with per-model settings

Add support for object-style entries in fallback_models arrays, enabling
per-model configuration of variant, reasoningEffort, temperature, top_p,
maxTokens, and thinking settings.

- Zod schema for FallbackModelObject with full validation
- normalizeFallbackModels() and flattenToFallbackModelStrings() utilities
- Provider-agnostic model resolution pipeline with fallback chain
- Session prompt params state management
- Fallback chain construction with prefix-match lookup
- Integration across delegate-task, background-agent, and plugin layers
This commit is contained in:
Ravi Tharuma
2026-03-18 14:21:27 +01:00
parent 7761e48dca
commit a77a16c494
34 changed files with 1686 additions and 126 deletions
+77 -49
View File
@@ -1,68 +1,96 @@
import { describe, test, expect } from "bun:test"
import { describe, test, expect, mock, afterEach } from "bun:test"
import { startTask } from "./spawner"
import type { BackgroundTask } from "./types"
import {
clearSessionPromptParams,
getSessionPromptParams,
} from "../../shared/session-prompt-params-state"
import { createTask, startTask } from "./spawner"
describe("background-agent spawner fallback model promotion", () => {
afterEach(() => {
clearSessionPromptParams("session-123")
})
describe("background-agent spawner.startTask", () => {
test("applies explicit child session permission rules when creating child session", async () => {
test("passes promoted fallback model settings through supported prompt channels", async () => {
//#given
const createCalls: any[] = []
const parentPermission = [
{ permission: "question", action: "allow" as const, pattern: "*" },
{ permission: "plan_enter", action: "deny" as const, pattern: "*" },
]
let promptArgs: any
const client = {
session: {
get: async () => ({ data: { directory: "/parent/dir", permission: parentPermission } }),
create: async (args?: any) => {
createCalls.push(args)
return { data: { id: "ses_child" } }
},
promptAsync: async () => ({}),
get: mock(async () => ({ data: { directory: "/tmp/test" } })),
create: mock(async () => ({ data: { id: "session-123" } })),
promptAsync: mock(async (input: any) => {
promptArgs = input
return { data: {} }
}),
},
}
} as any
const task = createTask({
const concurrencyManager = {
release: mock(() => {}),
} as any
const onTaskError = mock(() => {})
const task: BackgroundTask = {
id: "bg_test123",
status: "pending",
queuedAt: new Date(),
description: "Test task",
prompt: "Do work",
agent: "explore",
parentSessionID: "ses_parent",
parentMessageID: "msg_parent",
})
const item = {
task,
input: {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
parentModel: task.parentModel,
parentAgent: task.parentAgent,
model: task.model,
sessionPermission: [
{ permission: "question", action: "deny", pattern: "*" },
],
prompt: "Do the thing",
agent: "oracle",
parentSessionID: "parent-1",
parentMessageID: "message-1",
model: {
providerID: "openai",
modelID: "gpt-5.4",
variant: "low",
reasoningEffort: "high",
temperature: 0.4,
top_p: 0.7,
maxTokens: 4096,
thinking: { type: "disabled" },
},
}
const ctx = {
client,
directory: "/fallback",
concurrencyManager: { release: () => {} },
tmuxEnabled: false,
onTaskError: () => {},
const input = {
description: "Test task",
prompt: "Do the thing",
agent: "oracle",
parentSessionID: "parent-1",
parentMessageID: "message-1",
model: task.model,
}
//#when
await startTask(item as any, ctx as any)
await startTask(
{ task, input },
{
client,
directory: "/tmp/test",
concurrencyManager,
tmuxEnabled: false,
onTaskError,
},
)
await new Promise((resolve) => setTimeout(resolve, 0))
//#then
expect(createCalls).toHaveLength(1)
expect(createCalls[0]?.body?.permission).toEqual([
{ permission: "question", action: "deny", pattern: "*" },
])
expect(promptArgs.body.model).toEqual({
providerID: "openai",
modelID: "gpt-5.4",
})
expect(promptArgs.body.variant).toBe("low")
expect(promptArgs.body.options).toBeUndefined()
expect(getSessionPromptParams("session-123")).toEqual({
temperature: 0.4,
topP: 0.7,
options: {
reasoningEffort: "high",
thinking: { type: "disabled" },
maxTokens: 4096,
},
})
})
test("keeps agent when explicit model is configured", async () => {