From b0d0db1553f5c1a5a00c0f6847d8c42d107a18ab Mon Sep 17 00:00:00 2001 From: Whale-Zhang <53304091+Whale-Zhang@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:31:52 +0800 Subject: [PATCH] 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. --- docs/NOTES.md | 16 ++++++++ lib/client.js | 87 +++++++++++++++++++++++++++++++++++++++----- lib/fire.js | 3 ++ lib/host.js | 72 +++++++++++++++++++++++++++++++++++- lib/index.d.ts | 6 +++ lib/index.js | 6 ++- lib/store.js | 21 +++++++++++ lib/tools.js | 40 ++++++++++++++++++-- test/host.test.js | 81 +++++++++++++++++++++++++++++++++++++++-- test/package.test.js | 7 ++++ test/tools.test.js | 33 ++++++++++++++++- 11 files changed, 353 insertions(+), 19 deletions(-) diff --git a/docs/NOTES.md b/docs/NOTES.md index 8905f0d..5faacae 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -80,6 +80,22 @@ v1 用 `workspaceRegistry.archiveSession` 从工作区列表拿掉。归档当 --- +## 每个任务要能指定模型 + +到点跑的会话会打对应厂商的额度。如果只用「当时新会话默认」,用户不知道扣的是哪家,聊天里一换模型,定时任务也跟着变。 + +定下来: + +- 任务可存 `provider` + `model`(可选 `reasoningEffort`) +- 编辑器下拉已配置的模型;也可选「每次运行用当时的新会话默认」 +- 新建时默认钉死**当前**新会话模型,避免空白 +- `cron_create` 没传就快照当前会话模型 +- 列表标题旁显示模型名 + +空 provider/model 才在 fire 时读 `agentDefaultModel`。 + +--- + ## 执行失败 `{{model}}` 没有值 `agents.create` 不自动带上「新会话」用的默认模型。Persona 模板插 `{{model}}`,空值会让整轮装配失败。 diff --git a/lib/client.js b/lib/client.js index 3a86a76..c1db1f2 100644 --- a/lib/client.js +++ b/lib/client.js @@ -295,6 +295,9 @@ window.__ModuleLoader__.load({ cwdRecent: '最近使用的工作区', cwdCustom: '自定义路径…', cwdPlaceholder: '/absolute/path', cwdHint: '运行会在这个目录对应的工作区里开新会话。留空则用最近工作区。', + model: '模型', + modelDefault: '每次运行用当时的新会话默认', + modelHint: '定时任务会消耗这个模型的额度。指定后不会跟着聊天模型变。', editorLead: '保存后按日程在新会话里执行。点击下方某次运行,右侧会打开那次对话,可以继续。', lastOutput: '上次输出', noOutput: '还没有输出。先立即运行一次。', } @@ -319,6 +322,9 @@ window.__ModuleLoader__.load({ cwdRecent: 'Most recent workspace', cwdCustom: 'Custom path…', cwdPlaceholder: '/absolute/path', cwdHint: 'Runs start a session in this workspace folder. Leave empty to use the most recent workspace.', + model: 'Model', + modelDefault: 'Use the New Session default at fire time', + modelHint: 'Scheduled runs consume this model\'s quota. A pinned model will not follow the chat selector.', editorLead: 'Runs start a new session. Click a run below to open that conversation and continue it.', lastOutput: 'Last output', noOutput: 'No output yet. Run it once.', } @@ -557,10 +563,12 @@ window.__ModuleLoader__.load({ return ms == null ? raw : new Date(ms).toISOString() } - function emptyForm(timezone) { + function emptyForm(timezone, current) { return { name: '', prompt: '', kind: 'cron', expr: '0 9 * * 1-5', at: '', timezone: timezone || DEFAULTS.timezone, cwd: '', timeoutMinutes: 10, enabled: true, + provider: current?.provider || '', + model: current?.model || '', } } @@ -575,9 +583,28 @@ window.__ModuleLoader__.load({ cwd: job.cwd || '', timeoutMinutes: job.timeoutMinutes || 10, enabled: job.enabled !== false, + provider: job.provider || '', + model: job.model || '', } } + function modelKey(provider, model) { + if (!provider || !model) return '' + return `${provider}::${model}` + } + + function parseModelKey(value) { + const raw = String(value || '') + const at = raw.indexOf('::') + if (at <= 0) return { provider: '', model: '' } + return { provider: raw.slice(0, at), model: raw.slice(at + 2) } + } + + function jobModelLabel(job) { + if (job?.provider && job?.model) return job.model + return '' + } + function JobList({ t, jobs, runs, selection, expanded, onSelectJob, onSelectRun, onNew, onRun, onToggle, onRemove, onToggleGroup }) { const skin = workspaceSkin() const [query, setQuery] = useState('') @@ -590,6 +617,8 @@ window.__ModuleLoader__.load({ job.prompt, job.schedule?.expr, job.schedule?.at, + job.provider, + job.model, ...(runs.filter((run) => run.jobId === job.id).map((run) => run.summary || run.status)), ].join(' ').toLowerCase() return hay.includes(needle) @@ -676,7 +705,9 @@ window.__ModuleLoader__.load({ h('span', { className: joinClass(skin.arrow, open ? skin.arrowOpen : '') }, chevronIcon() || '▸')), h('span', { className: skin.projectText }, - h('span', { className: skin.title }, paused ? `${job.name} · ${t('paused')}` : job.name)), + h('span', { className: skin.title }, paused + ? `${job.name} · ${t('paused')}` + : (jobModelLabel(job) ? `${job.name} · ${jobModelLabel(job)}` : job.name))), h('span', { className: skin.rowActs, onClick: (e) => e.stopPropagation() }, h('button', { type: 'button', className: skin.rowIcon, title: t('runNow'), onClick: () => onRun(job.id) }, playIcon() || '▶'), @@ -760,7 +791,33 @@ window.__ModuleLoader__.load({ ) } - function JobEditor({ t, form, setForm, isNew, error, lastOutput, nextRunAt, lastRunAt, workspaces, onSave, onRun, onPause, onRemove }) { + function ModelField({ t, form, setForm, catalog }) { + const groups = Array.isArray(catalog?.groups) ? catalog.groups : [] + const current = catalog?.current + const selected = modelKey(form.provider, form.model) + const known = groups.some((group) => (group.models || []).some((entry) => modelKey(group.provider, entry.id) === selected)) + const defaultLabel = current?.model + ? `${t('modelDefault')}(${current.model})` + : t('modelDefault') + return h('label', null, t('model'), + h('select', { + value: selected, + onChange: (e) => setForm({ ...form, ...parseModelKey(e.target.value) }), + }, + h('option', { value: '' }, defaultLabel), + !known && selected + ? h('option', { value: selected }, `${form.model} · ${form.provider}`) + : null, + ...groups.flatMap((group) => (group.models || []).map((entry) => h('option', { + value: modelKey(group.provider, entry.id), + key: modelKey(group.provider, entry.id), + }, `${entry.name || entry.id} · ${group.displayName || group.provider}`))), + ), + h('span', { className: 'dsh-ct-cwdHint' }, t('modelHint')), + ) + } + + function JobEditor({ t, form, setForm, isNew, error, lastOutput, nextRunAt, lastRunAt, workspaces, catalog, onSave, onRun, onPause, onRemove }) { return h('div', { className: 'dsh-ct-editor' }, h('h1', null, isNew ? t('newJob') : (form.name || t('title'))), h('p', { className: 'dsh-ct-editorLead' }, t('editorLead')), @@ -791,6 +848,7 @@ window.__ModuleLoader__.load({ h('label', null, t('timeout'), h('input', { type: 'number', min: 1, max: 240, value: form.timeoutMinutes, onChange: (e) => setForm({ ...form, timeoutMinutes: Number(e.target.value) }) })), ), h(CwdField, { t, form, setForm, workspaces }), + h(ModelField, { t, form, setForm, catalog }), h('div', { className: 'dsh-ct-editorActs' }, h('button', { type: 'button', className: 'dsh-ct-primary', onClick: onSave }, t('save')), !isNew ? h('button', { type: 'button', className: 'dsh-ct-secondary', onClick: onRun }, t('runNow')) : null, @@ -813,18 +871,21 @@ window.__ModuleLoader__.load({ const [error, setError] = useState('') const [cronMode, setCronMode] = useState(false) const [workspaces, setWorkspaces] = useState([]) + const [catalog, setCatalog] = useState({ groups: [], current: null }) const skipAutoSelect = useRef(false) const load = useCallback(async () => { try { - const [jobBody, histBody, wsBody] = await Promise.all([ + const [jobBody, histBody, wsBody, modelBody] = await Promise.all([ api('/jobs'), api('/history'), api('/workspaces').catch(() => ({ workspaces: [] })), + api('/models').catch(() => ({ groups: [], current: null })), ]) setJobs(jobBody.jobs || []) setRuns(histBody.runs || []) setWorkspaces(wsBody.workspaces || []) + setCatalog({ groups: modelBody.groups || [], current: modelBody.current || null }) faces.cronSessionIds = new Set((histBody.runs || []).map((run) => run.sessionId).filter(Boolean)) setError('') } catch (err) { @@ -874,11 +935,17 @@ window.__ModuleLoader__.load({ useEffect(() => { if (!cronMode || selection.type !== 'new') return setForm((prev) => { - if (prev.cwd) return prev - const cwd = currentWorkspacePath(faces) - return cwd ? { ...prev, cwd } : prev + let next = prev + if (!next.cwd) { + const cwd = currentWorkspacePath(faces) + if (cwd) next = { ...next, cwd } + } + if ((!next.provider || !next.model) && catalog.current?.provider && catalog.current?.model) { + next = { ...next, provider: catalog.current.provider, model: catalog.current.model } + } + return next }) - }, [cronMode, selection.type]) + }, [cronMode, selection.type, catalog.current]) function selectJob(jobId) { const job = jobs.find((row) => row.id === jobId) @@ -890,7 +957,7 @@ window.__ModuleLoader__.load({ function selectNew() { skipAutoSelect.current = true setSelection({ type: 'new' }) - const next = emptyForm() + const next = emptyForm(undefined, catalog.current) next.cwd = currentWorkspacePath(faces) setForm(next) } @@ -921,6 +988,7 @@ window.__ModuleLoader__.load({ const payload = { name: form.name, prompt: form.prompt, schedule, cwd: form.cwd, timeoutMinutes: form.timeoutMinutes, enabled: form.enabled, + provider: form.provider, model: form.model, } if (selection.type === 'job' && selection.jobId) { await api(`/jobs/${selection.jobId}`, { method: 'PATCH', body: JSON.stringify(payload) }) @@ -965,6 +1033,7 @@ window.__ModuleLoader__.load({ nextRunAt: selectedJob?.nextRunAt, lastRunAt: selectedJob?.lastRunAt, workspaces, + catalog, onSave: save, onRun: () => selection.jobId && run(selection.jobId), onPause: () => { diff --git a/lib/fire.js b/lib/fire.js index 89886a3..ef263a4 100644 --- a/lib/fire.js +++ b/lib/fire.js @@ -52,6 +52,9 @@ export function publicJob(job) { enabled: job.enabled !== false, cwd: job.cwd || '', timeoutMinutes: job.timeoutMinutes || 10, + provider: job.provider || '', + model: job.model || '', + reasoningEffort: job.reasoningEffort || '', schedule: job.schedule, createdAt: job.createdAt, updatedAt: job.updatedAt, diff --git a/lib/host.js b/lib/host.js index e71679a..63154f6 100644 --- a/lib/host.js +++ b/lib/host.js @@ -156,6 +156,9 @@ export function createHostService(options = {}) { schedule: patch.schedule !== undefined ? patch.schedule : job.schedule, cwd: patch.cwd !== undefined ? patch.cwd : job.cwd, timeoutMinutes: patch.timeoutMinutes !== undefined ? patch.timeoutMinutes : job.timeoutMinutes, + provider: patch.provider !== undefined ? patch.provider : job.provider, + model: patch.model !== undefined ? patch.model : job.model, + reasoningEffort: patch.reasoningEffort !== undefined ? patch.reasoningEffort : job.reasoningEffort, } const record = createJobRecord(nextInput, current, t) record.createdAt = job.createdAt @@ -346,6 +349,14 @@ export function createHostService(options = {}) { return } + if (path === `${API_PREFIX}/models` && method === 'GET') { + const catalog = typeof sessionPort?.listModels === 'function' + ? await sessionPort.listModels() + : { groups: [], current: null } + write(200, { ok: true, ...catalog }) + return + } + if (path === `${API_PREFIX}/workspaces` && method === 'GET') { const workspaces = typeof sessionPort?.listWorkspaces === 'function' ? await sessionPort.listWorkspaces() @@ -695,6 +706,61 @@ export async function unarchiveSession(ctx, sessionId) { * Snapshot the same default model a New Session uses. * Persona templates interpolate `{{model}}`; an empty value fails assembly. */ +export function currentDefaultModel(ctx) { + try { + const selection = tryGet(ctx, 'agentDefaultModel')?.currentSelection?.() + const provider = typeof selection?.provider === 'string' ? selection.provider.trim() : '' + const model = typeof selection?.model === 'string' ? selection.model.trim() : '' + if (!provider || !model) return null + return { + provider, + model, + ...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort }, + } + } catch { + return null + } +} + +export async function listModelChoices(ctx) { + const llm = tryGet(ctx, 'llm') + const providers = typeof llm?.listProviders === 'function' ? llm.listProviders() : [] + const list = Array.isArray(providers) ? providers : [] + const groups = [] + for (const row of list) { + const provider = String(row?.id || row?.provider || '').trim() + if (!provider) continue + let models = [] + try { + models = typeof llm.listModels === 'function' ? await llm.listModels(provider) : [] + } catch { + models = [] + } + groups.push({ + provider, + displayName: String(row?.name || row?.displayName || provider), + models: (Array.isArray(models) ? models : []).map((entry) => ({ + id: String(entry?.id || entry?.model || '').trim(), + name: String(entry?.name || entry?.displayName || entry?.id || '').trim(), + })).filter((entry) => entry.id), + }) + } + return { groups, current: currentDefaultModel(ctx) } +} + +export async function resolveJobModel(ctx, job) { + const provider = typeof job?.provider === 'string' ? job.provider.trim() : '' + const model = typeof job?.model === 'string' ? job.model.trim() : '' + if (provider && model) { + return { + provider, + model, + ...job.reasoningEffort ? { reasoningEffort: job.reasoningEffort } : {}, + } + } + return resolveDefaultModel(ctx) +} + export async function resolveDefaultModel(ctx, options = {}) { const waitMs = Number.isFinite(options.waitMs) ? Math.max(0, options.waitMs) : 1500 let service = tryGet(ctx, 'agentDefaultModel') @@ -786,13 +852,14 @@ export function makeLiveSessionPort(ctx) { const placement = resolveSessionPlacement(ctx, job) const cwd = placement.cwd || defaultCwd() await mkdir(cwd, { recursive: true }) - const selection = await resolveDefaultModel(ctx) + const selection = await resolveJobModel(ctx, job) const composition = await composeCronAgent(ctx, selection) const handle = await agents.create({ sessionId, agentOptions: { provider: selection.provider, model: selection.model, + ...selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort }, }, meta: { cwd, @@ -847,6 +914,9 @@ export function makeLiveSessionPort(ctx) { async listWorkspaces() { return listWorkspaceChoices(ctx) }, + async listModels() { + return listModelChoices(ctx) + }, async waitForTurn(sessionId) { const entry = handles.get(sessionId) const agents = tryGet(ctx, 'agents') diff --git a/lib/index.d.ts b/lib/index.d.ts index 6b3013b..c96af76 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -17,8 +17,14 @@ export function createHostService(options?: object): object export function makeLiveSessionPort(ctx: object): object export function resolveSessionPlacement(ctx: object, job?: object): { cwd: string, workspace: object | null } export function listWorkspaceChoices(ctx: object): Array<{ id: string, title: string, path: string }> +export function listModelChoices(ctx: object): Promise<{ groups: Array<{ provider: string, displayName: string, models: Array<{ id: string, name: string }> }>, current: { provider: string, model: string, reasoningEffort?: string } | null }> +export function resolveJobModel(ctx: object, job?: object): Promise<{ provider: string, model: string, reasoningEffort?: string }> +export function currentDefaultModel(ctx: object): { provider: string, model: string, reasoningEffort?: string } | null +export function normalizeJobModel(input?: object): { provider: string, model: string, reasoningEffort: string } export function callerWorkingDirectory(exec?: object): string export function resolveCreateCwd(args?: object, exec?: object): string +export function resolveCreateModel(args?: object, exec?: object): { provider: string, model: string, reasoningEffort: string } +export function callerModelSelection(exec?: object): { provider: string, model: string, reasoningEffort: string } export function scheduleFromArgs(args?: object, nowMs?: number): { kind: string, at?: string, expr?: string, timezone: string } export function adoptSessionIntoWorkspace(ctx: object, sessionId: string): Promise<{ ok: boolean, attached: boolean, sessionId?: string, cwd?: string | null, workspaceId?: string | null }> export function shouldHideNativeWorkspaceGroup(input: { sessionRowCount: number, visibleSessionCount: number, ungrouped?: boolean, cronLabeled?: boolean }): boolean diff --git a/lib/index.js b/lib/index.js index 43228da..a8bb3b1 100644 --- a/lib/index.js +++ b/lib/index.js @@ -26,13 +26,16 @@ export { attachLiveSessionToWorkspace, bindModelSelection, createHostService, + currentDefaultModel, + listModelChoices, listWorkspaceChoices, makeLiveSessionPort, resolveDefaultModel, + resolveJobModel, resolveSessionPlacement, waitForAgentTurn, } from './host.js' -export { callerWorkingDirectory, registerCronTools, resolveCreateCwd, scheduleFromArgs } from './tools.js' +export { callerWorkingDirectory, callerModelSelection, registerCronTools, resolveCreateCwd, resolveCreateModel, scheduleFromArgs } from './tools.js' export { decideDispatch, nextFire, parseCron, tickJobs, validateSchedule } from './scheduler.js' export { applyRunIsolation, @@ -43,6 +46,7 @@ export { workspaceVisibleIds, } from './isolation.js' export { wrapScheduledPrompt } from './prompt.js' +export { normalizeJobModel } from './store.js' export { claimOccurrence, executeClaimedRun, extractAssistantText, TITLE_PREFIX } from './fire.js' function loadPkg(id) { diff --git a/lib/store.js b/lib/store.js index 6ca53e1..b1c0a4b 100644 --- a/lib/store.js +++ b/lib/store.js @@ -53,6 +53,23 @@ export function newId() { return randomUUID() } +export function normalizeJobModel(input = {}) { + const provider = typeof input.provider === 'string' ? input.provider.trim() : '' + const model = typeof input.model === 'string' ? input.model.trim() : '' + const reasoningEffort = typeof input.reasoningEffort === 'string' + ? input.reasoningEffort.trim() + : (typeof input.reasoning_effort === 'string' ? input.reasoning_effort.trim() : '') + if (!provider && !model) { + return { provider: '', model: '', reasoningEffort: '' } + } + if (!provider || !model) { + const error = new Error('provider and model must be set together') + error.code = 'INVALID_JOB' + throw error + } + return { provider, model, reasoningEffort } +} + /** * @param {object} input * @param {object} state @@ -74,6 +91,7 @@ export function createJobRecord(input, state, now) { const settings = state.settings || DEFAULT_SETTINGS const schedule = validateSchedule(input.schedule, settings.timezone) const timeoutMinutes = Number(input.timeoutMinutes) + const model = normalizeJobModel(input) const job = { id: String(input.id || newId()), name, @@ -83,6 +101,9 @@ export function createJobRecord(input, state, now) { timeoutMinutes: Number.isFinite(timeoutMinutes) && timeoutMinutes >= 1 ? Math.min(240, Math.round(timeoutMinutes)) : 10, + provider: model.provider, + model: model.model, + reasoningEffort: model.reasoningEffort, schedule: schedule.kind === 'cron' ? { kind: 'cron', expr: schedule.expr, timezone: schedule.timezone } : { kind: 'at', at: new Date(schedule.at).toISOString(), timezone: schedule.timezone }, diff --git a/lib/tools.js b/lib/tools.js index c709c7a..ad85159 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -15,6 +15,9 @@ const JOB_SCHEMA = { enabled: { type: 'boolean' }, cwd: { type: 'string' }, timeoutMinutes: { type: 'number' }, + provider: { type: 'string' }, + model: { type: 'string' }, + reasoningEffort: { type: 'string' }, schedule: { type: 'object', additionalProperties: true }, createdAt: { type: 'number' }, updatedAt: { type: 'number' }, @@ -89,7 +92,8 @@ function jobLine(job) { const sched = job.schedule?.kind === 'at' ? `at ${job.schedule.at}` : (job.schedule?.expr || 'cron') - return `${job.name} [${state}] ${sched} tz=${tz} cwd=${job.cwd || '(recent workspace)'} next=${when} id=${job.id}` + const model = job.provider && job.model ? `${job.provider}/${job.model}` : 'default-model' + return `${job.name} [${state}] ${sched} tz=${tz} cwd=${job.cwd || '(recent workspace)'} model=${model} next=${when} id=${job.id}` } export function callerWorkingDirectory(exec) { @@ -103,6 +107,26 @@ export function resolveCreateCwd(args, exec) { return callerWorkingDirectory(exec) } +export function callerModelSelection(exec) { + const opts = exec?.agent?.options || {} + const provider = String(opts.provider || '').trim() + const model = String(opts.model || '').trim() + if (!provider || !model) return { provider: '', model: '', reasoningEffort: '' } + const reasoningEffort = String(opts.reasoningEffort || '').trim() + return { provider, model, reasoningEffort } +} + +export function resolveCreateModel(args, exec) { + const provider = typeof args?.provider === 'string' ? args.provider.trim() : '' + const model = typeof args?.model === 'string' ? args.model.trim() : '' + const reasoningEffort = typeof args?.reasoning_effort === 'string' + ? args.reasoning_effort.trim() + : (typeof args?.reasoningEffort === 'string' ? args.reasoningEffort.trim() : '') + if (provider && model) return { provider, model, reasoningEffort } + const fromSession = callerModelSelection(exec) + return { ...fromSession, reasoningEffort: reasoningEffort || fromSession.reasoningEffort } +} + function requireId(args) { const id = typeof args?.id === 'string' ? args.id.trim() : '' if (!id) { @@ -117,7 +141,7 @@ export function cronToolDefinitions(service) { return [ { name: 'cron_create', - description: 'Create a DSH 定时任务 (sidebar scheduled job). NEVER use crontab. NOT schedule_create. Default timezone Asia/Shanghai. For "in N minutes / 一分钟后" pass only after_minutes. For "today/tonight at HH:MM" pass only hour+minute (24h; hour=0 is midnight). Do not also send at or expr. Extra schedule fields are ignored (after_minutes > hour > at > expr). cwd MUST be the current workspace path when chatting in a workspace; if omitted, uses the current session working directory.', + description: 'Create a DSH 定时任务 (sidebar scheduled job). NEVER use crontab. NOT schedule_create. Default timezone Asia/Shanghai. For "in N minutes / 一分钟后" pass only after_minutes. For "today/tonight at HH:MM" pass only hour+minute (24h; hour=0 is midnight). Do not also send at or expr. Extra schedule fields are ignored (after_minutes > hour > at > expr). cwd MUST be the current workspace path when chatting in a workspace; if omitted, uses the current session working directory. Pass provider+model for the job; if omitted, snapshots the current session model. Scheduled runs consume that model\'s quota, not whatever the user happens to have selected later.', parameters: { type: 'object', additionalProperties: false, @@ -134,6 +158,9 @@ export function cronToolDefinitions(service) { timezone: { type: 'string', description: 'IANA timezone for expr/at. Default Asia/Shanghai. Do not pass UTC unless the user asked for UTC.' }, time_zone: { type: 'string', description: 'Alias of timezone.' }, cwd: { type: 'string', description: 'Filesystem path of the workspace this job should run in. When the user is in a workspace conversation, pass THAT workspace path (current session cwd). If omitted, the current session cwd is used. Only skip this to isolate from the project if the user asked for a custom folder.' }, + provider: { type: 'string', description: 'Provider route for this job (e.g. minimax-cn, deepseek). Must be passed with model. If omitted, the current session model is stored so quota stays predictable.' }, + model: { type: 'string', description: 'Model id for this job. Must be passed with provider. Scheduled runs bill this model.' }, + reasoning_effort: { type: 'string', description: 'Optional reasoning effort for this job.' }, timeout_minutes: { type: 'integer', description: 'Per-run timeout in minutes, 1-240.' }, enabled: { type: 'boolean', description: 'If false, create paused. Default true.' }, }, @@ -153,7 +180,10 @@ export function cronToolDefinitions(service) { ? `one-shot ${job.schedule.at}` : `cron ${job?.schedule?.expr}` const cwd = job?.cwd ? ` cwd=${job.cwd}` : ' cwd=(recent workspace)' - return text(`Created "${job?.name}" (${job?.id}) ${sched} in ${tz}.${cwd}. Next run: ${when}. If that local time is wrong, delete this job and recreate with 24h hours (22=10pm).`) + const model = job?.provider && job?.model + ? ` model=${job.provider}/${job.model}` + : ' model=(new-session default at fire time)' + return text(`Created "${job?.name}" (${job?.id}) ${sched} in ${tz}.${cwd}.${model}. Next run: ${when}. If that local time is wrong, delete this job and recreate with 24h hours (22=10pm).`) }, }, presentCall: (args) => ({ card: 'generic', title: '创建定时任务', content: String(args?.name || '') }), @@ -166,6 +196,7 @@ export function cronToolDefinitions(service) { prompt: args.prompt, schedule: scheduleFromArgs(args, Date.now()), cwd: resolveCreateCwd(args, exec), + ...resolveCreateModel(args, exec), timeoutMinutes: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined, enabled: args.enabled !== false, }) @@ -301,6 +332,7 @@ export function cronGuidanceText(nowMs = Date.now(), timeZone = 'Asia/Shanghai') 'For "in N minutes / 一分钟后", call cron_create with after_minutes=N only (do not also pass at, hour, or expr).', 'For a clock time tonight, pass only hour and minute in 24h (晚上11点34 → hour=23, minute=34; 零点33 → hour=0, minute=33). Extra at/expr fields are ignored.', 'Working directory: if the user is chatting in a workspace, pass cwd as that workspace filesystem path (the current session working directory). If they name another workspace, use that path. If cwd is omitted, cron_create uses the current session cwd. Do not invent ~/.dsh/cron-tasks/workspace unless they asked for an isolated folder.', + 'Model: pass provider+model for the job. If omitted, cron_create stores the current session model. Scheduled runs consume that model\'s quota. Ask which model if the user cares about billing.', 'When the user asks to look at, create, pause, resume, or delete 定时任务 / scheduled tasks / cron jobs:', '1. If cron_list / cron_create / cron_pause / cron_resume / cron_delete are in your tool list, call them.', '2. If they are not listed, search/unlock tools or skills with query "定时任务" or "cron" (skill_search, skill_load, or dev_tool_search) and then call them.', @@ -321,7 +353,7 @@ export function makeCronSkill() { Tools: - cron_list — list jobs -- cron_create — "一分钟后" → after_minutes=1. Clock time → hour+minute only. Do not send at/expr at the same time. Pass cwd as the current workspace path when creating from a workspace chat. +- cron_create — "一分钟后" → after_minutes=1. Clock time → hour+minute only. Do not send at/expr at the same time. Pass cwd as the current workspace path when creating from a workspace chat. Pass provider+model or inherit the current session model. - cron_pause / cron_resume / cron_delete — by id from cron_list `, } diff --git a/test/host.test.js b/test/host.test.js index 601572d..9dd1e5c 100644 --- a/test/host.test.js +++ b/test/host.test.js @@ -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) { diff --git a/test/package.test.js b/test/package.test.js index dedf708..e0e30eb 100644 --- a/test/package.test.js +++ b/test/package.test.js @@ -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/) diff --git a/test/tools.test.js b/test/tools.test.js index 1f6473b..2be7611 100644 --- a/test/tools.test.js +++ b/test/tools.test.js @@ -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) => {