Allow each scheduled job to pin its provider and model.

New jobs snapshot the current New Session default so quota is predictable.
cron_create inherits the calling session model unless provider+model are set.
The editor lists configured models; the job row shows which model will run.
This commit is contained in:
Whale-Zhang 2026-08-24 14:31:52 +08:00
parent ef8ff6e10d
commit b0d0db1553
11 changed files with 353 additions and 19 deletions

View file

@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { adoptSessionIntoWorkspace, bindModelSelection, createHostService, listWorkspaceChoices, makeLiveSessionPort, resolveDefaultModel, resolveSessionPlacement, unarchiveSession, waitForAgentTurn } from '../lib/host.js'
import { adoptSessionIntoWorkspace, bindModelSelection, createHostService, listModelChoices, listWorkspaceChoices, makeLiveSessionPort, resolveDefaultModel, resolveJobModel, resolveSessionPlacement, unarchiveSession, waitForAgentTurn } from '../lib/host.js'
import { workspaceVisibleIds } from '../lib/isolation.js'
function createFakeAgent() {
@ -121,11 +121,24 @@ test('host service: create, list, run-now, history, and workspace isolation', as
assert.equal(job.enabled, true)
assert.equal(job.cwd, '/tmp/cron-workspace')
assert.equal(job.timeoutMinutes, 15)
assert.equal(job.provider, '')
assert.equal(job.model, '')
assert.ok(job.nextRunAt > clock)
const pinned = await service.createJob({
name: 'pinned-model',
prompt: 'use this model',
schedule: { kind: 'cron', expr: '0 10 * * *', timezone: 'Asia/Shanghai' },
provider: 'minimax-cn',
model: 'MiniMax-M3',
})
assert.equal(pinned.provider, 'minimax-cn')
assert.equal(pinned.model, 'MiniMax-M3')
const listed = await service.listJobs()
assert.equal(listed.length, 1)
assert.equal(listed[0].id, job.id)
assert.equal(listed.length, 2)
assert.ok(listed.some((row) => row.id === job.id))
assert.ok(listed.some((row) => row.id === pinned.id && row.model === 'MiniMax-M3'))
const result = await service.dispatchRun(job.id, 'run-now')
assert.ok(result.run)
@ -451,6 +464,68 @@ test('adoptSessionIntoWorkspace attaches a fork whose cwd matches a workspace',
assert.deepEqual(attached, ['child-1'])
})
test('resolveJobModel prefers a pinned job model over the New Session default', async () => {
const ctx = {
get(name) {
if (name === 'agentDefaultModel') {
return { currentSelection: () => ({ provider: 'deepseek', model: 'deepseek-chat' }) }
}
return undefined
},
}
const pinned = await resolveJobModel(ctx, { provider: 'minimax-cn', model: 'MiniMax-M3' })
assert.deepEqual(pinned, { provider: 'minimax-cn', model: 'MiniMax-M3' })
const fallback = await resolveJobModel(ctx, { provider: '', model: '' })
assert.equal(fallback.provider, 'deepseek')
assert.equal(fallback.model, 'deepseek-chat')
})
test('listModelChoices maps llm providers and the current default', async () => {
const ctx = {
get(name) {
if (name === 'agentDefaultModel') {
return { currentSelection: () => ({ provider: 'deepseek', model: 'deepseek-chat' }) }
}
if (name === 'llm') {
return {
listProviders: () => [{ id: 'minimax-cn', name: 'MiniMax' }],
async listModels(provider) {
assert.equal(provider, 'minimax-cn')
return [{ id: 'MiniMax-M3', name: 'M3', provider }]
},
}
}
return undefined
},
}
const catalog = await listModelChoices(ctx)
assert.equal(catalog.current.model, 'deepseek-chat')
assert.deepEqual(catalog.groups, [{
provider: 'minimax-cn',
displayName: 'MiniMax',
models: [{ id: 'MiniMax-M3', name: 'M3' }],
}])
})
test('GET /models lists session-port catalog', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const service = createHostService({
filePath: join(dir, 'store.json'),
sessionPort: {
async listModels() {
return { groups: [{ provider: 'deepseek', displayName: 'DeepSeek', models: [{ id: 'deepseek-chat', name: 'Chat' }] }], current: { provider: 'deepseek', model: 'deepseek-chat' } }
},
},
})
const http = await listen(service)
t.after(() => http.close())
const res = await jsonRequest(http.url, '/dsh-cron-tasks/models')
assert.equal(res.status, 200)
assert.equal(res.body.current.model, 'deepseek-chat')
assert.equal(res.body.groups[0].models[0].id, 'deepseek-chat')
})
test('listWorkspaceChoices maps registry entries to title and path', () => {
const ctx = {
get(name) {

View file

@ -48,6 +48,9 @@ test('installable bundle declares host apply, client half, unique id, and no @de
assert.match(host, /attachLiveSessionToWorkspace/)
assert.match(host, /listWorkspaceChoices/)
assert.match(host, /\/workspaces/)
assert.match(host, /listModelChoices/)
assert.match(host, /resolveJobModel/)
assert.match(host, /\/models/)
assert.doesNotMatch(host, /from ['"]@deepseek-ai\//)
const client = await readFile(join(root, 'lib/client.js'), 'utf8')
assert.match(client, /定时任务/)
@ -82,6 +85,10 @@ test('installable bundle declares host apply, client half, unique id, and no @de
assert.match(client, /cwdRecent/)
assert.match(client, /cwdCustom/)
assert.match(client, /CwdField/)
assert.match(client, /ModelField/)
assert.match(client, /api\('\/models'\)/)
assert.match(tools, /resolveCreateModel/)
assert.match(tools, /snapshots the current session model/)
assert.match(client, /api\('\/workspaces'\)/)
assert.match(client, /atToLocalInput/)
assert.match(client, /localInputToAt/)

View file

@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { createHostService } from '../lib/host.js'
import { callerWorkingDirectory, cronToolDefinitions, registerCronGuidance, registerCronTools, resolveCreateCwd, scheduleFromArgs } from '../lib/tools.js'
import { callerWorkingDirectory, cronToolDefinitions, registerCronGuidance, registerCronTools, resolveCreateCwd, resolveCreateModel, scheduleFromArgs } from '../lib/tools.js'
async function makeService(t) {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tools-'))
@ -78,11 +78,42 @@ test('cron_create cwd defaults to the calling session workspace path', async (t)
assert.equal(explicit.job.cwd, '/tmp/isolated')
})
test('cron_create snapshots the calling session model when provider is omitted', async (t) => {
const service = await makeService(t)
const tools = byName(cronToolDefinitions(service))
const created = await tools.cron_create.execute({
name: 'from-chat-model',
prompt: 'ping',
hour: 23,
minute: 1,
timezone: 'Asia/Shanghai',
}, { agent: { options: { provider: 'minimax-cn', model: 'MiniMax-M3' }, session: { header: { cwd: '/tmp/ws-app' } } } })
assert.equal(created.job.provider, 'minimax-cn')
assert.equal(created.job.model, 'MiniMax-M3')
const explicit = await tools.cron_create.execute({
name: 'pinned',
prompt: 'ping',
hour: 23,
minute: 2,
timezone: 'Asia/Shanghai',
provider: 'deepseek',
model: 'deepseek-chat',
}, { agent: { options: { provider: 'minimax-cn', model: 'MiniMax-M3' } } })
assert.equal(explicit.job.provider, 'deepseek')
assert.equal(explicit.job.model, 'deepseek-chat')
})
test('resolveCreateCwd prefers explicit cwd then the calling session', () => {
assert.equal(resolveCreateCwd({ cwd: '/tmp/a' }, { agent: { session: { header: { cwd: '/ws' } } } }), '/tmp/a')
assert.equal(callerWorkingDirectory({ agent: { session: { header: { cwd: '/ws' } } } }), '/ws')
assert.equal(resolveCreateCwd({}, { agent: { session: { header: { cwd: '/ws' } } } }), '/ws')
assert.equal(resolveCreateCwd({}, {}), '')
assert.deepEqual(resolveCreateModel({}, { agent: { options: { provider: 'minimax-cn', model: 'MiniMax-M3' } } }), {
provider: 'minimax-cn',
model: 'MiniMax-M3',
reasoningEffort: '',
})
})
test('cron_create hour+minute uses today and rejects a guessed past calendar date', async (t) => {