Fork dsh-cron-tasks as dsh-ops-cron with DSH/IM delivery.

Agent cron_* jobs default to IM when created from a WhatsApp session with a matching delivery target; otherwise DSH sidebar sessions. Fire path soft-calls ctx.dshIm.send for summaries.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-05 21:48:17 +08:00
parent 4447404361
commit 2f08136adf
17 changed files with 472 additions and 285 deletions

68
test/delivery.test.js Normal file
View file

@ -0,0 +1,68 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
matchTargetForPeer,
normalizeDelivery,
resolveCreateDelivery,
deliverRunToIm,
} from '../lib/delivery.js'
test('normalizeDelivery defaults to dsh', () => {
assert.deepEqual(normalizeDelivery({}), { kind: 'dsh' })
})
test('normalizeDelivery requires botId+targetId for im', () => {
assert.throws(() => normalizeDelivery({ kind: 'im', botId: 'b' }), /im delivery/)
assert.deepEqual(
normalizeDelivery({ kind: 'im', botId: 'bot_1', targetId: 'alerts' }),
{ kind: 'im', botId: 'bot_1', targetId: 'alerts' },
)
})
test('matchTargetForPeer matches whatsapp jid from conversationKey', () => {
const peer = { botId: 'bot_1', conversationKey: 'direct:8613800000000@s.whatsapp.net' }
const targets = [
{ targetId: 'other', route: { jid: '1@s.whatsapp.net' } },
{ targetId: 'alerts', route: { jid: '8613800000000@s.whatsapp.net' } },
]
assert.equal(matchTargetForPeer(targets, peer).targetId, 'alerts')
})
test('resolveCreateDelivery uses explicit im args', async () => {
const delivery = await resolveCreateDelivery({
delivery: 'im',
im_bot_id: 'bot_x',
im_target_id: 'tgt_y',
}, {})
assert.deepEqual(delivery, { kind: 'im', botId: 'bot_x', targetId: 'tgt_y' })
})
test('resolveCreateDelivery auto-matches peer target', async () => {
const dshIm = {
resolveSessionPeer: async () => ({
botId: 'bot_1',
conversationKey: 'direct:86138@s.whatsapp.net',
}),
listTargets: async () => ([
{ targetId: 'wa-dm', route: { jid: '86138@s.whatsapp.net' } },
]),
}
const delivery = await resolveCreateDelivery({}, {
agent: { session: { id: 'sess-1' } },
}, { dshIm })
assert.deepEqual(delivery, { kind: 'im', botId: 'bot_1', targetId: 'wa-dm' })
})
test('deliverRunToIm sends via dshIm', async () => {
const sent = []
await deliverRunToIm(
{ name: 'ping', delivery: { kind: 'im', botId: 'b', targetId: 't' } },
'hello world',
{ dshIm: { send: async (...args) => { sent.push(args) } } },
)
assert.equal(sent.length, 1)
assert.equal(sent[0][0], 'b')
assert.equal(sent[0][1], 't')
assert.match(sent[0][2], /运维定时/)
assert.match(sent[0][2], /hello world/)
})

View file

@ -89,7 +89,7 @@ async function jsonRequest(base, path, options = {}) {
}
test('host service: create, list, run-now, history, and workspace isolation', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const archived = []
@ -163,7 +163,7 @@ test('host service: create, list, run-now, history, and workspace isolation', as
})
test('overlap skip writes a skipped history row instead of a second session', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
let clock = Date.parse('2026-08-24T01:00:00.000Z')
let inflight = 0
@ -192,7 +192,7 @@ test('overlap skip writes a skipped history row instead of a second session', as
})
test('shipped HTTP handler: create, list, run-now, history', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const archived = []
const service = createHostService({
@ -210,7 +210,7 @@ test('shipped HTTP handler: create, list, run-now, history', async (t) => {
const http = await listen(service)
t.after(() => http.close())
const created = await jsonRequest(http.url, '/dsh-cron-tasks/jobs', {
const created = await jsonRequest(http.url, '/dsh-ops-cron/jobs', {
method: 'POST',
body: JSON.stringify({
name: 'http job',
@ -222,23 +222,23 @@ test('shipped HTTP handler: create, list, run-now, history', async (t) => {
assert.equal(created.body.job.name, 'http job')
const jobId = created.body.job.id
const listed = await jsonRequest(http.url, '/dsh-cron-tasks/jobs')
const listed = await jsonRequest(http.url, '/dsh-ops-cron/jobs')
assert.equal(listed.body.jobs.length, 1)
const ran = await jsonRequest(http.url, `/dsh-cron-tasks/jobs/${jobId}/run`, { method: 'POST' })
const ran = await jsonRequest(http.url, `/dsh-ops-cron/jobs/${jobId}/run`, { method: 'POST' })
assert.equal(ran.status, 200)
assert.equal(ran.body.run.sessionId, 'http-sess-1')
assert.ok(['succeeded', 'running'].includes(ran.body.run.status))
assert.deepEqual(archived, ['http-sess-1'])
const history = await jsonRequest(http.url, '/dsh-cron-tasks/history')
const history = await jsonRequest(http.url, '/dsh-ops-cron/history')
assert.ok(history.body.runs.some((row) => row.sessionId === 'http-sess-1'))
const visible = await jsonRequest(http.url, '/dsh-cron-tasks/workspace-visible?id=http-sess-1&id=other')
const visible = await jsonRequest(http.url, '/dsh-ops-cron/workspace-visible?id=http-sess-1&id=other')
assert.deepEqual(visible.body.visible, ['other'])
})
test('POST /runs/:id/open reveals the run session id', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const revealed = []
const service = createHostService({
@ -253,7 +253,7 @@ test('POST /runs/:id/open reveals the run session id', async (t) => {
})
const http = await listen(service)
t.after(() => http.close())
const created = await jsonRequest(http.url, '/dsh-cron-tasks/jobs', {
const created = await jsonRequest(http.url, '/dsh-ops-cron/jobs', {
method: 'POST',
body: JSON.stringify({
name: 'open-job',
@ -261,8 +261,8 @@ test('POST /runs/:id/open reveals the run session id', async (t) => {
schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'Asia/Shanghai' },
}),
})
const ran = await jsonRequest(http.url, `/dsh-cron-tasks/jobs/${created.body.job.id}/run`, { method: 'POST' })
const opened = await jsonRequest(http.url, `/dsh-cron-tasks/runs/${ran.body.run.id}/open`, { method: 'POST' })
const ran = await jsonRequest(http.url, `/dsh-ops-cron/jobs/${created.body.job.id}/run`, { method: 'POST' })
const opened = await jsonRequest(http.url, `/dsh-ops-cron/runs/${ran.body.run.id}/open`, { method: 'POST' })
assert.equal(opened.status, 200)
assert.equal(opened.body.sessionId, 'open-sess')
assert.deepEqual(revealed, ['open-sess'])
@ -330,7 +330,7 @@ test('resolveDefaultModel fails loud when Models has no selection', async () =>
})
test('POST /conceal archives every run session id', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const archived = []
const service = createHostService({
@ -345,7 +345,7 @@ test('POST /conceal archives every run session id', async (t) => {
})
const http = await listen(service)
t.after(() => http.close())
const created = await jsonRequest(http.url, '/dsh-cron-tasks/jobs', {
const created = await jsonRequest(http.url, '/dsh-ops-cron/jobs', {
method: 'POST',
body: JSON.stringify({
name: 'hide-job',
@ -353,15 +353,15 @@ test('POST /conceal archives every run session id', async (t) => {
schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'Asia/Shanghai' },
}),
})
await jsonRequest(http.url, `/dsh-cron-tasks/jobs/${created.body.job.id}/run`, { method: 'POST' })
await jsonRequest(http.url, `/dsh-ops-cron/jobs/${created.body.job.id}/run`, { method: 'POST' })
archived.length = 0
const hidden = await jsonRequest(http.url, '/dsh-cron-tasks/conceal', { method: 'POST' })
const hidden = await jsonRequest(http.url, '/dsh-ops-cron/conceal', { method: 'POST' })
assert.equal(hidden.status, 200)
assert.ok(archived.includes('hide-sess'))
})
test('one-shot at job fires once then later ticks wait instead of retriggering', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const at = Date.parse('2026-08-23T14:57:00.000Z')
let clock = at - 60_000
@ -508,7 +508,7 @@ test('listModelChoices maps llm providers and the current default', async () =>
})
test('GET /models lists session-port catalog', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const service = createHostService({
filePath: join(dir, 'store.json'),
@ -520,7 +520,7 @@ test('GET /models lists session-port catalog', async (t) => {
})
const http = await listen(service)
t.after(() => http.close())
const res = await jsonRequest(http.url, '/dsh-cron-tasks/models')
const res = await jsonRequest(http.url, '/dsh-ops-cron/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')
@ -547,7 +547,7 @@ test('listWorkspaceChoices maps registry entries to title and path', () => {
})
test('GET /workspaces lists session-port choices', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const service = createHostService({
filePath: join(dir, 'store.json'),
@ -559,13 +559,13 @@ test('GET /workspaces lists session-port choices', async (t) => {
})
const http = await listen(service)
t.after(() => http.close())
const res = await jsonRequest(http.url, '/dsh-cron-tasks/workspaces')
const res = await jsonRequest(http.url, '/dsh-ops-cron/workspaces')
assert.equal(res.status, 200)
assert.deepEqual(res.body.workspaces, [{ id: 'ws-1', title: 'app', path: '/tmp/app' }])
})
test('POST /sessions/:id/adopt uses the session port', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const adopted = []
const service = createHostService({
@ -579,7 +579,7 @@ test('POST /sessions/:id/adopt uses the session port', async (t) => {
})
const http = await listen(service)
t.after(() => http.close())
const res = await jsonRequest(http.url, '/dsh-cron-tasks/sessions/child-9/adopt', { method: 'POST' })
const res = await jsonRequest(http.url, '/dsh-ops-cron/sessions/child-9/adopt', { method: 'POST' })
assert.equal(res.status, 200)
assert.equal(res.body.attached, true)
assert.deepEqual(adopted, ['child-9'])
@ -598,7 +598,7 @@ test('waitForAgentTurn waits for running then idle, and fails if the agent never
})
test('after one live-shaped dispatch, the next run-now still fires', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const archived = []
const sessionPort = makeLiveSessionPort(fakeLiveCtx(archived))
@ -632,7 +632,7 @@ test('after one live-shaped dispatch, the next run-now still fires', async (t) =
})
test('after a live-shaped due tick settles, the next due occurrence still fires', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cron-tasks-'))
const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const archived = []
let clock = Date.parse('2026-08-24T01:00:00.000Z')

View file

@ -8,18 +8,19 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
test('installable bundle declares host apply, client half, unique id, and no @deepseek-ai runtime deps', async () => {
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
assert.equal(pkg.name, '@dsh-external/dsh-cron-tasks')
assert.equal(pkg.name, 'dsh-ops-cron')
assert.equal(pkg.dsh.bundle.patch, './cordis.patch.yml')
assert.equal(pkg.dsh.client.platform, 'web')
assert.ok(pkg.exports['./client'])
assert.equal(pkg.main, 'lib/index.js')
assert.ok(!pkg.dependencies || !Object.keys(pkg.dependencies).some((id) => id.startsWith('@deepseek-ai/')))
const patch = await readFile(join(root, 'cordis.patch.yml'), 'utf8')
assert.match(patch, /id:\s*dsh-cron-tasks/)
assert.match(patch, /id:\s*dsh-ops-cron/)
const index = await readFile(join(root, 'lib/index.js'), 'utf8')
assert.match(index, /export function apply\(/)
assert.match(index, /registerCronTools/)
assert.match(index, /inject\(\['tools'\]/)
assert.match(index, /getDshIm/)
const tools = await readFile(join(root, 'lib/tools.js'), 'utf8')
assert.match(tools, /name: 'cron_create'/)
assert.match(tools, /name: 'cron_list'/)
@ -28,9 +29,9 @@ test('installable bundle declares host apply, client half, unique id, and no @de
assert.match(tools, /hour/)
assert.match(tools, /Current local time/)
assert.match(tools, /source: 'runtime'/)
assert.match(tools, /current session cwd/)
assert.match(tools, /resolveCreateDelivery/)
assert.match(tools, /im_bot_id/)
assert.match(tools, /resolveCreateCwd/)
assert.match(tools, /Working directory: if the user is chatting in a workspace/)
assert.match(tools, /after_minutes/)
assert.match(tools, /Extra schedule fields are ignored/)
assert.doesNotMatch(tools, /pass only one of expr, at, or hour/)
@ -40,80 +41,26 @@ test('installable bundle declares host apply, client half, unique id, and no @de
assert.doesNotMatch(tools, /from ['"]@deepseek-ai\//)
const host = await readFile(join(root, 'lib/host.js'), 'utf8')
assert.match(host, /agentDefaultModel/)
assert.match(host, /system-prompt\/assemble/)
assert.match(host, /agentOptions/)
assert.match(host, /nextRunAt === null/)
assert.match(host, /deliverRunToIm/)
assert.match(host, /PLUGIN_NAME = 'dsh-ops-cron'/)
assert.match(host, /API_PREFIX = '\/dsh-ops-cron'/)
assert.match(host, /resolveSessionPlacement/)
assert.match(host, /adoptSessionIntoWorkspace/)
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, /定时任务/)
assert.match(client, /运维定时/)
assert.match(client, /dsh-ops-cron/)
assert.match(client, /settings\.plugin\.item/)
assert.match(client, /新建会话/)
assert.match(client, /logoRow/)
assert.match(client, /dsh-ct-entry/)
assert.match(client, /centerCol/)
assert.match(client, /data-dsh-ct-host/)
assert.match(client, /_collapsed"\]\[data-dsh-ct-mode=on\] \.dsh-ct-region/)
assert.match(client, /listRoot\.parentElement !== region\) region\.appendChild\(listRoot\)/)
assert.match(client, /jobStamp/)
assert.doesNotMatch(client, /\[data-dsh-ct-mode=on\] \.dsh-ct-region\{display:flex !important;animation:/)
assert.match(client, /visibility:hidden/)
assert.match(client, /createPortal\(editor, paneHost\)/)
assert.match(client, /sidebarCollapsed/)
assert.match(client, /_collapsed/)
assert.match(client, /width:36px/)
assert.match(client, /data-collapsed/)
assert.match(client, /translate\(-50%, -50%\)/)
assert.match(client, /返回工作区/)
assert.match(client, /runs\/\$\{run\.id\}\/open/)
assert.match(client, /regionArea/)
assert.match(client, /sessions\.open\(/)
assert.match(client, /getService\(ctx, faces, 'sessions'\)/)
assert.match(client, /workspaceSkin/)
assert.match(client, /IconFolderOpen16/)
assert.match(client, /IconProjectAddOutline16/)
assert.match(client, /IconPlayOutline16/)
assert.match(client, /IconPauseOutline16/)
assert.match(client, /IconSearchOutline16/)
assert.match(client, /searchPlaceholder/)
assert.match(client, /\/conceal/)
assert.match(client, /shouldPromoteCronFork/)
assert.match(client, /workspaceTitleFromCronFork/)
assert.match(client, /leaveCronMode/)
assert.match(client, /fork rename failed/)
assert.match(client, /sessions\/\$\{encodeURIComponent\(row\.id\)\}\/adopt/)
assert.match(client, /sessions\.length > 0 && visible\.length === 0/)
assert.match(client, /未分组|未分类/)
assert.match(client, /expanded\[job\.id\] === true/)
assert.match(client, /inject = \['slots', 'locale', 'settingsScope'\]/)
assert.doesNotMatch(client, /inject = \[[^\]]*sessions[^\]]*\]/)
assert.match(client, /工作目录/)
assert.match(client, /cwdRecent/)
assert.match(client, /cwdCustom/)
assert.match(client, /deliveryKind/)
assert.match(client, /imBotId/)
assert.match(client, /imTargetId/)
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/)
assert.match(client, /inject = \['slots', 'locale', 'settingsScope'\]/)
const delivery = await readFile(join(root, 'lib/delivery.js'), 'utf8')
assert.match(delivery, /resolveCreateDelivery/)
assert.match(delivery, /deliverRunToIm/)
const prd = await readFile(join(root, 'docs/PRD.md'), 'utf8')
assert.match(prd, /左侧侧边栏「新会话」按钮正下方的「定时任务」入口/)
assert.match(prd, /设置页「插件配置」里的定时任务设置/)
assert.match(prd, /定时任务历史/)
assert.match(prd, /chicheng-cron/)
assert.match(prd, /dsh-automation/)
assert.match(prd, /cronjob-dsh-plugin/)
assert.match(prd, /dsh-cron/)
assert.match(prd, /dsh-schedule-tasks/)
assert.match(prd, /定时任务/)
assert.match(prd, /dsh-schedule/)
assert.match(prd, /非目标/)
})