dsh-deepmoyu 2.3.0 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +91 -153
- package/CHANGELOG.md +27 -0
- package/CONTRIBUTING.md +51 -0
- package/CONTRIBUTORS.md +3 -7
- package/NOTICE.md +3 -3
- package/README.md +141 -81
- package/SECURITY.md +21 -0
- package/assets/screenshots/memory.png +0 -0
- package/assets/screenshots/menu.png +0 -0
- package/assets/screenshots/workspace.png +0 -0
- package/client.js +1348 -441
- package/cordis.patch.yml +1 -1
- package/docs/ASSETS.md +25 -0
- package/docs/DEPLOYMENT.md +102 -0
- package/docs/PRIVACY.md +32 -0
- package/docs/RELEASE_AUDIT.md +70 -0
- package/docs/RELEASING.md +94 -0
- package/docs/TEST_MATRIX.md +77 -0
- package/docs/assets-permissions.json +20 -0
- package/docs/tool-evidence.json +196 -0
- package/index.js +953 -270
- package/lib/asr.py +41 -35
- package/lib/background-output.mjs +64 -17
- package/lib/background-tools.mjs +1 -0
- package/lib/companion.mjs +43 -0
- package/lib/config-policy.mjs +20 -0
- package/lib/favops.py +9 -2
- package/lib/ids.mjs +41 -0
- package/lib/login-session.mjs +34 -0
- package/lib/login.mjs +5 -4
- package/lib/mcp.mjs +3 -2
- package/lib/memory-center.mjs +74 -0
- package/lib/mode.mjs +6 -7
- package/lib/model-relay.mjs +5 -4
- package/lib/open-directory.mjs +93 -0
- package/lib/pace.mjs +92 -12
- package/lib/persona.mjs +46 -0
- package/lib/product-status.mjs +123 -0
- package/lib/prompts.mjs +40 -5
- package/lib/pv-production.mjs +280 -0
- package/lib/pv-session.mjs +316 -0
- package/lib/pv.mjs +398 -306
- package/lib/pv.py +84 -22
- package/lib/pv_cover.py +108 -26
- package/lib/pv_editorial.py +336 -0
- package/lib/pv_verify.py +267 -0
- package/lib/python-env.mjs +61 -0
- package/lib/reply.mjs +10 -3
- package/lib/run-settings.mjs +6 -0
- package/lib/sticker-automation.mjs +5 -4
- package/lib/sticker.mjs +135 -25
- package/lib/tool-scope.mjs +65 -0
- package/lib/uvfetch.mjs +165 -153
- package/lib/verify.mjs +111 -127
- package/marketplace/pyyxzi__dsh-deepmoyu.yml +7 -0
- package/package.json +23 -12
- package/presets/deepmoyu.patch.yml +1 -1
- package/prompts/duty.md +4 -3
- package/screenshots.json +5 -0
- package/selfcheck.mjs +21 -6
- package/vendor/bilibili-mcp/requirements.in +9 -0
- package/vendor/bilibili-mcp/requirements.txt +1936 -11
- package/vendor/bilibili-mcp/runtime_guard.py +35 -10
- package/lib/__pycache__/pv.cpython-310.pyc +0 -0
- package/lib/__pycache__/pv_cover.cpython-310.pyc +0 -0
- package/tests/automation-routes.test.mjs +0 -183
- package/tests/automation.test.mjs +0 -79
- package/tests/background-tools.test.mjs +0 -182
- package/tests/background-window.test.mjs +0 -75
- package/tests/dup-cross-channel.test.mjs +0 -158
- package/tests/manual-browsing.test.mjs +0 -671
- package/tests/mcp-restart.test.mjs +0 -131
- package/tests/memory-chain.test.mjs +0 -413
- package/tests/model-relay.test.mjs +0 -116
- package/tests/persona.test.mjs +0 -67
- package/tests/pet-run-routes.test.mjs +0 -127
- package/tests/pet-run-settings.test.mjs +0 -38
- package/tests/pet-session.test.mjs +0 -125
- package/tests/pv-stage1.test.mjs +0 -203
- package/tests/sticker-routes.test.mjs +0 -141
- package/tests/sticker-upload-routes.test.mjs +0 -97
- package/tests/sticker-upload.test.mjs +0 -122
- package/tests/sticker.test.mjs +0 -169
- package/tests/test_guard_cross_channel.py +0 -141
- package/tests/test_pv_cover.py +0 -95
- package/tests/test_pv_stage1.py +0 -250
- package/tests/test_reply_pictures.py +0 -255
- package/tests/token-ledger.test.mjs +0 -78
- package/tests/verify_pv_sample.py +0 -91
|
@@ -286,7 +286,27 @@ class RuntimeGuard:
|
|
|
286
286
|
return
|
|
287
287
|
now, factor = self._clock(), self._pace_factor()
|
|
288
288
|
|
|
289
|
-
|
|
289
|
+
# JS pins the actual next free time into last[kind].next using the factor that
|
|
290
|
+
# was active when that send happened. Re-deriving it from `recent` with the
|
|
291
|
+
# *current* factor made "switch to careful" lengthen waits that were already
|
|
292
|
+
# fixed (observed: 115s became 355s). Keep the recorded value authoritative.
|
|
293
|
+
last = raw.get("last")
|
|
294
|
+
stored = []
|
|
295
|
+
for kind, row in (last.items() if isinstance(last, dict) else []):
|
|
296
|
+
if isinstance(row, dict):
|
|
297
|
+
at = _finite(row.get("at"))
|
|
298
|
+
nxt = _finite(row.get("next"))
|
|
299
|
+
stored.append((kind, at, nxt if nxt > 0 else None))
|
|
300
|
+
else:
|
|
301
|
+
stored.append((kind, _finite(row), None))
|
|
302
|
+
|
|
303
|
+
def recorded_next(kind, at):
|
|
304
|
+
for s_kind, s_at, s_next in stored:
|
|
305
|
+
if s_kind == kind and s_next and abs(s_at - at) <= 2000:
|
|
306
|
+
return s_next
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
def event(kind, stamp, next_stamp=None, pinned=False):
|
|
290
310
|
at = _finite(stamp) / 1000
|
|
291
311
|
if kind not in PACE or at <= 0:
|
|
292
312
|
return
|
|
@@ -300,8 +320,15 @@ class RuntimeGuard:
|
|
|
300
320
|
"WHERE kind=? AND status='sent' AND token<>'' AND ABS(at-?)<=2 "
|
|
301
321
|
"ORDER BY ABS(at-?) LIMIT 1", (kind, at, at)).fetchone()
|
|
302
322
|
if native:
|
|
303
|
-
|
|
304
|
-
|
|
323
|
+
# The host already pinned this interval (one random draw, shown in the GUI).
|
|
324
|
+
# Python's own independent draw must not silently lengthen it — otherwise the
|
|
325
|
+
# UI says "40s left" while the native guard still waits 90s.
|
|
326
|
+
if pinned:
|
|
327
|
+
conn.execute("UPDATE guard_pace_events SET next=? WHERE event_key=?",
|
|
328
|
+
(free, native["event_key"]))
|
|
329
|
+
else:
|
|
330
|
+
conn.execute("UPDATE guard_pace_events SET next=MAX(next,?) WHERE event_key=?",
|
|
331
|
+
(free, native["event_key"]))
|
|
305
332
|
return
|
|
306
333
|
key = f"seed:{kind}:{int(round(at * 1000))}"
|
|
307
334
|
conn.execute("INSERT INTO guard_pace_events(event_key,kind,at,next,status,token) "
|
|
@@ -311,13 +338,11 @@ class RuntimeGuard:
|
|
|
311
338
|
recent = raw.get("recent")
|
|
312
339
|
for row in (recent[-200:] if isinstance(recent, list) else []):
|
|
313
340
|
if isinstance(row, dict):
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
else:
|
|
320
|
-
event(kind, row)
|
|
341
|
+
kind, at = str(row.get("k") or ""), _finite(row.get("t"))
|
|
342
|
+
nxt = recorded_next(kind, at)
|
|
343
|
+
event(kind, at, nxt, pinned=nxt is not None)
|
|
344
|
+
for kind, at, nxt in stored:
|
|
345
|
+
event(kind, at, nxt, pinned=nxt is not None)
|
|
321
346
|
book = self._pace_state(conn)
|
|
322
347
|
cool = _finite(raw.get("cooldownUntil")) / 1000
|
|
323
348
|
if cool > book["cooldown_until"]:
|
|
Binary file
|
|
Binary file
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
import test from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import { EventEmitter } from 'node:events'
|
|
4
|
-
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'
|
|
5
|
-
import { createRequire, syncBuiltinESMExports } from 'node:module'
|
|
6
|
-
import { join } from 'node:path'
|
|
7
|
-
import { tmpdir } from 'node:os'
|
|
8
|
-
import { apply } from '../index.js'
|
|
9
|
-
import { learnAdd } from '../lib/learn.mjs'
|
|
10
|
-
import { reviewMcpRequest } from '../lib/learn-review-mcp.mjs'
|
|
11
|
-
|
|
12
|
-
test('schedule and review routes share background exclusion, persist days and isolate review decisions', async () => {
|
|
13
|
-
const root = mkdtempSync(join(tmpdir(), 'fish-automation-routes-')), home = join(root, 'bili-duty')
|
|
14
|
-
mkdirSync(home)
|
|
15
|
-
const dummy = join(root, 'mock-cli.js'); writeFileSync(dummy, '// spawn is mocked')
|
|
16
|
-
writeFileSync(join(home, 'config.json'), JSON.stringify({ dshBin: dummy, learnAutoReview: false, backgroundModelId: 'background/model', backgroundMinutes: 4, learnReviewModelId: 'reviewer/model' }))
|
|
17
|
-
const stateFile = join(home, 'state.json'), pending = learnAdd({}, { text: '指出是哪一段有效,不照抄评论', from: 'BV1234567890' })
|
|
18
|
-
writeFileSync(stateFile, JSON.stringify({ learn: pending.ledger, unrelated: 77 }))
|
|
19
|
-
const children = [], spawned = [], cleanups = [], intervals = [], timers = []
|
|
20
|
-
let handler
|
|
21
|
-
const ctx = { logger: { info() {} }, get(key) { return key === 'llm' ? { listProviders: () => [{ id: 'background' }, { id: 'reviewer' }] } : key === 'dshHomePath' ? (...parts) => join(root, ...parts) : key === 'agents' ? { list: () => [] } : undefined },
|
|
22
|
-
tools: { register: tool => tool }, settings: { describe: () => [] }, on() { return () => {} },
|
|
23
|
-
effect(fn) { const cleanup = fn(); if (typeof cleanup === 'function') cleanups.push(cleanup) },
|
|
24
|
-
webServer: { register(route) { handler = route.handler; return { dispose() {} } } },
|
|
25
|
-
}
|
|
26
|
-
const actual = { setTimeout, clearTimeout, setInterval, clearInterval }
|
|
27
|
-
globalThis.setTimeout = (fn, ms) => { const t = { fn, ms, unref() {} }; timers.push(t); return t }
|
|
28
|
-
globalThis.clearTimeout = () => {}
|
|
29
|
-
globalThis.setInterval = (fn, ms) => { const t = { fn, ms, unref() {} }; intervals.push(t); return t }
|
|
30
|
-
globalThis.clearInterval = () => {}
|
|
31
|
-
const cp = createRequire(import.meta.url)('node:child_process'), actualSpawn = cp.spawn
|
|
32
|
-
cp.spawn = (command, args, opts) => {
|
|
33
|
-
spawned.push({ command, args, opts })
|
|
34
|
-
const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.exitCode = null
|
|
35
|
-
child.kill = () => { child.killed = true; return true }; children.push(child); return child
|
|
36
|
-
}; syncBuiltinESMExports()
|
|
37
|
-
const state = () => JSON.parse(readFileSync(stateFile, 'utf8'))
|
|
38
|
-
async function request(path, body = {}, method = 'POST') {
|
|
39
|
-
const req = new EventEmitter(); req.url = '/api/bili-duty' + path; req.method = method; req.headers = {}; req.destroy = () => {}
|
|
40
|
-
const res = { writeHead(code) { this.code = code }, end(text) { this.body = JSON.parse(text) } }
|
|
41
|
-
const promise = handler(req, res)
|
|
42
|
-
if (method === 'POST') { req.emit('data', Buffer.from(JSON.stringify(body))); req.emit('end') }
|
|
43
|
-
await promise; return { statusCode: res.code, ...res.body }
|
|
44
|
-
}
|
|
45
|
-
try {
|
|
46
|
-
apply(ctx, {})
|
|
47
|
-
await new Promise(resolve => setImmediate(resolve))
|
|
48
|
-
assert.ok(intervals.some(t => t.ms === 15000)); assert.ok(timers.some(t => t.ms === 1500))
|
|
49
|
-
const cfg = await request('/config', {}, 'GET')
|
|
50
|
-
assert.equal(cfg.config.scheduleEnabled, false); assert.equal(cfg.config.learnAutoReview, false)
|
|
51
|
-
const launch = await request('/schedule', { scheduleEnabled: true, scheduleAllDay: true })
|
|
52
|
-
assert.equal(launch.started, true); assert.equal(spawned.length, 1); assert.equal(launch.schedule.ran, true)
|
|
53
|
-
assert.match(spawned[0].args.at(-1), /duty\.md/)
|
|
54
|
-
assert.equal((await request('/learn/review')).busy, true)
|
|
55
|
-
assert.equal((await request('/schedule', { action: 'check' })).busy, true)
|
|
56
|
-
children[0].exitCode = 0; children[0].emit('exit', 0)
|
|
57
|
-
assert.equal((await request('/learn/review')).busy, true, 'wait for final stdio before releasing worker')
|
|
58
|
-
children[0].emit('close', 0)
|
|
59
|
-
assert.equal((await request('/schedule', { action: 'check' })).schedule.ran, true)
|
|
60
|
-
assert.equal(spawned.length, 1)
|
|
61
|
-
assert.equal(state().unrelated, 77)
|
|
62
|
-
|
|
63
|
-
// Restarting from the same home keeps today's start consumed.
|
|
64
|
-
for (const cleanup of cleanups.splice(0).reverse()) cleanup()
|
|
65
|
-
apply(ctx, {})
|
|
66
|
-
await new Promise(resolve => setImmediate(resolve))
|
|
67
|
-
await request('/schedule', { action: 'check' }); assert.equal(spawned.length, 1)
|
|
68
|
-
|
|
69
|
-
// The next failed spawn must release its own marker and retain a retry cooldown.
|
|
70
|
-
const fresh = state(); fresh.schedule = {}; writeFileSync(stateFile, JSON.stringify(fresh))
|
|
71
|
-
assert.equal((await request('/schedule', { action: 'check' })).started, true)
|
|
72
|
-
children[1].emit('error', new Error('ENOENT fixture'))
|
|
73
|
-
const afterFailure = await request('/schedule', { action: 'check' })
|
|
74
|
-
assert.equal(afterFailure.schedule.ran, false); assert.match(afterFailure.schedule.lastError, /ENOENT/)
|
|
75
|
-
assert.equal(spawned.length, 2)
|
|
76
|
-
await request('/schedule', { scheduleEnabled: false })
|
|
77
|
-
|
|
78
|
-
const review = await request('/learn/review')
|
|
79
|
-
assert.equal(review.started, true); assert.equal(spawned.length, 3)
|
|
80
|
-
const overlay = readFileSync(join(home, 'headless-learn-review-overlay.yml'), 'utf8')
|
|
81
|
-
assert.match(overlay, /mcp-dafeiyu-learn-review/); assert.match(overlay, /learn-review-mcp\.mjs/)
|
|
82
|
-
assert.match(overlay, /provider: 'dafeiyu-parent'/); assert.match(overlay, /model: 'reviewer\/model'/)
|
|
83
|
-
assert.match(overlay, /model-relay-adapter\.mjs/); assert.doesNotMatch(overlay, /BILI_DUTY_RELAY_CAPABILITY|BILI_MCP_TOOLS/)
|
|
84
|
-
assert.match(spawned[2].opts.env.BILI_DUTY_RELAY_CAPABILITY, /^[a-f0-9]{64}$/)
|
|
85
|
-
assert.doesNotMatch(spawned[2].args.at(-1), /先看自己的作品/)
|
|
86
|
-
const manifestFile = join(home, review.learnReview.id + '.json'), manifest = JSON.parse(readFileSync(manifestFile, 'utf8'))
|
|
87
|
-
const reply = reviewMcpRequest({ id: 5, method: 'tools/call', params: { name: 'bili_learn', arguments: { action: 'approve', id: pending.id, why: '具体、可复用且有来源' } } }, manifest, { stateFile, manifestFile })
|
|
88
|
-
assert.equal(reply.result.isError, false)
|
|
89
|
-
assert.equal(state().learn.items[pending.id].state, 'approved')
|
|
90
|
-
await request('/config', { learnReviewModelId: 'background/next-review' })
|
|
91
|
-
const liveReview = (await request('/learn/review', {}, 'GET')).learnReview
|
|
92
|
-
assert.equal(liveReview.running, true); assert.equal(liveReview.approved, 1); assert.equal(liveReview.remaining, 0)
|
|
93
|
-
assert.equal(liveReview.model, 'reviewer/model', 'current run keeps its actual model when next-run settings change')
|
|
94
|
-
assert.equal((await request('/learn/review', { action: 'stop' })).stopped, true)
|
|
95
|
-
assert.equal(existsSync(manifestFile), false)
|
|
96
|
-
const revoked = reviewMcpRequest({ id: 6, method: 'tools/call', params: { name: 'bili_learn', arguments: { action: 'reject', id: pending.id, why: 'stale worker' } } }, manifest, { stateFile, manifestFile })
|
|
97
|
-
assert.match(revoked.result.content[0].text, /REVIEW_RUN_REVOKED/)
|
|
98
|
-
assert.equal((await request('/pet/start', { mode: 'background' })).busy, true)
|
|
99
|
-
children[2].emit('close', null)
|
|
100
|
-
await request('/schedule', { action: 'check' })
|
|
101
|
-
const reviewDone = await request('/learn/review', {}, 'GET')
|
|
102
|
-
assert.equal(reviewDone.learnReview.running, false); assert.equal(reviewDone.learnReview.approved, 1)
|
|
103
|
-
assert.equal(reviewDone.learnReview.stopReason, 'user')
|
|
104
|
-
assert.equal(state().unrelated, 77)
|
|
105
|
-
const log = await request('/pet/log?n=500', {}, 'GET')
|
|
106
|
-
for (const type of ['schedule-start', 'schedule-error', 'learn-review-start', 'learn-review-done']) assert.ok(log.entries.some(row => row.type === type), type)
|
|
107
|
-
const bad = await request('/learn/review', { action: 'approve' }); assert.equal(bad.statusCode, 400)
|
|
108
|
-
} finally {
|
|
109
|
-
for (const cleanup of cleanups.reverse()) { try { cleanup() } catch {} }
|
|
110
|
-
Object.assign(globalThis, actual); cp.spawn = actualSpawn; syncBuiltinESMExports()
|
|
111
|
-
rmSync(root, { recursive: true, force: true })
|
|
112
|
-
}
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
test('default automatic review starts on idle startup, respects retry and off switch, then finishes pending work', async () => {
|
|
116
|
-
const root = mkdtempSync(join(tmpdir(), 'fish-auto-review-')), home = join(root, 'bili-duty')
|
|
117
|
-
mkdirSync(home)
|
|
118
|
-
const dummy = join(root, 'mock-cli.js'); writeFileSync(dummy, '// spawn is mocked')
|
|
119
|
-
writeFileSync(join(home, 'config.json'), JSON.stringify({ dshBin: dummy, backgroundModelId: 'reviewer/model' }))
|
|
120
|
-
const stateFile = join(home, 'state.json'), pending = learnAdd({}, { text: '说明具体场景,再决定如何接话', from: 'BV1234567890' })
|
|
121
|
-
writeFileSync(stateFile, JSON.stringify({ learn: pending.ledger, unrelated: 99 }))
|
|
122
|
-
const children = [], spawned = [], cleanups = [], intervals = [], timers = []
|
|
123
|
-
let handler
|
|
124
|
-
const ctx = { logger: { info() {} }, get(key) { return key === 'llm' ? { listProviders: () => [{ id: 'reviewer' }] } : key === 'dshHomePath' ? (...parts) => join(root, ...parts) : key === 'agents' ? { list: () => [] } : undefined },
|
|
125
|
-
tools: { register: tool => tool }, settings: { describe: () => [] }, on() { return () => {} },
|
|
126
|
-
effect(fn) { const cleanup = fn(); if (typeof cleanup === 'function') cleanups.push(cleanup) },
|
|
127
|
-
webServer: { register(route) { handler = route.handler; return { dispose() {} } } },
|
|
128
|
-
}
|
|
129
|
-
const actual = { setTimeout, clearTimeout, setInterval, clearInterval }
|
|
130
|
-
globalThis.setTimeout = (fn, ms) => { const t = { fn, ms, unref() {} }; timers.push(t); return t }
|
|
131
|
-
globalThis.clearTimeout = () => {}
|
|
132
|
-
globalThis.setInterval = (fn, ms) => { const t = { fn, ms, unref() {} }; intervals.push(t); return t }
|
|
133
|
-
globalThis.clearInterval = () => {}
|
|
134
|
-
const cp = createRequire(import.meta.url)('node:child_process'), actualSpawn = cp.spawn
|
|
135
|
-
cp.spawn = (command, args, opts) => {
|
|
136
|
-
spawned.push({ command, args, opts })
|
|
137
|
-
const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.exitCode = null
|
|
138
|
-
child.kill = () => true; children.push(child); return child
|
|
139
|
-
}; syncBuiltinESMExports()
|
|
140
|
-
const flush = () => new Promise(resolve => setImmediate(resolve))
|
|
141
|
-
async function request(path, body, method = 'GET') {
|
|
142
|
-
const req = new EventEmitter(); req.url = '/api/bili-duty' + path; req.method = method; req.headers = {}; req.destroy = () => {}
|
|
143
|
-
const res = { writeHead(code) { this.code = code }, end(text) { this.body = JSON.parse(text) } }
|
|
144
|
-
const promise = handler(req, res)
|
|
145
|
-
if (method === 'POST') { req.emit('data', Buffer.from(JSON.stringify(body || {}))); req.emit('end') }
|
|
146
|
-
await promise; return res.body
|
|
147
|
-
}
|
|
148
|
-
try {
|
|
149
|
-
apply(ctx, {}); await flush()
|
|
150
|
-
assert.equal((await request('/config')).config.learnAutoReview, true)
|
|
151
|
-
const startup = timers.find(t => t.ms === 1500), interval = intervals.find(t => t.ms === 15000)
|
|
152
|
-
startup.fn(); await flush()
|
|
153
|
-
assert.equal(spawned.length, 1, 'startup automatically launches pending review without a manual request')
|
|
154
|
-
assert.match(spawned[0].args.at(-1), /独立的学习队列模型审核任务/)
|
|
155
|
-
assert.equal((await request('/learn/review')).learnReview.model, 'reviewer/model', 'empty reviewer setting uses background model')
|
|
156
|
-
interval.fn(); await flush(); assert.equal(spawned.length, 1, 'busy worker is never duplicated')
|
|
157
|
-
children[0].emit('close', 0); await flush()
|
|
158
|
-
const incomplete = (await request('/learn/review')).learnReview
|
|
159
|
-
assert.equal(incomplete.complete, false); assert.equal(incomplete.pending, 1)
|
|
160
|
-
assert.ok(incomplete.nextRetryAt > Date.now()); assert.equal(incomplete.remaining, 1)
|
|
161
|
-
interval.fn(); await flush(); assert.equal(spawned.length, 1, 'zero exit with unprocessed pending work respects backoff')
|
|
162
|
-
const ready = JSON.parse(readFileSync(stateFile, 'utf8'))
|
|
163
|
-
ready.learnReview.lastAttemptAt = Date.now() - 180001; ready.learnReview.nextRetryAt = Date.now() - 1
|
|
164
|
-
writeFileSync(stateFile, JSON.stringify(ready))
|
|
165
|
-
await request('/config', { learnAutoReview: false }, 'POST')
|
|
166
|
-
interval.fn(); await flush(); assert.equal(spawned.length, 1, 'explicit off is preserved')
|
|
167
|
-
await request('/config', { learnAutoReview: true }, 'POST')
|
|
168
|
-
interval.fn(); await flush(); assert.equal(spawned.length, 2, 'idle scheduler resumes pending review after cooldown')
|
|
169
|
-
const running = (await request('/learn/review')).learnReview
|
|
170
|
-
const manifestFile = join(home, running.id + '.json'), manifest = JSON.parse(readFileSync(manifestFile, 'utf8'))
|
|
171
|
-
const reply = reviewMcpRequest({ id: 9, method: 'tools/call', params: { name: 'bili_learn', arguments: { action: 'approve', id: pending.id, why: '具体、可复用且有来源' } } }, manifest, { stateFile, manifestFile })
|
|
172
|
-
assert.equal(reply.result.isError, false)
|
|
173
|
-
children[1].emit('close', 0); await flush()
|
|
174
|
-
const complete = (await request('/learn/review')).learnReview
|
|
175
|
-
assert.equal(complete.complete, true); assert.equal(complete.approved, 1); assert.equal(complete.pending, 0)
|
|
176
|
-
assert.equal(JSON.parse(readFileSync(stateFile, 'utf8')).unrelated, 99)
|
|
177
|
-
interval.fn(); await flush(); assert.equal(spawned.length, 2, 'empty pending queue stays idle')
|
|
178
|
-
} finally {
|
|
179
|
-
for (const cleanup of cleanups.reverse()) { try { cleanup() } catch {} }
|
|
180
|
-
Object.assign(globalThis, actual); cp.spawn = actualSpawn; syncBuiltinESMExports()
|
|
181
|
-
rmSync(root, { recursive: true, force: true })
|
|
182
|
-
}
|
|
183
|
-
})
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import test from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
-
import { join } from 'node:path'
|
|
5
|
-
import { tmpdir } from 'node:os'
|
|
6
|
-
import { AUTOMATION_DEFAULTS, clampAutomationSettings, dailyScheduleStatus, markDailyStarted, releaseDailyStart } from '../lib/automation.mjs'
|
|
7
|
-
import { learnAdd, learnDecide, learnInject } from '../lib/learn.mjs'
|
|
8
|
-
import { learningReviewBatch, buildLearningReviewPrompt, decideLearningReview } from '../lib/learn-review.mjs'
|
|
9
|
-
import { reviewMcpRequest } from '../lib/learn-review-mcp.mjs'
|
|
10
|
-
import { readRecordEvents } from '../lib/records.mjs'
|
|
11
|
-
|
|
12
|
-
const local = (h, m = 0, day = 8) => new Date(2026, 0, day, h, m)
|
|
13
|
-
const config = { scheduleEnabled: true, scheduleStart: '18:00', scheduleEnd: '09:00' }
|
|
14
|
-
test('daily windows are local, half-open, cross midnight and persist consumed days', () => {
|
|
15
|
-
assert.equal(AUTOMATION_DEFAULTS.scheduleEnabled, false)
|
|
16
|
-
assert.equal(AUTOMATION_DEFAULTS.learnAutoReview, true)
|
|
17
|
-
assert.equal(dailyScheduleStatus(config, {}, local(17, 59)).due, false)
|
|
18
|
-
const open = dailyScheduleStatus(config, {}, local(18))
|
|
19
|
-
assert.equal(open.due, true); assert.equal(open.windowDay, '2026-01-08')
|
|
20
|
-
assert.equal(dailyScheduleStatus(config, {}, local(8, 59, 9)).windowDay, '2026-01-08')
|
|
21
|
-
assert.equal(dailyScheduleStatus(config, {}, local(9)).inWindow, false)
|
|
22
|
-
const record = markDailyStarted({}, open.windowDay, local(18), 'one')
|
|
23
|
-
assert.equal(dailyScheduleStatus(config, JSON.parse(JSON.stringify(record)), local(8, 0, 9)).due, false)
|
|
24
|
-
assert.equal(new Date(dailyScheduleStatus(config, record, local(8, 0, 9)).nextRunAt).getTime(), local(18, 0, 9).getTime())
|
|
25
|
-
assert.equal(dailyScheduleStatus(config, record, local(18, 0, 9)).due, true)
|
|
26
|
-
assert.equal(releaseDailyStart(record, 'stale', 'error'), record)
|
|
27
|
-
assert.equal(dailyScheduleStatus(config, releaseDailyStart(record, 'one', 'ENOENT'), local(19)).due, true)
|
|
28
|
-
})
|
|
29
|
-
test('all day, empty/equal clocks, ordinary window boundaries and clamps', () => {
|
|
30
|
-
const allDay = { ...config, scheduleAllDay: true }
|
|
31
|
-
assert.equal(dailyScheduleStatus(allDay, {}, local(0)).due, true)
|
|
32
|
-
assert.equal(dailyScheduleStatus({ ...config, scheduleEnabled: false }, {}, local(19)).nextRunAt, null)
|
|
33
|
-
assert.equal(dailyScheduleStatus({ ...config, scheduleStart: '09:00', scheduleEnd: '09:00' }, {}, local(12)).due, false)
|
|
34
|
-
const sameDay = { ...config, scheduleStart: '09:00', scheduleEnd: '17:00' }
|
|
35
|
-
assert.equal(dailyScheduleStatus(sameDay, {}, local(9)).due, true)
|
|
36
|
-
assert.equal(dailyScheduleStatus(sameDay, {}, local(17)).due, false)
|
|
37
|
-
assert.deepEqual(clampAutomationSettings({ scheduleStart: '3:04', scheduleEnd: '29:99', learnReviewBatchSize: 999, learnReviewMinutes: 0, learnReviewTokenBudget: -1 }),
|
|
38
|
-
{ scheduleStart: '03:04', scheduleEnd: '09:00', learnReviewBatchSize: 50, learnReviewMinutes: 1, learnReviewTokenBudget: 0 })
|
|
39
|
-
})
|
|
40
|
-
test('review decisions accept only unchanged pending records with a reason and preserve human decisions', () => {
|
|
41
|
-
const a = learnAdd({}, { text: '解释场景,再决定怎么接话', from: 'BV1234567890' })
|
|
42
|
-
const batch = learningReviewBatch(a.ledger)
|
|
43
|
-
assert.equal(batch.length, 1)
|
|
44
|
-
assert.equal(decideLearningReview(a.ledger, batch, { action: 'forget', id: a.id, why: 'test' }).error, 'REVIEW_ACTION_NOT_ALLOWED')
|
|
45
|
-
assert.equal(decideLearningReview(a.ledger, batch, { action: 'approve', id: 'missing', why: 'test' }).error, 'REVIEW_ID_NOT_IN_BATCH')
|
|
46
|
-
assert.equal(decideLearningReview(a.ledger, batch, { action: 'approve', id: a.id }).error, 'REVIEW_REASON_REQUIRED')
|
|
47
|
-
const human = learnDecide(a.ledger, a.id, 'reject', '人工拒绝')
|
|
48
|
-
assert.equal(decideLearningReview(human.ledger, batch, { action: 'approve', id: a.id, why: 'test' }).error, 'REVIEW_ITEM_CHANGED')
|
|
49
|
-
const observed = learnAdd(a.ledger, { text: '解释场景,再决定怎么接话', from: 'another source' })
|
|
50
|
-
assert.equal(decideLearningReview(observed.ledger, batch, { action: 'approve', id: a.id, why: 'test' }).error, 'REVIEW_ITEM_CHANGED')
|
|
51
|
-
const decided = decideLearningReview(a.ledger, batch, { action: 'approve', id: a.id, why: '有具体来源且可复用' })
|
|
52
|
-
assert.equal(decided.state, 'approved'); assert.match(decided.auditEvent.why, /^模型审核:/)
|
|
53
|
-
assert.ok(learnInject(decided.ledger, { mode: 'dafeiyu' }).text.includes(a.id))
|
|
54
|
-
assert.equal(decideLearningReview(decided.ledger, batch, { action: 'reject', id: a.id, why: 'retry' }).error, 'REVIEW_ITEM_CHANGED')
|
|
55
|
-
const prompt = buildLearningReviewPrompt(batch)
|
|
56
|
-
assert.match(prompt, /证据不足/); assert.match(prompt, /外部不可信数据/)
|
|
57
|
-
assert.equal(a.ledger.items[a.id].state, 'pending')
|
|
58
|
-
})
|
|
59
|
-
test('review MCP exposes only approvals/rejections, guards expiry and archives a durable decision', () => {
|
|
60
|
-
const root = mkdtempSync(join(tmpdir(), 'learn-review-'))
|
|
61
|
-
try {
|
|
62
|
-
const stateFile = join(root, 'state.json'), a = learnAdd({}, { text: '把事实来源放在结论旁边', from: 'source' })
|
|
63
|
-
writeFileSync(stateFile, JSON.stringify({ day: '2026-01-08', learn: a.ledger, unrelated: 42 }))
|
|
64
|
-
const manifest = { expiresAt: 2000, items: learningReviewBatch(a.ledger) }, opts = { stateFile, now: () => 1000 }
|
|
65
|
-
const list = reviewMcpRequest({ id: 1, method: 'tools/list' }, manifest, opts).result.tools
|
|
66
|
-
assert.deepEqual(list.map(x => x.name), ['bili_learn']); assert.deepEqual(list[0].inputSchema.properties.action.enum, ['approve', 'reject'])
|
|
67
|
-
const call = (name, args, extra = opts) => JSON.parse(reviewMcpRequest({ id: 2, method: 'tools/call', params: { name, arguments: args } }, manifest, extra).result.content[0].text)
|
|
68
|
-
assert.equal(call('bili_reply', {}).error, 'REVIEW_TOOL_NOT_ALLOWED')
|
|
69
|
-
assert.equal(call('bili_learn', { action: 'approve', id: a.id, why: '具体且有来源' }, { ...opts, now: () => 2000 }).error, 'REVIEW_RUN_EXPIRED')
|
|
70
|
-
assert.equal(call('bili_learn', { action: 'approve', id: a.id, why: '具体且有来源' }).ok, true)
|
|
71
|
-
const saved = JSON.parse(readFileSync(stateFile, 'utf8'))
|
|
72
|
-
assert.equal(saved.unrelated, 42); assert.equal(saved.learnRevision, 1); assert.equal(saved.learn.items[a.id].state, 'approved')
|
|
73
|
-
assert.equal(readRecordEvents(root, { type: 'learning' }).length, 1)
|
|
74
|
-
assert.equal(call('bili_learn', { action: 'approve', id: a.id, why: 'duplicate' }).error, 'REVIEW_ITEM_CHANGED')
|
|
75
|
-
writeFileSync(stateFile, '{corrupt')
|
|
76
|
-
assert.equal(call('bili_learn', { action: 'reject', id: a.id, why: 'test' }).ok, false)
|
|
77
|
-
assert.equal(readFileSync(stateFile, 'utf8'), '{corrupt')
|
|
78
|
-
} finally { rmSync(root, { recursive: true, force: true }) }
|
|
79
|
-
})
|
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
import test from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import { EventEmitter } from 'node:events'
|
|
4
|
-
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
5
|
-
import { tmpdir } from 'node:os'
|
|
6
|
-
import { join } from 'node:path'
|
|
7
|
-
import { createRequire, syncBuiltinESMExports } from 'node:module'
|
|
8
|
-
import { apply } from '../index.js'
|
|
9
|
-
import { createRelayClient, createRelayAdapter } from '../lib/model-relay-adapter.mjs'
|
|
10
|
-
import { RELAY_PROVIDER } from '../lib/model-relay.mjs'
|
|
11
|
-
import { McpClient } from '../lib/mcp.mjs'
|
|
12
|
-
import { backgroundMcpRequest } from '../lib/background-mcp.mjs'
|
|
13
|
-
import { backgroundToolNames, toolInputSchema, validateToolArguments } from '../lib/background-tools.mjs'
|
|
14
|
-
|
|
15
|
-
// No Python, credentials or Bilibili requests: exercise the real parent wrappers via localhost MCP.
|
|
16
|
-
test('background MCP shares host activity, pending learning, usage and live automation switches', async () => {
|
|
17
|
-
const root = mkdtempSync(join(tmpdir(), 'fish-tool-bridge-')), home = join(root, 'bili-duty')
|
|
18
|
-
mkdirSync(home)
|
|
19
|
-
const cli = join(root, 'mock-cli.js'); writeFileSync(cli, '// child spawn mocked')
|
|
20
|
-
writeFileSync(join(home, 'config.json'), JSON.stringify({ dshBin: cli, learnAutoReview: false, backgroundModelId: 'buddy/fixture', reportLevel: 'off', replyToComments: true, autoComment: false, autoReplyDm: false }))
|
|
21
|
-
const cp = createRequire(import.meta.url)('node:child_process'), originalSpawn = cp.spawn, cleanups = [], registered = new Map()
|
|
22
|
-
let handler, childEnv, child
|
|
23
|
-
cp.spawn = (command, args, options) => {
|
|
24
|
-
childEnv = options.env
|
|
25
|
-
child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.exitCode = null
|
|
26
|
-
child.kill = () => true
|
|
27
|
-
return child
|
|
28
|
-
}; syncBuiltinESMExports()
|
|
29
|
-
let streams = 0
|
|
30
|
-
const llm = {
|
|
31
|
-
listProviders: () => [{ id: 'buddy' }],
|
|
32
|
-
resolveModelInfo: async (provider, id) => ({ provider, id, name: id, context: { contextWindow: 8192 } }),
|
|
33
|
-
prepareCall: async config => ({ config, async *stream() {
|
|
34
|
-
yield { type: 'text-delta', index: 0, text: 'fixture reply' }
|
|
35
|
-
if (++streams <= 2) yield { type: 'usage', usage: { inputTokens: 17, outputTokens: 3, cacheReadTokens: 10 } }
|
|
36
|
-
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
37
|
-
} }),
|
|
38
|
-
}
|
|
39
|
-
const ctx = {
|
|
40
|
-
logger: { info() {} },
|
|
41
|
-
get: key => key === 'llm' ? llm : key === 'dshHomePath' ? (...parts) => join(root, ...parts) : key === 'agents' ? { list: () => [] } : undefined,
|
|
42
|
-
tools: { register(spec) { registered.set(spec.name, spec); return spec } }, settings: { describe: () => [] }, on() { return () => {} },
|
|
43
|
-
effect(fn) { const cleanup = fn(); if (typeof cleanup === 'function') cleanups.push(cleanup) },
|
|
44
|
-
webServer: { register(route) { handler = route.handler; return { dispose() {} } } },
|
|
45
|
-
}
|
|
46
|
-
async function request(path, body = {}, method = 'POST') {
|
|
47
|
-
const req = new EventEmitter(); req.url = '/api/bili-duty' + path; req.method = method; req.headers = {}; req.destroy = () => {}
|
|
48
|
-
const res = { writeHead(code) { this.code = code }, end(text) { this.body = JSON.parse(text) } }
|
|
49
|
-
const pending = handler(req, res)
|
|
50
|
-
if (method === 'POST') { req.emit('data', Buffer.from(JSON.stringify(body))); req.emit('end') }
|
|
51
|
-
await pending; return { statusCode: res.code, ...res.body }
|
|
52
|
-
}
|
|
53
|
-
try {
|
|
54
|
-
apply(ctx, {}); await new Promise(resolve => setImmediate(resolve))
|
|
55
|
-
assert.equal((await request('/pet/start', { mode: 'background' })).started, true)
|
|
56
|
-
const overlay = readFileSync(join(home, 'headless-overlay.yml'), 'utf8')
|
|
57
|
-
assert.match(overlay, /background-mcp\.mjs/); assert.doesNotMatch(overlay, /mcp_server\.py|BILI_DUTY_RELAY_CAPABILITY/)
|
|
58
|
-
const client = createRelayClient(childEnv), list = (await backgroundMcpRequest({ id: 1, method: 'tools/list' }, client)).result.tools
|
|
59
|
-
assert.ok(list.some(x => x.name === 'bili_reply')); assert.ok(list.some(x => x.name === 'bili_learn'))
|
|
60
|
-
for (const name of ['bili_dm_reply', 'bili_moment', 'bili_config', 'bili_run', 'bili_setup']) assert.ok(!list.some(x => x.name === name), name)
|
|
61
|
-
assert.ok(!('force' in list.find(x => x.name === 'bili_reply').inputSchema.properties))
|
|
62
|
-
let seq = 1
|
|
63
|
-
const call = async (name, args) => {
|
|
64
|
-
const result = (await backgroundMcpRequest({ id: ++seq, method: 'tools/call', params: { name, arguments: args } }, client)).result
|
|
65
|
-
return { isError: result.isError, ...JSON.parse(result.content[0].text) }
|
|
66
|
-
}
|
|
67
|
-
const say = await call('bili_say', { text: '字幕没有来源,今天先空手。', bvid: null })
|
|
68
|
-
assert.equal(say.ok, true)
|
|
69
|
-
const activity = (await request('/activity', {}, 'GET')).rows
|
|
70
|
-
assert.ok(activity.some(x => x.kind === 'say' && /今天先空手/.test(x.text)))
|
|
71
|
-
const learned = await call('bili_learn', { action: 'add', kind: 'style', text: '先回应一个明确细节,再简短表达自己的感受', from: 'fixture:comment' })
|
|
72
|
-
assert.equal(learned.ok, true)
|
|
73
|
-
const state = () => JSON.parse(readFileSync(join(home, 'state.json'), 'utf8'))
|
|
74
|
-
assert.equal(state().learn.items[learned.id].state, 'pending')
|
|
75
|
-
const blockedApproval = await call('bili_learn', { action: 'approve', id: learned.id })
|
|
76
|
-
assert.equal(blockedApproval.isError, true)
|
|
77
|
-
assert.equal(state().learn.items[learned.id].state, 'pending')
|
|
78
|
-
assert.equal((await registered.get('bili_soul').execute({ action: 'read' }, {})).reason, 'mode_required')
|
|
79
|
-
assert.equal((await call('bili_reply', { bvid: 'BV1234567890', text: 'fixture', force: true })).isError, true)
|
|
80
|
-
await request('/config', { replyToComments: false })
|
|
81
|
-
const disabled = await call('bili_reply', { bvid: 'BV1234567890', text: 'fixture' })
|
|
82
|
-
assert.equal(disabled.isError, true); assert.match(disabled.error, /BACKGROUND_TOOL_DISABLED/)
|
|
83
|
-
|
|
84
|
-
const adapter = createRelayAdapter(childEnv)
|
|
85
|
-
for (let i = 0; i < 3; i++) for await (const _ of adapter.stream({ provider: RELAY_PROVIDER, model: 'buddy/fixture', messages: [] })) {}
|
|
86
|
-
// A state update is async; await an unrelated host request/turn before reading its persisted projection.
|
|
87
|
-
await new Promise(resolve => setImmediate(resolve))
|
|
88
|
-
assert.equal(state().tokensTotal, 60)
|
|
89
|
-
assert.equal(state().tokensToday, 60)
|
|
90
|
-
child.exitCode = 0; child.emit('close', 0)
|
|
91
|
-
const status = await request('/status', {}, 'GET')
|
|
92
|
-
assert.equal(status.backgroundRun.stopReason, 'exit')
|
|
93
|
-
assert.equal(status.backgroundRun.requests, 3)
|
|
94
|
-
assert.equal(status.backgroundRun.unknownRequests, 1)
|
|
95
|
-
const round = status.tokenRounds.entries[0]
|
|
96
|
-
assert.equal(round.tokensUnknown, false); assert.deepEqual(round.tokens, { input: 54, output: 6, total: 60 })
|
|
97
|
-
assert.equal(round.unknownRequests, 1)
|
|
98
|
-
assert.equal(status.tokenRounds.sums.partial, 1)
|
|
99
|
-
const log = (await request('/pet/log?n=500', {}, 'GET')).entries
|
|
100
|
-
assert.ok(log.some(x => x.type === 'bg-progress' && x.stage === 'tool-done' && /bili_say/.test(x.text)))
|
|
101
|
-
assert.ok(log.some(x => x.type === 'bg-done' && x.tokens?.total === 60 && x.unknownRequests === 1))
|
|
102
|
-
await assert.rejects(client.post({ action: 'tools-list' }), /撤销或过期/)
|
|
103
|
-
} finally {
|
|
104
|
-
for (const cleanup of cleanups.reverse()) { try { cleanup() } catch {} }
|
|
105
|
-
cp.spawn = originalSpawn; syncBuiltinESMExports()
|
|
106
|
-
rmSync(root, { recursive: true, force: true })
|
|
107
|
-
}
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
test('stopping during DM qualification never submits a new send after the pending read returns', async () => {
|
|
111
|
-
const root = mkdtempSync(join(tmpdir(), 'fish-stop-dm-')), home = join(root, 'bili-duty')
|
|
112
|
-
mkdirSync(home)
|
|
113
|
-
const cli = join(root, 'mock-cli.js'); writeFileSync(cli, '// child spawn mocked')
|
|
114
|
-
writeFileSync(join(home, 'config.json'), JSON.stringify({ dshBin: cli, learnAutoReview: false, backgroundModelId: 'buddy/fixture', reportLevel: 'off', autoReplyDm: true }))
|
|
115
|
-
const cp = createRequire(import.meta.url)('node:child_process'), originalSpawn = cp.spawn, originalCall = McpClient.prototype.call
|
|
116
|
-
const cleanups = []
|
|
117
|
-
let handler, childEnv, child, reads = 0, sends = 0, releaseRead, readStarted
|
|
118
|
-
const reading = new Promise(resolve => { readStarted = resolve })
|
|
119
|
-
const released = new Promise(resolve => { releaseRead = resolve })
|
|
120
|
-
const calls = []
|
|
121
|
-
cp.spawn = (command, args, options) => {
|
|
122
|
-
const proc = new EventEmitter(); proc.stdout = new EventEmitter(); proc.stderr = new EventEmitter(); proc.exitCode = null; proc.kill = () => true
|
|
123
|
-
if (options.env?.BILI_DUTY_RELAY_CAPABILITY) { child = proc; childEnv = options.env }
|
|
124
|
-
return proc
|
|
125
|
-
}; syncBuiltinESMExports()
|
|
126
|
-
McpClient.prototype.call = async function(name) {
|
|
127
|
-
calls.push(name)
|
|
128
|
-
if (name === 'bili_dm_list') {
|
|
129
|
-
if (++reads === 1) { readStarted(); await released }
|
|
130
|
-
return { items: [{ uid: '123', msg_seqno: '456', is_follow: true, days_ago: 0 }] }
|
|
131
|
-
}
|
|
132
|
-
if (name === 'bili_send_message') { sends++; return { success: true } }
|
|
133
|
-
throw new Error('unexpected MCP call ' + name)
|
|
134
|
-
}
|
|
135
|
-
const llm = { listProviders: () => [{ id: 'buddy' }] }
|
|
136
|
-
const ctx = {
|
|
137
|
-
logger: { info() {} }, get: key => key === 'llm' ? llm : key === 'dshHomePath' ? (...parts) => join(root, ...parts) : key === 'agents' ? { list: () => [] } : undefined,
|
|
138
|
-
tools: { register(spec) { return spec } }, settings: { describe: () => [] }, on() { return () => {} },
|
|
139
|
-
effect(fn) { const cleanup = fn(); if (typeof cleanup === 'function') cleanups.push(cleanup) },
|
|
140
|
-
webServer: { register(route) { handler = route.handler; return { dispose() {} } } },
|
|
141
|
-
}
|
|
142
|
-
async function request(path, body = {}) {
|
|
143
|
-
const req = new EventEmitter(); req.url = '/api/bili-duty' + path; req.method = 'POST'; req.headers = {}; req.destroy = () => {}
|
|
144
|
-
const res = { writeHead(code) { this.code = code }, end(text) { this.body = JSON.parse(text) } }
|
|
145
|
-
const pending = handler(req, res); req.emit('data', Buffer.from(JSON.stringify(body))); req.emit('end'); await pending
|
|
146
|
-
return res.body
|
|
147
|
-
}
|
|
148
|
-
try {
|
|
149
|
-
apply(ctx, {}); await new Promise(resolve => setImmediate(resolve))
|
|
150
|
-
assert.equal((await request('/pet/start', { mode: 'background' })).started, true)
|
|
151
|
-
const pending = backgroundMcpRequest({ id: 1, method: 'tools/call', params: { name: 'bili_dm_reply', arguments: { uid: '123', msg_seqno: '456', text: '这条先等一下,小鱼看到了。' } } }, createRelayClient(childEnv))
|
|
152
|
-
const settled = pending.then(value => value, error => ({ error }))
|
|
153
|
-
await reading
|
|
154
|
-
assert.equal((await request('/pet/stop')).stopped, true)
|
|
155
|
-
releaseRead(); await settled
|
|
156
|
-
await new Promise(resolve => setImmediate(resolve))
|
|
157
|
-
assert.equal(sends, 0)
|
|
158
|
-
assert.ok(!calls.includes('bili_send_message'))
|
|
159
|
-
child.emit('close', null)
|
|
160
|
-
// A fresh task still works: cancelled execution context must not bleed into it.
|
|
161
|
-
assert.equal((await request('/pet/start', { mode: 'background' })).started, true)
|
|
162
|
-
const result = await backgroundMcpRequest({ id: 2, method: 'tools/call', params: { name: 'bili_dm_reply', arguments: { uid: '123', msg_seqno: '456', text: '小鱼刚看到,先慢慢来呀。' } } }, createRelayClient(childEnv))
|
|
163
|
-
assert.equal(result.result.isError, false)
|
|
164
|
-
assert.equal(JSON.parse(result.result.content[0].text).success, true)
|
|
165
|
-
assert.equal(sends, 1)
|
|
166
|
-
child.emit('close', 0)
|
|
167
|
-
} finally {
|
|
168
|
-
releaseRead()
|
|
169
|
-
for (const cleanup of cleanups.reverse()) { try { cleanup() } catch {} }
|
|
170
|
-
cp.spawn = originalSpawn; syncBuiltinESMExports(); McpClient.prototype.call = originalCall
|
|
171
|
-
rmSync(root, { recursive: true, force: true })
|
|
172
|
-
}
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
test('background schemas preserve ID strings and reject fabricated human authorization', () => {
|
|
176
|
-
const schema = toolInputSchema({ id: { type: 'string', required: true }, force: { type: 'boolean' }, action: { type: 'string', enum: ['read', 'add'] } })
|
|
177
|
-
assert.deepEqual(schema.required, ['id'])
|
|
178
|
-
assert.deepEqual(validateToolArguments(schema, { id: '1234567890123456789', action: null }), { id: '1234567890123456789' })
|
|
179
|
-
for (const bad of [{ id: 1234567890123456789 }, { id: 'x', force: true }, { id: 'x', action: 'approve' }, {}]) assert.throws(() => validateToolArguments(schema, bad), /BACKGROUND_TOOL_/)
|
|
180
|
-
assert.ok(!backgroundToolNames({}).includes('bili_reply'))
|
|
181
|
-
assert.ok(backgroundToolNames({ autoReplyDm: true }).includes('bili_dm_reply'))
|
|
182
|
-
})
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import test from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import { parseHM, backgroundWindowActive, backgroundWindowEndAt, backgroundWindowLaunch } from '../lib/run-settings.mjs'
|
|
4
|
-
|
|
5
|
-
/* 后台「时段」任务的纯函数(2026-10-28):指定 start–end,段内一直刷。
|
|
6
|
-
* 单测钉三类边界:常规段 / 跨午夜段(重启后接着刷尾段)/ 非法与相等段。 */
|
|
7
|
-
const at = (h, m, day = 28) => new Date(2026, 9, day, h, m, 0, 0) // 2026-10-28(周三)
|
|
8
|
-
|
|
9
|
-
test('parseHM 只认 00:00–23:59', () => {
|
|
10
|
-
assert.deepEqual(parseHM('20:00'), { h: 20, m: 0 })
|
|
11
|
-
assert.deepEqual(parseHM(' 9:05 '), { h: 9, m: 5 })
|
|
12
|
-
assert.equal(parseHM('24:00'), null)
|
|
13
|
-
assert.equal(parseHM('20:60'), null)
|
|
14
|
-
assert.equal(parseHM('20'), null)
|
|
15
|
-
assert.equal(parseHM(''), null)
|
|
16
|
-
assert.equal(parseHM(null), null)
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
test('常规段:end 在 start 之后 ⇒ 今天结束;段外也已返回今天的 end(由调用方判断是否过期)', () => {
|
|
20
|
-
assert.equal(backgroundWindowEndAt('20:00', '23:00', at(20, 30)), at(23, 0).getTime())
|
|
21
|
-
assert.equal(backgroundWindowEndAt('09:00', '12:00', at(10, 0)), at(12, 0).getTime())
|
|
22
|
-
assert.equal(backgroundWindowEndAt('09:00', '12:00', at(8, 0)), at(12, 0).getTime())
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
test('跨午夜段:晚间主体 ⇒ 结束在次日;半夜重启接着刷尾段 ⇒ 结束就在今天', () => {
|
|
26
|
-
assert.equal(backgroundWindowEndAt('20:00', '02:00', at(20, 30)), new Date(2026, 9, 29, 2, 0).getTime())
|
|
27
|
-
assert.equal(backgroundWindowEndAt('20:00', '02:00', at(23, 59)), new Date(2026, 9, 29, 2, 0).getTime())
|
|
28
|
-
assert.equal(backgroundWindowEndAt('20:00', '02:00', at(1, 30)), at(2, 0).getTime())
|
|
29
|
-
assert.equal(backgroundWindowEndAt('22:00', '00:00', at(22, 5)), new Date(2026, 9, 29, 0, 0).getTime())
|
|
30
|
-
assert.equal(backgroundWindowEndAt('22:00', '00:00', at(0, 10)), at(0, 0).getTime())
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
test('start==end 或解析失败 ⇒ null(视为没配时段)', () => {
|
|
34
|
-
assert.equal(backgroundWindowEndAt('20:00', '20:00', at(20, 0)), null)
|
|
35
|
-
assert.equal(backgroundWindowEndAt('abc', '23:00', at(20, 0)), null)
|
|
36
|
-
assert.equal(backgroundWindowEndAt('', '', at(20, 0)), null)
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
test('backgroundWindowActive:[start, end) 左闭右开,跨午夜两段都算', () => {
|
|
40
|
-
assert.equal(backgroundWindowActive('20:00', '23:00', at(20, 0)), true)
|
|
41
|
-
assert.equal(backgroundWindowActive('20:00', '23:00', at(22, 59)), true)
|
|
42
|
-
assert.equal(backgroundWindowActive('20:00', '23:00', at(23, 0)), false)
|
|
43
|
-
assert.equal(backgroundWindowActive('20:00', '23:00', at(19, 59)), false)
|
|
44
|
-
assert.equal(backgroundWindowActive('20:00', '02:00', at(21, 0)), true)
|
|
45
|
-
assert.equal(backgroundWindowActive('20:00', '02:00', at(0, 30)), true)
|
|
46
|
-
assert.equal(backgroundWindowActive('20:00', '02:00', at(2, 0)), false)
|
|
47
|
-
assert.equal(backgroundWindowActive('20:00', '02:00', at(19, 59)), false)
|
|
48
|
-
assert.equal(backgroundWindowActive('20:00', '20:00', at(20, 0)), false)
|
|
49
|
-
assert.equal(backgroundWindowActive('x', 'y', at(20, 0)), false)
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
test('backgroundWindowLaunch:这一跳要不要拉起鱼(常设时段任务的核心决策)', () => {
|
|
53
|
-
const cfg = { backgroundWindowStart: '20:00', backgroundWindowEnd: '23:00' }
|
|
54
|
-
// 段内、没挂免跑牌 ⇒ 拉起,endAt = 今天 23:00,剩余整算成分钟
|
|
55
|
-
const hit = backgroundWindowLaunch(cfg, {}, at(20, 30))
|
|
56
|
-
assert.equal(hit.endAt, at(23, 0).getTime())
|
|
57
|
-
assert.equal(hit.remainMin, 150)
|
|
58
|
-
// 不在段内(含结束边界)/ 没配 / start==end ⇒ null
|
|
59
|
-
assert.equal(backgroundWindowLaunch(cfg, {}, at(19, 59)), null)
|
|
60
|
-
assert.equal(backgroundWindowLaunch(cfg, {}, at(23, 0)), null)
|
|
61
|
-
assert.equal(backgroundWindowLaunch({}, {}, at(21, 0)), null)
|
|
62
|
-
assert.equal(backgroundWindowLaunch({ backgroundWindowStart: '20:00', backgroundWindowEnd: '20:00' }, {}, at(20, 0)), null)
|
|
63
|
-
// 免跑牌:hold 在未来 ⇒ 不拉(手动停止后调度钩子认账);hold 已过期 ⇒ 照常拉
|
|
64
|
-
assert.equal(backgroundWindowLaunch(cfg, { bgWindowHold: at(21, 0).getTime() }, at(20, 30)), null)
|
|
65
|
-
const after = backgroundWindowLaunch(cfg, { bgWindowHold: at(20, 0).getTime() }, at(20, 30))
|
|
66
|
-
assert.equal(after.endAt, at(23, 0).getTime())
|
|
67
|
-
// 跨午夜段:半夜尾段 ⇒ endAt 就是今天;晚间主体 ⇒ 次天
|
|
68
|
-
const nightTail = backgroundWindowLaunch({ backgroundWindowStart: '20:00', backgroundWindowEnd: '02:00' }, {}, at(1, 0))
|
|
69
|
-
assert.equal(nightTail.endAt, at(2, 0).getTime())
|
|
70
|
-
const nightMain = backgroundWindowLaunch({ backgroundWindowStart: '20:00', backgroundWindowEnd: '02:00' }, {}, at(21, 0))
|
|
71
|
-
assert.equal(nightMain.endAt, new Date(2026, 9, 29, 2, 0).getTime())
|
|
72
|
-
// 段尾剩余 <1 分钟 ⇒ remainMin 兜底 1(别出 0 分钟的任务)
|
|
73
|
-
const tail = backgroundWindowLaunch(cfg, {}, at(22, 59))
|
|
74
|
-
assert.equal(tail.remainMin, 1)
|
|
75
|
-
})
|